From 76d8c0d693592de3ac7609cc3d6f453d1f91745d Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Sat, 3 Oct 2026 18:05:43 +0100 Subject: [PATCH 01/12] Phase 0: integration branch with the API batch (spf_dag, EdgePriority, from_hops, cost metadata) Co-Authored-By: Claude Opus 5.5 --- .pre-commit-config.yaml | 4 +- CHANGELOG.md | 13 + README.md | 5 +- bindings/python/module.cpp | 27 +- dev/perf/spf_queue/README.md | 8 + dev/perf/spf_queue/harness.cpp | 38 +- dev/perf/spf_queue/results/dag/a1-old.csv | 11 + dev/perf/spf_queue/results/dag/a2-old.csv | 11 + dev/perf/spf_queue/results/dag/b-new-dag.csv | 11 + dev/perf/spf_queue/results/dag/b-new-full.csv | 11 + .../spf_queue/results/dag/environment.txt | 6 + dev/perf/spf_queue/results/dag/load.txt | 2 + .../spf_queue/results/netgraph-api/a1-old.csv | 17 + .../spf_queue/results/netgraph-api/a2-old.csv | 17 + .../results/netgraph-api/b-new-heap.csv | 17 + .../spf_queue/results/netgraph-api/b-new.csv | 17 + .../results/netgraph-api/environment.txt | 8 + .../spf_queue/results/netgraph-api/load.txt | 5 + .../spf_queue/results/priority/a1-old.csv | 11 + .../spf_queue/results/priority/a2-old.csv | 11 + .../results/priority/b-new-capacity.csv | 11 + .../results/priority/b-new-headroom.csv | 11 + .../priority/b-new-residual-single.csv | 11 + dev/perf/spf_queue/results/priority/b-new.csv | 11 + .../results/priority/environment.txt | 6 + dev/perf/spf_queue/results/priority/load.txt | 5 + include/netgraph/core/algorithms.hpp | 5 + include/netgraph/core/backend.hpp | 17 + include/netgraph/core/flow_graph.hpp | 2 + include/netgraph/core/flow_policy.hpp | 3 + include/netgraph/core/shortest_paths.hpp | 26 ++ include/netgraph/core/types.hpp | 14 +- pyproject.toml | 2 + python/netgraph_core/__init__.py | 3 + python/netgraph_core/_docs.py | 73 +++- src/cpu_backend.cpp | 7 + src/flow_policy.cpp | 22 +- src/flow_state.cpp | 10 +- src/k_shortest_paths.cpp | 8 +- src/max_flow.cpp | 8 +- src/shortest_paths.cpp | 399 +++++++++++------- tests/cpp/flow_graph_tests.cpp | 20 + tests/cpp/shortest_paths_tests.cpp | 142 ++++++- tests/py/test_graph_cost_metadata.py | 29 ++ tests/py/test_pred_dag_from_hops.py | 74 ++++ tests/py/test_spf_edge_priority.py | 71 ++++ 46 files changed, 1063 insertions(+), 177 deletions(-) create mode 100644 dev/perf/spf_queue/results/dag/a1-old.csv create mode 100644 dev/perf/spf_queue/results/dag/a2-old.csv create mode 100644 dev/perf/spf_queue/results/dag/b-new-dag.csv create mode 100644 dev/perf/spf_queue/results/dag/b-new-full.csv create mode 100644 dev/perf/spf_queue/results/dag/environment.txt create mode 100644 dev/perf/spf_queue/results/dag/load.txt create mode 100644 dev/perf/spf_queue/results/netgraph-api/a1-old.csv create mode 100644 dev/perf/spf_queue/results/netgraph-api/a2-old.csv create mode 100644 dev/perf/spf_queue/results/netgraph-api/b-new-heap.csv create mode 100644 dev/perf/spf_queue/results/netgraph-api/b-new.csv create mode 100644 dev/perf/spf_queue/results/netgraph-api/environment.txt create mode 100644 dev/perf/spf_queue/results/netgraph-api/load.txt create mode 100644 dev/perf/spf_queue/results/priority/a1-old.csv create mode 100644 dev/perf/spf_queue/results/priority/a2-old.csv create mode 100644 dev/perf/spf_queue/results/priority/b-new-capacity.csv create mode 100644 dev/perf/spf_queue/results/priority/b-new-headroom.csv create mode 100644 dev/perf/spf_queue/results/priority/b-new-residual-single.csv create mode 100644 dev/perf/spf_queue/results/priority/b-new.csv create mode 100644 dev/perf/spf_queue/results/priority/environment.txt create mode 100644 dev/perf/spf_queue/results/priority/load.txt create mode 100644 tests/py/test_graph_cost_metadata.py create mode 100644 tests/py/test_pred_dag_from_hops.py create mode 100644 tests/py/test_spf_edge_priority.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0dfd90b..e33a25d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,9 +6,9 @@ repos: - id: check-yaml # Recorded measurement artefacts and review patches must stay byte-exact. - id: end-of-file-fixer - exclude: ^dev/(research/|perf/.*/(results|review-[^/]+)/) + exclude: ^dev/(research/|perf/.*/(results|review-[^/]+)/|reviews/|design/.*/experiments/) - id: trailing-whitespace - exclude: ^dev/(research/|perf/.*/(results|review-[^/]+)/) + exclude: ^dev/(research/|perf/.*/(results|review-[^/]+)/|reviews/|design/.*/experiments/) - id: mixed-line-ending - id: debug-statements diff --git a/CHANGELOG.md b/CHANGELOG.md index e2b5c1a..62bdd65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 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.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Shortest Paths**: `shortest_paths_dag(g, src, dst, ...)` (C++) and `Backend::spf_dag` / `Algorithms::spf_dag` return the destination's cost and the predecessor DAG without materialising the N-sized distance vector; `calc_max_flow`, `FlowState::place_max_flow`, `FlowPolicy` and the KSP spur searches now use it (outputs unchanged). `Backend::spf_dag` has a default implementation derived from `spf`, so other backends need no change. Measured old/new/old against 0.11.0: about 1.25x on first-neighbour searches at 16k–262k nodes, unchanged on full searches. +- **Graph**: `StrictMultiDiGraph.max_cost()` / `cost_gcd()` in Python, the inputs of the bucket-queue dispatch rule (`max_cost // cost_gcd + 1 <= 65536`). +- **Explicit Paths**: `PredDAG.from_hops(graph, hops)` (C++ `make_hops_dag`) builds a route whose hops may each use several parallel edges, so a pinned bundle carries all of them (a 4 x 10 bundle carries 40 where `from_edges` with one edge carries 10). `from_edges` is the one-edge-per-hop case and shares the validation. +- **Shortest Paths**: `EdgeSelection.priority` (`EdgePriority.RESIDUAL` | `CAPACITY` | `HEADROOM`) selects the per-edge value that the `PREFER_HIGHER_RESIDUAL` tie-break and the node-level bottleneck ordering rank by: the residual (the default and the previous behaviour), the static capacity even when a residual is supplied, or the headroom `residual / capacity`. Capacity gating is unchanged and always uses the residual. The default route is unaffected (same code, bit-identical outputs); the other priorities cost one extra capacity load (and a division for headroom) per examined edge. + +### Fixed + +- **Flow Policy**: two policies managing the same `(src, dst, flowClass)` on one `FlowGraph` assign the same flow ids, so they previously merged their allocations under identical ledger keys and `remove_demand` on one zeroed the other's flow. Creating a flow whose index the ledger already holds now raises `ValueError` instead. + ## [0.11.0] - 2026-10-02 ### Changed diff --git a/README.md b/README.md index 6bd2eff..ee28cef 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ NetGraph-Core provides a specialized graph implementation for networking problem - **Shortest Paths (SPF)**: - Modified Dijkstra returns a **Predecessor DAG** to capture all equal-cost paths. - Supports **ECMP** (Equal-Cost Multi-Path) routing. - - Features **node/edge masking** and **residual-aware tie-breaking**. + - Features **node/edge masking** and **residual-aware tie-breaking**, with a selectable priority value (residual, static capacity, or headroom = residual / capacity). - **K-Shortest Paths (KSP)**: - Yen's algorithm returning DAG-wrapped paths. @@ -30,7 +30,8 @@ NetGraph-Core provides a specialized graph implementation for networking problem - **Explicit Paths**: - `PredDAG.from_edges(graph, edges)` builds a path bundle from an operator-supplied - edge sequence, usable anywhere a `PredDAG` is accepted. + edge sequence, usable anywhere a `PredDAG` is accepted; `PredDAG.from_hops(graph, hops)` + does the same for routes whose hops use several parallel edges. - **Max-Flow**: - **Algorithm**: Iterative augmentation using Successive Shortest Path on residual graphs, pushing flow across full ECMP/WCMP DAGs at each step. For `Proportional` placement with `require_capacity=True`, a final residual completion phase augments with reverse arcs so the result is a true maximum flow; the other modes are placement models and may report less. diff --git a/bindings/python/module.cpp b/bindings/python/module.cpp index e01488f..0909e39 100644 --- a/bindings/python/module.cpp +++ b/bindings/python/module.cpp @@ -95,18 +95,25 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { .value("DETERMINISTIC", EdgeTieBreak::Deterministic) .value("PREFER_HIGHER_RESIDUAL", EdgeTieBreak::PreferHigherResidual); + py::enum_(m, "EdgePriority") + .value("RESIDUAL", EdgePriority::Residual) + .value("CAPACITY", EdgePriority::Capacity) + .value("HEADROOM", EdgePriority::Headroom); + py::class_(m, "EdgeSelection") .def(py::init<>()) - .def(py::init([](bool multi_edge, bool require_capacity, EdgeTieBreak tie_break){ - EdgeSelection s; s.multi_edge = multi_edge; s.require_capacity = require_capacity; s.tie_break = tie_break; return s; + .def(py::init([](bool multi_edge, bool require_capacity, EdgeTieBreak tie_break, EdgePriority priority){ + EdgeSelection s; s.multi_edge = multi_edge; s.require_capacity = require_capacity; s.tie_break = tie_break; s.priority = priority; return s; }), py::kw_only(), py::arg("multi_edge") = true, py::arg("require_capacity") = false, - py::arg("tie_break") = EdgeTieBreak::Deterministic) + py::arg("tie_break") = EdgeTieBreak::Deterministic, + py::arg("priority") = EdgePriority::Residual) .def_readwrite("multi_edge", &EdgeSelection::multi_edge) .def_readwrite("require_capacity", &EdgeSelection::require_capacity) - .def_readwrite("tie_break", &EdgeSelection::tie_break); + .def_readwrite("tie_break", &EdgeSelection::tie_break) + .def_readwrite("priority", &EdgeSelection::priority); py::enum_(m, "FlowPlacement") .value("PROPORTIONAL", FlowPlacement::Proportional) @@ -138,6 +145,8 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { py::arg("ext_edge_ids")) .def("num_nodes", &StrictMultiDiGraph::num_nodes) .def("num_edges", &StrictMultiDiGraph::num_edges) + .def("max_cost", &StrictMultiDiGraph::max_cost) + .def("cost_gcd", &StrictMultiDiGraph::cost_gcd) .def("capacity_view", [](const StrictMultiDiGraph& g){ return copy_to_numpy(g.capacity_view()); }) .def("edge_src_view", [](const StrictMultiDiGraph& g){ return copy_to_numpy(g.edge_src_view()); }) .def("edge_dst_view", [](const StrictMultiDiGraph& g){ return copy_to_numpy(g.edge_dst_view()); }) @@ -379,6 +388,16 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { .def_property_readonly("via_edges", [](const PredDAG& d){ return copy_to_numpy(std::span(d.via_edges.data(), d.via_edges.size())); }) + .def_static("from_hops", [](const StrictMultiDiGraph& g, py::sequence hops){ + std::vector> h; + h.reserve(static_cast(py::len(hops))); + for (auto hop : hops) { + std::vector edges; + for (auto e : py::cast(hop)) edges.push_back(py::cast(e)); + h.push_back(std::move(edges)); + } + return make_hops_dag(g, h); + }, py::arg("graph"), py::arg("hops")) .def_static("from_edges", [](const StrictMultiDiGraph& g, py::sequence edges){ std::vector e; e.reserve(py::len(edges)); diff --git a/dev/perf/spf_queue/README.md b/dev/perf/spf_queue/README.md index 9b1c88d..fe7286a 100644 --- a/dev/perf/spf_queue/README.md +++ b/dev/perf/spf_queue/README.md @@ -46,6 +46,14 @@ with the ten-worker batch timings in the research reports. each binary was built from (`cpu-queue-old` is built from a `git archive` of the base commit); `b-new-heap` forces the heap route in the new binary as an in-binary control. +- `results/priority/`: old/new/old for the `EdgeSelection::priority` addition + (default route against 0.11.0, plus the opt-in `capacity` and `headroom` + priorities timed in single-path `PreferHigherResidual` mode via + `SPFX_PRIORITY=capacity|headroom`). +- `results/dag/`: old/new/old for `shortest_paths_dag` (the DAG-only + destination search used by max-flow, FlowPolicy and KSP), timed via + `SPFX_DAG=1` on the destination rows: about 1.25x over 0.11.0 on + first-neighbour queries, unchanged elsewhere. - `bench_netgraph.py` + `run_netgraph_aba.sh` + `results/netgraph/`: old/new/old on NetGraph workloads run through NetGraph's own API and interpreter (full SPF, bound-context max-flow with pseudo nodes, failure exclusions, demand diff --git a/dev/perf/spf_queue/harness.cpp b/dev/perf/spf_queue/harness.cpp index 1e8b869..7aa04c5 100644 --- a/dev/perf/spf_queue/harness.cpp +++ b/dev/perf/spf_queue/harness.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -120,11 +121,19 @@ struct Query { bool use_residual; bool use_masks; bool reverse = false; }; +// SPFX_DAG=1 times shortest_paths_dag for destination queries; its result is +// folded into the Result shape with the target cost as the only distance. +static const bool kDagMode = std::getenv("SPFX_DAG") != nullptr; + static Result run_ref(const Case& c, const Query& q) { auto res = q.use_residual ? std::span(c.residual) : std::span{}; auto nm = q.use_masks ? c.nm() : std::span{}; auto em = q.use_masks ? c.em() : std::span{}; if (q.reverse) return shortest_paths_to(c.graph, q.src, q.multipath, q.sel, res, nm, em, {}); + if (kDagMode && q.dst) { + auto [cost, dag] = shortest_paths_dag(c.graph, q.src, *q.dst, q.multipath, q.sel, res, nm, em); + return Result{std::vector{cost}, std::move(dag)}; + } return shortest_paths(c.graph, q.src, q.dst, q.multipath, q.sel, res, nm, em); } @@ -163,6 +172,15 @@ static bool same(const Result& a, const Result& b) { return a.first == b.first && a.second.parent_offsets == b.second.parent_offsets && a.second.parents == b.second.parents && a.second.via_edges == b.second.via_edges; } +// In DAG mode the expected result is the full search; compare its target cost and DAG. +static bool same_for_query(const Query& q, const Result& expected, const Result& actual) { + if (kDagMode && q.dst && !q.reverse) { + return actual.first.size() == 1 && actual.first[0] == expected.first[static_cast(*q.dst)] && + expected.second.parent_offsets == actual.second.parent_offsets && + expected.second.parents == actual.second.parents && expected.second.via_edges == actual.second.via_edges; + } + return same(expected, actual); +} static NodeId far_node(const std::vector& d) { NodeId best = -1; Cost bd = -1; @@ -251,6 +269,12 @@ static void time_workloads(std::vector& wls, const std::string& varian std::vector qs; for (int i = 0; i < w.queries; ++i) { Query q; q.multipath = true; q.sel = EdgeSelection{}; q.use_residual = true; q.use_masks = true; + if (const char* pr = std::getenv("SPFX_PRIORITY")) { // time a non-default EdgePriority + const std::string p(pr); + if (p == "capacity") q.sel.priority = EdgePriority::Capacity; + else if (p == "headroom") q.sel.priority = EdgePriority::Headroom; + q.multipath = false; q.sel.tie_break = EdgeTieBreak::PreferHigherResidual; // where the value is used most + } q.src = c.fixed_src >= 0 ? c.fixed_src : (i * 997) % n; if (w.dst_mode == "reverse") q.reverse = true; else if (w.dst_mode == "fixed") q.dst = c.fixed_dst; @@ -262,7 +286,16 @@ static void time_workloads(std::vector& wls, const std::string& varian } qs.push_back(q); } - std::vector expected; for (auto& q : qs) expected.push_back(run_ref(c, q)); + std::vector expected; + for (auto& q : qs) { // reference: the full forward search for DAG-mode destination queries + if (kDagMode && q.dst && !q.reverse) { + expected.push_back(shortest_paths(c.graph, q.src, q.dst, q.multipath, q.sel, + q.use_residual ? std::span(c.residual) : std::span{}, + q.use_masks ? c.nm() : std::span{}, q.use_masks ? c.em() : std::span{})); + } else { + expected.push_back(run_ref(c, q)); + } + } Runner r; r.variant = variant; r.max_width = maxw; r.prepare(c); std::vector times; for (int rep = -2; rep < samples; ++rep) { @@ -270,7 +303,8 @@ static void time_workloads(std::vector& wls, const std::string& varian auto start = Clock::now(); for (std::size_t i = 0; i < qs.size(); ++i) actual[i] = r.run(c, qs[i]); double el = ms(start); - for (std::size_t i = 0; i < qs.size(); ++i) if (!same(expected[i], actual[i])) throw std::runtime_error("timed result mismatch in " + w.label); + for (std::size_t i = 0; i < qs.size(); ++i) if (!same_for_query(qs[i], expected[i], actual[i])) + throw std::runtime_error("timed result mismatch in " + w.label + " query " + std::to_string(i) + " dst=" + (qs[i].dst ? std::to_string(*qs[i].dst) : "none") + " dist sizes " + std::to_string(expected[i].first.size()) + "/" + std::to_string(actual[i].first.size())); if (rep >= 0) times.push_back(el); } std::vector sorted = times; std::sort(sorted.begin(), sorted.end()); diff --git a/dev/perf/spf_queue/results/dag/a1-old.csv b/dev/perf/spf_queue/results/dag/a1-old.csv new file mode 100644 index 0000000..fefc4ce --- /dev/null +++ b/dev/perf/spf_queue/results/dag/a1-old.csv @@ -0,0 +1,11 @@ +case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms +random,16384,131072,64,none,ref,0,32,152.648875,7,152.167166;153.360875;152.907875;152.632458;152.822292;152.368542;152.648875 +random,65536,524288,1,none,ref,0,32,14.920958,7,16.236834;14.825542;16.897875;13.913708;15.185750;14.424959;14.920958 +clos,800,320000,64,none,ref,0,2,111.433875,7,112.056041;111.918375;110.848834;111.597542;110.760041;111.433875;110.599667 +chain,2048,2047,1,none,ref,0,32,0.029333,7,0.025750;0.029416;0.026708;0.027667;0.029417;0.029333;0.029667 +random,16384,131072,64,near,ref,0,32,0.407667,7,0.425208;0.403834;0.399167;0.407667;0.411083;0.402583;0.411292 +random,65536,524288,64,near,ref,0,32,1.579917,7,1.499792;1.495166;1.604000;2.009208;1.673083;1.579917;1.566625 +random,262144,2097152,16,near,ref,0,32,1.454875,7,1.460209;1.514375;1.394875;1.452666;1.452750;1.454875;1.578333 +random,262144,2097152,1,none,ref,0,32,97.267917,7,97.108584;97.267917;98.792542;96.674833;98.478833;97.012041;97.615250 +random,4096,32768,64,reverse,ref,0,32,34.863542,7,34.934208;35.170667;34.723084;34.276083;34.754542;34.863542;34.981958 +clos,800,320000,16,reverse,ref,0,2,40.670541,7,39.681333;41.645791;41.529250;40.433167;40.670541;40.816292;40.299417 diff --git a/dev/perf/spf_queue/results/dag/a2-old.csv b/dev/perf/spf_queue/results/dag/a2-old.csv new file mode 100644 index 0000000..d1546b1 --- /dev/null +++ b/dev/perf/spf_queue/results/dag/a2-old.csv @@ -0,0 +1,11 @@ +case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms +random,16384,131072,64,none,ref,0,32,153.165667,7,152.957375;151.711541;153.660375;153.659708;152.538166;153.165667;155.704375 +random,65536,524288,1,none,ref,0,32,13.659042,7,14.052708;13.553333;13.659042;14.954041;14.541875;12.997458;13.054584 +clos,800,320000,64,none,ref,0,2,113.659542,7,113.740625;114.077916;112.995792;113.659542;112.719250;114.274875;112.979500 +chain,2048,2047,1,none,ref,0,32,0.032583,7,0.032458;0.032958;0.032583;0.032292;0.032875;0.032833;0.032375 +random,16384,131072,64,near,ref,0,32,0.408459,7,0.396667;0.396875;0.394208;0.408459;0.411042;0.411958;0.420041 +random,65536,524288,64,near,ref,0,32,1.589042,7,1.506250;1.943542;1.643042;1.527500;1.519208;1.729375;1.589042 +random,262144,2097152,16,near,ref,0,32,1.516084,7,1.547583;1.555666;1.506417;1.528833;1.492750;1.516084;1.471041 +random,262144,2097152,1,none,ref,0,32,98.082583,7,98.082583;97.663250;100.059834;98.294333;98.673375;97.223625;97.694875 +random,4096,32768,64,reverse,ref,0,32,34.828750,7,34.917375;35.040416;34.719875;34.828458;35.091791;34.828750;34.700792 +clos,800,320000,16,reverse,ref,0,2,39.611083,7,39.576708;39.611083;39.915917;40.217500;39.978833;39.580583;39.046459 diff --git a/dev/perf/spf_queue/results/dag/b-new-dag.csv b/dev/perf/spf_queue/results/dag/b-new-dag.csv new file mode 100644 index 0000000..bbd56d0 --- /dev/null +++ b/dev/perf/spf_queue/results/dag/b-new-dag.csv @@ -0,0 +1,11 @@ +case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms +random,16384,131072,64,none,ref,0,32,147.204834,7,148.269791;146.921083;148.045750;146.513042;148.202917;147.204834;146.236167 +random,65536,524288,1,none,ref,0,32,13.981000,7,15.172208;15.104417;13.815083;13.842041;12.977916;15.596917;13.981000 +clos,800,320000,64,none,ref,0,2,102.374208,7,102.762583;102.448042;103.339666;102.294750;101.823958;102.374208;101.949791 +chain,2048,2047,1,none,ref,0,32,0.028000,7,0.027750;0.027709;0.028083;0.027917;0.028375;0.028000;0.028292 +random,16384,131072,64,near,ref,0,32,0.336208,7,0.362583;0.336208;0.351084;0.340000;0.309000;0.329541;0.334750 +random,65536,524288,64,near,ref,0,32,1.255792,7,1.378250;1.308292;1.330916;1.255792;1.228958;1.246417;1.253375 +random,262144,2097152,16,near,ref,0,32,1.204792,7,1.203208;1.234666;1.229458;1.204792;1.208625;1.188083;1.200250 +random,262144,2097152,1,none,ref,0,32,92.972125,7,93.473416;93.923791;94.374750;92.760375;92.890000;92.972125;92.331208 +random,4096,32768,64,reverse,ref,0,32,35.038958,7,35.038958;34.932333;35.057625;35.124458;34.640500;34.992333;35.300584 +clos,800,320000,16,reverse,ref,0,2,37.358000,7,38.057375;36.721375;38.048250;36.772167;37.358000;37.697042;36.755500 diff --git a/dev/perf/spf_queue/results/dag/b-new-full.csv b/dev/perf/spf_queue/results/dag/b-new-full.csv new file mode 100644 index 0000000..4ff193c --- /dev/null +++ b/dev/perf/spf_queue/results/dag/b-new-full.csv @@ -0,0 +1,11 @@ +case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms +random,16384,131072,64,none,ref,0,32,147.974625,7,147.189625;148.072833;146.896084;146.343458;148.334375;147.974625;150.991416 +random,65536,524288,1,none,ref,0,32,14.055917,7,15.668625;13.799667;14.560458;12.363667;14.155667;14.055917;12.275209 +clos,800,320000,64,none,ref,0,2,102.649416,7,102.649416;101.224791;102.788625;104.979084;103.555125;102.376875;101.695209 +chain,2048,2047,1,none,ref,0,32,0.026792,7,0.026708;0.026667;0.026250;0.026792;0.054167;0.034500;0.027459 +random,16384,131072,64,near,ref,0,32,0.425917,7,0.426292;0.410125;0.473583;0.429334;0.425917;0.400333;0.415375 +random,65536,524288,64,near,ref,0,32,1.647166,7,1.590334;1.812166;1.589500;1.647166;2.255959;1.654959;1.498917 +random,262144,2097152,16,near,ref,0,32,1.518500,7,1.526666;1.503250;1.493500;1.518500;1.483500;1.531750;1.591500 +random,262144,2097152,1,none,ref,0,32,91.525542,7,92.345542;92.210584;91.222125;91.525542;92.527084;91.375000;91.478625 +random,4096,32768,64,reverse,ref,0,32,35.060250,7,34.390375;34.607625;35.061334;35.060250;35.146916;35.280375;34.569750 +clos,800,320000,16,reverse,ref,0,2,37.389334,7,37.535584;36.957875;37.363000;37.207291;37.389334;37.840417;37.400125 diff --git a/dev/perf/spf_queue/results/dag/environment.txt b/dev/perf/spf_queue/results/dag/environment.txt new file mode 100644 index 0000000..52b0a04 --- /dev/null +++ b/dev/perf/spf_queue/results/dag/environment.txt @@ -0,0 +1,6 @@ +date: 2026-10-02T18:14:27Z +old: d5717cc (0.11.0) via build.sh old d5717cc; new: working tree with shortest_paths_dag +phases: a1-old, b-new-full (shortest_paths), b-new-dag (SPFX_DAG=1: shortest_paths_dag for destination rows), a2-old; short mode, 7 samples +b1760ef54d46011673e5f517d1b6242fcda0f9cd500898ec4748b2df4bb054ff src/shortest_paths.cpp +494b95a4a3b7fd9105a37c5f53acf4e88642167549b7eb56f4e01f64e013cd5e build/perf/cpu-queue +7de733ce4ec111c3414e2b262b6063a253b5cd790e4beceab893d802f0c5bf6a build/perf/cpu-queue-old diff --git a/dev/perf/spf_queue/results/dag/load.txt b/dev/perf/spf_queue/results/dag/load.txt new file mode 100644 index 0000000..75af2c4 --- /dev/null +++ b/dev/perf/spf_queue/results/dag/load.txt @@ -0,0 +1,2 @@ +19:13 up 52 days, 23:49, 3 users, load averages: 4.78 12.34 19.44 +19:13 up 52 days, 23:49, 3 users, load averages: 4.34 11.75 19.06 diff --git a/dev/perf/spf_queue/results/netgraph-api/a1-old.csv b/dev/perf/spf_queue/results/netgraph-api/a1-old.csv new file mode 100644 index 0000000..885fbf3 --- /dev/null +++ b/dev/perf/spf_queue/results/netgraph-api/a1-old.csv @@ -0,0 +1,17 @@ +workload,label,median_s,samples,checksum,samples_s +spf_full_clos_200x200,a1-old,0.164199,5,946e7cf8b5a36980,0.164200;0.163339;0.163427;0.164199;0.164551 +spf_full_grid_100x100_weighted,a1-old,0.180069,5,4c2f7751ff9a5057,0.181406;0.179029;0.180069;0.179642;0.180765 +maxflow_clos_200x200_combine,a1-old,0.047420,5,2576c34f1fd9b85e,0.047402;0.047450;0.047170;0.047846;0.047420 +maxflow_failures_clos_100x100_x100,a1-old,0.040212,5,26dc7541b17fd051,0.040212;0.040723;0.040146;0.040529;0.039536 +maxflow_grid_100x100_weighted_combine,a1-old,0.172536,5,b0453b641647871b,0.174130;0.172536;0.173179;0.172331;0.170554 +maxflow_grid_100x100_weighted_pairwise,a1-old,0.214922,5,4fc2486a01221d75,0.220338;0.213739;0.182009;0.216421;0.214922 +placement_backbone_clos,a1-old,0.070275,5,a3bbef71a61656e1,0.070098;0.070461;0.070740;0.070275;0.070173 +placement_mc_backbone_clos_x300,a1-old,0.069225,5,13b9a5f311b911a9,0.070705;0.069225;0.068087;0.069235;0.069128 +maxflow_mc_backbone_clos_x300,a1-old,0.027504,5,ac971ec1b53a4b0a,0.028031;0.027778;0.027288;0.027374;0.027504 +ksp_clos3_pairwise_k8,a1-old,0.047320,5,9bf30f2f50792fe9,0.046562;0.046913;0.047353;0.047320;0.047576 +maxflow_clos3_pod_pairs_combine,a1-old,0.169847,5,2e23a5712f361ff7,0.169803;0.168511;0.206648;0.170894;0.169847 +placement_clos3_wcmp_and_ecmp,a1-old,0.385817,5,14d7ba5534c9ce95,0.387960;0.384565;0.385817;0.387922;0.345817 +placement_clos3_failures_x20,a1-old,7.906144,5,eafa50e144281360,7.858035;7.889058;7.998602;7.906144;7.938671 +scenario_square_mesh_iter100,a1-old,0.009979,5,5046cbe45e195f6e,0.009979;0.009923;0.010203;0.009979;0.010667 +scenario_nsfnet_iter200,a1-old,0.964245,5,d966e67ffac4393c,0.964245;0.964259;0.948062;1.007654;0.961705 +scenario_backbone_clos_iter100,a1-old,0.079351,5,e904ac0839c6cac1,0.080707;0.079139;0.078732;0.081250;0.079351 diff --git a/dev/perf/spf_queue/results/netgraph-api/a2-old.csv b/dev/perf/spf_queue/results/netgraph-api/a2-old.csv new file mode 100644 index 0000000..5b25f39 --- /dev/null +++ b/dev/perf/spf_queue/results/netgraph-api/a2-old.csv @@ -0,0 +1,17 @@ +workload,label,median_s,samples,checksum,samples_s +spf_full_clos_200x200,a2-old,0.164252,5,946e7cf8b5a36980,0.164252;0.164089;0.163610;0.164940;0.169394 +spf_full_grid_100x100_weighted,a2-old,0.180147,5,4c2f7751ff9a5057,0.181338;0.183952;0.180147;0.178888;0.180012 +maxflow_clos_200x200_combine,a2-old,0.047663,5,2576c34f1fd9b85e,0.048326;0.048074;0.047356;0.047663;0.047493 +maxflow_failures_clos_100x100_x100,a2-old,0.040527,5,26dc7541b17fd051,0.040527;0.040400;0.040282;0.040551;0.040915 +maxflow_grid_100x100_weighted_combine,a2-old,0.173642,5,b0453b641647871b,0.173692;0.171167;0.170759;0.174903;0.173642 +maxflow_grid_100x100_weighted_pairwise,a2-old,0.220239,5,4fc2486a01221d75,0.220239;0.216990;0.185458;0.222288;0.221898 +placement_backbone_clos,a2-old,0.070354,5,a3bbef71a61656e1,0.071010;0.070354;0.071020;0.070158;0.069685 +placement_mc_backbone_clos_x300,a2-old,0.069533,5,13b9a5f311b911a9,0.070353;0.070167;0.069416;0.069204;0.069533 +maxflow_mc_backbone_clos_x300,a2-old,0.027473,5,ac971ec1b53a4b0a,0.028459;0.027473;0.028129;0.027393;0.027454 +ksp_clos3_pairwise_k8,a2-old,0.046714,5,9bf30f2f50792fe9,0.046218;0.046637;0.046996;0.047152;0.046714 +maxflow_clos3_pod_pairs_combine,a2-old,0.170560,5,2e23a5712f361ff7,0.168721;0.170560;0.207953;0.170134;0.171528 +placement_clos3_wcmp_and_ecmp,a2-old,0.388207,5,14d7ba5534c9ce95,0.392023;0.388207;0.384011;0.395217;0.342003 +placement_clos3_failures_x20,a2-old,7.890882,5,eafa50e144281360,7.823005;7.897283;7.948790;7.803927;7.890882 +scenario_square_mesh_iter100,a2-old,0.010269,5,5046cbe45e195f6e,0.010292;0.010212;0.010466;0.010269;0.010108 +scenario_nsfnet_iter200,a2-old,0.959533,5,d966e67ffac4393c,0.963123;0.959533;0.955776;1.009108;0.953370 +scenario_backbone_clos_iter100,a2-old,0.080060,5,e904ac0839c6cac1,0.080287;0.078875;0.080223;0.079661;0.080060 diff --git a/dev/perf/spf_queue/results/netgraph-api/b-new-heap.csv b/dev/perf/spf_queue/results/netgraph-api/b-new-heap.csv new file mode 100644 index 0000000..aace29a --- /dev/null +++ b/dev/perf/spf_queue/results/netgraph-api/b-new-heap.csv @@ -0,0 +1,17 @@ +workload,label,median_s,samples,checksum,samples_s +spf_full_clos_200x200,b-new-heap,0.154301,5,946e7cf8b5a36980,0.154292;0.153986;0.156239;0.155807;0.154301 +spf_full_grid_100x100_weighted,b-new-heap,0.241783,5,4c2f7751ff9a5057,0.241890;0.241332;0.241625;0.241783;0.242557 +maxflow_clos_200x200_combine,b-new-heap,0.045705,5,2576c34f1fd9b85e,0.045888;0.045705;0.045257;0.045775;0.045253 +maxflow_failures_clos_100x100_x100,b-new-heap,0.037736,5,26dc7541b17fd051,0.037736;0.037922;0.037631;0.037622;0.037749 +maxflow_grid_100x100_weighted_combine,b-new-heap,0.221427,5,b0453b641647871b,0.221236;0.219613;0.221466;0.221565;0.221427 +maxflow_grid_100x100_weighted_pairwise,b-new-heap,0.220093,5,4fc2486a01221d75,0.224849;0.218149;0.185887;0.221495;0.220093 +placement_backbone_clos,b-new-heap,0.069219,5,a3bbef71a61656e1,0.071393;0.069138;0.067831;0.069622;0.069219 +placement_mc_backbone_clos_x300,b-new-heap,0.074105,5,13b9a5f311b911a9,0.074105;0.080540;0.074366;0.073329;0.072530 +maxflow_mc_backbone_clos_x300,b-new-heap,0.028863,5,ac971ec1b53a4b0a,0.028903;0.028762;0.029184;0.028828;0.028863 +ksp_clos3_pairwise_k8,b-new-heap,0.043672,5,9bf30f2f50792fe9,0.043959;0.043646;0.043925;0.043672;0.042721 +maxflow_clos3_pod_pairs_combine,b-new-heap,0.171207,5,2e23a5712f361ff7,0.171207;0.169931;0.205680;0.170800;0.171347 +placement_clos3_wcmp_and_ecmp,b-new-heap,0.382684,5,14d7ba5534c9ce95,0.382684;0.383823;0.379763;0.385744;0.345352 +placement_clos3_failures_x20,b-new-heap,7.783453,5,eafa50e144281360,7.757524;7.783453;7.861714;7.729196;7.797888 +scenario_square_mesh_iter100,b-new-heap,0.010213,5,5046cbe45e195f6e,0.009939;0.010215;0.010081;0.010415;0.010213 +scenario_nsfnet_iter200,b-new-heap,0.947496,5,d966e67ffac4393c,0.952279;0.947496;0.933890;0.984148;0.935582 +scenario_backbone_clos_iter100,b-new-heap,0.081325,5,e904ac0839c6cac1,0.081325;0.081223;0.082362;0.082170;0.080886 diff --git a/dev/perf/spf_queue/results/netgraph-api/b-new.csv b/dev/perf/spf_queue/results/netgraph-api/b-new.csv new file mode 100644 index 0000000..4c97086 --- /dev/null +++ b/dev/perf/spf_queue/results/netgraph-api/b-new.csv @@ -0,0 +1,17 @@ +workload,label,median_s,samples,checksum,samples_s +spf_full_clos_200x200,b-new,0.157989,5,946e7cf8b5a36980,0.158181;0.157301;0.157989;0.157473;0.158350 +spf_full_grid_100x100_weighted,b-new,0.180796,5,4c2f7751ff9a5057,0.180796;0.181414;0.180503;0.182915;0.180604 +maxflow_clos_200x200_combine,b-new,0.045661,5,2576c34f1fd9b85e,0.046378;0.045917;0.045661;0.045093;0.045019 +maxflow_failures_clos_100x100_x100,b-new,0.038526,5,26dc7541b17fd051,0.038590;0.038414;0.038400;0.038529;0.038526 +maxflow_grid_100x100_weighted_combine,b-new,0.171153,5,b0453b641647871b,0.172839;0.173989;0.170848;0.169668;0.171153 +maxflow_grid_100x100_weighted_pairwise,b-new,0.218760,5,4fc2486a01221d75,0.222728;0.217684;0.183078;0.218760;0.220241 +placement_backbone_clos,b-new,0.069585,5,a3bbef71a61656e1,0.069335;0.070424;0.069104;0.070529;0.069585 +placement_mc_backbone_clos_x300,b-new,0.068743,5,13b9a5f311b911a9,0.068743;0.069898;0.067806;0.067056;0.068923 +maxflow_mc_backbone_clos_x300,b-new,0.027577,5,ac971ec1b53a4b0a,0.027735;0.027662;0.027234;0.027577;0.027326 +ksp_clos3_pairwise_k8,b-new,0.044595,5,9bf30f2f50792fe9,0.044767;0.044511;0.045780;0.044129;0.044595 +maxflow_clos3_pod_pairs_combine,b-new,0.170686,5,2e23a5712f361ff7,0.169656;0.171535;0.205557;0.170686;0.170334 +placement_clos3_wcmp_and_ecmp,b-new,0.382645,5,14d7ba5534c9ce95,0.390539;0.383919;0.382645;0.381250;0.344172 +placement_clos3_failures_x20,b-new,7.832697,5,eafa50e144281360,7.797490;7.832697;7.863150;7.782276;7.885314 +scenario_square_mesh_iter100,b-new,0.010150,5,5046cbe45e195f6e,0.010457;0.010075;0.010150;0.010102;0.010323 +scenario_nsfnet_iter200,b-new,0.950681,5,d966e67ffac4393c,0.964545;0.950681;0.944029;0.992850;0.947637 +scenario_backbone_clos_iter100,b-new,0.079879,5,e904ac0839c6cac1,0.080338;0.079758;0.081120;0.079879;0.079752 diff --git a/dev/perf/spf_queue/results/netgraph-api/environment.txt b/dev/perf/spf_queue/results/netgraph-api/environment.txt new file mode 100644 index 0000000..db978f6 --- /dev/null +++ b/dev/perf/spf_queue/results/netgraph-api/environment.txt @@ -0,0 +1,8 @@ +date: 2026-10-02T18:15:33Z +new: d5717cced8a4451258f114d8e78d67ab0dc14248 (dirty: 29 paths) +old: d5717cced8a4451258f114d8e78d67ab0dc14248 +netgraph: 33fadebf7fe05985cc70647fc422287afaa3221c +dd3b394c0ba16f7ed0e0e403188f4fcc38dbd9ce2cf594368d2b2a8a19a105a0 dev/perf/spf_queue/bench_netgraph.py +be97554943acc1301b21dfb4b12d1a8603de1f8017b843243902ef09c8e6b981 /Users/networmix/ws/NetGraph-Core/venv/lib/python3.14/site-packages/_netgraph_core.cpython-314-darwin.so +9d684e1280714cd8d4b275c418b34cf936e0b340243612bfab40913bbe60610c /Users/networmix/ws/NetGraph-Core-main/venv/lib/python3.14/site-packages/_netgraph_core.cpython-314-darwin.so +Apple M4 Max diff --git a/dev/perf/spf_queue/results/netgraph-api/load.txt b/dev/perf/spf_queue/results/netgraph-api/load.txt new file mode 100644 index 0000000..c657a0f --- /dev/null +++ b/dev/perf/spf_queue/results/netgraph-api/load.txt @@ -0,0 +1,5 @@ +19:15 up 52 days, 23:51, 3 users, load averages: 4.90 9.98 17.60 +19:16 up 52 days, 23:52, 3 users, load averages: 5.13 9.03 16.68 +19:17 up 52 days, 23:53, 3 users, load averages: 4.07 8.05 15.79 +19:18 up 52 days, 23:54, 3 users, load averages: 5.16 7.58 15.05 +19:19 up 52 days, 23:55, 3 users, load averages: 4.97 7.09 14.35 diff --git a/dev/perf/spf_queue/results/priority/a1-old.csv b/dev/perf/spf_queue/results/priority/a1-old.csv new file mode 100644 index 0000000..065c3ab --- /dev/null +++ b/dev/perf/spf_queue/results/priority/a1-old.csv @@ -0,0 +1,11 @@ +case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms +random,16384,131072,64,none,ref,0,32,149.947042,7,148.096291;146.522667;164.538958;170.384166;168.072167;147.836833;149.947042 +random,65536,524288,1,none,ref,0,32,14.036084,7,14.626750;16.255041;13.739958;14.972333;13.566417;13.306292;14.036084 +clos,800,320000,64,none,ref,0,2,108.680541,7,109.401542;108.082334;108.293334;110.645417;109.045250;108.680541;108.196625 +chain,2048,2047,1,none,ref,0,32,0.029709,7,0.030458;0.029709;0.029875;0.029833;0.029375;0.029209;0.026541 +random,16384,131072,64,near,ref,0,32,0.394292,7,0.392916;0.396292;0.394292;0.393292;0.393416;0.411791;0.408000 +random,65536,524288,64,near,ref,0,32,1.513084,7,1.513084;1.514875;1.520500;1.531958;1.490333;1.493584;1.503292 +random,262144,2097152,16,near,ref,0,32,1.475125,7,1.464167;1.463459;1.468833;1.475125;1.486791;1.477875;1.503500 +random,262144,2097152,1,none,ref,0,32,91.345667,7,91.712708;91.345667;91.229959;88.957541;93.626250;92.104708;90.893166 +random,4096,32768,64,reverse,ref,0,32,34.358542,7,33.641125;33.889917;34.118042;34.358542;35.146875;35.473083;34.650833 +clos,800,320000,16,reverse,ref,0,2,37.327792,7,37.838500;36.963750;36.841042;37.327792;37.318583;37.761125;38.013791 diff --git a/dev/perf/spf_queue/results/priority/a2-old.csv b/dev/perf/spf_queue/results/priority/a2-old.csv new file mode 100644 index 0000000..75b9513 --- /dev/null +++ b/dev/perf/spf_queue/results/priority/a2-old.csv @@ -0,0 +1,11 @@ +case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms +random,16384,131072,64,none,ref,0,32,148.279417,7,148.184917;153.213083;148.147000;148.114083;149.098542;150.540208;148.279417 +random,65536,524288,1,none,ref,0,32,12.526334,7,13.445667;13.420208;13.369916;11.817458;12.019041;12.369042;12.526334 +clos,800,320000,64,none,ref,0,2,108.296291,7,108.092833;107.961917;109.981458;111.156125;108.413750;108.062584;108.296291 +chain,2048,2047,1,none,ref,0,32,0.026500,7,0.025875;0.025958;0.026334;0.026500;0.028834;0.029125;0.028750 +random,16384,131072,64,near,ref,0,32,0.405958,7,0.401833;0.409375;0.393375;0.421708;0.394041;0.405958;0.422333 +random,65536,524288,64,near,ref,0,32,1.511792,7,1.504083;1.570667;1.511792;1.529917;1.506250;1.516625;1.490667 +random,262144,2097152,16,near,ref,0,32,1.465083,7,1.449375;1.456542;1.473583;1.465083;1.481459;1.499750;1.449583 +random,262144,2097152,1,none,ref,0,32,91.408000,7,89.104458;92.220833;93.374208;92.226583;89.439250;91.408000;88.846250 +random,4096,32768,64,reverse,ref,0,32,34.143500,7,34.236500;34.307791;34.026709;33.957084;34.143500;34.224667;33.820292 +clos,800,320000,16,reverse,ref,0,2,37.667583,7,37.375000;38.367917;38.190458;37.667583;37.762083;36.788125;36.170958 diff --git a/dev/perf/spf_queue/results/priority/b-new-capacity.csv b/dev/perf/spf_queue/results/priority/b-new-capacity.csv new file mode 100644 index 0000000..9682476 --- /dev/null +++ b/dev/perf/spf_queue/results/priority/b-new-capacity.csv @@ -0,0 +1,11 @@ +case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms +random,16384,131072,64,none,ref,0,32,151.414958,7,151.624500;150.795833;156.696459;149.771292;149.438541;152.103750;151.414958 +random,65536,524288,1,none,ref,0,32,13.349125,7,12.904959;14.485500;14.418417;13.398958;13.349125;13.241584;12.693167 +clos,800,320000,64,none,ref,0,2,55.644000,7,56.308125;56.209333;55.583083;55.285958;55.671083;55.644000;55.416084 +chain,2048,2047,1,none,ref,0,32,0.026625,7,0.026791;0.026125;0.026625;0.026375;0.026666;0.026167;0.026667 +random,16384,131072,64,near,ref,0,32,0.394917,7,0.414792;0.393667;0.399500;0.397209;0.394000;0.394292;0.394917 +random,65536,524288,64,near,ref,0,32,1.507209,7,1.512250;1.507209;1.497959;1.404750;1.510208;1.500500;1.523250 +random,262144,2097152,16,near,ref,0,32,1.476625,7,1.493833;1.472417;1.452833;1.515292;1.476625;1.449916;1.496917 +random,262144,2097152,1,none,ref,0,32,93.582083,7,93.134083;92.153500;99.965167;103.025542;94.254500;93.268208;93.582083 +random,4096,32768,64,reverse,ref,0,32,35.378500,7,36.071667;35.307708;35.378500;35.161625;35.456250;35.369500;35.790583 +clos,800,320000,16,reverse,ref,0,2,18.969208,7,19.031709;18.969208;18.642166;18.671083;18.642459;19.099584;19.787417 diff --git a/dev/perf/spf_queue/results/priority/b-new-headroom.csv b/dev/perf/spf_queue/results/priority/b-new-headroom.csv new file mode 100644 index 0000000..b7e3068 --- /dev/null +++ b/dev/perf/spf_queue/results/priority/b-new-headroom.csv @@ -0,0 +1,11 @@ +case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms +random,16384,131072,64,none,ref,0,32,146.796625,7,145.908792;146.796625;144.728250;144.656834;147.028583;150.304333;147.094459 +random,65536,524288,1,none,ref,0,32,12.153208,7,11.960625;12.153208;12.526166;11.373416;11.399250;12.413167;14.555958 +clos,800,320000,64,none,ref,0,2,53.643875,7,53.569250;53.643875;53.827125;53.712458;53.620333;53.635208;54.391542 +chain,2048,2047,1,none,ref,0,32,0.029000,7,0.028667;0.029000;0.028667;0.028833;0.031875;0.031667;0.029542 +random,16384,131072,64,near,ref,0,32,0.391958,7,0.384208;0.380708;0.409042;0.391958;0.389500;0.399166;0.399667 +random,65536,524288,64,near,ref,0,32,1.518458,7,1.518458;1.544708;1.665042;1.463750;1.537667;1.509084;1.510625 +random,262144,2097152,16,near,ref,0,32,1.469083,7,1.502083;1.467167;1.469083;1.480875;1.467584;1.508083;1.463042 +random,262144,2097152,1,none,ref,0,32,74.176667,7,74.176083;74.533000;74.875209;75.312667;74.176667;73.903292;73.634333 +random,4096,32768,64,reverse,ref,0,32,34.583708,7,34.863042;35.064666;34.825333;34.549792;34.583708;34.488125;34.552875 +clos,800,320000,16,reverse,ref,0,2,17.343000,7,17.329250;17.511542;17.439958;17.322833;17.687459;17.190833;17.343000 diff --git a/dev/perf/spf_queue/results/priority/b-new-residual-single.csv b/dev/perf/spf_queue/results/priority/b-new-residual-single.csv new file mode 100644 index 0000000..513f8c9 --- /dev/null +++ b/dev/perf/spf_queue/results/priority/b-new-residual-single.csv @@ -0,0 +1,11 @@ +case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms +random,16384,131072,64,none,ref,0,32,147.487417,7,147.317250;147.268583;147.801167;147.176166;147.487417;148.155750;149.413417 +random,65536,524288,1,none,ref,0,32,11.326166,7,11.819458;12.234750;11.800084;10.985958;11.052417;11.079250;11.326166 +clos,800,320000,64,none,ref,0,2,54.487083,7,54.487083;54.215458;54.391500;55.709875;55.749041;54.909458;53.593959 +chain,2048,2047,1,none,ref,0,32,0.027667,7,0.027459;0.027458;0.027375;0.030750;0.032917;0.027667;0.042333 +random,16384,131072,64,near,ref,0,32,0.393666,7,0.398708;0.394875;0.418917;0.389875;0.393666;0.389583;0.388291 +random,65536,524288,64,near,ref,0,32,1.546584,7,1.612292;1.560833;1.556750;1.543833;1.539750;1.469000;1.546584 +random,262144,2097152,16,near,ref,0,32,1.451500,7,1.404000;1.479292;1.451500;1.453292;1.521333;1.432792;1.447250 +random,262144,2097152,1,none,ref,0,32,90.980209,7,91.145958;90.980209;89.013833;89.918084;94.619875;92.344875;90.443500 +random,4096,32768,64,reverse,ref,0,32,34.809083,7,35.012083;34.426667;34.760625;34.992375;34.809083;35.228750;34.739417 +clos,800,320000,16,reverse,ref,0,2,16.962417,7,16.856250;16.830958;16.741042;16.962417;16.981292;17.517209;17.196833 diff --git a/dev/perf/spf_queue/results/priority/b-new.csv b/dev/perf/spf_queue/results/priority/b-new.csv new file mode 100644 index 0000000..e565dcc --- /dev/null +++ b/dev/perf/spf_queue/results/priority/b-new.csv @@ -0,0 +1,11 @@ +case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms +random,16384,131072,64,none,ref,0,32,144.942292,7,144.519542;145.649625;143.703583;144.353833;146.777459;145.086292;144.942292 +random,65536,524288,1,none,ref,0,32,11.476084,7,11.089792;11.476084;12.379958;12.141416;11.163583;11.282458;12.805625 +clos,800,320000,64,none,ref,0,2,103.054334,7,102.478542;103.054334;103.844792;102.837041;102.534541;104.274334;103.562792 +chain,2048,2047,1,none,ref,0,32,0.026333,7,0.026208;0.026208;0.026333;0.026917;0.030417;0.026208;0.026375 +random,16384,131072,64,near,ref,0,32,0.401375,7,0.409125;0.369000;0.401375;0.388167;0.393292;0.423291;0.404333 +random,65536,524288,64,near,ref,0,32,1.526208,7,1.508333;1.530334;1.526208;1.530458;1.518584;1.551291;1.520959 +random,262144,2097152,16,near,ref,0,32,1.478416,7,1.457625;1.478416;1.459708;1.477666;1.564958;1.481375;1.547083 +random,262144,2097152,1,none,ref,0,32,88.424125,7,86.749000;91.559250;92.226208;89.160375;88.424125;86.360209;87.833709 +random,4096,32768,64,reverse,ref,0,32,34.549417,7,35.338417;35.121542;34.662875;34.200791;34.330667;34.455125;34.549417 +clos,800,320000,16,reverse,ref,0,2,38.633208,7,37.534500;38.848417;39.507667;39.273625;38.633208;36.195500;37.918709 diff --git a/dev/perf/spf_queue/results/priority/environment.txt b/dev/perf/spf_queue/results/priority/environment.txt new file mode 100644 index 0000000..441cf03 --- /dev/null +++ b/dev/perf/spf_queue/results/priority/environment.txt @@ -0,0 +1,6 @@ +date: 2026-10-02T17:38:30Z +old: d5717cc (0.11.0) via build.sh old d5717cc; new: working tree with EdgeSelection::priority +phases: a1-old, b-new (default priority), b-new-residual-single (single-path, PreferHigherResidual, priority=Residual), b-new-capacity, b-new-headroom, a2-old; short mode, 7 samples +382f443bfde62bc56871d5335af9dc23ddc307a838b0624368217127c78fa499 src/shortest_paths.cpp +5499dbbdb66f7f63ad6c97f5718d0205c4f2f05992b60f4822a2ddcd5461f5d3 build/perf/cpu-queue +7de733ce4ec111c3414e2b262b6063a253b5cd790e4beceab893d802f0c5bf6a build/perf/cpu-queue-old diff --git a/dev/perf/spf_queue/results/priority/load.txt b/dev/perf/spf_queue/results/priority/load.txt new file mode 100644 index 0000000..6c26122 --- /dev/null +++ b/dev/perf/spf_queue/results/priority/load.txt @@ -0,0 +1,5 @@ +18:36 up 52 days, 23:12, 3 users, load averages: 13.67 8.95 6.69 +18:36 up 52 days, 23:12, 3 users, load averages: 13.05 8.90 6.69 +18:36 up 52 days, 23:12, 3 users, load averages: 12.09 8.77 6.65 +18:37 up 52 days, 23:12, 3 users, load averages: 10.86 8.61 6.62 +18:37 up 52 days, 23:12, 3 users, load averages: 10.07 8.49 6.59 diff --git a/include/netgraph/core/algorithms.hpp b/include/netgraph/core/algorithms.hpp index 0f22bfd..c4dc1cc 100644 --- a/include/netgraph/core/algorithms.hpp +++ b/include/netgraph/core/algorithms.hpp @@ -21,6 +21,11 @@ class Algorithms { return backend_->spf(gh, src, opts); } + [[nodiscard]] std::pair + spf_dag(const GraphHandle& gh, NodeId src, NodeId dst, const SpfOptions& opts) const { + return backend_->spf_dag(gh, src, dst, opts); + } + [[nodiscard]] std::pair, PredDAG> spf_to(const GraphHandle& gh, NodeId dst, const SpfToOptions& opts) const { return backend_->spf_to(gh, dst, opts); diff --git a/include/netgraph/core/backend.hpp b/include/netgraph/core/backend.hpp index 724de2b..67098b3 100644 --- a/include/netgraph/core/backend.hpp +++ b/include/netgraph/core/backend.hpp @@ -6,6 +6,7 @@ */ #pragma once +#include #include #include #include @@ -55,6 +56,22 @@ class Backend { [[nodiscard]] virtual std::pair, PredDAG> spf( const GraphHandle& gh, NodeId src, const SpfOptions& opts) = 0; + // Destination-limited search returning dst's cost and the predecessor DAG + // without the N-sized distance vector; see shortest_paths_dag() in + // shortest_paths.hpp. opts.dst is ignored (dst is the argument). The default + // implementation derives the result from spf(), so backends that do not + // specialise it stay correct. + [[nodiscard]] virtual std::pair spf_dag( + const GraphHandle& gh, NodeId src, NodeId dst, const SpfOptions& opts) { + SpfOptions o = opts; + o.dst = dst; + auto res = spf(gh, src, o); + const Cost cost = (dst >= 0 && static_cast(dst) < res.first.size()) + ? res.first[static_cast(dst)] + : std::numeric_limits::max(); + return {cost, std::move(res.second)}; + } + // Computes shortest paths from every node to dst (reverse SPF); see // shortest_paths_to() in shortest_paths.hpp. Returns distances to dst and a // forward-oriented predecessor DAG valid for placement from any node toward dst. diff --git a/include/netgraph/core/flow_graph.hpp b/include/netgraph/core/flow_graph.hpp index 8687d36..1e90fd6 100644 --- a/include/netgraph/core/flow_graph.hpp +++ b/include/netgraph/core/flow_graph.hpp @@ -61,6 +61,8 @@ class FlowGraph { // Inspect: return a copy of the flow's edges and amounts. [[nodiscard]] std::vector> get_flow_edges(const FlowIndex& idx) const; + // Whether the ledger holds this flow index (placed and not removed). + [[nodiscard]] bool has_flow(const FlowIndex& idx) const noexcept { return ledger_.find(idx) != ledger_.end(); } // Monotonic stamp identifying the exact residual state of this FlowGraph. // uid is process-unique per instance (never reused across FlowGraph diff --git a/include/netgraph/core/flow_policy.hpp b/include/netgraph/core/flow_policy.hpp index ccc8384..c968178 100644 --- a/include/netgraph/core/flow_policy.hpp +++ b/include/netgraph/core/flow_policy.hpp @@ -181,6 +181,9 @@ class FlowPolicy { [[nodiscard]] FlowRecord* create_flow(FlowGraph& fg, NodeId src, NodeId dst, FlowClass flowClass, std::optional min_flow); [[nodiscard]] FlowRecord* reoptimize_flow(FlowGraph& fg, const FlowIndex& idx, double headroom); + // Next fresh index for this policy; throws if the FlowGraph already holds it + // (another policy managing the same (src, dst, flowClass) on this FlowGraph). + [[nodiscard]] FlowIndex next_flow_index(const FlowGraph& fg, NodeId src, NodeId dst, FlowClass flowClass); // Config ExecutionContext ctx_; diff --git a/include/netgraph/core/shortest_paths.hpp b/include/netgraph/core/shortest_paths.hpp index 30bcd9d..5c86a93 100644 --- a/include/netgraph/core/shortest_paths.hpp +++ b/include/netgraph/core/shortest_paths.hpp @@ -71,6 +71,21 @@ shortest_paths(const StrictMultiDiGraph& g, NodeId src, std::span edge_mask = {}, SpfQueue queue = SpfQueue::Auto); +// Destination-limited search that returns only what placement needs: the cost +// of dst (INT64_MAX when unreachable, or when src/dst is invalid or masked) and +// the predecessor DAG. Semantics and the DAG are exactly those of +// shortest_paths(g, src, dst, ...); only the N-sized distance vector is not +// materialised (the DAG is still indexed by node id). Used by max-flow, +// FlowPolicy and KSP, which inspect no other distance. +[[nodiscard]] std::pair +shortest_paths_dag(const StrictMultiDiGraph& g, NodeId src, NodeId dst, + bool multipath, + const EdgeSelection& selection, + std::span residual = {}, + std::span node_mask = {}, + std::span edge_mask = {}, + SpfQueue queue = SpfQueue::Auto); + // Build the SPF-compatible (distances, PredDAG) pair for one concrete path given // as node/edge sequences (nodes[i] -> nodes[i+1] via edges[i]; so nodes.size() == // edges.size() + 1, or a single node with no edges). Distances are cumulative edge @@ -92,6 +107,17 @@ path_to_pred_dag(const StrictMultiDiGraph& g, [[nodiscard]] PredDAG make_path_dag(const StrictMultiDiGraph& g, std::span edges); +// Build a PredDAG for one explicit route whose hops may each use several +// parallel edges: hops[i] lists the edges of hop i, all joining the same +// (u, v) pair, and hop i's head is hop i+1's tail. Every listed edge becomes a +// parent entry of its hop's head node, in the given order, so placement splits +// across the bundle exactly as it does on an SPF multipath DAG. make_path_dag +// is the single-edge-per-hop case. Throws std::invalid_argument on an empty +// route or hop, an id out of range, an edge repeated within a hop, hop edges +// that are not parallel, non-contiguous hops, or a repeated node. +[[nodiscard]] PredDAG make_hops_dag(const StrictMultiDiGraph& g, + std::span> hops); + // Enumerate concrete paths represented by a PredDAG from src to dst. // Each path is returned as a sequence of (node_id, (edge_ids...)) pairs ending with (dst, ()). // When split_parallel_edges=false, parallel edges per hop are grouped in the tuple. diff --git a/include/netgraph/core/types.hpp b/include/netgraph/core/types.hpp index 9835920..10c5236 100644 --- a/include/netgraph/core/types.hpp +++ b/include/netgraph/core/types.hpp @@ -78,7 +78,17 @@ enum class FlowPlacement { // Tie-breaking rule when multiple equal-cost edges exist between the same (u,v) pair. enum class EdgeTieBreak { Deterministic = 1, // Use edge order from graph construction (reproducible) - PreferHigherResidual = 2 // Prefer edge with more available capacity + PreferHigherResidual = 2 // Prefer the edge with the higher priority value (EdgePriority) +}; + +// Per-edge value that ranks equal-cost alternatives: the PreferHigherResidual +// edge tie-break and the node-level bottleneck ordering (equal-cost nodes +// settle in descending order of the smallest value along their path). It does +// not affect which edges are eligible: capacity gating always uses the residual. +enum class EdgePriority { + Residual = 1, // residual capacity when a residual is supplied, otherwise capacity (default) + Capacity = 2, // static capacity, even when a residual is supplied + Headroom = 3 // residual / capacity: the fraction of the edge still free (1 without a residual) }; // Edge selection policy for shortest path algorithms. @@ -89,6 +99,8 @@ struct EdgeSelection { // require_capacity: if true, only consider edges with residual capacity > kMinCap. bool require_capacity { false }; EdgeTieBreak tie_break { EdgeTieBreak::Deterministic }; + // priority: which per-edge value tie_break and the bottleneck ordering rank by. + EdgePriority priority { EdgePriority::Residual }; }; } // namespace netgraph::core diff --git a/pyproject.toml b/pyproject.toml index 82f3e35..22da3f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,8 @@ markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"] [tool.ruff] line-length = 88 indent-width = 4 +# Recorded reviewer artefacts are kept verbatim. +extend-exclude = ["dev/reviews"] [tool.ruff.lint] select = ["E4", "E7", "E9", "F", "B", "I"] diff --git a/python/netgraph_core/__init__.py b/python/netgraph_core/__init__.py index 3bb8a27..f586db7 100644 --- a/python/netgraph_core/__init__.py +++ b/python/netgraph_core/__init__.py @@ -13,6 +13,7 @@ from _netgraph_core import ( Algorithms, Backend, + EdgePriority, EdgeSelection, EdgeTieBreak, FlowGraph, @@ -43,6 +44,7 @@ from ._docs import ( # noqa: I001 Algorithms as Algorithms, Backend as Backend, + EdgePriority as EdgePriority, EdgeSelection as EdgeSelection, EdgeTieBreak as EdgeTieBreak, FlowGraph as FlowGraph, @@ -67,6 +69,7 @@ "StrictMultiDiGraph", "FlowGraph", "Graph", + "EdgePriority", "EdgeSelection", "EdgeTieBreak", "PathAlg", diff --git a/python/netgraph_core/_docs.py b/python/netgraph_core/_docs.py index 984b099..9eed25a 100644 --- a/python/netgraph_core/_docs.py +++ b/python/netgraph_core/_docs.py @@ -18,7 +18,12 @@ class EdgeTieBreak: - """Tie-break rule among equal-cost parallel edges (pybind11 enum).""" + """Tie-break rule among equal-cost parallel edges (pybind11 enum). + + PREFER_HIGHER_RESIDUAL ranks edges by the value EdgeSelection.priority + selects (the residual by default); DETERMINISTIC uses the edge order from + graph construction. + """ DETERMINISTIC: ClassVar[EdgeTieBreak] PREFER_HIGHER_RESIDUAL: ClassVar[EdgeTieBreak] @@ -32,12 +37,40 @@ def name(self) -> str: ... def value(self) -> int: ... +class EdgePriority: + """Per-edge value that ranks equal-cost alternatives (pybind11 enum). + + It drives the PREFER_HIGHER_RESIDUAL edge tie-break and the node-level + ordering (equal-cost nodes settle in descending order of the smallest value + along their path). It never changes which edges are eligible: capacity + gating always uses the residual. + + RESIDUAL: residual capacity when a residual is supplied, otherwise the + static capacity. The default and the historical behaviour. + CAPACITY: the static capacity even when a residual is supplied. + HEADROOM: residual / capacity, the fraction of the edge still free + (1 for every edge when no residual is supplied, 0 for zero capacity). + """ + + RESIDUAL: ClassVar[EdgePriority] + CAPACITY: ClassVar[EdgePriority] + HEADROOM: ClassVar[EdgePriority] + __members__: ClassVar[dict[str, EdgePriority]] + + def __init__(self, value: int) -> None: ... + + class EdgeSelection: - """Edge selection policy. The constructor is keyword-only.""" + """Edge selection policy. The constructor is keyword-only. + + priority selects the per-edge value that tie_break and the bottleneck + ordering rank by (see EdgePriority); the default keeps today's behaviour. + """ multi_edge: bool require_capacity: bool tie_break: EdgeTieBreak + priority: EdgePriority def __init__( self, @@ -45,6 +78,7 @@ def __init__( multi_edge: bool = True, require_capacity: bool = False, tie_break: EdgeTieBreak = ..., + priority: EdgePriority = ..., ) -> None: ... @@ -93,6 +127,26 @@ class PredDAG: parents: np.ndarray via_edges: np.ndarray + @staticmethod + def from_hops( + graph: "StrictMultiDiGraph", hops: "Sequence[Sequence[int]]" + ) -> "PredDAG": + """Build a PredDAG for one explicit route with parallel edges per hop. + + hops[i] lists the edge ids of hop i, which must all join the same node + pair; consecutive hops must connect and the walk must be a simple + path. Every listed edge becomes a parent entry of its hop's head node, + so placement splits across the bundle exactly as on an SPF multipath + DAG (a 4 x 10 bundle carries 40, where from_edges with one edge + carries 10). from_edges is the one-edge-per-hop case. + + Raises: + ValueError: If the route or a hop is empty, an id is out of range, + an edge is repeated within a hop, a hop's edges are not + parallel, hops do not connect, or the walk revisits a node. + """ + ... + @staticmethod def from_edges(graph: "StrictMultiDiGraph", edges: "Sequence[int]") -> "PredDAG": """Build a single-path PredDAG from a contiguous edge-id sequence. @@ -381,6 +435,21 @@ def from_arrays( def num_nodes(self) -> int: ... def num_edges(self) -> int: ... + def max_cost(self) -> int: + """Largest edge cost (0 for a graph without edges). + + Shortest-path searches use a bucket queue when + ``max_cost() // cost_gcd() + 1 <= 65536`` and a binary heap otherwise. + Cost scales that exceed the limit (for example latencies in nanoseconds) + can be divided by a common factor before building the graph to stay on + the faster route; results are identical either way. + """ + ... + + def cost_gcd(self) -> int: + """Greatest common divisor of all edge costs (0 when every cost is 0).""" + ... + def capacity_view(self) -> "np.ndarray": """Copy of per-edge capacities, float64[E].""" ... diff --git a/src/cpu_backend.cpp b/src/cpu_backend.cpp index b173d7d..fd35c66 100644 --- a/src/cpu_backend.cpp +++ b/src/cpu_backend.cpp @@ -37,6 +37,13 @@ class CpuBackend final : public Backend { opts.residual, opts.node_mask, opts.edge_mask); } + std::pair spf_dag( + const GraphHandle& gh, NodeId src, NodeId dst, const SpfOptions& opts) override { + const StrictMultiDiGraph& g = *gh.graph; + return netgraph::core::shortest_paths_dag(g, src, dst, opts.multipath, opts.selection, + opts.residual, opts.node_mask, opts.edge_mask); + } + std::pair, PredDAG> spf_to( const GraphHandle& gh, NodeId dst, const SpfToOptions& opts) override { const StrictMultiDiGraph& g = *gh.graph; diff --git a/src/flow_policy.cpp b/src/flow_policy.cpp index 001da5d..b6c0d83 100644 --- a/src/flow_policy.cpp +++ b/src/flow_policy.cpp @@ -179,9 +179,9 @@ std::optional> FlowPolicy::get_path_bundle(const FlowGr spf_memo_.begin() + hit_idx + 1); } } else { - auto res = ctx_.algorithms->spf(ctx_.graph, src, opts); + auto res = ctx_.algorithms->spf_dag(ctx_.graph, src, dst, opts); dag = std::move(res.second); - dst_cost = res.first[static_cast(dst)]; + dst_cost = res.first; if (use_memo) { SpfMemoEntry entry; entry.src = src; @@ -238,12 +238,22 @@ std::optional> FlowPolicy::get_path_bundle(const FlowGr return std::make_optional(std::make_pair(std::move(dag), dst_cost)); } +FlowIndex FlowPolicy::next_flow_index(const FlowGraph& fg, NodeId src, NodeId dst, FlowClass flowClass) { + FlowIndex idx{src, dst, flowClass, next_flow_id_++}; + if (fg.has_flow(idx)) { + throw std::invalid_argument( + "FlowPolicy: flow id " + std::to_string(idx.flowId) + " for (" + std::to_string(src) + ", " + + std::to_string(dst) + ", class " + std::to_string(flowClass) + ") already exists in the FlowGraph; " + "another policy manages the same demand on this FlowGraph"); + } + return idx; +} + /* Create a new flow using the current path bundle. Returns nullptr if no admissible path is available given constraints. */ FlowRecord* FlowPolicy::create_flow(FlowGraph& fg, NodeId src, NodeId dst, FlowClass flowClass, std::optional min_flow) { - // Generate a unique flow index. - FlowIndex idx{src, dst, flowClass, next_flow_id_++}; + FlowIndex idx = next_flow_index(fg, src, dst, flowClass); // Request a path DAG from shortest paths algorithm. auto pb = get_path_bundle(fg, src, dst, min_flow); @@ -419,7 +429,7 @@ std::pair FlowPolicy::place_demand_body(FlowGraph& fg, "Source and destination nodes of static paths do not match demand."); } for (auto const& b : static_bundles_) { - FlowIndex idx{src, dst, flowClass, next_flow_id_++}; + FlowIndex idx = next_flow_index(fg, src, dst, flowClass); flows_.emplace(idx, FlowRecord(idx, src, dst, b.dag, b.cost)); } } else { @@ -437,7 +447,7 @@ std::pair FlowPolicy::place_demand_body(FlowGraph& fg, if (initial > 0) { if (auto pb = get_path_bundle(fg, src, dst, min_req)) { for (int i = 0; i < initial; ++i) { - FlowIndex idx{src, dst, flowClass, next_flow_id_++}; + FlowIndex idx = next_flow_index(fg, src, dst, flowClass); FlowRecord f(idx, src, dst, pb->first, pb->second); flows_.emplace(idx, std::move(f)); } diff --git a/src/flow_state.cpp b/src/flow_state.cpp index 3262a1b..b95caff 100644 --- a/src/flow_state.cpp +++ b/src/flow_state.cpp @@ -537,7 +537,7 @@ Flow FlowState::place_max_flow(NodeId src, NodeId dst, FlowPlacement placement, Flow total = static_cast(0.0); // With require_capacity=false the search ignores residuals, so its inputs // are identical on every pass: compute it once and reuse the result. - std::pair, PredDAG> path_result; + std::pair path_result; bool have_path_result = false; while (true) { EdgeSelection sel; @@ -545,12 +545,12 @@ Flow FlowState::place_max_flow(NodeId src, NodeId dst, FlowPlacement placement, sel.require_capacity = require_capacity; sel.tie_break = EdgeTieBreak::Deterministic; if (require_capacity || !have_path_result) { - path_result = shortest_paths(*g_, src, dst, /*multipath=*/true, sel, - require_capacity ? residual_ : std::span{}, - node_mask, edge_mask); + path_result = shortest_paths_dag(*g_, src, dst, /*multipath=*/true, sel, + require_capacity ? residual_ : std::span{}, + node_mask, edge_mask); have_path_result = true; } - const auto& [dist, dag] = path_result; + const PredDAG& dag = path_result.second; if (static_cast(dst) >= dag.parent_offsets.size()-1 || dag.parent_offsets[static_cast(dst)] == dag.parent_offsets[static_cast(dst)+1]) { break; } diff --git a/src/k_shortest_paths.cpp b/src/k_shortest_paths.cpp index ce410e6..8146a03 100644 --- a/src/k_shortest_paths.cpp +++ b/src/k_shortest_paths.cpp @@ -280,15 +280,15 @@ std::vector, PredDAG>> k_shortest_paths( em = std::span(em_buf2.get(), static_cast(g.num_edges())); } EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = false; sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist_spur, dag_spur] = shortest_paths(g, spur_node, dst, /*multipath=*/true, sel, std::span(), nm, em); + auto [spur_cost, dag_spur] = shortest_paths_dag(g, spur_node, dst, /*multipath=*/true, sel, std::span(), nm, em); // If no spur parents for t, skip if (static_cast(dst + 1) >= dag_spur.parent_offsets.size() || dag_spur.parent_offsets[static_cast(dst)] == dag_spur.parent_offsets[static_cast(dst + 1)]) { continue; } - // Every spur path in the DAG costs dist_spur[dst]; skip the whole family - // if the resulting candidates would exceed the cost ceiling. - if (static_cast(prefix_cost[j]) + static_cast(dist_spur[static_cast(dst)]) > max_cost) { + // Every spur path in the DAG costs spur_cost; skip the whole family if + // the resulting candidates would exceed the cost ceiling. + if (static_cast(prefix_cost[j]) + static_cast(spur_cost) > max_cost) { continue; } // At most (k - accepted) further paths can ever be accepted, so that many diff --git a/src/max_flow.cpp b/src/max_flow.cpp index 91ee426..10eef37 100644 --- a/src/max_flow.cpp +++ b/src/max_flow.cpp @@ -122,7 +122,7 @@ calc_max_flow(const StrictMultiDiGraph& g, NodeId src, NodeId dst, // require_capacity=false: Routes based on costs only, ignore capacity (IP/IGP behavior) // With require_capacity=false the search ignores residuals, so its inputs // are identical on every tier: compute it once and reuse the result. - std::pair, PredDAG> path_result; + std::pair path_result; bool have_path_result = false; while (true) { EdgeSelection sel; @@ -130,7 +130,7 @@ calc_max_flow(const StrictMultiDiGraph& g, NodeId src, NodeId dst, sel.require_capacity = require_capacity; sel.tie_break = EdgeTieBreak::Deterministic; if (require_capacity || !have_path_result) { - path_result = shortest_paths( + path_result = shortest_paths_dag( g, src, dst, /*multipath=*/true, sel, @@ -139,7 +139,7 @@ calc_max_flow(const StrictMultiDiGraph& g, NodeId src, NodeId dst, use_edge_mask ? edge_mask : std::span{}); have_path_result = true; } - const auto& [dist, dag] = path_result; + const PredDAG& dag = path_result.second; // No path if t has no parents in DAG if (static_cast(dst) >= dag.parent_offsets.size() - 1 || @@ -147,7 +147,7 @@ calc_max_flow(const StrictMultiDiGraph& g, NodeId src, NodeId dst, break; } - Cost path_cost = dist[static_cast(dst)]; + Cost path_cost = path_result.first; Flow placed = fs.place_on_dag(src, dst, dag, std::numeric_limits::infinity(), placement); if (placed < kMinFlow) break; diff --git a/src/shortest_paths.cpp b/src/shortest_paths.cpp index 16a40e5..080074e 100644 --- a/src/shortest_paths.cpp +++ b/src/shortest_paths.cpp @@ -69,38 +69,80 @@ path_to_pred_dag(const StrictMultiDiGraph& g, return {std::move(dist), std::move(dag)}; } -PredDAG make_path_dag(const StrictMultiDiGraph& g, std::span edges) { - if (edges.empty()) { - throw std::invalid_argument("make_path_dag: edges must be non-empty"); - } +namespace { + +// Shared builder for explicit routes (make_path_dag, make_hops_dag): validates +// the hop sequence and lays the entries out in PredDAG form. +PredDAG build_route_dag(const char* what, const StrictMultiDiGraph& g, + std::span> hops) { + const std::string who(what); + if (hops.empty()) throw std::invalid_argument(who + ": route must be non-empty"); + const auto N = g.num_nodes(); const auto E = static_cast(g.num_edges()); const auto esrc = g.edge_src_view(); const auto edst = g.edge_dst_view(); - std::vector nodes; - nodes.reserve(edges.size() + 1); - std::vector seen(static_cast(g.num_nodes()), 0); - for (std::size_t i = 0; i < edges.size(); ++i) { - const auto e = edges[i]; - if (e < 0 || static_cast(e) >= E) { - throw std::invalid_argument("make_path_dag: edge id out of range"); + std::vector seen(static_cast(N), 0); + PredDAG dag; + dag.parent_offsets.assign(static_cast(N) + 1, 0); + NodeId prev = -1; + for (std::size_t i = 0; i < hops.size(); ++i) { + const auto& hop = hops[i]; + if (hop.empty()) throw std::invalid_argument(who + ": hop " + std::to_string(i) + " is empty"); + NodeId hu = -1, hv = -1; + for (std::size_t k = 0; k < hop.size(); ++k) { + const auto e = hop[k]; + if (e < 0 || static_cast(e) >= E) { + throw std::invalid_argument(who + ": edge id out of range"); + } + for (std::size_t m = 0; m < k; ++m) { + if (hop[m] == e) throw std::invalid_argument(who + ": edge " + std::to_string(e) + " repeated in hop " + std::to_string(i)); + } + const auto u = esrc[static_cast(e)]; + const auto v = edst[static_cast(e)]; + if (k == 0) { hu = u; hv = v; } + else if (u != hu || v != hv) { + throw std::invalid_argument(who + ": edges of hop " + std::to_string(i) + " are not parallel (they must join the same node pair)"); + } } - const auto u = esrc[static_cast(e)]; - const auto v = edst[static_cast(e)]; if (i == 0) { - nodes.push_back(u); - seen[static_cast(u)] = 1; - } else if (u != nodes.back()) { + seen[static_cast(hu)] = 1; + } else if (hu != prev) { throw std::invalid_argument( - "make_path_dag: edges are not contiguous (edge source does not match the " - "previous edge's destination)"); + who + ": hops are not contiguous (edge source does not match the previous hop's destination)"); } - if (seen[static_cast(v)]) { - throw std::invalid_argument("make_path_dag: path revisits a node (must be a simple path)"); + if (seen[static_cast(hv)]) { + throw std::invalid_argument(who + ": route revisits a node (must be a simple path)"); } - seen[static_cast(v)] = 1; - nodes.push_back(v); + seen[static_cast(hv)] = 1; + prev = hv; + dag.parent_offsets[static_cast(hv) + 1] = static_cast(hop.size()); } - return path_to_pred_dag(g, nodes, edges).second; + for (std::size_t v = 1; v < dag.parent_offsets.size(); ++v) dag.parent_offsets[v] += dag.parent_offsets[v - 1]; + dag.parents.resize(static_cast(dag.parent_offsets.back())); + dag.via_edges.resize(static_cast(dag.parent_offsets.back())); + for (const auto& hop : hops) { + const auto hu = esrc[static_cast(hop[0])]; + const auto hv = edst[static_cast(hop[0])]; + auto base = static_cast(dag.parent_offsets[static_cast(hv)]); + for (std::size_t k = 0; k < hop.size(); ++k) { + dag.parents[base + k] = hu; + dag.via_edges[base + k] = hop[k]; + } + } + return dag; +} + +} // namespace + +PredDAG make_path_dag(const StrictMultiDiGraph& g, std::span edges) { + std::vector> hops; + hops.reserve(edges.size()); + for (auto e : edges) hops.push_back({e}); + return build_route_dag("make_path_dag", g, hops); +} + +PredDAG make_hops_dag(const StrictMultiDiGraph& g, std::span> hops) { + return build_route_dag("make_hops_dag", g, hops); } std::vector>>> @@ -413,6 +455,21 @@ struct BucketQueue { struct BucketPlan { bool eligible = false; std::size_t width = 0; Cost stride = 1; }; +// The value equal-cost alternatives are ranked by (EdgeSelection::priority). +// `rem` is the residual when one was supplied, otherwise the capacity; the +// default instantiation returns it untouched and never reads `cap`. +template +inline Cap priority_of(Cap rem, std::span cap, std::size_t e) { + if constexpr (P == EdgePriority::Residual) { + (void)cap; (void)e; return rem; + } else if constexpr (P == EdgePriority::Capacity) { + return cap[e]; + } else { + const Cap c = cap[e]; + return c > static_cast(0) ? rem / c : static_cast(0); + } +} + BucketPlan plan_buckets(const StrictMultiDiGraph& g) { BucketPlan p; p.stride = g.cost_gcd() > 0 ? g.cost_gcd() : 1; @@ -484,6 +541,19 @@ struct ForwardWorkspace { HeapQueue heap; BucketQueue bucket; + // Predecessor lists: pred_head/pred_tail index into the ent_* arrays, whose + // entries are (parent, via_edge) pairs appended in discovery order; pred_count + // is the live length, so the result is sized without walking the lists twice. + void clear_preds(std::size_t v) { pred_head[v] = -1; pred_tail[v] = -1; pred_count[v] = 0; } + void append_pred(std::size_t v, NodeId p, EdgeId e) { + const auto idx = static_cast(ent_parent.size()); + ent_parent.push_back(p); ent_edge.push_back(e); ent_next.push_back(-1); + if (pred_head[v] < 0) pred_head[v] = idx; + else ent_next[static_cast(pred_tail[v])] = idx; + pred_tail[v] = idx; + ++pred_count[v]; + } + void acquire(std::size_t n) { const bool shrank = ensure_sized(dist, n, kInf); ensure_sized(min_res, n, static_cast(0)); @@ -516,10 +586,8 @@ struct ForwardWorkspace { } }; -// Reverse search scratch; same invariant. Successor lists per tail node u are -// a flat intrusive list of (v, e) entries (succ_head/succ_tail into ent_*). -// has_in_entry[v]: some DAG entry u -> v exists; a fanout edge may not leave -// such a node. +// Reverse search scratch; same invariant. has_in_entry[v]: some DAG entry +// u -> v exists; a fanout edge may not leave such a node. struct ReverseWorkspace { std::vector dist; std::vector min_res; @@ -534,6 +602,16 @@ struct ReverseWorkspace { HeapQueue heap; BucketQueue bucket; + // Successor lists per tail node u: (v, e) entries in the ent_* arrays. + void clear_succs(std::size_t u) { succ_head[u] = -1; succ_tail[u] = -1; } + void append_succ(std::size_t u, NodeId v, EdgeId e) { + const auto idx = static_cast(ent_node.size()); + ent_node.push_back(v); ent_edge.push_back(e); ent_next.push_back(-1); + if (succ_head[u] < 0) succ_head[u] = idx; + else ent_next[static_cast(succ_tail[u])] = idx; + succ_tail[u] = idx; + } + void acquire(std::size_t n) { const bool shrank = ensure_sized(dist, n, kInf); ensure_sized(min_res, n, static_cast(0)); @@ -581,7 +659,7 @@ struct WorkspaceLease { // The search loop, shared by both queues. Precondition: src is allowed and // initialised in ws (dist 0, min_res max, touched). -template +template void run_forward(Queue& pq, ForwardWorkspace& ws, const StrictMultiDiGraph& g, NodeId src, std::optional dst, bool multipath, const EdgeSelection& selection, std::span residual, std::span node_mask, @@ -597,33 +675,13 @@ void run_forward(Queue& pq, ForwardWorkspace& ws, const StrictMultiDiGraph& g, N // bases around every store (the workspace is a thread-local object). Cost* const dist = ws.dist.data(); Cap* const min_residual_to_node = ws.min_res.data(); - std::int32_t* const pred_head = ws.pred_head.data(); - std::int32_t* const pred_tail = ws.pred_tail.data(); - std::int32_t* const pred_count = ws.pred_count.data(); char* const settled = ws.settled.data(); - auto& ent_parent = ws.ent_parent; - auto& ent_edge = ws.ent_edge; - auto& ent_next = ws.ent_next; const bool use_node_mask = (node_mask.size() == static_cast(N)); const bool use_edge_mask = (edge_mask.size() == static_cast(g.num_edges())); const bool has_residual = (residual.size() == static_cast(g.num_edges())); const bool require_cap = selection.require_capacity || has_residual; - // Predecessor storage as a flat intrusive list: pred_head/pred_tail index into - // the ent_* arrays, whose entries are (parent, via_edge) pairs appended in - // discovery order. pred_count tracks the live length of each list so the - // result can be sized without walking the lists twice. - auto pred_clear = [&](std::size_t v){ pred_head[v] = -1; pred_tail[v] = -1; pred_count[v] = 0; }; - auto pred_append = [&](std::size_t v, NodeId p, EdgeId e){ - const auto idx = static_cast(ent_parent.size()); - ent_parent.push_back(p); ent_edge.push_back(e); ent_next.push_back(-1); - if (pred_head[v] < 0) { pred_head[v] = idx; } - else { ent_next[static_cast(pred_tail[v])] = idx; } - pred_tail[v] = idx; - ++pred_count[v]; - }; - // Queue items are (cost, -residual, node): cost (minimize) -> residual // (maximize) -> node (deterministic). This naturally distributes flows across // equal-cost paths based on available capacity. @@ -679,6 +737,7 @@ void run_forward(Queue& pq, ForwardWorkspace& ws, const StrictMultiDiGraph& g, N if (use_edge_mask && !edge_mask[e]) continue; const Cap rem = has_residual ? residual[e] : cap[e]; if (require_cap && rem < kMinCap) continue; + const Cap pri = priority_of

(rem, cap, e); const Cost ecost = static_cast(cost[e]); if (ecost < min_edge_cost) { min_edge_cost = ecost; @@ -687,7 +746,7 @@ void run_forward(Queue& pq, ForwardWorkspace& ws, const StrictMultiDiGraph& g, N selected_edges.push_back(static_cast(aei[j])); } else { best_edge_id = static_cast(e); - best_rem_for_min_cost = static_cast(rem); + best_rem_for_min_cost = static_cast(pri); } } else if (ecost == min_edge_cost) { if (selection.multi_edge) { @@ -695,10 +754,10 @@ void run_forward(Queue& pq, ForwardWorkspace& ws, const StrictMultiDiGraph& g, N } else { // tie-break among equal-cost edges if (selection.tie_break == EdgeTieBreak::PreferHigherResidual) { - if (static_cast(rem) > best_rem_for_min_cost + kEpsilon) { + if (static_cast(pri) > best_rem_for_min_cost + kEpsilon) { best_edge_id = static_cast(e); - best_rem_for_min_cost = static_cast(rem); - } else if (std::abs(static_cast(rem) - best_rem_for_min_cost) <= kEpsilon) { + best_rem_for_min_cost = static_cast(pri); + } else if (std::abs(static_cast(pri) - best_rem_for_min_cost) <= kEpsilon) { // further tie-break deterministically by smaller edge id if (best_edge_id < 0 || static_cast(e) < best_edge_id) { best_edge_id = static_cast(e); @@ -720,12 +779,14 @@ void run_forward(Queue& pq, ForwardWorkspace& ws, const StrictMultiDiGraph& g, N if (!selected_edges.empty()) { Cost new_cost = static_cast(d_u + min_edge_cost); auto v_idx = static_cast(v); - // Bottleneck capacity along the path to v through u (node-level tie-breaking). + // Bottleneck along the path to v through u (node-level tie-breaking), in + // the selected priority value. Cap max_edge_residual = static_cast(0); for (auto edge_id : selected_edges) { - const Cap rem = has_residual ? residual[static_cast(edge_id)] - : cap[static_cast(edge_id)]; - if (rem > max_edge_residual) max_edge_residual = rem; + const auto idx = static_cast(edge_id); + const Cap rem = has_residual ? residual[idx] : cap[idx]; + const Cap pri = priority_of

(rem, cap, idx); + if (pri > max_edge_residual) max_edge_residual = pri; } Cap path_residual = std::min(min_residual_to_node[static_cast(u)], max_edge_residual); // Relaxation: shorter path, or equal-cost path with better capacity (single-path mode). @@ -735,15 +796,15 @@ void run_forward(Queue& pq, ForwardWorkspace& ws, const StrictMultiDiGraph& g, N if (dist[v_idx] == kInf) ws.touched.push_back(v); dist[v_idx] = new_cost; min_residual_to_node[v_idx] = path_residual; - pred_clear(v_idx); - for (auto sel_e : selected_edges) pred_append(v_idx, u, sel_e); + ws.clear_preds(v_idx); + for (auto sel_e : selected_edges) ws.append_pred(v_idx, u, sel_e); pq.push(new_cost, -path_residual, v); } // Multipath: equal-cost alternative (only while v is unsettled; see above). // min_residual_to_node is not updated here: all equal-cost paths are kept, // none is chosen by residual. else if (multipath && new_cost == dist[v_idx] && !settled[v_idx]) { - for (auto sel_e : selected_edges) pred_append(v_idx, u, sel_e); + for (auto sel_e : selected_edges) ws.append_pred(v_idx, u, sel_e); } } i = j; @@ -752,18 +813,29 @@ void run_forward(Queue& pq, ForwardWorkspace& ws, const StrictMultiDiGraph& g, N } } -// Materialise (distances, PredDAG) from the forward workspace. Per-node -// predecessor lists are independent, so visiting only touched nodes yields the -// same arrays as visiting every node. -std::pair, PredDAG> finish_forward(ForwardWorkspace& ws, std::int32_t N) { +// Runs the forward search on the chosen queue for one priority. +template +void forward_with(bool bucket, ForwardWorkspace& ws, const StrictMultiDiGraph& g, NodeId src, + std::optional dst, bool multipath, const EdgeSelection& selection, + std::span residual, std::span node_mask, + std::span edge_mask, const BucketPlan& plan) { + if (bucket) run_forward(ws.bucket, ws, g, src, dst, multipath, selection, residual, node_mask, edge_mask, plan); + else run_forward(ws.heap, ws, g, src, dst, multipath, selection, residual, node_mask, edge_mask, plan); +} + +// Materialise the distance vector from the forward workspace. +std::vector materialise_dist(const ForwardWorkspace& ws, std::int32_t N) { + if (!sparse_reset(ws)) return ws.dist; + std::vector dist(static_cast(N), kInf); + for (auto v : ws.touched) dist[static_cast(v)] = ws.dist[static_cast(v)]; + return dist; +} + +// Materialise the PredDAG from the forward workspace. Per-node predecessor +// lists are independent, so visiting only touched nodes yields the same arrays +// as visiting every node. +PredDAG materialise_dag(const ForwardWorkspace& ws, std::int32_t N) { const bool sparse = sparse_reset(ws); - std::vector dist; - if (sparse) { - dist.assign(static_cast(N), kInf); - for (auto v : ws.touched) dist[static_cast(v)] = ws.dist[static_cast(v)]; - } else { - dist = ws.dist; - } PredDAG dag; dag.parent_offsets.assign(static_cast(N+1), 0); if (sparse) { @@ -786,7 +858,40 @@ std::pair, PredDAG> finish_forward(ForwardWorkspace& ws, std:: }; if (sparse) { for (auto v : ws.touched) fill(v); } else { for (std::int32_t v = 0; v < N; ++v) fill(v); } - return {std::move(dist), std::move(dag)}; + return dag; +} + +PredDAG empty_dag(std::int32_t N) { + PredDAG dag; + dag.parent_offsets.assign(static_cast(N + 1), 0); + return dag; +} + +bool source_allowed(const StrictMultiDiGraph& g, NodeId src, std::span node_mask) { + const auto N = g.num_nodes(); + const bool use_node_mask = (node_mask.size() == static_cast(N)); + return src >= 0 && src < N && (!use_node_mask || node_mask[static_cast(src)]); +} + +// Runs the forward search into the acquired workspace. Precondition: the +// source is allowed (source_allowed) and the lease is held by the caller. +void search_forward(ForwardWorkspace& ws, const StrictMultiDiGraph& g, NodeId src, + std::optional dst, bool multipath, const EdgeSelection& selection, + std::span residual, std::span node_mask, + std::span edge_mask, SpfQueue queue) { + ws.touched.push_back(src); // before the writes: see WorkspaceLease + ws.dist[static_cast(src)] = static_cast(0); + ws.min_res[static_cast(src)] = std::numeric_limits::max(); + BucketPlan plan; + const bool bucket = use_bucket_queue(g, queue, plan); + switch (selection.priority) { + case EdgePriority::Capacity: + forward_with(bucket, ws, g, src, dst, multipath, selection, residual, node_mask, edge_mask, plan); break; + case EdgePriority::Headroom: + forward_with(bucket, ws, g, src, dst, multipath, selection, residual, node_mask, edge_mask, plan); break; + default: + forward_with(bucket, ws, g, src, dst, multipath, selection, residual, node_mask, edge_mask, plan); break; + } } std::pair, PredDAG> @@ -800,27 +905,47 @@ shortest_paths_core(const StrictMultiDiGraph& g, NodeId src, SpfQueue queue) { NGRAPH_PROFILE_SCOPE("shortest_paths_core"); const auto N = g.num_nodes(); - const bool use_node_mask = (node_mask.size() == static_cast(N)); - const bool src_allowed = (src >= 0 && src < N && (!use_node_mask || node_mask[static_cast(src)])); - if (!src_allowed) { + if (!source_allowed(g, src, node_mask)) { // Source is out of range or masked out: no traversal, return empty DAG. - PredDAG dag; - dag.parent_offsets.assign(static_cast(N + 1), 0); - return {std::vector(static_cast(N), kInf), std::move(dag)}; + return {std::vector(static_cast(N), kInf), empty_dag(N)}; } ForwardWorkspace& ws = tls_forward; ws.acquire(static_cast(N)); WorkspaceLease lease{ws}; - ws.touched.push_back(src); // before the writes: see WorkspaceLease - ws.dist[static_cast(src)] = static_cast(0); - ws.min_res[static_cast(src)] = std::numeric_limits::max(); - BucketPlan plan; - if (use_bucket_queue(g, queue, plan)) { - run_forward(ws.bucket, ws, g, src, dst, multipath, selection, residual, node_mask, edge_mask, plan); - } else { - run_forward(ws.heap, ws, g, src, dst, multipath, selection, residual, node_mask, edge_mask, plan); + search_forward(ws, g, src, dst, multipath, selection, residual, node_mask, edge_mask, queue); + return {materialise_dist(ws, N), materialise_dag(ws, N)}; +} + +std::pair +shortest_paths_dag_core(const StrictMultiDiGraph& g, NodeId src, NodeId dst, + bool multipath, + const EdgeSelection& selection, + std::span residual, + std::span node_mask, + std::span edge_mask, + SpfQueue queue) { + NGRAPH_PROFILE_SCOPE("shortest_paths_core"); + const auto N = g.num_nodes(); + if (!source_allowed(g, src, node_mask)) return {kInf, empty_dag(N)}; + ForwardWorkspace& ws = tls_forward; + ws.acquire(static_cast(N)); + WorkspaceLease lease{ws}; + search_forward(ws, g, src, dst, multipath, selection, residual, node_mask, edge_mask, queue); + const Cost cost = (dst >= 0 && dst < N) ? ws.dist[static_cast(dst)] : kInf; + return {cost, materialise_dag(ws, N)}; +} + +void validate_spans(const char* what, const StrictMultiDiGraph& g, std::span residual, + std::span node_mask, std::span edge_mask) { + if (!node_mask.empty() && node_mask.size() != static_cast(g.num_nodes())) { + throw std::invalid_argument(std::string(what) + ": node_mask length mismatch"); + } + if (!edge_mask.empty() && edge_mask.size() != static_cast(g.num_edges())) { + throw std::invalid_argument(std::string(what) + ": edge_mask length mismatch"); + } + if (!residual.empty() && residual.size() != static_cast(g.num_edges())) { + throw std::invalid_argument(std::string(what) + ": residual length mismatch"); } - return finish_forward(ws, N); } } // namespace @@ -834,18 +959,22 @@ shortest_paths(const StrictMultiDiGraph& g, NodeId src, std::span node_mask, std::span edge_mask, SpfQueue queue) { - if (!node_mask.empty() && node_mask.size() != static_cast(g.num_nodes())) { - throw std::invalid_argument("shortest_paths: node_mask length mismatch"); - } - if (!edge_mask.empty() && edge_mask.size() != static_cast(g.num_edges())) { - throw std::invalid_argument("shortest_paths: edge_mask length mismatch"); - } - if (!residual.empty() && residual.size() != static_cast(g.num_edges())) { - throw std::invalid_argument("shortest_paths: residual length mismatch"); - } + validate_spans("shortest_paths", g, residual, node_mask, edge_mask); return shortest_paths_core(g, src, dst, multipath, selection, residual, node_mask, edge_mask, queue); } +std::pair +shortest_paths_dag(const StrictMultiDiGraph& g, NodeId src, NodeId dst, + bool multipath, + const EdgeSelection& selection, + std::span residual, + std::span node_mask, + std::span edge_mask, + SpfQueue queue) { + validate_spans("shortest_paths_dag", g, residual, node_mask, edge_mask); + return shortest_paths_dag_core(g, src, dst, multipath, selection, residual, node_mask, edge_mask, queue); +} + } // namespace netgraph::core /* @@ -863,7 +992,7 @@ namespace netgraph::core { namespace { -template +template void run_reverse(Queue& pq, ReverseWorkspace& ws, const StrictMultiDiGraph& g, NodeId target, bool multipath, const EdgeSelection& selection, std::span residual, std::span node_mask, @@ -877,28 +1006,14 @@ void run_reverse(Queue& pq, ReverseWorkspace& ws, const StrictMultiDiGraph& g, N const auto cap = g.capacity_view(); Cost* const dist = ws.dist.data(); Cap* const min_residual_to_dst = ws.min_res.data(); - std::int32_t* const succ_head = ws.succ_head.data(); - std::int32_t* const succ_tail = ws.succ_tail.data(); char* const settled = ws.settled.data(); char* const has_in_entry = ws.has_in_entry.data(); - auto& ent_node = ws.ent_node; - auto& ent_edge = ws.ent_edge; - auto& ent_next = ws.ent_next; const bool use_node_mask = (node_mask.size() == static_cast(N)); const bool use_edge_mask = (edge_mask.size() == static_cast(E)); const bool has_residual = (residual.size() == static_cast(E)); const bool require_cap = selection.require_capacity || has_residual; - auto succ_clear = [&](std::size_t u){ succ_head[u] = -1; succ_tail[u] = -1; }; - auto succ_append = [&](std::size_t u, NodeId v, EdgeId e){ - const auto idx = static_cast(ent_node.size()); - ent_node.push_back(v); ent_edge.push_back(e); ent_next.push_back(-1); - if (succ_head[u] < 0) { succ_head[u] = idx; } - else { ent_next[static_cast(succ_tail[u])] = idx; } - succ_tail[u] = idx; - }; - pq.begin(plan.width, plan.stride); pq.push(static_cast(0), -std::numeric_limits::max(), target); std::vector& selected_edges = ws.sel_buf; @@ -930,6 +1045,7 @@ void run_reverse(Queue& pq, ReverseWorkspace& ws, const StrictMultiDiGraph& g, N if (use_edge_mask && !edge_mask[e]) continue; const Cap rem = has_residual ? residual[e] : cap[e]; if (require_cap && rem < kMinCap) continue; + const Cap pri = priority_of

(rem, cap, e); const Cost ecost = static_cast(cost[e]); if (ecost < min_edge_cost) { min_edge_cost = ecost; @@ -938,16 +1054,16 @@ void run_reverse(Queue& pq, ReverseWorkspace& ws, const StrictMultiDiGraph& g, N selected_edges.push_back(static_cast(iaei[j])); } else { best_edge_id = static_cast(e); - best_rem_for_min_cost = static_cast(rem); + best_rem_for_min_cost = static_cast(pri); } } else if (ecost == min_edge_cost) { if (selection.multi_edge) { selected_edges.push_back(static_cast(iaei[j])); } else if (selection.tie_break == EdgeTieBreak::PreferHigherResidual) { - if (static_cast(rem) > best_rem_for_min_cost + kEpsilon) { + if (static_cast(pri) > best_rem_for_min_cost + kEpsilon) { best_edge_id = static_cast(e); - best_rem_for_min_cost = static_cast(rem); - } else if (std::abs(static_cast(rem) - best_rem_for_min_cost) <= kEpsilon) { + best_rem_for_min_cost = static_cast(pri); + } else if (std::abs(static_cast(pri) - best_rem_for_min_cost) <= kEpsilon) { if (best_edge_id < 0 || static_cast(e) < best_edge_id) best_edge_id = static_cast(e); } } else { @@ -964,9 +1080,10 @@ void run_reverse(Queue& pq, ReverseWorkspace& ws, const StrictMultiDiGraph& g, N const auto u_idx = static_cast(u); Cap max_edge_residual = static_cast(0); for (auto edge_id : selected_edges) { - const Cap rem = has_residual ? residual[static_cast(edge_id)] - : cap[static_cast(edge_id)]; - if (rem > max_edge_residual) max_edge_residual = rem; + const auto idx = static_cast(edge_id); + const Cap rem = has_residual ? residual[idx] : cap[idx]; + const Cap pri = priority_of

(rem, cap, idx); + if (pri > max_edge_residual) max_edge_residual = pri; } const Cap path_residual = std::min(min_residual_to_dst[static_cast(v)], max_edge_residual); if (new_cost < dist[u_idx] || @@ -975,12 +1092,12 @@ void run_reverse(Queue& pq, ReverseWorkspace& ws, const StrictMultiDiGraph& g, N if (dist[u_idx] == kInf) ws.touched.push_back(u); dist[u_idx] = new_cost; min_residual_to_dst[u_idx] = path_residual; - succ_clear(u_idx); - for (auto sel_e : selected_edges) succ_append(u_idx, v, sel_e); + ws.clear_succs(u_idx); + for (auto sel_e : selected_edges) ws.append_succ(u_idx, v, sel_e); has_in_entry[static_cast(v)] = 1; pq.push(new_cost, -path_residual, u); } else if (multipath && new_cost == dist[u_idx] && !settled[u_idx]) { - for (auto sel_e : selected_edges) succ_append(u_idx, v, sel_e); + for (auto sel_e : selected_edges) ws.append_succ(u_idx, v, sel_e); has_in_entry[static_cast(v)] = 1; } } @@ -989,6 +1106,16 @@ void run_reverse(Queue& pq, ReverseWorkspace& ws, const StrictMultiDiGraph& g, N } } +// Runs the reverse search on the chosen queue for one priority. +template +void reverse_with(bool bucket, ReverseWorkspace& ws, const StrictMultiDiGraph& g, NodeId target, + bool multipath, const EdgeSelection& selection, + std::span residual, std::span node_mask, + std::span edge_mask, const BucketPlan& plan) { + if (bucket) run_reverse(ws.bucket, ws, g, target, multipath, selection, residual, node_mask, edge_mask, plan); + else run_reverse(ws.heap, ws, g, target, multipath, selection, residual, node_mask, edge_mask, plan); +} + std::pair, PredDAG> shortest_paths_to_core(const StrictMultiDiGraph& g, NodeId target, bool multipath, @@ -1021,21 +1148,17 @@ shortest_paths_to_core(const StrictMultiDiGraph& g, NodeId target, ws.dist[static_cast(target)] = static_cast(0); ws.min_res[static_cast(target)] = std::numeric_limits::max(); BucketPlan plan; - if (use_bucket_queue(g, queue, plan)) { - run_reverse(ws.bucket, ws, g, target, multipath, selection, residual, node_mask, edge_mask, plan); - } else { - run_reverse(ws.heap, ws, g, target, multipath, selection, residual, node_mask, edge_mask, plan); + const bool bucket = use_bucket_queue(g, queue, plan); + switch (selection.priority) { + case EdgePriority::Capacity: + reverse_with(bucket, ws, g, target, multipath, selection, residual, node_mask, edge_mask, plan); break; + case EdgePriority::Headroom: + reverse_with(bucket, ws, g, target, multipath, selection, residual, node_mask, edge_mask, plan); break; + default: + reverse_with(bucket, ws, g, target, multipath, selection, residual, node_mask, edge_mask, plan); break; } } - auto succ_append = [&](std::size_t u, NodeId v, EdgeId e){ - const auto idx = static_cast(ws.ent_node.size()); - ws.ent_node.push_back(v); ws.ent_edge.push_back(e); ws.ent_next.push_back(-1); - if (ws.succ_head[u] < 0) { ws.succ_head[u] = idx; } - else { ws.ent_next[static_cast(ws.succ_tail[u])] = idx; } - ws.succ_tail[u] = idx; - }; - // Forced fan-out entries (see the header). for (auto e_raw : fanout_edges) { if (e_raw < 0 || e_raw >= E) { @@ -1060,7 +1183,7 @@ shortest_paths_to_core(const StrictMultiDiGraph& g, NodeId target, } if (present) continue; if (ws.dist[static_cast(u)] == kInf) ws.touched.push_back(u); - succ_append(static_cast(u), v, e_raw); + ws.append_succ(static_cast(u), v, e_raw); if (ws.dist[static_cast(u)] == kInf) { ws.dist[static_cast(u)] = static_cast(cost[e]) + ws.dist[static_cast(v)]; } @@ -1115,15 +1238,7 @@ shortest_paths_to(const StrictMultiDiGraph& g, NodeId dst, std::span edge_mask, std::span fanout_edges, SpfQueue queue) { - if (!node_mask.empty() && node_mask.size() != static_cast(g.num_nodes())) { - throw std::invalid_argument("shortest_paths_to: node_mask length mismatch"); - } - if (!edge_mask.empty() && edge_mask.size() != static_cast(g.num_edges())) { - throw std::invalid_argument("shortest_paths_to: edge_mask length mismatch"); - } - if (!residual.empty() && residual.size() != static_cast(g.num_edges())) { - throw std::invalid_argument("shortest_paths_to: residual length mismatch"); - } + validate_spans("shortest_paths_to", g, residual, node_mask, edge_mask); return shortest_paths_to_core(g, dst, multipath, selection, residual, node_mask, edge_mask, fanout_edges, queue); } diff --git a/tests/cpp/flow_graph_tests.cpp b/tests/cpp/flow_graph_tests.cpp index 687492d..bac6bf7 100644 --- a/tests/cpp/flow_graph_tests.cpp +++ b/tests/cpp/flow_graph_tests.cpp @@ -1,4 +1,5 @@ #include +#include #include "netgraph/core/flow_graph.hpp" #include "netgraph/core/shortest_paths.hpp" #include "netgraph/core/strict_multidigraph.hpp" @@ -310,3 +311,22 @@ TEST(FlowGraphStamp, EqualVersionDivergentCopiesCompareUnequal) { EXPECT_FALSE(a.state_stamp() == b.state_stamp()); } + +TEST(FlowGraph, HopBundleCarriesEveryParallelEdge) { + // Four parallel 10-unit links 0 -> 1, then 1 -> 2 (100). A route pinned to one + // edge carries 10; the same hop pinned as a bundle carries 40. + std::vector src = {0, 0, 0, 0, 1}, dst = {1, 1, 1, 1, 2}; + std::vector cap = {10, 10, 10, 10, 100}; + std::vector cost = {1, 1, 1, 1, 1}; + auto g = StrictMultiDiGraph::from_arrays(3, src, dst, cap, cost); + auto one = make_path_dag(g, std::array{0, 4}); + auto bundle = make_hops_dag(g, std::vector>{{0, 1, 2, 3}, {4}}); + FlowGraph fg(g); + const FlowIndex a{0, 2, 0, 1}, b{0, 2, 0, 2}; + EXPECT_DOUBLE_EQ(fg.place(a, 0, 2, one, 100.0, FlowPlacement::Proportional), 10.0); + fg.remove(a); + EXPECT_DOUBLE_EQ(fg.place(b, 0, 2, bundle, 100.0, FlowPlacement::Proportional), 40.0); + const auto flows = fg.edge_flow_view(); + for (std::size_t e = 0; e < 4; ++e) EXPECT_DOUBLE_EQ(flows[e], 10.0); + EXPECT_DOUBLE_EQ(flows[4], 40.0); +} diff --git a/tests/cpp/shortest_paths_tests.cpp b/tests/cpp/shortest_paths_tests.cpp index 0483b8b..2ba2255 100644 --- a/tests/cpp/shortest_paths_tests.cpp +++ b/tests/cpp/shortest_paths_tests.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include "netgraph/core/constants.hpp" @@ -529,11 +530,13 @@ const std::vector kDiffSpecs = { template void for_each_option(const DiffGraph& d, Fn&& fn) { for (int mp = 0; mp < 2; ++mp) for (int me = 0; me < 2; ++me) for (int tb = 0; tb < 2; ++tb) - for (int rc = 0; rc < 2; ++rc) for (int ur = 0; ur < 2; ++ur) for (int um = 0; um < 2; ++um) { + for (int rc = 0; rc < 2; ++rc) for (int ur = 0; ur < 2; ++ur) for (int um = 0; um < 2; ++um) + for (EdgePriority pri : {EdgePriority::Residual, EdgePriority::Capacity, EdgePriority::Headroom}) { EdgeSelection sel; sel.multi_edge = me != 0; sel.require_capacity = rc != 0; sel.tie_break = tb ? EdgeTieBreak::PreferHigherResidual : EdgeTieBreak::Deterministic; + sel.priority = pri; auto res = ur ? std::span(d.residual) : std::span{}; auto nm = um ? d.nm() : std::span{}; auto em = um ? d.em() : std::span{}; @@ -568,11 +571,21 @@ TEST(ShortestPaths, QueueDifferential_Forward) { // Auto must equal one of the two explicit routes, and both equal each other. auto automatic = shortest_paths(d.g, s, dst, mp, sel, res, nm, em); ASSERT_TRUE(same_result(expected, automatic)); + // The DAG-only variant is the same search minus the distance vector. + if (dst) { // *dst may be -1 (no reachable node): then the cost is INT64_MAX + const Cost want = (*dst >= 0 && *dst < n) ? expected.first[static_cast(*dst)] + : std::numeric_limits::max(); + for (SpfQueue q : {SpfQueue::Heap, SpfQueue::Bucket}) { + auto [cost, dag] = shortest_paths_dag(d.g, s, *dst, mp, sel, res, nm, em, q); + ASSERT_EQ(cost, want); + ASSERT_TRUE(same_result(expected, SpfResult{expected.first, dag})); + } + } } }); } } - EXPECT_GT(checked, 20000u); + EXPECT_GT(checked, 60000u); } TEST(ShortestPaths, QueueDifferential_Reverse) { @@ -598,7 +611,7 @@ TEST(ShortestPaths, QueueDifferential_Reverse) { }); } } - EXPECT_GT(checked, 5000u); + EXPECT_GT(checked, 15000u); } TEST(ShortestPaths, QueueDifferential_ComparisonHasTeeth) { @@ -699,3 +712,126 @@ TEST(ShortestPaths, WorkspaceReuseAcrossGraphSizesAndEmptyGraphs) { EXPECT_TRUE(same_result(s1, s3)); } } + +// --------------------------------------------------------------------------- +// EdgeSelection::priority: which per-edge value ranks equal-cost alternatives. +// --------------------------------------------------------------------------- +TEST(ShortestPaths, EdgePriorityRanksParallelEdges) { + // Two parallel equal-cost edges 0 -> 1: capacities 10 and 5, residuals 2 and 4. + std::vector src = {0, 0}, dst = {1, 1}; + std::vector cap = {10.0, 5.0}; + std::vector cost = {1, 1}; + auto g = StrictMultiDiGraph::from_arrays(2, src, dst, cap, cost); + std::vector residual = {2.0, 4.0}; + // Edge ids after construction: both cost 1, same (src,dst): input order kept, so id 0 has cap 10. + ASSERT_DOUBLE_EQ(g.capacity_view()[0], 10.0); + auto pick = [&](EdgePriority pri, bool with_residual) { + EdgeSelection sel; sel.multi_edge = false; sel.tie_break = EdgeTieBreak::PreferHigherResidual; sel.priority = pri; + auto [d, dag] = shortest_paths(g, 0, std::nullopt, true, sel, + with_residual ? std::span(residual) : std::span{}, {}, {}); + EXPECT_EQ(dag.parent_offsets[2] - dag.parent_offsets[1], 1); + return dag.via_edges[static_cast(dag.parent_offsets[1])]; + }; + // With a residual: Residual prefers residual 4 (edge 1); Capacity prefers cap 10 (edge 0); + // Headroom prefers 4/5 = 0.8 over 2/10 = 0.2 (edge 1). + EXPECT_EQ(pick(EdgePriority::Residual, true), 1); + EXPECT_EQ(pick(EdgePriority::Capacity, true), 0); + EXPECT_EQ(pick(EdgePriority::Headroom, true), 1); + // Without a residual every priority falls back to the capacity (headroom is 1 + // for both, so the deterministic smaller id wins). + EXPECT_EQ(pick(EdgePriority::Residual, false), 0); + EXPECT_EQ(pick(EdgePriority::Capacity, false), 0); + EXPECT_EQ(pick(EdgePriority::Headroom, false), 0); +} + +TEST(ShortestPaths, EdgePriorityRanksEqualCostPaths) { + // 0 -> 1 -> 3 and 0 -> 2 -> 3, both cost 2. Path via 1: capacities 100/100 with + // residuals 10/10 (headroom 0.1). Path via 2: capacities 20/20 with residuals + // 8/8 (headroom 0.4). Single-path mode keeps the path with the better bottleneck. + std::vector src = {0, 1, 0, 2}, dst = {1, 3, 2, 3}; + std::vector cap = {100.0, 100.0, 20.0, 20.0}; + std::vector cost = {1, 1, 1, 1}; + auto g = StrictMultiDiGraph::from_arrays(4, src, dst, cap, cost); + std::vector residual(4); + const auto esrc = g.edge_src_view(), edst = g.edge_dst_view(); + for (std::size_t e = 0; e < 4; ++e) residual[e] = (esrc[e] == 1 || edst[e] == 1) ? 10.0 : 8.0; + auto parent_of_dst = [&](EdgePriority pri) { + EdgeSelection sel; sel.multi_edge = true; sel.priority = pri; + auto [d, dag] = shortest_paths(g, 0, std::nullopt, /*multipath=*/false, sel, residual, {}, {}); + EXPECT_EQ(d[3], 2); + EXPECT_EQ(dag.parent_offsets[4] - dag.parent_offsets[3], 1); + return dag.parents[static_cast(dag.parent_offsets[3])]; + }; + EXPECT_EQ(parent_of_dst(EdgePriority::Residual), 1); // bottleneck residual 10 > 8 + EXPECT_EQ(parent_of_dst(EdgePriority::Capacity), 1); // bottleneck capacity 100 > 20 + EXPECT_EQ(parent_of_dst(EdgePriority::Headroom), 2); // headroom 0.4 > 0.1 + // Both queue routes agree for every priority. + for (EdgePriority pri : {EdgePriority::Residual, EdgePriority::Capacity, EdgePriority::Headroom}) { + EdgeSelection sel; sel.multi_edge = false; sel.tie_break = EdgeTieBreak::PreferHigherResidual; sel.priority = pri; + auto a = shortest_paths(g, 0, std::nullopt, false, sel, residual, {}, {}, SpfQueue::Heap); + auto b = shortest_paths(g, 0, std::nullopt, false, sel, residual, {}, {}, SpfQueue::Bucket); + EXPECT_TRUE(same_result(a, b)); + auto ra = shortest_paths_to(g, 3, false, sel, residual, {}, {}, {}, SpfQueue::Heap); + auto rb = shortest_paths_to(g, 3, false, sel, residual, {}, {}, {}, SpfQueue::Bucket); + EXPECT_TRUE(same_result(ra, rb)); + } +} + +TEST(ShortestPaths, DagVariantHandlesInvalidEndpoints) { + auto d = make_diff_graph(kDiffSpecs[0]); + const int n = d.g.num_nodes(); + EdgeSelection sel; + // Unreachable or out-of-range destinations report INT64_MAX; the DAG is the + // full-search DAG (nothing stops the search early). + auto [c_out, dag_out] = shortest_paths_dag(d.g, 0, NodeId(n + 5), true, sel, {}, {}, {}); + EXPECT_EQ(c_out, std::numeric_limits::max()); + auto full = shortest_paths(d.g, 0, std::nullopt, true, sel, {}, {}, {}); + EXPECT_TRUE(same_result(full, SpfResult{full.first, dag_out})); + // A masked-out source yields an empty DAG and no cost. + auto masked = make_bool_mask(static_cast(n), true); + masked[0] = false; + auto [c_m, dag_m] = shortest_paths_dag(d.g, 0, 1, true, sel, {}, std::span(masked.get(), static_cast(n)), {}); + EXPECT_EQ(c_m, std::numeric_limits::max()); + EXPECT_EQ(dag_m.parents.size(), 0u); + EXPECT_EQ(dag_m.parent_offsets.size(), static_cast(n) + 1); +} + +// --------------------------------------------------------------------------- +// make_hops_dag: explicit routes with parallel edges per hop. +// --------------------------------------------------------------------------- +TEST(ShortestPaths, MakeHopsDagBuildsBundledHops) { + // 0 -> 1 via four parallel edges, 1 -> 2 via one edge, 1 -> 3 unrelated. + std::vector src = {0, 0, 0, 0, 1, 1}, dst = {1, 1, 1, 1, 2, 3}; + std::vector cap = {10, 10, 10, 10, 50, 5}; + std::vector cost = {1, 1, 1, 1, 1, 1}; + auto g = StrictMultiDiGraph::from_arrays(4, src, dst, cap, cost); + std::vector> hops = {{0, 1, 2, 3}, {4}}; + auto dag = make_hops_dag(g, hops); + ASSERT_EQ(dag.parent_offsets, (std::vector{0, 0, 4, 5, 5})); + EXPECT_EQ(dag.parents, (std::vector{0, 0, 0, 0, 1})); + EXPECT_EQ(dag.via_edges, (std::vector{0, 1, 2, 3, 4})); + // Hop order is kept as given. + auto reordered = make_hops_dag(g, std::vector>{{3, 1}, {4}}); + EXPECT_EQ(reordered.via_edges, (std::vector{3, 1, 4})); + // from_edges is the one-edge-per-hop case. + auto single = make_path_dag(g, std::array{2, 4}); + EXPECT_EQ(single.via_edges, (std::vector{2, 4})); + EXPECT_EQ(single.parent_offsets, (std::vector{0, 0, 1, 2, 2})); +} + +TEST(ShortestPaths, MakeHopsDagRejectsMalformedRoutes) { + std::vector src = {0, 0, 1, 1, 2}, dst = {1, 1, 2, 3, 0}; + std::vector cap = {1, 1, 1, 1, 1}; + std::vector cost = {1, 1, 1, 1, 1}; + auto g = StrictMultiDiGraph::from_arrays(4, src, dst, cap, cost); + using H = std::vector>; + EXPECT_THROW((void)make_hops_dag(g, H{}), std::invalid_argument); // empty route + EXPECT_THROW((void)make_hops_dag(g, H{{0}, {}}), std::invalid_argument); // empty hop + EXPECT_THROW((void)make_hops_dag(g, H{{0, 7}}), std::invalid_argument); // id out of range + EXPECT_THROW((void)make_hops_dag(g, H{{0, 0}}), std::invalid_argument); // repeated edge in a hop + EXPECT_THROW((void)make_hops_dag(g, H{{2, 3}}), std::invalid_argument); // not parallel (1->2 and 1->3) + EXPECT_NO_THROW((void)make_hops_dag(g, H{{0, 1}, {3}})); // 0->1 bundle, then 1->3 + EXPECT_THROW((void)make_hops_dag(g, H{{0}, {4}}), std::invalid_argument); // 0->1 then 2->0: not contiguous + EXPECT_THROW((void)make_hops_dag(g, H{{0}, {2}, {4}}), std::invalid_argument); // 0->1->2->0 revisits 0 + EXPECT_THROW((void)make_path_dag(g, std::array{0, 4}), std::invalid_argument); +} diff --git a/tests/py/test_graph_cost_metadata.py b/tests/py/test_graph_cost_metadata.py new file mode 100644 index 0000000..f5e4735 --- /dev/null +++ b/tests/py/test_graph_cost_metadata.py @@ -0,0 +1,29 @@ +"""StrictMultiDiGraph.max_cost / cost_gcd: the inputs of the SPF queue dispatch rule.""" + +from __future__ import annotations + +import numpy as np + +import netgraph_core as ngc + + +def _graph(costs): + n = len(costs) + 1 + return ngc.StrictMultiDiGraph.from_arrays( + n, + src=np.arange(len(costs), dtype=np.int32), + dst=np.arange(1, n, dtype=np.int32), + capacity=np.ones(len(costs), dtype=np.float64), + cost=np.array(costs, dtype=np.int64), + ext_edge_ids=np.arange(len(costs), dtype=np.int64), + ) + + +def test_cost_metadata(): + assert _graph([6, 9, 0]).max_cost() == 9 + assert _graph([6, 9, 0]).cost_gcd() == 3 + assert _graph([0, 0]).max_cost() == 0 + assert _graph([0, 0]).cost_gcd() == 0 + big = (1 << 40) + 7 + assert _graph([big]).max_cost() == big + assert _graph([big]).cost_gcd() == big diff --git a/tests/py/test_pred_dag_from_hops.py b/tests/py/test_pred_dag_from_hops.py new file mode 100644 index 0000000..5069168 --- /dev/null +++ b/tests/py/test_pred_dag_from_hops.py @@ -0,0 +1,74 @@ +"""PredDAG.from_hops: explicit routes whose hops may use several parallel edges.""" + +from __future__ import annotations + +import numpy as np +import pytest + +import netgraph_core as ngc + + +def _graph(): + # Four parallel 10-unit links 0 -> 1 (ids 0-3), 1 -> 0 back (id 4) and + # 1 -> 2 (id 5): edge ids follow the graph's (cost, src, dst) ordering. + return ngc.StrictMultiDiGraph.from_arrays( + 3, + src=np.array([0, 0, 0, 0, 1, 1], dtype=np.int32), + dst=np.array([1, 1, 1, 1, 2, 0], dtype=np.int32), + capacity=np.array([10.0, 10.0, 10.0, 10.0, 100.0, 1.0], dtype=np.float64), + cost=np.array([1, 1, 1, 1, 1, 1], dtype=np.int64), + ext_edge_ids=np.arange(6, dtype=np.int64), + ) + + +def test_from_hops_layout_and_order(): + g = _graph() + dag = ngc.PredDAG.from_hops(g, [[3, 1], [5]]) + assert list(dag.parent_offsets) == [0, 0, 2, 3] + assert list(dag.parents) == [0, 0, 1] + assert list(dag.via_edges) == [3, 1, 5] + single = ngc.PredDAG.from_edges(g, [2, 5]) + assert list(single.via_edges) == [2, 5] + + +def test_from_hops_bundle_carries_every_parallel_edge(): + g = _graph() + fg = ngc.FlowGraph(g) + one = ngc.PredDAG.from_edges(g, [0, 5]) + bundle = ngc.PredDAG.from_hops(g, [[0, 1, 2, 3], [5]]) + idx_a = ngc.FlowIndex(0, 2, 0, 1) + idx_b = ngc.FlowIndex(0, 2, 0, 2) + assert fg.place( + idx_a, 0, 2, one, 100.0, ngc.FlowPlacement.PROPORTIONAL + ) == pytest.approx(10.0) + fg.remove(idx_a) + assert fg.place( + idx_b, 0, 2, bundle, 100.0, ngc.FlowPlacement.PROPORTIONAL + ) == pytest.approx(40.0) + + +@pytest.mark.parametrize( + "hops, message", + [ + ([], "non-empty"), + ([[0], []], "empty"), + ([[0, 9]], "out of range"), + ([[0, 0]], "repeated"), + ([[4, 5]], "not parallel"), # 1 -> 0 and 1 -> 2 + ([[0], [5], [4]], "contiguous"), # 0 -> 1 -> 2, then 1 -> 0 + ([[0], [5]], None), # valid: 0 -> 1 -> 2 + ], +) +def test_from_hops_validation(hops, message): + g = _graph() + if message is None: + ngc.PredDAG.from_hops(g, hops) + return + with pytest.raises(ValueError, match=message): + ngc.PredDAG.from_hops(g, hops) + + +def test_from_hops_rejects_revisited_node(): + g = _graph() + with pytest.raises(ValueError, match="simple path"): + ngc.PredDAG.from_hops(g, [[0], [4]]) # 0 -> 1 -> 0 diff --git a/tests/py/test_spf_edge_priority.py b/tests/py/test_spf_edge_priority.py new file mode 100644 index 0000000..15d2e79 --- /dev/null +++ b/tests/py/test_spf_edge_priority.py @@ -0,0 +1,71 @@ +"""EdgeSelection.priority: which per-edge value ranks equal-cost alternatives.""" + +from __future__ import annotations + +import numpy as np + +import netgraph_core as ngc + + +def _graph(): + # Two parallel equal-cost edges 0 -> 1: capacities 10 and 5, residuals 2 and 4. + g = ngc.StrictMultiDiGraph.from_arrays( + 2, + src=np.array([0, 0], dtype=np.int32), + dst=np.array([1, 1], dtype=np.int32), + capacity=np.array([10.0, 5.0], dtype=np.float64), + cost=np.array([1, 1], dtype=np.int64), + ext_edge_ids=np.array([100, 101], dtype=np.int64), + ) + algs = ngc.Algorithms(ngc.Backend.cpu()) + return g, algs, algs.build_graph(g) + + +def _chosen_edge(algs, gh, priority, residual): + sel = ngc.EdgeSelection( + multi_edge=False, + tie_break=ngc.EdgeTieBreak.PREFER_HIGHER_RESIDUAL, + priority=priority, + ) + _, dag = algs.spf(gh, 0, selection=sel, residual=residual) + via = np.asarray(dag.via_edges) + assert len(via) == 1 + return int(via[0]) + + +def test_priority_default_and_attribute(): + sel = ngc.EdgeSelection() + assert sel.priority == ngc.EdgePriority.RESIDUAL + sel.priority = ngc.EdgePriority.HEADROOM + assert sel.priority == ngc.EdgePriority.HEADROOM + assert set(ngc.EdgePriority.__members__) == {"RESIDUAL", "CAPACITY", "HEADROOM"} + + +def test_priority_selects_the_ranking_value(): + _, algs, gh = _graph() + residual = np.array([2.0, 4.0], dtype=np.float64) + assert _chosen_edge(algs, gh, ngc.EdgePriority.RESIDUAL, residual) == 1 + assert _chosen_edge(algs, gh, ngc.EdgePriority.CAPACITY, residual) == 0 + assert _chosen_edge(algs, gh, ngc.EdgePriority.HEADROOM, residual) == 1 # 0.8 > 0.2 + + +def test_priority_without_residual_falls_back_to_capacity(): + _, algs, gh = _graph() + for pri in ( + ngc.EdgePriority.RESIDUAL, + ngc.EdgePriority.CAPACITY, + ngc.EdgePriority.HEADROOM, + ): + assert _chosen_edge(algs, gh, pri, None) == 0 + + +def test_priority_does_not_change_eligibility(): + # A saturated edge stays excluded under capacity gating whatever the priority. + _, algs, gh = _graph() + residual = np.array([0.0, 4.0], dtype=np.float64) + for pri in ( + ngc.EdgePriority.RESIDUAL, + ngc.EdgePriority.CAPACITY, + ngc.EdgePriority.HEADROOM, + ): + assert _chosen_edge(algs, gh, pri, residual) == 1 From df3b89406ff2fb4b99fdc70ec8225f9afecbcdc0 Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Sat, 3 Oct 2026 18:40:43 +0100 Subject: [PATCH 02/12] Phase 0: version 0.12.0 on the integration branch; lint excludes for review artefacts Co-Authored-By: Claude Fable 5.1 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 22da3f5..7f9a41a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build" [project] name = "netgraph-core" -version = "0.11.0" +version = "0.12.0" description = "C++ implementation of graph algorithms for network flow analysis and traffic engineering with Python bindings" readme = "README.md" requires-python = ">=3.11" @@ -66,7 +66,7 @@ markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"] line-length = 88 indent-width = 4 # Recorded reviewer artefacts are kept verbatim. -extend-exclude = ["dev/reviews"] +extend-exclude = ["dev/reviews", "dev/design/2026-10-streamline/review", "dev/design/2026-10-streamline/review-*"] [tool.ruff.lint] select = ["E4", "E7", "E9", "F", "B", "I"] From b4766397e3594a0242d319f456ead87d1e47b007 Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Sun, 4 Oct 2026 21:35:02 -0700 Subject: [PATCH 03/12] =?UTF-8?q?Phase=201a:=20graph=20on=20integer=20unit?= =?UTF-8?q?s=20with=20B=E2=80=B2=20edge=20ids=20and=20the=20min-cost=20flo?= =?UTF-8?q?w=20core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete the old library (FlowState, the old FlowGraph and FlowPolicy, the backend, the old search entry points and their bindings: 411 symbols, 65 files) and build the first piece of the streamlined Core against APPROACH revision 25: - StrictMultiDiGraph(num_nodes, src, dst, capacity_units, cost, resolution): capacities as int64 units of an explicit resolution, costs >= 1, edges in the canonical (src, cost, dst, input) order so the forward CSR slot is the public EdgeId, input_index() as the only map, to_units (half up) and as_float, validation in the contract's order (resolution, unit values, shapes, costs, bounds) before any narrowing. - The flow core: successive shortest paths with potentials on reduced costs, Dinic phases per tier, forward and reverse arcs interleaved in ascending EdgeId at every node, virtual terminals, a fresh solve from zero on the view, the two-branch behaviour for an input cap of 0, the closure seeded at the real sources; exposed to the recorder through the private _place_global_units entry with the typed payload (placed, cost_distribution, edge_flows, min_cut, potentials with NEVER_REACHED = -1, six int64 counters). - 38 C++ tests (every validation error, B′ order, the review witnesses: attained value, update-before-drain, two Dinic phases in one tier, the legal maximum, the zero-view cap, masks and cost factors) and 154 Python tests. Gates (dev/design/2026-10-streamline/gates/1a/, uncommitted design tree): workspace check and sanitizers green; independent oracle population 3,821 pass / 0 fail; cross-check against the Python reference on 30,000 random graphs identical; the equivalence run against the G0 baseline 0 failures under the 1a manifest rules; inventory: 0 Core callers of any deleted symbol (NetGraph callers pending until Phase 2); construction and max-flow A/B/A rows 23 to 48 percent faster than df3b894. Reviewed adversarially (review-1a, review-1a-fixes): D1 to D4 fixed or settled in the contract. NetGraph is red from this commit until its migration converges (Phase 2), by decision of 2026-10-04. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 24 + CMakeLists.txt | 17 +- Makefile | 4 +- README.md | 109 +- bindings/python/module.cpp | 1201 +++++------ dev/benchmark_profiling_overhead.py | 297 --- include/netgraph/core/algorithms.hpp | 62 - include/netgraph/core/backend.hpp | 149 -- include/netgraph/core/constants.hpp | 13 - include/netgraph/core/flow.hpp | 28 - include/netgraph/core/flow_graph.hpp | 94 - include/netgraph/core/flow_policy.hpp | 261 --- include/netgraph/core/flow_state.hpp | 112 - include/netgraph/core/k_shortest_paths.hpp | 26 - include/netgraph/core/max_flow.hpp | 118 +- include/netgraph/core/options.hpp | 50 - include/netgraph/core/profiling.hpp | 8 +- include/netgraph/core/shortest_paths.hpp | 170 -- include/netgraph/core/strict_multidigraph.hpp | 146 +- include/netgraph/core/types.hpp | 109 +- python/netgraph_core/__init__.py | 72 +- python/netgraph_core/_docs.py | 996 +-------- src/cpu_backend.cpp | 146 -- src/flow_graph.cpp | 185 -- src/flow_policy.cpp | 850 -------- src/flow_state.cpp | 674 ------ src/k_shortest_paths.cpp | 349 ---- src/max_flow.cpp | 841 ++++---- src/shortest_paths.cpp | 1245 ----------- src/strict_multidigraph.cpp | 376 ++-- tests/cpp/flow_graph_tests.cpp | 332 --- tests/cpp/flow_policy_tests.cpp | 507 ----- tests/cpp/flow_state_tests.cpp | 589 ------ tests/cpp/k_shortest_paths_tests.cpp | 331 --- tests/cpp/masking_tests.cpp | 698 +------ tests/cpp/max_flow_tests.cpp | 1811 +++++------------ tests/cpp/shortest_paths_tests.cpp | 837 -------- tests/cpp/smoke_test.cpp | 46 +- tests/cpp/strict_multidigraph_tests.cpp | 567 +++--- tests/cpp/test_utils.hpp | 783 ++++--- tests/py/conftest.py | 956 --------- tests/py/flow_oracle.py | 198 ++ tests/py/test_api_validation.py | 147 -- tests/py/test_batch_max_flow_consistency.py | 72 - tests/py/test_batch_max_flow_masks.py | 35 - tests/py/test_cross_validation.py | 356 ---- tests/py/test_edge_cases.py | 184 -- tests/py/test_equal_balanced_modes.py | 218 -- tests/py/test_ext_edge_ids.py | 142 -- tests/py/test_flow_policy.py | 282 --- tests/py/test_flow_policy_configurations.py | 513 ----- tests/py/test_flow_policy_edge_cases.py | 408 ---- tests/py/test_flow_policy_lifecycle.py | 76 - tests/py/test_flow_policy_masks.py | 383 ---- .../py/test_flow_policy_path_distribution.py | 532 ----- tests/py/test_flow_policy_spf_memo.py | 158 -- tests/py/test_flow_policy_validation.py | 385 ---- tests/py/test_graph.py | 358 ++++ tests/py/test_graph_cost_metadata.py | 29 - tests/py/test_graph_from_arrays.py | 69 - tests/py/test_graph_structure.py | 27 - tests/py/test_imports.py | 40 +- tests/py/test_ksp.py | 89 - tests/py/test_ksp_variants.py | 135 -- tests/py/test_lifetime_safety.py | 329 --- tests/py/test_mask_semantics.py | 180 -- tests/py/test_mask_validation.py | 230 --- tests/py/test_masking_comprehensive.py | 653 ------ tests/py/test_max_flow.py | 310 --- tests/py/test_max_flow_cost_distribution.py | 166 -- tests/py/test_max_flow_summary.py | 53 - tests/py/test_maxflow_certificate.py | 197 -- tests/py/test_paths_resolve.py | 67 - tests/py/test_place_global_units.py | 287 +++ .../py/test_policy_vs_maxflow_equivalence.py | 213 -- tests/py/test_pred_dag_from_hops.py | 74 - tests/py/test_profiling.py | 100 - tests/py/test_review_regressions.py | 590 ------ tests/py/test_sensitivity.py | 196 -- tests/py/test_spf_basic.py | 184 -- tests/py/test_spf_edge_priority.py | 71 - tests/py/test_spf_edge_select.py | 186 -- tests/py/test_spf_masks.py | 51 - tests/py/test_spf_queue_env.py | 69 - tests/py/test_spf_to.py | 114 -- tests/py/test_static_paths.py | 392 ---- tests/py/test_thread_safety.py | 360 ---- tests/py/test_units.py | 134 ++ 88 files changed, 3742 insertions(+), 22489 deletions(-) delete mode 100644 dev/benchmark_profiling_overhead.py delete mode 100644 include/netgraph/core/algorithms.hpp delete mode 100644 include/netgraph/core/backend.hpp delete mode 100644 include/netgraph/core/constants.hpp delete mode 100644 include/netgraph/core/flow.hpp delete mode 100644 include/netgraph/core/flow_graph.hpp delete mode 100644 include/netgraph/core/flow_policy.hpp delete mode 100644 include/netgraph/core/flow_state.hpp delete mode 100644 include/netgraph/core/k_shortest_paths.hpp delete mode 100644 include/netgraph/core/options.hpp delete mode 100644 include/netgraph/core/shortest_paths.hpp delete mode 100644 src/cpu_backend.cpp delete mode 100644 src/flow_graph.cpp delete mode 100644 src/flow_policy.cpp delete mode 100644 src/flow_state.cpp delete mode 100644 src/k_shortest_paths.cpp delete mode 100644 src/shortest_paths.cpp delete mode 100644 tests/cpp/flow_graph_tests.cpp delete mode 100644 tests/cpp/flow_policy_tests.cpp delete mode 100644 tests/cpp/flow_state_tests.cpp delete mode 100644 tests/cpp/k_shortest_paths_tests.cpp delete mode 100644 tests/cpp/shortest_paths_tests.cpp delete mode 100644 tests/py/conftest.py create mode 100644 tests/py/flow_oracle.py delete mode 100644 tests/py/test_api_validation.py delete mode 100644 tests/py/test_batch_max_flow_consistency.py delete mode 100644 tests/py/test_batch_max_flow_masks.py delete mode 100644 tests/py/test_cross_validation.py delete mode 100644 tests/py/test_edge_cases.py delete mode 100644 tests/py/test_equal_balanced_modes.py delete mode 100644 tests/py/test_ext_edge_ids.py delete mode 100644 tests/py/test_flow_policy.py delete mode 100644 tests/py/test_flow_policy_configurations.py delete mode 100644 tests/py/test_flow_policy_edge_cases.py delete mode 100644 tests/py/test_flow_policy_lifecycle.py delete mode 100644 tests/py/test_flow_policy_masks.py delete mode 100644 tests/py/test_flow_policy_path_distribution.py delete mode 100644 tests/py/test_flow_policy_spf_memo.py delete mode 100644 tests/py/test_flow_policy_validation.py create mode 100644 tests/py/test_graph.py delete mode 100644 tests/py/test_graph_cost_metadata.py delete mode 100644 tests/py/test_graph_from_arrays.py delete mode 100644 tests/py/test_graph_structure.py delete mode 100644 tests/py/test_ksp.py delete mode 100644 tests/py/test_ksp_variants.py delete mode 100644 tests/py/test_lifetime_safety.py delete mode 100644 tests/py/test_mask_semantics.py delete mode 100644 tests/py/test_mask_validation.py delete mode 100644 tests/py/test_masking_comprehensive.py delete mode 100644 tests/py/test_max_flow.py delete mode 100644 tests/py/test_max_flow_cost_distribution.py delete mode 100644 tests/py/test_max_flow_summary.py delete mode 100644 tests/py/test_maxflow_certificate.py delete mode 100644 tests/py/test_paths_resolve.py create mode 100644 tests/py/test_place_global_units.py delete mode 100644 tests/py/test_policy_vs_maxflow_equivalence.py delete mode 100644 tests/py/test_pred_dag_from_hops.py delete mode 100644 tests/py/test_profiling.py delete mode 100644 tests/py/test_review_regressions.py delete mode 100644 tests/py/test_sensitivity.py delete mode 100644 tests/py/test_spf_basic.py delete mode 100644 tests/py/test_spf_edge_priority.py delete mode 100644 tests/py/test_spf_edge_select.py delete mode 100644 tests/py/test_spf_masks.py delete mode 100644 tests/py/test_spf_queue_env.py delete mode 100644 tests/py/test_spf_to.py delete mode 100644 tests/py/test_static_paths.py delete mode 100644 tests/py/test_thread_safety.py create mode 100644 tests/py/test_units.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 62bdd65..8d33ba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Streamline sub-PR 1a (breaking; integration branch `streamline`) + +The release rebuilds Core in sub-PRs (`dev/design/2026-10-streamline/`). Sub-PR 1a: + +- **Removed**: the previous library and its bindings: `FlowState`, `FlowGraph`, `FlowPolicy` + and their configuration, the backend and algorithms facade, the search entry points + (`shortest_paths`, `shortest_paths_to`, `spf_dag`, `k_shortest_paths`), max-flow, batch + max-flow and sensitivity, the SPF memo and `StateStamp`, `kMinFlow`, `kEpsilon`, + `constants.hpp`, the Python profiling accessors, and their tests. The ledger, kernels, + policies, searches and evaluators return in sub-PRs 1b to 1d; the entries below the 1a block + describe the removed API. +- **Graph**: `StrictMultiDiGraph(num_nodes, src, dst, capacity_units, cost, resolution)` is the + one constructor: capacities are `int64` units of an explicit resolution (a power of two in + `[2^-32, 2^32]`), costs are positive integers, and the public `EdgeId` is the rank of an edge in + `(src, cost, dst, input)` with `input_index()` mapping ids to input positions. Validation + reports the first defect in a fixed order (unit values, shapes, costs, remaining bounds, + including the cost-sum guard). Accessors return read-only NumPy views. `Eligibility` is built + from excluded node ids and EdgeIds. `to_units` (round half up) and `as_float` are the only + conversions between physical values and units. +- **Flow core**: one integer minimum-cost flow (successive shortest paths with potentials, Dinic + phases per cost tier, every edge its own arc, arcs at a node in ascending EdgeId) behind the + private entry `_netgraph_core._place_global_units`, returning the value, the marginal cost + curve, edge flows, the residual minimum cut, the potentials and six operation counters. + ### Added - **Shortest Paths**: `shortest_paths_dag(g, src, dst, ...)` (C++) and `Backend::spf_dag` / `Algorithms::spf_dag` return the destination's cost and the predecessor DAG without materialising the N-sized distance vector; `calc_max_flow`, `FlowState::place_max_flow`, `FlowPolicy` and the KSP spur searches now use it (outputs unchanged). `Backend::spf_dag` has a default implementation derived from `spf`, so other backends need no change. Measured old/new/old against 0.11.0: about 1.25x on first-neighbour searches at 16k–262k nodes, unchanged on full searches. diff --git a/CMakeLists.txt b/CMakeLists.txt index 1f08d4c..f92f3d6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,13 +15,8 @@ if(NETGRAPH_CORE_BUILD_TESTS) enable_testing() add_executable(netgraph_core_tests tests/cpp/smoke_test.cpp - tests/cpp/flow_policy_tests.cpp tests/cpp/strict_multidigraph_tests.cpp - tests/cpp/shortest_paths_tests.cpp - tests/cpp/flow_state_tests.cpp - tests/cpp/flow_graph_tests.cpp tests/cpp/max_flow_tests.cpp - tests/cpp/k_shortest_paths_tests.cpp tests/cpp/masking_tests.cpp ) target_link_libraries(netgraph_core_tests PRIVATE netgraph_core GTest::gtest_main) @@ -76,13 +71,7 @@ endif() add_library(netgraph_core STATIC src/strict_multidigraph.cpp - src/shortest_paths.cpp - src/k_shortest_paths.cpp src/max_flow.cpp - src/flow_state.cpp - src/flow_graph.cpp - src/flow_policy.cpp - src/cpu_backend.cpp src/profiling.cpp ) target_include_directories(netgraph_core PUBLIC @@ -209,9 +198,9 @@ endif() if(NETGRAPH_CORE_COVERAGE) message(STATUS "Enabling coverage instrumentation (disables optimizations)") if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") - # batch_max_flow and sensitivity_analysis run worker threads, and the default - # non-atomic profile counters corrupt under concurrent updates (gcov then - # emits impossible values such as "branch 4 taken -1"). + # Worker threads (batch and sensitivity evaluators from 1c on) corrupt the default + # non-atomic profile counters under concurrent updates (gcov then emits impossible + # values such as "branch 4 taken -1"). set(COV_FLAGS -O0 -g --coverage) if(CMAKE_CXX_COMPILER_ID MATCHES "GNU") list(APPEND COV_FLAGS -fprofile-update=atomic) diff --git a/Makefile b/Makefile index 3828d0f..0e41d81 100644 --- a/Makefile +++ b/Makefile @@ -133,8 +133,8 @@ install-pgo: @rm -rf build/ $(PGO_DIR) @mkdir -p $(PGO_DIR) @$(ENV_MACOS) $(ENV_CC) $(ENV_CXX) CMAKE_ARGS="$(strip $(CMAKE_ARGS) -DNETGRAPH_CORE_PGO_GENERATE=ON -DNETGRAPH_CORE_PGO_DIR=$(PGO_DIR) -DNETGRAPH_CORE_NATIVE=ON)" $(PIP) install -e . - @echo "📦 PGO Build Phase 2/3: Collecting profile (running benchmark)..." - @$(PYTHON) dev/benchmark_profiling_overhead.py --mesh-size 25 --spf-iters 1000 --flow-iters 200 >/dev/null 2>&1 + @echo "📦 PGO Build Phase 2/3: Collecting profile (running the Python test suite)..." + @$(PYTEST) -q tests/py >/dev/null 2>&1 @# Clang: merge raw profiles into .profdata (profile may be in cwd or PGO_DIR) @if ls $(PGO_DIR)/*.profraw >/dev/null 2>&1; then \ echo " Merging Clang profile data..."; \ diff --git a/README.md b/README.md index ee28cef..2f616bb 100644 --- a/README.md +++ b/README.md @@ -2,84 +2,62 @@ C++ graph engine for network flow analysis, traffic engineering simulation, and capacity planning. -## Overview +> **Streamline in progress.** The `streamline` branch rebuilds Core in sub-PRs +> (`dev/design/2026-10-streamline/`). This build (sub-PR 1a) carries the graph on integer +> units and the minimum-cost flow core behind a private entry point; the ledger, placement +> kernels, policies, searches and evaluators return in the following sub-PRs. The previous API +> (0.12 and earlier) is in git history and on PyPI. -NetGraph-Core provides a specialized graph implementation for networking problems. Key design priorities: +## Overview -- **Determinism**: Guaranteed reproducible edge ordering by (cost, src, dst). -- **Flow Modeling**: Native support for multi-commodity flow state, residual tracking, and ECMP/WCMP placement. -- **Performance**: Immutable CSR (Compressed Sparse Row) adjacency, and zero-copy NumPy views over mutable flow state. +- **Exact integer amounts**: a graph carries an explicit resolution `q` (a power of two in + `[2^-32, 2^32]`); capacities and flows are `int64` units of `q`. Physical values enter only + through `to_units` (round half up) and leave through `as_float`. +- **Positive costs**: edge costs are integers `>= 1`, so the shortest-path DAG is unique. +- **Stable edge ids**: the public `EdgeId` is the rank of an edge in the order + `(src, cost, dst, input)`; `input_index()` maps ids back to input positions. +- **One flow core**: an integer minimum-cost flow (successive shortest paths with potentials, + Dinic phases per cost tier) returning the flow value, the marginal cost curve, per-edge + flows, the residual minimum cut and the dual potentials. ## Core Features -### 1. Graph Representations - -- **`StrictMultiDiGraph`**: Immutable directed multigraph using CSR adjacency. Supports parallel edges (multi-graph), essential for network topologies. -- **`FlowGraph`**: Topology overlay managing mutable flow state, per-flow edge allocations, and residual capacities. - -### 2. Network Algorithms - -- **Shortest Paths (SPF)**: - - Modified Dijkstra returns a **Predecessor DAG** to capture all equal-cost paths. - - Supports **ECMP** (Equal-Cost Multi-Path) routing. - - Features **node/edge masking** and **residual-aware tie-breaking**, with a selectable priority value (residual, static capacity, or headroom = residual / capacity). - -- **K-Shortest Paths (KSP)**: - - Yen's algorithm returning DAG-wrapped paths. - - Configurable constraints on cost factors (e.g., paths within 1.5x of optimal). - -- **Explicit Paths**: - - `PredDAG.from_edges(graph, edges)` builds a path bundle from an operator-supplied - edge sequence, usable anywhere a `PredDAG` is accepted; `PredDAG.from_hops(graph, hops)` - does the same for routes whose hops use several parallel edges. +### Graph -- **Max-Flow**: - - **Algorithm**: Iterative augmentation using Successive Shortest Path on residual graphs, pushing flow across full ECMP/WCMP DAGs at each step. For `Proportional` placement with `require_capacity=True`, a final residual completion phase augments with reverse arcs so the result is a true maximum flow; the other modes are placement models and may report less. - - **Traffic Engineering (TE) Mode**: Routing adapts to residual capacity (progressive fill). - - **IP Routing Mode**: Cost-only routing (ECMP/WCMP) ignoring capacity constraints. +- **`StrictMultiDiGraph(num_nodes, src, dst, capacity_units, cost, resolution)`**: immutable + directed multigraph with forward and reverse CSR adjacency; parallel edges and self-loops are + allowed. Validation reports the first defect in a fixed order (unit values, shapes, costs, + remaining bounds). Accessors (`capacity_units()`, `cost()`, `edge_src()`, `edge_dst()`, + `input_index()`, `row_offsets()`, `in_row_offsets()`, `in_adj_edge_index()`) return read-only + NumPy views that keep the graph alive. +- **`Eligibility`**: immutable, graph-bound static eligibility built by + `graph.eligibility(excluded_nodes, excluded_edges)`. -- **Analysis**: - - **Sensitivity Analysis**: Identifies critical edges by removing each saturated edge and measuring how much total flow is *lost*. Supports `shortest_path` mode to analyze only edges used under ECMP routing (IP/IGP networks) vs. full max-flow (SDN/TE networks). - - **Min-Cut**: Computes minimum cuts on residual graphs. +### Flow core -### 3. Flow Policy Engine - -Unified configuration object (`FlowPolicy`) that models diverse routing behaviors: - -- **Modeling**: Unified configuration for **IP Routing** (static costs) and **Traffic Engineering** (dynamic residuals). -- **Placement Strategies**: - - `EqualBalanced`: **ECMP** (equal splitting) - equal distribution across next-hops and parallel edges. - - `Proportional`: **WCMP** (weighted splitting) - distribution proportional to residual capacity. -- **Lifecycle Management**: Handles demand placement, re-optimization of existing flows, and constraints (path cost, stretch factor, flow counts). -- **Static (Pinned) Paths**: `FlowPolicy.set_static_paths()` pins a demand to explicit path bundles (MPLS-style). One flow per usable bundle; a bundle with no path surviving the failure masks is *down* and carries nothing, since a pinned path does not reroute. - -### 4. Python Integration - -- **Zero-Copy**: `FlowState` and `FlowGraph` `*_view()` methods expose C++ buffers as read-only NumPy arrays that track mutation in place. `StrictMultiDiGraph.*_view()` returns copies instead, so the graph's immutability cannot be violated. -- **Concurrency**: Releases the Python GIL during the long-running algorithms (SPF, KSP, max-flow, placement) to enable threading. `PredDAG.resolve_to_paths` holds the GIL. `batch_max_flow` and `sensitivity_analysis` also use internal worker threads, sized by `NGRAPH_CORE_BATCH_THREADS` / `NGRAPH_CORE_SENSITIVITY_THREADS`. +`_netgraph_core._place_global_units(graph, view, sources, destinations, elig, cap)` computes +the minimum-cost flow of the maximum value attainable under `cap` (`None`: uncapped) from a +source set to a destination set over the eligible edges, on the capacities `view` (units, id +order), starting from zero flow. It returns `{placed, cost_distribution, edge_flows, min_cut, +potentials, counters}`. It is private: the recorder and the gates use it; the public evaluators +built on it arrive with the policy engine. ## Quick Start ```python -import numpy as np import netgraph_core as ngc +import _netgraph_core -# Two parallel paths from node 0 to node 3. -graph = ngc.StrictMultiDiGraph.from_arrays( - num_nodes=4, - src=np.array([0, 1, 0, 2], dtype=np.int32), - dst=np.array([1, 3, 2, 3], dtype=np.int32), - capacity=np.array([10.0, 10.0, 5.0, 5.0], dtype=np.float64), - cost=np.array([1, 1, 1, 1], dtype=np.int64), - ext_edge_ids=np.arange(4, dtype=np.int64), # your own stable edge ids -) - -algs = ngc.Algorithms(ngc.Backend.cpu()) -handle = algs.build_graph(graph) +q = 2.0**-12 +# Two parallel paths from node 0 to node 3 (physical capacities 10 and 5). +caps = ngc.to_units([10.0, 10.0, 5.0, 5.0], q) +graph = ngc.StrictMultiDiGraph(4, [0, 1, 0, 2], [1, 3, 2, 3], caps, [1, 1, 2, 2], q) -total, summary = algs.max_flow(handle, 0, 3) -print(total) # 15.0 -print(summary.min_cut.edges.tolist()) # [0, 1] -- bottleneck edge ids +payload = _netgraph_core._place_global_units( + graph, graph.capacity_units(), [0], [3], None, None +) +print(ngc.as_float(payload["placed"], q)) # 15.0 +print(payload["cost_distribution"]) # [[2, 40960], [4, 20480]] ``` ## Installation @@ -134,10 +112,7 @@ described in [dev/perf/README.md](dev/perf/README.md). | Variable | Effect | | --- | --- | -| `NGRAPH_CORE_PROFILE=1` | Enable profiling of C++ hot paths (`profiling_dump()` / `profiling_reset()`). | -| `NGRAPH_CORE_BATCH_THREADS` | Worker threads for `batch_max_flow` (default: hardware concurrency). Set to `1` when calling from your own worker pool. | -| `NGRAPH_CORE_SENSITIVITY_THREADS` | Worker threads for `sensitivity_analysis` (default: hardware concurrency). | -| `NGRAPH_CORE_SPF_QUEUE` | Frontier queue for SPF: `heap` forces the reference binary heap, `bucket` prefers the bucket queue where the graph is eligible. Unset (default) selects automatically; both give bit-identical results. Read once per process; for benchmarking and diagnosis. | +| `NGRAPH_CORE_PROFILE=1` | Enable the C++ profiling scopes (`profiling.hpp`); the Python accessors return with `netgraph_core.diagnostics`. | ## Requirements diff --git a/bindings/python/module.cpp b/bindings/python/module.cpp index 0909e39..b681bdb 100644 --- a/bindings/python/module.cpp +++ b/bindings/python/module.cpp @@ -1,705 +1,606 @@ /* - Pybind11 module exposing NetGraph-Core C++ APIs to Python. - - Uses NumPy arrays with zero-copy spans where possible for FlowState/FlowGraph views (read-only) - - Returns distances as float64 arrays by default (inf for unreachable); dtype='int64' available - - Validates edge/node masks for bool dtype and length - - StrictMultiDiGraph view methods return copies (by design) to avoid exposing lifetime + Pybind11 module `_netgraph_core`: the graph on integer units and the private flow-core entry. + + - Graph accessors return read-only NumPy views that keep the graph alive (no per-call copy). + - Integer inputs (capacities, costs, ids, views, caps) must be integers: Python ints, NumPy + integer scalars or integer arrays; floats and bools are rejected with ValueError, never + truncated. Values are read without narrowing (WideInts) and validated in the contract's + order before any conversion to the C++ types (APPROACH 3, revision 25). Physical values + enter only through to_units; the resolution is validated before anything else. + - std::invalid_argument from Core maps to ValueError. + - _place_global_units is the recorder's private entry point (APPROACH 2.2): it returns the + payload as a dict of Python ints and lists. */ #include #include #include -#include + +#include +#include +#include +#include #include #include +#include +#include -#include "netgraph/core/k_shortest_paths.hpp" #include "netgraph/core/max_flow.hpp" -#include "netgraph/core/flow_state.hpp" -#include "netgraph/core/shortest_paths.hpp" #include "netgraph/core/strict_multidigraph.hpp" #include "netgraph/core/types.hpp" -#include "netgraph/core/flow_graph.hpp" -#include "netgraph/core/flow.hpp" -#include "netgraph/core/flow_policy.hpp" -#include "netgraph/core/backend.hpp" -#include "netgraph/core/algorithms.hpp" -#include "netgraph/core/options.hpp" -#include "netgraph/core/profiling.hpp" namespace py = pybind11; using namespace netgraph::core; -// Helper opaque wrappers (defined at file scope to ensure stable type identity) -struct PyBackend { BackendPtr impl; }; -struct PyGraph { GraphHandle handle; py::object keep_alive; std::int32_t num_nodes; std::int32_t num_edges; }; - -// Helpers to check NumPy arrays namespace { -// Small helper: copy a span into a new NumPy array of the same dtype. + +// Read-only NumPy view of a span owned by `owner`. template -py::array_t copy_to_numpy(std::span s) { - py::array_t arr(s.size()); - if (!s.empty()) std::memcpy(arr.mutable_data(), s.data(), s.size()*sizeof(T)); +py::array readonly_view(std::span s, const py::handle& owner, const py::dtype& dtype) { + py::array arr(dtype, {static_cast(s.size())}, {static_cast(sizeof(T))}, + s.data(), owner); + py::detail::array_proxy(arr.ptr())->flags &= ~py::detail::npy_api::NPY_ARRAY_WRITEABLE_; return arr; } -// Owns a bool[] buffer and exposes a const span view onto it. -struct BoolSpan { - std::unique_ptr buf; - std::span view; -}; +template +py::array readonly_view(std::span s, const py::handle& owner) { + return readonly_view(s, owner, py::dtype::of()); +} + +bool is_bool(const py::handle& obj) { + return PyBool_Check(obj.ptr()) || py::isinstance(obj, py::module_::import("numpy").attr("bool_")); +} + +// The Python int of an integer-like object (a Python int or a NumPy integer scalar); bools and +// everything else are rejected. +py::object python_int(const py::handle& item, const std::string& what) { + if (is_bool(item) || !PyIndex_Check(item.ptr())) { + throw py::value_error(what + " must be an integer (got " + + std::string(py::str(py::type::of(item).attr("__name__"))) + ")"); + } + py::object idx = py::reinterpret_steal(PyNumber_Index(item.ptr())); + if (!idx) throw py::error_already_set(); + return idx; +} + +// One integer of [lo, hi] (scalar arguments). +std::int64_t integer_value(const py::handle& item, std::int64_t lo, std::int64_t hi, const std::string& what) { + py::object idx = python_int(item, what); + int overflow = 0; + const long long v = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); + if (overflow != 0 || v < lo || v > hi) { + throw py::value_error(what + " = " + std::string(py::str(idx)) + " is out of range [" + + std::to_string(lo) + ", " + std::to_string(hi) + "]"); + } + return static_cast(v); +} -// Validate 1-D C-contig NumPy bool array and deep-copy into bool[]. -static BoolSpan to_bool_span_from_numpy(py::object obj, std::size_t expected_len, const char* what) { - BoolSpan out; - if (obj.is_none()) return out; // empty span - py::array arr = obj.cast(); - if (!(arr.flags() & py::array::c_style)) { - throw py::type_error(std::string(what) + " must be C-contiguous"); +// A 1-D integer sequence read without narrowing: every entry's int64 value where it fits, and +// the sign of the overflow where it does not (+1 above INT64_MAX, -1 below INT64_MIN). Only type +// defects raise here (a non-integer, a bool, a float array, a non-sequence); every value bound +// is checked by the caller in the contract's order, so the reported error does not depend on +// the representation of the input (APPROACH 3, revision 25). +struct WideInts { + py::object source; + std::vector value; + std::vector overflow; // empty when no entry overflows + [[nodiscard]] std::size_t size() const noexcept { return value.size(); } + [[nodiscard]] int sign(std::size_t i) const noexcept { return overflow.empty() ? 0 : overflow[i]; } + // The entry as Python prints it (exact also beyond int64). + [[nodiscard]] std::string text(std::size_t i) const { + if (sign(i) == 0) return std::to_string(value[i]); + return std::string(py::str(source[py::int_(i)])); } - auto b = arr.request(); - if (b.ndim != 1 || b.format != py::format_descriptor::format()) { - throw py::type_error(std::string(what) + " must be 1-D bool"); + [[nodiscard]] bool in(std::size_t i, std::int64_t lo, std::int64_t hi) const noexcept { + return sign(i) == 0 && value[i] >= lo && value[i] <= hi; } - if (static_cast(b.shape[0]) != expected_len) { - throw py::type_error(std::string(what) + " length must equal " + std::to_string(expected_len)); + template + [[nodiscard]] std::vector narrow() const { // after every bound was checked + return std::vector(value.begin(), value.end()); + } +}; + +bool not_a_sequence(const py::handle& obj) { + return py::isinstance(obj) || py::isinstance(obj) || !py::isinstance(obj); +} + +WideInts wide_integers(const py::handle& obj, const char* name) { + WideInts out; + out.source = py::reinterpret_borrow(obj); + if (py::isinstance(obj)) { + auto arr = py::reinterpret_borrow(obj); + if (arr.ndim() != 1) { + throw py::value_error(std::string(name) + " must be a 1-D array (got " + std::to_string(arr.ndim()) + + " dimensions)"); + } + const char kind = arr.dtype().kind(); + if (kind != 'i' && kind != 'u') { + throw py::value_error(std::string(name) + " entries must be integers (got dtype " + + std::string(py::str(arr.dtype())) + ")"); + } + out.value.reserve(static_cast(arr.size())); + if (kind == 'u' && arr.itemsize() == 8) { + auto u = py::array_t::ensure(arr); + auto r = u.unchecked<1>(); + constexpr auto kMax = static_cast(std::numeric_limits::max()); + for (py::ssize_t i = 0; i < r.shape(0); ++i) { + if (r(i) > kMax) { + if (out.overflow.empty()) out.overflow.assign(static_cast(r.shape(0)), 0); + out.overflow[static_cast(i)] = 1; + out.value.push_back(std::numeric_limits::max()); + } else { + out.value.push_back(static_cast(r(i))); + } + } + return out; + } + auto a = py::array_t::ensure(arr); + auto r = a.unchecked<1>(); + for (py::ssize_t i = 0; i < r.shape(0); ++i) out.value.push_back(r(i)); + return out; + } + if (not_a_sequence(obj)) throw py::value_error(std::string(name) + " must be a sequence of integers"); + auto seq = py::reinterpret_borrow(obj); + const std::size_t n = seq.size(); + out.value.reserve(n); + for (std::size_t i = 0; i < n; ++i) { + py::object idx = python_int(seq[i], std::string(name) + "[" + std::to_string(i) + "]"); + int overflow = 0; + const long long v = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); + if (overflow != 0) { + if (out.overflow.empty()) out.overflow.assign(n, 0); + out.overflow[i] = static_cast(overflow); + out.value.push_back(overflow > 0 ? std::numeric_limits::max() + : std::numeric_limits::min()); + } else { + out.value.push_back(static_cast(v)); + } } - out.buf.reset(new bool[expected_len]); - // NumPy bool_ uses 1 byte; C++ bool is 1 byte on supported platforms. - std::memcpy(out.buf.get(), b.ptr, expected_len * sizeof(bool)); - out.view = std::span(out.buf.get(), expected_len); return out; } -} // namespace -// Helpers to check NumPy arrays -template -static std::span as_span(const py::array& arr, const char* name) { - if (!py::isinstance>(arr)) { - throw py::type_error(std::string(name) + ": expected numpy array of correct dtype"); +// An entry that is itself a sequence or an array of rank >= 1 (a shape defect of its argument). +bool is_nested(const py::handle& item) { + if (py::isinstance(item)) return py::reinterpret_borrow(item).ndim() >= 1; + return !py::isinstance(item) && !py::isinstance(item) && PySequence_Check(item.ptr()) != 0; +} + +// The shape step: the length of a rank-1 integer sequence argument. A NumPy array of another +// rank (0-D included), a nested sequence or a non-sequence is a shape defect. Entry types and +// values are not read here; their steps come later. +std::size_t sequence_length(const py::handle& obj, const char* name) { + if (py::isinstance(obj)) { + auto arr = py::reinterpret_borrow(obj); + if (arr.ndim() != 1) { + throw py::value_error(std::string(name) + " must be a 1-D array (got " + std::to_string(arr.ndim()) + + " dimensions)"); + } + return static_cast(arr.size()); } - if (!(arr.flags() & py::array::c_style)) { - throw py::type_error(std::string(name) + ": array must be C-contiguous (use np.ascontiguousarray)"); + if (not_a_sequence(obj)) { + throw py::value_error(std::string(name) + " must be a 1-D sequence of integers (got " + + std::string(py::str(py::type::of(obj).attr("__name__"))) + ")"); } - auto buf = arr.request(); - if (buf.ndim != 1) { - throw py::type_error(std::string(name) + ": expected 1-D array"); + auto seq = py::reinterpret_borrow(obj); + const std::size_t n = seq.size(); + for (std::size_t i = 0; i < n; ++i) { + if (is_nested(seq[i])) { + throw py::value_error(std::string(name) + " must be a 1-D sequence of integers (entry " + std::to_string(i) + + " is a sequence)"); + } } - return std::span(static_cast(buf.ptr), static_cast(buf.size)); + return n; } -PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { - m.doc() = "NetGraph-Core C++ bindings"; +// The unit-value step on the capacity argument whatever its shape (the shape step comes next): +// every integer entry it holds must lie in [0, 2^62). A NumPy array is read flattened; nested +// entries of a Python sequence and a non-integer non-sequence are left to the shape step. +void check_unit_values(const py::handle& capacity_units) { + const auto out_of_domain = [](const std::string& what, const std::string& value) { + return py::value_error(what + " = " + value + " is outside the units domain [0, 2^62)"); + }; + if (py::isinstance(capacity_units)) { + py::object flat = capacity_units.attr("ravel")(); + const auto w = wide_integers(flat, "capacity_units"); + for (std::size_t i = 0; i < w.size(); ++i) { + if (!w.in(i, 0, kDomainLimit - 1)) throw out_of_domain("capacity_units[" + std::to_string(i) + "]", w.text(i)); + } + return; + } + if (!not_a_sequence(capacity_units)) { + auto seq = py::reinterpret_borrow(capacity_units); + for (std::size_t i = 0; i < seq.size(); ++i) { + py::object item = seq[i]; + if (is_nested(item)) continue; + const std::string what = "capacity_units[" + std::to_string(i) + "]"; + py::object idx = python_int(item, what); + int overflow = 0; + const long long v = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); + if (overflow != 0 || v < 0 || v >= kDomainLimit) throw out_of_domain(what, std::string(py::str(idx))); + } + return; + } + if (!is_bool(capacity_units) && PyIndex_Check(capacity_units.ptr())) { + py::object idx = python_int(capacity_units, "capacity_units"); + int overflow = 0; + const long long v = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); + if (overflow != 0 || v < 0 || v >= kDomainLimit) throw out_of_domain("capacity_units", std::string(py::str(idx))); + } +} - py::enum_(m, "EdgeTieBreak") - .value("DETERMINISTIC", EdgeTieBreak::Deterministic) - .value("PREFER_HIGHER_RESIDUAL", EdgeTieBreak::PreferHigherResidual); +// Validates a resolution (a power of two in [2^-32, 2^32]) and returns its exponent. +int resolution_exponent(double resolution) { + (void)as_float(0, resolution); // throws the resolution message + return std::ilogb(resolution); +} - py::enum_(m, "EdgePriority") - .value("RESIDUAL", EdgePriority::Residual) - .value("CAPACITY", EdgePriority::Capacity) - .value("HEADROOM", EdgePriority::Headroom); +// Physical numbers as a float64 array (or a 0-d array for a scalar); bools and strings rejected. +py::array_t physical_array(const py::handle& x) { + if (is_bool(x) || py::isinstance(x) || py::isinstance(x)) { + throw py::value_error("to_units takes physical numbers (a float or an array of floats)"); + } + auto np = py::module_::import("numpy"); + py::array raw = np.attr("asarray")(x); + const char kind = raw.dtype().kind(); + if (kind != 'f' && kind != 'i' && kind != 'u') { + throw py::value_error("to_units takes physical numbers (got dtype " + std::string(py::str(raw.dtype())) + ")"); + } + return py::array_t::ensure(raw); +} - py::class_(m, "EdgeSelection") - .def(py::init<>()) - .def(py::init([](bool multi_edge, bool require_capacity, EdgeTieBreak tie_break, EdgePriority priority){ - EdgeSelection s; s.multi_edge = multi_edge; s.require_capacity = require_capacity; s.tie_break = tie_break; s.priority = priority; return s; - }), - py::kw_only(), - py::arg("multi_edge") = true, - py::arg("require_capacity") = false, - py::arg("tie_break") = EdgeTieBreak::Deterministic, - py::arg("priority") = EdgePriority::Residual) - .def_readwrite("multi_edge", &EdgeSelection::multi_edge) - .def_readwrite("require_capacity", &EdgeSelection::require_capacity) - .def_readwrite("tie_break", &EdgeSelection::tie_break) - .def_readwrite("priority", &EdgeSelection::priority); +// x * 2^k for an unbounded Python int, correctly rounded: Python's int shift and int true +// division are exact or correctly rounded, so only a result beyond the finite doubles fails. +double scaled_python_int(const py::object& x, int k) { + try { + py::object r; + if (k >= 0) { + r = py::float_(x.attr("__lshift__")(py::int_(k))); + } else { + r = x.attr("__truediv__")(py::int_(1).attr("__lshift__")(py::int_(-k))); + } + return r.cast(); + } catch (py::error_already_set& e) { + if (e.matches(PyExc_OverflowError)) { + throw py::value_error("as_float: " + std::string(py::str(x)) + + " units times the resolution exceed the largest finite double"); + } + throw; + } +} - py::enum_(m, "FlowPlacement") - .value("PROPORTIONAL", FlowPlacement::Proportional) - .value("EQUAL_BALANCED", FlowPlacement::EqualBalanced) - .value("EQUAL_BALANCED_LOSSY", FlowPlacement::EqualBalancedLossy); +// One Amount to a physical double; the resolution's exponent k is already validated. +double amount_as_float(const py::handle& item, int k, double resolution) { + py::object idx = python_int(item, "as_float: x"); + int overflow = 0; + const long long v = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); + if (overflow == 0) return as_float(static_cast(v), resolution); + return scaled_python_int(idx, k); +} - py::class_(m, "StrictMultiDiGraph") - .def_static( - "from_arrays", - [](std::int32_t num_nodes, - py::array src, py::array dst, - py::array capacity, py::array cost, - py::array ext_edge_ids) { - // public API: src/dst are int32; pass through as int32 to core - auto src_s = as_span(src, "src"); - auto dst_s = as_span(dst, "dst"); - if (src_s.size() != dst_s.size()) throw py::type_error("src and dst must have the same length"); - auto cap_s = as_span(capacity, "capacity"); - // Cost dtype must be int64 to match internal Cost type - auto cost_s = as_span(cost, "cost"); - // ext_edge_ids required: maps internal edge indices to caller's stable IDs - auto ext_s = as_span(ext_edge_ids, "ext_edge_ids"); - return StrictMultiDiGraph::from_arrays(num_nodes, - src_s, - dst_s, - cap_s, cost_s, ext_s); - }, - py::arg("num_nodes"), py::arg("src"), py::arg("dst"), py::arg("capacity"), py::arg("cost"), - py::arg("ext_edge_ids")) - .def("num_nodes", &StrictMultiDiGraph::num_nodes) - .def("num_edges", &StrictMultiDiGraph::num_edges) - .def("max_cost", &StrictMultiDiGraph::max_cost) - .def("cost_gcd", &StrictMultiDiGraph::cost_gcd) - .def("capacity_view", [](const StrictMultiDiGraph& g){ return copy_to_numpy(g.capacity_view()); }) - .def("edge_src_view", [](const StrictMultiDiGraph& g){ return copy_to_numpy(g.edge_src_view()); }) - .def("edge_dst_view", [](const StrictMultiDiGraph& g){ return copy_to_numpy(g.edge_dst_view()); }) - .def("ext_edge_ids_view", [](const StrictMultiDiGraph& g){ return copy_to_numpy(g.ext_edge_ids_view()); }) - .def("cost_view", [](const StrictMultiDiGraph& g){ return copy_to_numpy(g.cost_view()); }) - .def("row_offsets_view", [](const StrictMultiDiGraph& g){ return copy_to_numpy(g.row_offsets_view()); }) - .def("col_indices_view", [](const StrictMultiDiGraph& g){ return copy_to_numpy(g.col_indices_view()); }) - .def("adj_edge_index_view", [](const StrictMultiDiGraph& g){ return copy_to_numpy(g.adj_edge_index_view()); }) - .def("in_row_offsets_view", [](const StrictMultiDiGraph& g){ return copy_to_numpy(g.in_row_offsets_view()); }) - .def("in_col_indices_view", [](const StrictMultiDiGraph& g){ return copy_to_numpy(g.in_col_indices_view()); }) - .def("in_adj_edge_index_view", [](const StrictMultiDiGraph& g){ return copy_to_numpy(g.in_adj_edge_index_view()); }); +std::shared_ptr make_graph(const py::object& num_nodes, const py::object& src, + const py::object& dst, const py::object& capacity_units, + const py::object& cost, double resolution) { + // APPROACH 3 (revision 25): the resolution, the unit values, the shapes, zero or negative + // costs, the remaining bounds (ids in range, then the sums); every value is read without + // narrowing, and the messages are the constructor's, which re-checks the same order. + (void)resolution_exponent(resolution); + check_unit_values(capacity_units); + // The shapes: num_nodes, then every array argument rank 1 (in signature order), then equal + // lengths; no entry of src, dst or cost is read before the cost step. + const auto n = integer_value(num_nodes, 0, std::numeric_limits::max(), "num_nodes"); + const std::size_t m = sequence_length(src, "src"); + const std::size_t md = sequence_length(dst, "dst"); + const std::size_t mu = sequence_length(capacity_units, "capacity_units"); + const std::size_t mc = sequence_length(cost, "cost"); + if (md != m || mu != m || mc != m) { + throw py::value_error("src, dst, capacity_units and cost must have the same length (got " + + std::to_string(m) + ", " + std::to_string(md) + ", " + std::to_string(mu) + ", " + + std::to_string(mc) + ")"); + } + const auto cap = wide_integers(capacity_units, "capacity_units"); // rank 1, values checked + if (m > static_cast(std::numeric_limits::max())) { + throw py::value_error("the number of edges exceeds INT32_MAX (array length)"); + } + const auto w = wide_integers(cost, "cost"); + for (std::size_t i = 0; i < m; ++i) { + if (w.sign(i) < 0 || (w.sign(i) == 0 && w.value[i] < 1)) { + throw py::value_error("cost[" + std::to_string(i) + "] = " + w.text(i) + ": costs must be positive (cost >= 1)"); + } + } + const auto s = wide_integers(src, "src"); + const auto d = wide_integers(dst, "dst"); + for (std::size_t i = 0; i < m; ++i) { + if (!s.in(i, 0, n - 1) || !d.in(i, 0, n - 1)) { + throw py::value_error("edge " + std::to_string(i) + ": endpoint (" + s.text(i) + ", " + d.text(i) + + ") out of node range [0, " + std::to_string(n) + ")"); + } + } + constexpr auto kLimit = static_cast(kDomainLimit); + std::uint64_t cap_sum = 0; + std::uint64_t cost_sum = 0; + for (std::size_t i = 0; i < m; ++i) { + cap_sum += static_cast(cap.value[i]); // each below 2^62: no wrap before the check + if (cap_sum >= kLimit) { + throw py::value_error("the sum of all capacities reaches the bound of the units domain (2^62 units)"); + } + if (w.sign(i) > 0 || static_cast(w.value[i]) >= kLimit - cost_sum) { + throw py::value_error("the sum of all edge costs reaches the bound 2^62: path costs would overflow int64"); + } + cost_sum += static_cast(w.value[i]); + } + auto cap_v = cap.narrow(); + auto cost_v = w.narrow(); + auto src_v = s.narrow(); + auto dst_v = d.narrow(); + py::gil_scoped_release release; + return std::make_shared(static_cast(n), src_v, dst_v, cap_v, cost_v, resolution); +} - // Backend and Algorithms +py::list int_list(const std::vector& v) { + py::list out(v.size()); + for (std::size_t i = 0; i < v.size(); ++i) out[i] = py::int_(v[i]); + return out; +} - // Opaque wrapper types - py::class_(m, "Backend") - .def_static("cpu", [](){ return PyBackend{ make_cpu_backend() }; }); +} // namespace - py::class_(m, "Graph") - // Opaque holder; constructed via Algorithms.build_graph - .def_property_readonly("num_nodes", [](const PyGraph& pg){ return pg.num_nodes; }) - .def_property_readonly("num_edges", [](const PyGraph& pg){ return pg.num_edges; }); +PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { + m.doc() = "NetGraph-Core: the graph on integer units and the minimum-cost flow core"; - py::class_>(m, "Algorithms") - .def(py::init([](const PyBackend& b){ return std::make_shared(b.impl); }), py::arg("backend")) - .def("build_graph", [](const Algorithms& algs, py::object graph_obj){ - // graph_obj is py::object so PyGraph can keep it alive; check the type - // explicitly, since a bare .cast<> failure surfaces as an opaque - // "RuntimeError: Unable to cast ... to C++ type '?'". - if (!py::isinstance(graph_obj)) { - throw py::type_error("build_graph: graph must be a StrictMultiDiGraph"); - } - const StrictMultiDiGraph& g = graph_obj.cast(); - auto gh = algs.build_graph(g); - // Keep the Python graph object alive alongside the handle when - // referencing a non-owned graph instance. - return PyGraph{ gh, graph_obj, g.num_nodes(), g.num_edges() }; - }, py::arg("graph")) - .def("spf", [](const Algorithms& algs, const PyGraph& pg, std::int32_t src, - py::object dst, py::object selection_obj, py::object residual_obj, - py::object node_mask, py::object edge_mask, bool multipath, std::string dtype) -> py::tuple { - if (src < 0 || src >= pg.num_nodes) throw py::value_error("src out of range"); - SpfOptions opts; if (!selection_obj.is_none()) opts.selection = py::cast(selection_obj); - if (!dst.is_none()) { auto d = py::cast(dst); if (d < 0 || d >= pg.num_nodes) throw py::value_error("dst out of range"); opts.dst = static_cast(d); } - std::vector residual_vec; - if (!residual_obj.is_none()) { - auto arr = py::cast(residual_obj); - if (!(arr.flags() & py::array::c_style)) throw py::type_error("residual must be C-contiguous (np.ascontiguousarray)"); - auto buf = arr.request(); - if (buf.ndim != 1 || buf.format != py::format_descriptor::format()) throw py::type_error("residual must be 1-D float64"); - // Check length here so this matches the TypeError raised by every other - // length check (masks, FlowState residual); otherwise the core throws - // std::invalid_argument, which reaches Python as ValueError. - if (static_cast(buf.shape[0]) != pg.num_edges) { - throw py::type_error("residual length must equal " + std::to_string(pg.num_edges)); + m.def( + "to_units", + [](const py::object& x, double resolution) -> py::object { + (void)resolution_exponent(resolution); // before the shape: an empty array is no exemption + auto arr = physical_array(x); + if (arr.ndim() == 0) return py::int_(to_units(*arr.data(), resolution)); + if (arr.ndim() != 1) throw py::value_error("to_units takes a float or a 1-D array of floats"); + py::array_t out(arr.size()); + auto in = arr.unchecked<1>(); + auto o = out.mutable_unchecked<1>(); + for (py::ssize_t i = 0; i < in.shape(0); ++i) o(i) = to_units(in(i), resolution); + return std::move(out); + }, + py::arg("x"), py::arg("resolution"), + "Physical value(s) to units of the resolution q, rounding half up: an int for a scalar, an\n" + "int64 array for an array. NaN, infinity, negative values and results at or above 2^62\n" + "units raise ValueError; q must be a power of two in [2^-32, 2^32]."); + + m.def( + "as_float", + [](const py::object& x, double resolution) -> py::object { + const int k = resolution_exponent(resolution); // before the shape (review-1a D3) + const bool array = py::isinstance(x) && py::reinterpret_borrow(x).ndim() > 0; + if (array || !not_a_sequence(x)) { + const auto v = wide_integers(x, "as_float: x"); + py::array_t out(static_cast(v.size())); + auto o = out.mutable_unchecked<1>(); + for (std::size_t i = 0; i < v.size(); ++i) { + o(static_cast(i)) = v.sign(i) == 0 ? as_float(v.value[i], resolution) + : amount_as_float(v.source[py::int_(i)], k, resolution); } - residual_vec.resize(static_cast(buf.shape[0])); - std::memcpy(residual_vec.data(), buf.ptr, residual_vec.size()*sizeof(double)); - opts.residual = std::span(residual_vec.data(), residual_vec.size()); + return std::move(out); } - auto node_bs = to_bool_span_from_numpy(node_mask, static_cast(pg.num_nodes), "node_mask"); - auto edge_bs = to_bool_span_from_numpy(edge_mask, static_cast(pg.num_edges), "edge_mask"); - opts.node_mask = node_bs.view; - opts.edge_mask = edge_bs.view; - opts.multipath = multipath; - py::gil_scoped_release rel; auto res = algs.spf(pg.handle, src, opts); py::gil_scoped_acquire acq; - auto maxc = std::numeric_limits::max(); - if (dtype == "int64") { - py::array_t dist_arr(res.first.size()); - auto* out = dist_arr.mutable_data(); - for (std::size_t i=0;i(maxc) : static_cast(res.first[i]); - return py::make_tuple(std::move(dist_arr), std::move(res.second)); - } else if (dtype == "float64") { - py::array_t dist_arr(res.first.size()); - auto* out = dist_arr.mutable_data(); - for (std::size_t i=0;i::infinity() : static_cast(res.first[i]); - return py::make_tuple(std::move(dist_arr), std::move(res.second)); - } else { - throw py::value_error("dtype must be 'float64' or 'int64'"); + return py::float_(amount_as_float(x, k, resolution)); + }, + py::arg("x"), py::arg("resolution"), + "Units to physical value(s): x * q, correctly rounded (exact below 2^53 units). An int, of\n" + "any size, gives a float, an integer array a float64 array; a result beyond the finite\n" + "doubles raises ValueError; q must be a power of two in [2^-32, 2^32]."); + + py::class_>( + m, "Eligibility", + "Immutable, graph-bound static eligibility: dense node and edge flags. Built by\n" + "StrictMultiDiGraph.eligibility(excluded_nodes, excluded_edges).") + .def("graph_uid", &Eligibility::graph_uid, "uid of the graph this value belongs to") + .def("uid", &Eligibility::uid, "0 for the all-allowed value, a fresh uid otherwise") + .def( + "node_allowed", + [](const py::object& self) { + const auto& el = self.cast(); + return readonly_view(el.node_allowed(), self, py::dtype("bool")); + }, + "bool[N] read-only view") + .def( + "edge_allowed", + [](const py::object& self) { + const auto& el = self.cast(); + return readonly_view(el.edge_allowed(), self, py::dtype("bool")); + }, + "bool[E] read-only view (not excluded and both endpoints allowed)"); + + py::class_>( + m, "StrictMultiDiGraph", + "Immutable directed multigraph on integer units. Edges are given in input order; the\n" + "public EdgeId is the rank in the stable order (src, cost, dst, input), input_index()[e]\n" + "is the input position of e, and every other per-edge array is in id order. Costs are\n" + "positive integers; capacities are int64 units of the explicit resolution q (a power of\n" + "two in [2^-32, 2^32]); physical capacities enter through to_units.") + .def(py::init(&make_graph), py::arg("num_nodes"), py::arg("src"), py::arg("dst"), + py::arg("capacity_units"), py::arg("cost"), py::arg("resolution"), + "Validates, in order, and reports the first defect as ValueError: the resolution (a power of\n" + "two in [2^-32, 2^32]), the unit values (every capacity in [0, 2^62)), the array shapes,\n" + "zero or negative costs (rule 28), the remaining bounds (endpoints in range, then the\n" + "capacity and cost sums below 2^62). Integers are read without narrowing, so the error\n" + "does not depend on how the input is represented (APPROACH 3).") + .def("uid", &StrictMultiDiGraph::uid, "process-unique id (never reused)") + .def("resolution", &StrictMultiDiGraph::resolution, "the resolution q (units of capacity)") + .def("num_nodes", &StrictMultiDiGraph::num_nodes) + .def("num_edges", &StrictMultiDiGraph::num_edges) + .def("max_cost", &StrictMultiDiGraph::max_cost, "largest edge cost (0 without edges)") + .def("cost_gcd", &StrictMultiDiGraph::cost_gcd, "gcd of the edge costs (0 without edges)") + .def( + "capacity_units", [](const py::object& self) { + const auto& g = self.cast(); + return readonly_view(g.capacity_units(), self); + }, + "int64[E] capacities in units, id order (read-only view)") + .def( + "cost", [](const py::object& self) { + const auto& g = self.cast(); + return readonly_view(g.cost(), self); + }, + "int64[E] costs, id order (read-only view)") + .def( + "edge_src", [](const py::object& self) { + const auto& g = self.cast(); + return readonly_view(g.edge_src(), self); + }, + "int32[E] source node per edge (read-only view)") + .def( + "edge_dst", [](const py::object& self) { + const auto& g = self.cast(); + return readonly_view(g.edge_dst(), self); + }, + "int32[E] destination node per edge (read-only view)") + .def( + "input_index", [](const py::object& self) { + const auto& g = self.cast(); + return readonly_view(g.input_index(), self); + }, + "int32[E]: id -> input position (read-only view); the inverse is the caller's") + .def( + "row_offsets", [](const py::object& self) { + const auto& g = self.cast(); + return readonly_view(g.row_offsets(), self); + }, + "int32[N + 1]: the out-edges of u are the ids [row_offsets[u], row_offsets[u + 1])") + .def( + "in_row_offsets", [](const py::object& self) { + const auto& g = self.cast(); + return readonly_view(g.in_row_offsets(), self); + }, + "int32[N + 1] reverse CSR offsets") + .def( + "in_adj_edge_index", [](const py::object& self) { + const auto& g = self.cast(); + return readonly_view(g.in_adj_edge_index(), self); + }, + "int32[E]: the in-edges of v in (cost, src, input) order, as EdgeIds") + .def( + "eligibility", + [](const StrictMultiDiGraph& g, const py::object& excluded_nodes, const py::object& excluded_edges) { + // Nodes, then edges, each range-checked before narrowing (the constructor's order). + const auto xn = wide_integers(excluded_nodes, "excluded_nodes"); + for (std::size_t i = 0; i < xn.size(); ++i) { + if (!xn.in(i, 0, static_cast(g.num_nodes()) - 1)) { + throw py::value_error("excluded node " + xn.text(i) + " out of node range [0, " + + std::to_string(g.num_nodes()) + ")"); + } + } + const auto xe = wide_integers(excluded_edges, "excluded_edges"); + for (std::size_t i = 0; i < xe.size(); ++i) { + if (!xe.in(i, 0, static_cast(g.num_edges()) - 1)) { + throw py::value_error("excluded edge " + xe.text(i) + " out of edge range [0, " + + std::to_string(g.num_edges()) + ")"); + } + } + return std::make_shared(g.eligibility(xn.narrow(), xe.narrow())); + }, + py::arg("excluded_nodes") = py::tuple(), py::arg("excluded_edges") = py::tuple(), + "Static eligibility from excluded node ids and EdgeIds (duplicates and unsorted lists\n" + "accepted, out-of-range ids rejected); an edge is allowed when it is not excluded and\n" + "both endpoints are allowed. Empty lists mean all allowed (uid 0)."); + + m.def( + "_place_global_units", + [](const std::shared_ptr& graph, const py::object& view, const py::object& sources, + const py::object& destinations, const std::shared_ptr& elig, + const py::object& cap) -> py::dict { + if (!graph) throw py::value_error("graph must be a StrictMultiDiGraph"); + // The core's order (view length, view entries, sources, destinations, overlap, cap, + // Eligibility), every value checked before narrowing; the core re-checks the same order. + const auto num_edges = static_cast(graph->num_edges()); + const std::size_t view_len = sequence_length(view, "view"); + if (view_len != num_edges) { + throw py::value_error("view has length " + std::to_string(view_len) + ", expected " + + std::to_string(num_edges) + " (one entry per edge)"); } - }, py::arg("graph"), py::arg("src"), py::arg("dst") = py::none(), py::kw_only(), py::arg("selection") = py::none(), py::arg("residual") = py::none(), py::arg("node_mask") = py::none(), py::arg("edge_mask") = py::none(), py::arg("multipath") = true, py::arg("dtype") = "float64") - .def("spf_to", [](const Algorithms& algs, const PyGraph& pg, std::int32_t dst, - py::object selection_obj, py::object residual_obj, - py::object node_mask, py::object edge_mask, bool multipath, - py::object fanout_obj, std::string dtype) -> py::tuple { - if (dst < 0 || dst >= pg.num_nodes) throw py::value_error("dst out of range"); - SpfToOptions opts; if (!selection_obj.is_none()) opts.selection = py::cast(selection_obj); - std::vector residual_vec; - if (!residual_obj.is_none()) { - auto arr = py::cast(residual_obj); - if (!(arr.flags() & py::array::c_style)) throw py::type_error("residual must be C-contiguous (np.ascontiguousarray)"); - auto buf = arr.request(); - if (buf.ndim != 1 || buf.format != py::format_descriptor::format()) throw py::type_error("residual must be 1-D float64"); - if (static_cast(buf.shape[0]) != pg.num_edges) { - throw py::type_error("residual length must equal " + std::to_string(pg.num_edges)); + const auto v = wide_integers(view, "view (capacity units)"); + const auto capacity = graph->capacity_units(); + for (std::size_t e = 0; e < num_edges; ++e) { + if (!v.in(e, 0, capacity[e])) { + throw py::value_error("view[" + std::to_string(e) + "] = " + v.text(e) + " is outside [0, capacity_units[" + + std::to_string(e) + "] = " + std::to_string(capacity[e]) + "]"); } - residual_vec.resize(static_cast(buf.shape[0])); - std::memcpy(residual_vec.data(), buf.ptr, residual_vec.size()*sizeof(double)); - opts.residual = std::span(residual_vec.data(), residual_vec.size()); } - std::vector fanout_vec; - if (!fanout_obj.is_none()) { - for (auto item : py::iterable(fanout_obj)) { - auto e = py::cast(item); - if (e < 0 || e >= pg.num_edges) throw py::value_error("fanout edge id out of range"); - fanout_vec.push_back(static_cast(e)); + const auto endpoints = [&](const py::object& obj, const char* name) { + const auto ids = wide_integers(obj, (std::string(name) + " node ids").c_str()); + if (ids.size() == 0) throw py::value_error(std::string(name) + ": the endpoint set is empty"); + for (std::size_t i = 0; i < ids.size(); ++i) { + if (!ids.in(i, 0, static_cast(graph->num_nodes()) - 1)) { + throw py::value_error(std::string(name) + ": node " + ids.text(i) + " out of node range [0, " + + std::to_string(graph->num_nodes()) + ")"); + } } - opts.fanout_edges = std::span(fanout_vec.data(), fanout_vec.size()); - } - auto node_bs = to_bool_span_from_numpy(node_mask, static_cast(pg.num_nodes), "node_mask"); - auto edge_bs = to_bool_span_from_numpy(edge_mask, static_cast(pg.num_edges), "edge_mask"); - opts.node_mask = node_bs.view; - opts.edge_mask = edge_bs.view; - opts.multipath = multipath; - py::gil_scoped_release rel; auto res = algs.spf_to(pg.handle, dst, opts); py::gil_scoped_acquire acq; - auto maxc = std::numeric_limits::max(); - if (dtype == "int64") { - py::array_t dist_arr(res.first.size()); - auto* out = dist_arr.mutable_data(); - for (std::size_t i=0;i(maxc) : static_cast(res.first[i]); - return py::make_tuple(std::move(dist_arr), std::move(res.second)); - } else if (dtype == "float64") { - py::array_t dist_arr(res.first.size()); - auto* out = dist_arr.mutable_data(); - for (std::size_t i=0;i::infinity() : static_cast(res.first[i]); - return py::make_tuple(std::move(dist_arr), std::move(res.second)); - } else { - throw py::value_error("dtype must be 'float64' or 'int64'"); - } - }, py::arg("graph"), py::arg("dst"), py::kw_only(), py::arg("selection") = py::none(), py::arg("residual") = py::none(), py::arg("node_mask") = py::none(), py::arg("edge_mask") = py::none(), py::arg("multipath") = true, py::arg("fanout_edges") = py::none(), py::arg("dtype") = "float64", - "Shortest paths from every node to dst (reverse SPF). Returns (distances_to_dst, dag); the DAG is forward-oriented and valid for placement from any node toward dst. fanout_edges are added to the DAG regardless of cost (see shortest_paths.hpp).") - .def("ksp", [](const Algorithms& algs, const PyGraph& pg, std::int32_t src, std::int32_t dst, - int k, py::object max_cost_factor, bool unique, py::object node_mask, py::object edge_mask, std::string dtype){ - if (src < 0 || src >= pg.num_nodes || dst < 0 || dst >= pg.num_nodes) throw py::value_error("src/dst out of range"); - if (k <= 0) throw py::value_error("k must be >= 1"); - if (dtype != "float64" && dtype != "int64") throw py::value_error("dtype must be 'float64' or 'int64'"); - KspOptions opts; opts.k = k; opts.unique = unique; if (!max_cost_factor.is_none()) opts.max_cost_factor = py::cast(max_cost_factor); - auto node_bs = to_bool_span_from_numpy(node_mask, static_cast(pg.num_nodes), "node_mask"); - auto edge_bs = to_bool_span_from_numpy(edge_mask, static_cast(pg.num_edges), "edge_mask"); - opts.node_mask = node_bs.view; opts.edge_mask = edge_bs.view; - py::gil_scoped_release rel; auto items = algs.ksp(pg.handle, src, dst, opts); py::gil_scoped_acquire acq; - py::list out; - auto maxc = std::numeric_limits::max(); - for (auto& pr : items) { - auto& dist = pr.first; - if (dtype == "int64") { - py::array_t dist_arr(dist.size()); - auto* outp = dist_arr.mutable_data(); - for (std::size_t i=0;i(maxc) : static_cast(dist[i]); - out.append(py::make_tuple(std::move(dist_arr), pr.second)); - } else if (dtype == "float64") { - py::array_t dist_arr(dist.size()); - auto* outp = dist_arr.mutable_data(); - for (std::size_t i=0;i::infinity() : static_cast(dist[i]); - out.append(py::make_tuple(std::move(dist_arr), pr.second)); + return ids.narrow(); + }; + const auto s = endpoints(sources, "sources"); + const auto d = endpoints(destinations, "destinations"); + std::vector ss(s); + std::vector dd(d); + std::sort(ss.begin(), ss.end()); + std::sort(dd.begin(), dd.end()); + for (std::size_t i = 0, k = 0; i < ss.size() && k < dd.size();) { + if (ss[i] == dd[k]) { + throw py::value_error("node " + std::to_string(ss[i]) + + " is in both endpoint sets: sources and destinations must not overlap"); } + if (ss[i] < dd[k]) ++i; else ++k; } - return out; - }, py::arg("graph"), py::arg("src"), py::arg("dst"), py::kw_only(), py::arg("k"), py::arg("max_cost_factor") = py::none(), py::arg("unique") = true, py::arg("node_mask") = py::none(), py::arg("edge_mask") = py::none(), py::arg("dtype") = "float64") - .def("max_flow", [](const Algorithms& algs, const PyGraph& pg, std::int32_t src, std::int32_t dst, - FlowPlacement placement, bool shortest_path, bool require_capacity, bool with_edge_flows, bool with_reachable, bool with_residuals, - py::object node_mask, py::object edge_mask){ - if (src < 0 || src >= pg.num_nodes || dst < 0 || dst >= pg.num_nodes) throw py::value_error("src/dst out of range"); - MaxFlowOptions o; o.placement = placement; o.shortest_path = shortest_path; o.require_capacity = require_capacity; o.with_edge_flows = with_edge_flows; o.with_reachable = with_reachable; o.with_residuals = with_residuals; - auto node_bs = to_bool_span_from_numpy(node_mask, static_cast(pg.num_nodes), "node_mask"); - auto edge_bs = to_bool_span_from_numpy(edge_mask, static_cast(pg.num_edges), "edge_mask"); - o.node_mask = node_bs.view; o.edge_mask = edge_bs.view; - py::gil_scoped_release rel; auto res = algs.max_flow(pg.handle, src, dst, o); py::gil_scoped_acquire acq; return res; - }, py::arg("graph"), py::arg("src"), py::arg("dst"), py::kw_only(), py::arg("flow_placement") = FlowPlacement::Proportional, py::arg("shortest_path") = false, py::arg("require_capacity") = true, py::arg("with_edge_flows") = false, py::arg("with_reachable") = false, py::arg("with_residuals") = false, py::arg("node_mask") = py::none(), py::arg("edge_mask") = py::none()) - .def("batch_max_flow", [](const Algorithms& algs, const PyGraph& pg, py::array pairs, - py::object node_masks, py::object edge_masks, - FlowPlacement placement, bool shortest_path, bool require_capacity, - bool with_edge_flows, bool with_reachable, bool with_residuals){ - // Check the dtype, not the buffer format string: NumPy spells int32 as - // NPY_LONG ('l') on LLP64 (Windows) and NPY_INT ('i') on LP64, while - // format_descriptor is always 'i'. Comparing format strings - // therefore rejects a genuine int32 array on Windows. isinstance uses - // dtype equivalence, matching as_span() above. - if (!py::isinstance>(pairs)) throw py::type_error("pairs dtype must be int32"); - if (!(pairs.flags() & py::array::c_style)) throw py::type_error("pairs must be C-contiguous (use np.ascontiguousarray)"); - auto buf = pairs.request(); - if (buf.ndim != 2 || buf.shape[1] != 2) throw py::type_error("pairs must be shape [B,2]"); - const std::size_t B = static_cast(buf.shape[0]); - std::vector> pp; pp.reserve(B); - auto* p = static_cast(buf.ptr); - for (std::size_t i=0;i= pg.num_nodes || - pp[i].second < 0 || pp[i].second >= pg.num_nodes) { - throw py::value_error("pairs[" + std::to_string(i) + "] has a src/dst out of range"); + std::optional c; + if (!cap.is_none()) { + if (is_bool(cap) || !PyIndex_Check(cap.ptr())) { + throw py::value_error("cap must be an integer number of units in [0, 2^63 - 2] or None"); } - } - std::vector node_bufs, edge_bufs; - std::vector> node_spans; std::vector> edge_spans; - auto parse_mask_list = [&](py::object list_obj, std::size_t expected_len, const char* what, - std::vector& bufs, std::vector>& spans) { - if (list_obj.is_none()) return; - auto seq = py::cast(list_obj); - if (static_cast(py::len(seq)) != B) - throw py::type_error(std::string(what) + " length must equal number of pairs"); - bufs.reserve(B); spans.reserve(B); - for (auto item : seq) { - auto bs = to_bool_span_from_numpy(item, expected_len, what); - spans.push_back(bs.view); - bufs.push_back(std::move(bs)); // keep storage alive across GIL release + py::object idx = python_int(cap, "cap"); + int overflow = 0; + const long long value = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); + if (overflow != 0 || value < 0 || value > kMaxFlowCap) { + throw py::value_error("cap " + std::string(py::str(idx)) + " is outside [0, 2^63 - 2]"); } - }; - parse_mask_list(node_masks, static_cast(pg.num_nodes), "node_masks", node_bufs, node_spans); - parse_mask_list(edge_masks, static_cast(pg.num_edges), "edge_masks", edge_bufs, edge_spans); - MaxFlowOptions o; o.placement = placement; o.shortest_path = shortest_path; o.require_capacity = require_capacity; o.with_edge_flows = with_edge_flows; o.with_reachable = with_reachable; o.with_residuals = with_residuals; - py::gil_scoped_release rel; auto out = algs.batch_max_flow(pg.handle, pp, o, node_spans, edge_spans); py::gil_scoped_acquire acq; return out; - }, py::arg("graph"), py::arg("pairs"), py::kw_only(), py::arg("node_masks") = py::none(), py::arg("edge_masks") = py::none(), py::arg("flow_placement") = FlowPlacement::Proportional, py::arg("shortest_path") = false, py::arg("require_capacity") = true, py::arg("with_edge_flows") = false, py::arg("with_reachable") = false, py::arg("with_residuals") = false) - .def("sensitivity_analysis", [](const Algorithms& algs, const PyGraph& pg, std::int32_t src, std::int32_t dst, - FlowPlacement placement, bool shortest_path, bool require_capacity, - py::object node_mask, py::object edge_mask){ - if (src < 0 || src >= pg.num_nodes || dst < 0 || dst >= pg.num_nodes) throw py::value_error("src/dst out of range"); - MaxFlowOptions o; o.placement = placement; o.shortest_path = shortest_path; o.require_capacity = require_capacity; - auto node_bs = to_bool_span_from_numpy(node_mask, static_cast(pg.num_nodes), "node_mask"); - auto edge_bs = to_bool_span_from_numpy(edge_mask, static_cast(pg.num_edges), "edge_mask"); - o.node_mask = node_bs.view; o.edge_mask = edge_bs.view; - py::gil_scoped_release rel; auto res = algs.sensitivity_analysis(pg.handle, src, dst, o); py::gil_scoped_acquire acq; - // Return as list of tuples (EdgeId, FlowDelta) - py::list out; - for (auto const& pr : res) { - out.append(py::make_tuple(pr.first, pr.second)); + c = static_cast(value); } - return out; - }, py::arg("graph"), py::arg("src"), py::arg("dst"), py::kw_only(), py::arg("flow_placement") = FlowPlacement::Proportional, py::arg("shortest_path") = false, py::arg("require_capacity") = true, py::arg("node_mask") = py::none(), py::arg("edge_mask") = py::none()); - - py::class_(m, "PredDAG") - .def_property_readonly("parent_offsets", [](const PredDAG& d){ - return copy_to_numpy(std::span(d.parent_offsets.data(), d.parent_offsets.size())); - }) - .def_property_readonly("parents", [](const PredDAG& d){ - return copy_to_numpy(std::span(d.parents.data(), d.parents.size())); - }) - .def_property_readonly("via_edges", [](const PredDAG& d){ - return copy_to_numpy(std::span(d.via_edges.data(), d.via_edges.size())); - }) - .def_static("from_hops", [](const StrictMultiDiGraph& g, py::sequence hops){ - std::vector> h; - h.reserve(static_cast(py::len(hops))); - for (auto hop : hops) { - std::vector edges; - for (auto e : py::cast(hop)) edges.push_back(py::cast(e)); - h.push_back(std::move(edges)); + FlowCoreResult r; + { + py::gil_scoped_release release; + const auto view_units = v.narrow(); + r = place_global_units(*graph, view_units, s, d, elig.get(), c); } - return make_hops_dag(g, h); - }, py::arg("graph"), py::arg("hops")) - .def_static("from_edges", [](const StrictMultiDiGraph& g, py::sequence edges){ - std::vector e; - e.reserve(py::len(edges)); - for (auto item : edges) e.push_back(py::cast(item)); - return make_path_dag(g, e); - }, py::arg("graph"), py::arg("edges"), - "Build a single-path PredDAG from a contiguous edge-id sequence.\n" - "Validates that each edge exists, consecutive edges connect, and the walk " - "is a simple path. The result works anywhere a PredDAG is accepted " - "(place_on_dag, FlowGraph.place, FlowPolicy.set_static_paths).") - .def("resolve_to_paths", [](const PredDAG& dag, std::int32_t src, std::int32_t dst, bool split_parallel_edges, py::object max_paths){ - std::optional mp; - if (!max_paths.is_none()) mp = py::cast(max_paths); - auto out = netgraph::core::resolve_to_paths(dag, src, dst, split_parallel_edges, mp); - py::list paths; - for (auto& path : out) { - py::list py_elems; - for (auto& pr : path) { - py::tuple edge_tuple(pr.second.size()); - for (std::size_t i=0;i(m, "MinCut") - .def_property_readonly("edges", [](const MinCut& mc){ - return copy_to_numpy(std::span(mc.edges.data(), mc.edges.size())); - }); - - py::class_(m, "FlowSummary") - .def_readonly("total_flow", &FlowSummary::total_flow) - .def_readonly("min_cut", &FlowSummary::min_cut) - .def_property_readonly("costs", [](const FlowSummary& s){ - return copy_to_numpy(std::span(s.costs.data(), s.costs.size())); - }) - .def_property_readonly("flows", [](const FlowSummary& s){ - return copy_to_numpy(std::span(s.flows.data(), s.flows.size())); - }) - .def_property_readonly("edge_flows", [](const FlowSummary& s){ - return copy_to_numpy(std::span(s.edge_flows.data(), s.edge_flows.size())); - }) - .def_property_readonly("residual_capacity", [](const FlowSummary& s){ - return copy_to_numpy(std::span(s.residual_capacity.data(), s.residual_capacity.size())); - }) - .def_property_readonly("reachable_nodes", [](const FlowSummary& s){ - // Store as bool array for Python; cast bytes to bool - py::array_t arr(s.reachable_nodes.size()); - auto* out = arr.mutable_data(); - for (std::size_t i=0;i(s.reachable_nodes[i]); - return arr; - }); - - // FlowState bindings - py::class_(m, "FlowState") - .def(py::init([](const StrictMultiDiGraph& g){ return FlowState(g); }), - py::arg("graph"), py::keep_alive<1, 2>()) - .def(py::init([](const StrictMultiDiGraph& g, py::array residual){ - if (!(py::isinstance>(residual))) throw py::type_error("residual must be a numpy float64 array"); - if (!(residual.flags() & py::array::c_style)) throw py::type_error("residual must be C-contiguous"); - auto buf = residual.request(); - if (buf.ndim != 1 || static_cast(buf.shape[0]) != static_cast(g.num_edges())) throw py::type_error("residual length must equal num_edges"); - std::span rspan(static_cast(buf.ptr), static_cast(buf.shape[0])); - return FlowState(g, rspan); - }), py::arg("graph"), py::arg("residual"), py::keep_alive<1, 2>()) - .def("reset", [](FlowState& fs){ fs.reset(); }) - .def("reset", [](FlowState& fs, py::array residual){ - if (!(py::isinstance>(residual))) throw py::type_error("residual must be a numpy float64 array"); - if (!(residual.flags() & py::array::c_style)) throw py::type_error("residual must be C-contiguous"); - auto buf = residual.request(); - if (buf.ndim != 1 || static_cast(buf.shape[0]) != static_cast(fs.capacity_view().size())) throw py::type_error("residual length must equal num_edges"); - std::span rspan(static_cast(buf.ptr), static_cast(buf.shape[0])); - fs.reset(rspan); - }) - .def("capacity_view", [](py::object self_obj){ - const FlowState& fs = py::cast(self_obj); - auto s = fs.capacity_view(); - py::array view(py::buffer_info(const_cast(s.data()), sizeof(double), py::format_descriptor::format(), 1, { s.size() }, { sizeof(double) }), self_obj); - view.attr("setflags")(py::arg("write") = false); - return view; - }) - .def("residual_view", [](py::object self_obj){ - const FlowState& fs = py::cast(self_obj); - auto s = fs.residual_view(); - py::array view(py::buffer_info(const_cast(s.data()), sizeof(double), py::format_descriptor::format(), 1, { s.size() }, { sizeof(double) }), self_obj); - view.attr("setflags")(py::arg("write") = false); - return view; - }) - .def("edge_flow_view", [](py::object self_obj){ - const FlowState& fs = py::cast(self_obj); - auto s = fs.edge_flow_view(); - py::array view(py::buffer_info(const_cast(s.data()), sizeof(double), py::format_descriptor::format(), 1, { s.size() }, { sizeof(double) }), self_obj); - view.attr("setflags")(py::arg("write") = false); - return view; - }) - .def("place_on_dag", [](FlowState& fs, std::int32_t src, std::int32_t dst, const PredDAG& dag, double requested_flow, FlowPlacement placement){ - py::gil_scoped_release rel; auto placed = fs.place_on_dag(src, dst, dag, requested_flow, placement); py::gil_scoped_acquire acq; return placed; - }, py::arg("src"), py::arg("dst"), py::arg("dag"), py::arg("requested_flow") = std::numeric_limits::infinity(), py::arg("flow_placement") = FlowPlacement::Proportional) - .def("place_max_flow", [](py::object self_obj, std::int32_t src, std::int32_t dst, - FlowPlacement placement, bool shortest_path, bool require_capacity, - py::object node_mask, py::object edge_mask){ - FlowState& fs = py::cast(self_obj); - const StrictMultiDiGraph& g = fs.graph(); - auto node_bs = to_bool_span_from_numpy(node_mask, static_cast(g.num_nodes()), "node_mask"); - auto edge_bs = to_bool_span_from_numpy(edge_mask, static_cast(g.num_edges()), "edge_mask"); - py::gil_scoped_release rel; - auto total = fs.place_max_flow(src, dst, placement, shortest_path, require_capacity, - node_bs.view, edge_bs.view); - py::gil_scoped_acquire acq; - return total; - }, py::arg("src"), py::arg("dst"), - py::arg("flow_placement") = FlowPlacement::Proportional, - py::arg("shortest_path") = false, py::arg("require_capacity") = true, - py::kw_only(), py::arg("node_mask") = py::none(), py::arg("edge_mask") = py::none()) - .def("compute_min_cut", [](py::object self_obj, std::int32_t src, py::object node_mask, py::object edge_mask){ - const FlowState& fs = py::cast(self_obj); - const StrictMultiDiGraph& g = fs.graph(); - auto node_bs = to_bool_span_from_numpy(node_mask, static_cast(g.num_nodes()), "node_mask"); - auto edge_bs = to_bool_span_from_numpy(edge_mask, static_cast(g.num_edges()), "edge_mask"); - py::gil_scoped_release rel; - auto mc = fs.compute_min_cut(src, node_bs.view, edge_bs.view); - py::gil_scoped_acquire acq; - return mc; - }, py::arg("src"), py::kw_only(), py::arg("node_mask") = py::none(), py::arg("edge_mask") = py::none()); - - // FlowIndex and FlowGraph bindings - py::class_(m, "FlowIndex") - .def(py::init()) - .def_readonly("src", &FlowIndex::src) - .def_readonly("dst", &FlowIndex::dst) - .def_readonly("flowClass", &FlowIndex::flowClass) - .def_readonly("flowId", &FlowIndex::flowId); - - py::class_(m, "FlowGraph") - .def(py::init([](const StrictMultiDiGraph& g){ return FlowGraph(g); }), - py::arg("graph"), py::keep_alive<1, 2>()) - .def("capacity_view", [](py::object self_obj){ - const FlowGraph& fg = py::cast(self_obj); - auto s = fg.capacity_view(); - py::array view(py::buffer_info(const_cast(s.data()), sizeof(double), py::format_descriptor::format(), 1, { s.size() }, { sizeof(double) }), self_obj); - view.attr("setflags")(py::arg("write") = false); - return view; - }) - .def("residual_view", [](py::object self_obj){ - const FlowGraph& fg = py::cast(self_obj); - auto s = fg.residual_view(); - py::array view(py::buffer_info(const_cast(s.data()), sizeof(double), py::format_descriptor::format(), 1, { s.size() }, { sizeof(double) }), self_obj); - view.attr("setflags")(py::arg("write") = false); - return view; - }) - .def("edge_flow_view", [](py::object self_obj){ - const FlowGraph& fg = py::cast(self_obj); - auto s = fg.edge_flow_view(); - py::array view(py::buffer_info(const_cast(s.data()), sizeof(double), py::format_descriptor::format(), 1, { s.size() }, { sizeof(double) }), self_obj); - view.attr("setflags")(py::arg("write") = false); - return view; - }) - .def_property_readonly("graph", [](const FlowGraph& fg){ return &fg.graph(); }, py::return_value_policy::reference_internal) - .def("place", [](FlowGraph& fg, const FlowIndex& idx, std::int32_t src, std::int32_t dst, const PredDAG& dag, double amount, FlowPlacement placement){ py::gil_scoped_release rel; auto placed = fg.place(idx, src, dst, dag, amount, placement); py::gil_scoped_acquire acq; return placed; }, py::arg("index"), py::arg("src"), py::arg("dst"), py::arg("dag"), py::arg("amount"), py::arg("flow_placement") = FlowPlacement::Proportional) - .def("place_with_drops", [](FlowGraph& fg, const FlowIndex& idx, std::int32_t src, std::int32_t dst, const PredDAG& dag, double amount, FlowPlacement placement){ - std::vector> drops; - double placed; - { py::gil_scoped_release rel; placed = fg.place(idx, src, dst, dag, amount, placement, &drops); } - py::list out; for (auto const& pr : drops) out.append(py::make_tuple(pr.first, pr.second)); - return py::make_tuple(placed, out); - }, py::arg("index"), py::arg("src"), py::arg("dst"), py::arg("dag"), py::arg("amount"), py::arg("flow_placement") = FlowPlacement::EqualBalancedLossy, - "Like place(), but also returns the per-edge dropped volume as a list of (edge_id, dropped) pairs. Only EQUAL_BALANCED_LOSSY drops flow; other placements return an empty list.") - .def("remove", [](FlowGraph& fg, const FlowIndex& idx){ py::gil_scoped_release rel; fg.remove(idx); py::gil_scoped_acquire acq; }) - .def("remove_by_class", [](FlowGraph& fg, std::int32_t cls){ py::gil_scoped_release rel; fg.remove_by_class(cls); py::gil_scoped_acquire acq; }) - .def("reset", [](FlowGraph& fg){ py::gil_scoped_release rel; fg.reset(); py::gil_scoped_acquire acq; }) - .def("get_flow_edges", [](const FlowGraph& fg, const FlowIndex& idx){ auto v = fg.get_flow_edges(idx); py::list out; for (auto const& pr : v) { out.append(py::make_tuple(pr.first, pr.second)); } return out; }) - .def("get_flow_path", [](const FlowGraph& fg, const FlowIndex& idx){ auto v = fg.get_flow_path(idx); py::list out; for (auto e : v) out.append(e); return out; }); - - // NOTE: FlowRecord is deliberately not bound. No binding constructs or returns one - // (FlowPolicy.flows yields plain tuples), so the class was unreachable, and the - // Python name "Flow" collided with the C++ alias `using Flow = double`. If per-flow - // records become part of the public API, bind it under an unambiguous name such as - // "FlowRecord" and have FlowPolicy.flows return it instead of tuples. - - py::enum_(m, "PathAlg") - .value("SPF", PathAlg::SPF); - - py::class_(m, "FlowPolicyConfig") - .def(py::init<>()) - .def(py::init([](PathAlg path_alg, - FlowPlacement flow_placement, - EdgeSelection selection, - bool require_capacity, - bool multipath, - int min_flow_count, - py::object max_flow_count, - py::object max_path_cost, - py::object max_path_cost_factor, - bool shortest_path, - bool reoptimize_flows_on_each_placement, - int max_no_progress_iterations, - int max_total_iterations, - bool diminishing_returns_enabled, - int diminishing_returns_window, - double diminishing_returns_epsilon_frac){ - FlowPolicyConfig cfg; - cfg.path_alg = path_alg; - cfg.flow_placement = flow_placement; - cfg.selection = selection; - cfg.require_capacity = require_capacity; - cfg.multipath = multipath; - cfg.min_flow_count = min_flow_count; - if (!max_flow_count.is_none()) cfg.max_flow_count = py::cast(max_flow_count); - if (!max_path_cost.is_none()) cfg.max_path_cost = py::cast(max_path_cost); - if (!max_path_cost_factor.is_none()) cfg.max_path_cost_factor = py::cast(max_path_cost_factor); - cfg.shortest_path = shortest_path; - cfg.reoptimize_flows_on_each_placement = reoptimize_flows_on_each_placement; - cfg.max_no_progress_iterations = max_no_progress_iterations; - cfg.max_total_iterations = max_total_iterations; - cfg.diminishing_returns_enabled = diminishing_returns_enabled; - cfg.diminishing_returns_window = diminishing_returns_window; - cfg.diminishing_returns_epsilon_frac = diminishing_returns_epsilon_frac; - return cfg; - }), - py::kw_only(), - py::arg("path_alg") = PathAlg::SPF, - py::arg("flow_placement") = FlowPlacement::Proportional, - py::arg("selection") = EdgeSelection{}, - py::arg("require_capacity") = true, - py::arg("multipath") = true, - py::arg("min_flow_count") = 1, - py::arg("max_flow_count") = py::none(), - py::arg("max_path_cost") = py::none(), - py::arg("max_path_cost_factor") = py::none(), - py::arg("shortest_path") = false, - py::arg("reoptimize_flows_on_each_placement") = false, - py::arg("max_no_progress_iterations") = 100, - py::arg("max_total_iterations") = 10000, - py::arg("diminishing_returns_enabled") = true, - py::arg("diminishing_returns_window") = 8, - py::arg("diminishing_returns_epsilon_frac") = 1e-3) - .def_readwrite("path_alg", &FlowPolicyConfig::path_alg) - .def_readwrite("flow_placement", &FlowPolicyConfig::flow_placement) - .def_readwrite("selection", &FlowPolicyConfig::selection) - .def_readwrite("require_capacity", &FlowPolicyConfig::require_capacity) - .def_readwrite("multipath", &FlowPolicyConfig::multipath) - .def_readwrite("min_flow_count", &FlowPolicyConfig::min_flow_count) - .def_readwrite("max_flow_count", &FlowPolicyConfig::max_flow_count) - .def_readwrite("max_path_cost", &FlowPolicyConfig::max_path_cost) - .def_readwrite("max_path_cost_factor", &FlowPolicyConfig::max_path_cost_factor) - .def_readwrite("shortest_path", &FlowPolicyConfig::shortest_path) - .def_readwrite("reoptimize_flows_on_each_placement", &FlowPolicyConfig::reoptimize_flows_on_each_placement) - .def_readwrite("max_no_progress_iterations", &FlowPolicyConfig::max_no_progress_iterations) - .def_readwrite("max_total_iterations", &FlowPolicyConfig::max_total_iterations) - .def_readwrite("diminishing_returns_enabled", &FlowPolicyConfig::diminishing_returns_enabled) - .def_readwrite("diminishing_returns_window", &FlowPolicyConfig::diminishing_returns_window) - .def_readwrite("diminishing_returns_epsilon_frac", &FlowPolicyConfig::diminishing_returns_epsilon_frac); - - py::class_(m, "FlowPolicy") - .def("__init__", [](FlowPolicy& self, py::object algs_obj, const PyGraph& pg, FlowPolicyConfig cfg, - py::object node_mask, py::object edge_mask){ - if (!py::isinstance(algs_obj)) { - throw py::type_error("FlowPolicy: algorithms must be an Algorithms instance"); - } - std::shared_ptr algs = py::cast>(algs_obj); - - auto node_bs = to_bool_span_from_numpy(node_mask, static_cast(pg.num_nodes), "node_mask"); - auto edge_bs = to_bool_span_from_numpy(edge_mask, static_cast(pg.num_edges), "edge_mask"); - - cfg.node_mask = node_bs.view; - cfg.edge_mask = edge_bs.view; - - ExecutionContext ctx(algs, pg.handle); - new (&self) FlowPolicy(ctx, cfg); - }, - py::arg("algorithms"), py::arg("graph"), py::arg("config"), - py::kw_only(), - py::arg("node_mask") = py::none(), - py::arg("edge_mask") = py::none(), - py::keep_alive<1, 2>(), // self keeps algorithms alive - py::keep_alive<1, 3>()) // self keeps graph alive - .def("flow_count", &FlowPolicy::flow_count) - .def("placed_demand", &FlowPolicy::placed_demand) - .def("place_demand", [](FlowPolicy& p, FlowGraph& fg, std::int32_t src, std::int32_t dst, FlowClass flowClass, double volume, py::object target_per_flow, py::object min_flow){ std::optional tpf; if (!target_per_flow.is_none()) tpf = py::cast(target_per_flow); std::optional mfl; if (!min_flow.is_none()) mfl = py::cast(min_flow); py::gil_scoped_release rel; auto pr = p.place_demand(fg, src, dst, flowClass, volume, tpf, mfl); py::gil_scoped_acquire acq; return py::make_tuple(pr.first, pr.second); }, py::arg("flow_graph"), py::arg("src"), py::arg("dst"), py::arg("flowClass"), py::arg("volume"), py::arg("target_per_flow") = py::none(), py::arg("min_flow") = py::none()) - .def("rebalance_demand", [](FlowPolicy& p, FlowGraph& fg, std::int32_t src, std::int32_t dst, FlowClass flowClass, double target){ py::gil_scoped_release rel; auto pr = p.rebalance_demand(fg, src, dst, flowClass, target); py::gil_scoped_acquire acq; return py::make_tuple(pr.first, pr.second); }, - py::arg("flow_graph"), py::arg("src"), py::arg("dst"), py::arg("flowClass"), py::arg("target")) - .def("remove_demand", [](FlowPolicy& p, FlowGraph& fg){ py::gil_scoped_release rel; p.remove_demand(fg); py::gil_scoped_acquire acq; }) - .def("set_static_paths", [](FlowPolicy& p, std::int32_t src, std::int32_t dst, py::sequence paths){ - std::vector bundles; - bundles.reserve(py::len(paths)); - for (auto item : paths) bundles.push_back(py::cast(item)); - py::gil_scoped_release rel; - p.set_static_paths(src, dst, std::move(bundles)); - py::gil_scoped_acquire acq; - }, py::arg("src"), py::arg("dst"), py::arg("paths"), - "Pin this policy's demand to explicit path bundles (one flow per usable " - "bundle, bound in supply order). See FlowPolicy docs for validation, mask " - "(failure) semantics, and which config knobs become inert.") - .def_property_readonly("flows", [](const FlowPolicy& p){ py::dict out; for (auto const& kv : p.flows()) { const auto& idx = kv.first; const auto& f = kv.second; out[py::make_tuple(idx.src, idx.dst, idx.flowClass, idx.flowId)] = py::make_tuple(f.src, f.dst, f.cost, f.placed_flow); } return out; }); - - // Profiling functions (enabled via NGRAPH_CORE_PROFILE=1 environment variable) - m.def("profiling_enabled", &profiling_enabled, - "Return True if profiling is enabled (NGRAPH_CORE_PROFILE=1)."); - m.def("profiling_dump", []{ ProfilingStats::instance().dump(); }, - "Print profiling statistics to stderr."); - m.def("profiling_reset", []{ ProfilingStats::instance().reset(); }, - "Clear all profiling statistics."); + out["cost_distribution"] = curve; + out["edge_flows"] = int_list(r.edge_flows); + if (r.min_cut.has_value()) { + py::list cut; + for (EdgeId e : *r.min_cut) cut.append(py::int_(e)); + out["min_cut"] = cut; + } else { + out["min_cut"] = py::none(); + } + out["potentials"] = int_list(r.potentials); + py::dict counters; + counters["dijkstra_runs"] = py::int_(r.counters.dijkstra_runs); + counters["dijkstra_pops"] = py::int_(r.counters.dijkstra_pops); + counters["settled"] = py::int_(r.counters.settled); + counters["arc_scans"] = py::int_(r.counters.arc_scans); + counters["dinic_phases"] = py::int_(r.counters.dinic_phases); + counters["augmentations"] = py::int_(r.counters.augmentations); + out["counters"] = counters; + return out; + }, + py::arg("graph"), py::arg("view"), py::arg("sources"), py::arg("destinations"), py::arg("elig"), + py::arg("cap"), + "Private recorder entry (APPROACH 2.2): the minimum-cost flow of the maximum value under\n" + "cap (None: uncapped) from the source set to the destination set over the eligible edges,\n" + "on the capacities `view` (int64 units, id order), from zero flow. Returns {placed,\n" + "cost_distribution: [[cost, units]], edge_flows, min_cut (None while the destinations stay\n" + "connected, and on cap 0), potentials (-1 = never reached), counters}. Invalid inputs raise\n" + "ValueError before any work."); } diff --git a/dev/benchmark_profiling_overhead.py b/dev/benchmark_profiling_overhead.py deleted file mode 100644 index 1a32e68..0000000 --- a/dev/benchmark_profiling_overhead.py +++ /dev/null @@ -1,297 +0,0 @@ -#!/usr/bin/env python3 -"""Benchmark for measuring netgraph_core performance and profiling overhead. - -Runs SPF and flow placement operations on a mesh graph topology. -Useful for: - - Measuring profiling infrastructure overhead (compare with/without NGRAPH_CORE_PROFILE=1) - - Regression testing after code changes - - Comparing performance across versions - -Usage: - # Default run (400-node mesh, 500 SPF + 100 flow ops) - python dev/benchmark_profiling_overhead.py - - # Quick smoke test - python dev/benchmark_profiling_overhead.py --quick - - # Custom parameters - python dev/benchmark_profiling_overhead.py --mesh-size 30 --spf-iters 1000 --flow-iters 200 - - # Compare profiling overhead - python dev/benchmark_profiling_overhead.py # baseline - NGRAPH_CORE_PROFILE=1 python dev/benchmark_profiling_overhead.py # with profiling - - # JSON output for scripting - python dev/benchmark_profiling_overhead.py --json -""" - -import argparse -import json -import statistics -import sys -import time - -import numpy as np - -import netgraph_core as ngc - - -def create_mesh_graph(n: int) -> ngc.StrictMultiDiGraph: - """Create an n x n mesh graph with bidirectional edges. - - Args: - n: Grid dimension (creates n*n nodes) - - Returns: - Graph with 4-connected mesh topology, unit costs, 100.0 capacity per edge. - """ - num_nodes = n * n - edges = [] - - for row in range(n): - for col in range(n): - node = row * n + col - if col < n - 1: - right = row * n + (col + 1) - edges.append((node, right, 1, 100.0)) - edges.append((right, node, 1, 100.0)) - if row < n - 1: - down = (row + 1) * n + col - edges.append((node, down, 1, 100.0)) - edges.append((down, node, 1, 100.0)) - - src = np.array([e[0] for e in edges], dtype=np.int32) - dst = np.array([e[1] for e in edges], dtype=np.int32) - cost = np.array([e[2] for e in edges], dtype=np.int64) - cap = np.array([e[3] for e in edges], dtype=np.float64) - ext_edge_ids = np.arange(len(edges), dtype=np.int64) - - return ngc.StrictMultiDiGraph.from_arrays( - num_nodes, src, dst, cap, cost, ext_edge_ids - ) - - -def benchmark_spf( - graph_handle, algs: ngc.Algorithms, num_nodes: int, iterations: int -) -> list[float]: - """Run SPF iterations and return per-call times in ms.""" - times = [] - edge_sel = ngc.EdgeSelection( - multi_edge=False, - require_capacity=False, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - - for i in range(iterations): - src = i % num_nodes - start = time.perf_counter() - algs.spf(graph_handle, src, selection=edge_sel) - times.append((time.perf_counter() - start) * 1000) - - return times - - -def benchmark_flow_placement( - g: ngc.StrictMultiDiGraph, graph_handle, algs: ngc.Algorithms, iterations: int -) -> list[float]: - """Run flow placement iterations and return per-call times in ms.""" - num_nodes = g.num_nodes() - times = [] - - for i in range(iterations): - fg = ngc.FlowGraph(g) - cfg = ngc.FlowPolicyConfig() - cfg.flow_placement = ngc.FlowPlacement.EQUAL_BALANCED - cfg.max_flow_count = 16 - cfg.min_flow_count = 16 - cfg.selection = ngc.EdgeSelection( - multi_edge=False, - require_capacity=True, - tie_break=ngc.EdgeTieBreak.PREFER_HIGHER_RESIDUAL, - ) - cfg.require_capacity = True - cfg.multipath = False - cfg.reoptimize_flows_on_each_placement = True - policy = ngc.FlowPolicy(algs, graph_handle, cfg) - - src = i % num_nodes - dst = (num_nodes - 1 - i) % num_nodes - if src == dst: - dst = (dst + 1) % num_nodes - - start = time.perf_counter() - policy.place_demand(fg, src, dst, 0, 1000.0) - times.append((time.perf_counter() - start) * 1000) - - return times - - -def compute_stats(name: str, times: list[float]) -> dict: - """Compute statistics for a benchmark run.""" - return { - "name": name, - "iterations": len(times), - "mean_ms": statistics.mean(times), - "median_ms": statistics.median(times), - "stdev_ms": statistics.stdev(times) if len(times) > 1 else 0, - "min_ms": min(times), - "max_ms": max(times), - "total_ms": sum(times), - } - - -def print_stats(stats: dict) -> None: - """Print benchmark statistics.""" - print(f"\n{stats['name']}:") - print(f" Iterations: {stats['iterations']}") - print(f" Mean: {stats['mean_ms']:.3f} ms") - print(f" Median: {stats['median_ms']:.3f} ms") - print(f" Stdev: {stats['stdev_ms']:.3f} ms") - print(f" Min: {stats['min_ms']:.3f} ms") - print(f" Max: {stats['max_ms']:.3f} ms") - print(f" Total: {stats['total_ms']:.1f} ms") - - -def main(): - parser = argparse.ArgumentParser( - description="Benchmark netgraph_core performance and profiling overhead.", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - %(prog)s # default benchmark - %(prog)s --quick # fast smoke test - %(prog)s --mesh-size 30 # larger graph - %(prog)s --json # machine-readable output - NGRAPH_CORE_PROFILE=1 %(prog)s # measure with profiling enabled -""", - ) - parser.add_argument( - "--mesh-size", - type=int, - default=20, - metavar="N", - help="mesh grid size (N x N nodes, default: 20)", - ) - parser.add_argument( - "--spf-iters", - type=int, - default=500, - metavar="N", - help="number of SPF iterations (default: 500)", - ) - parser.add_argument( - "--flow-iters", - type=int, - default=100, - metavar="N", - help="number of flow placement iterations (default: 100)", - ) - parser.add_argument( - "--quick", - action="store_true", - help="quick run with minimal iterations (mesh=10, spf=50, flow=10)", - ) - parser.add_argument( - "--json", - action="store_true", - help="output results as JSON (for scripting)", - ) - args = parser.parse_args() - - if args.quick: - args.mesh_size = 10 - args.spf_iters = 50 - args.flow_iters = 10 - - profiling_enabled = getattr(ngc, "profiling_enabled", lambda: None)() - - if not args.json: - print("=" * 60) - print("NetGraph-Core Benchmark") - print("=" * 60) - print(f"\nVersion: {ngc.__version__}") - if profiling_enabled is not None: - print(f"Profiling: {'ON' if profiling_enabled else 'OFF'}") - print( - f"\nGraph: {args.mesh_size}x{args.mesh_size} mesh ({args.mesh_size**2} nodes)" - ) - print(f"SPF iterations: {args.spf_iters}") - print(f"Flow iterations: {args.flow_iters}") - print("\nCreating graph...", end=" ", flush=True) - - g = create_mesh_graph(args.mesh_size) - num_nodes = g.num_nodes() - num_edges = g.num_edges() - - if not args.json: - print(f"done ({num_nodes} nodes, {num_edges} edges)") - - backend = ngc.Backend.cpu() - algs = ngc.Algorithms(backend) - graph_handle = algs.build_graph(g) - - # Warmup (results discarded, profiling stats reset after) - if not args.json: - print("\nWarmup...", end=" ", flush=True) - sys.stdout.flush() - _ = benchmark_spf(graph_handle, algs, num_nodes, min(10, args.spf_iters)) - _ = benchmark_flow_placement(g, graph_handle, algs, min(5, args.flow_iters)) - if hasattr(ngc, "profiling_reset"): - ngc.profiling_reset() - if not args.json: - print("done", flush=True) - print("\n" + "-" * 40) - print("Running benchmarks...", flush=True) - sys.stdout.flush() - - spf_times = benchmark_spf(graph_handle, algs, num_nodes, args.spf_iters) - spf_stats = compute_stats("Shortest Paths (SPF)", spf_times) - - flow_times = benchmark_flow_placement(g, graph_handle, algs, args.flow_iters) - flow_stats = compute_stats("Flow Placement", flow_times) - - total_ops = args.spf_iters + args.flow_iters - total_time_ms = spf_stats["total_ms"] + flow_stats["total_ms"] - throughput = total_ops / (total_time_ms / 1000) if total_time_ms > 0 else 0 - - if args.json: - result = { - "version": ngc.__version__, - "profiling_enabled": profiling_enabled, - "config": { - "mesh_size": args.mesh_size, - "num_nodes": num_nodes, - "num_edges": num_edges, - "spf_iterations": args.spf_iters, - "flow_iterations": args.flow_iters, - }, - "spf": {k: v for k, v in spf_stats.items() if k != "name"}, - "flow": {k: v for k, v in flow_stats.items() if k != "name"}, - "summary": { - "total_ops": total_ops, - "total_time_ms": total_time_ms, - "throughput_ops_per_sec": throughput, - }, - } - print(json.dumps(result, indent=2)) - else: - print_stats(spf_stats) - print_stats(flow_stats) - - print("\n" + "=" * 60) - print("SUMMARY") - print("=" * 60) - print(f"Total operations: {total_ops}") - print(f"Total time: {total_time_ms:.1f} ms ({total_time_ms / 1000:.2f} s)") - print(f"Throughput: {throughput:.1f} ops/sec") - - if profiling_enabled: - print("\n" + "-" * 40) - print("Profiling stats:", flush=True) - sys.stdout.flush() - ngc.profiling_dump() - sys.stderr.flush() - - -if __name__ == "__main__": - main() diff --git a/include/netgraph/core/algorithms.hpp b/include/netgraph/core/algorithms.hpp deleted file mode 100644 index c4dc1cc..0000000 --- a/include/netgraph/core/algorithms.hpp +++ /dev/null @@ -1,62 +0,0 @@ -/* Algorithms façade that forwards to a selected Backend implementation. */ -#pragma once - -#include - -#include "netgraph/core/backend.hpp" -#include "netgraph/core/options.hpp" - -namespace netgraph::core { - -class Algorithms { -public: - explicit Algorithms(BackendPtr backend) : backend_(std::move(backend)) {} - - [[nodiscard]] GraphHandle build_graph(const StrictMultiDiGraph& g) const { - return backend_->build_graph(g); - } - - [[nodiscard]] std::pair, PredDAG> - spf(const GraphHandle& gh, NodeId src, const SpfOptions& opts) const { - return backend_->spf(gh, src, opts); - } - - [[nodiscard]] std::pair - spf_dag(const GraphHandle& gh, NodeId src, NodeId dst, const SpfOptions& opts) const { - return backend_->spf_dag(gh, src, dst, opts); - } - - [[nodiscard]] std::pair, PredDAG> - spf_to(const GraphHandle& gh, NodeId dst, const SpfToOptions& opts) const { - return backend_->spf_to(gh, dst, opts); - } - - [[nodiscard]] std::vector, PredDAG>> - ksp(const GraphHandle& gh, NodeId src, NodeId dst, const KspOptions& opts) const { - return backend_->ksp(gh, src, dst, opts); - } - - [[nodiscard]] std::pair - max_flow(const GraphHandle& gh, NodeId src, NodeId dst, const MaxFlowOptions& opts) const { - return backend_->max_flow(gh, src, dst, opts); - } - - [[nodiscard]] std::vector - batch_max_flow(const GraphHandle& gh, - const std::vector>& pairs, - const MaxFlowOptions& opts, - const std::vector>& node_masks = {}, - const std::vector>& edge_masks = {}) const { - return backend_->batch_max_flow(gh, pairs, opts, node_masks, edge_masks); - } - - [[nodiscard]] std::vector> - sensitivity_analysis(const GraphHandle& gh, NodeId src, NodeId dst, const MaxFlowOptions& opts) const { - return backend_->sensitivity_analysis(gh, src, dst, opts); - } - -private: - BackendPtr backend_; -}; - -} // namespace netgraph::core diff --git a/include/netgraph/core/backend.hpp b/include/netgraph/core/backend.hpp deleted file mode 100644 index 67098b3..0000000 --- a/include/netgraph/core/backend.hpp +++ /dev/null @@ -1,149 +0,0 @@ -/* - Backend interface — abstracts SPF/MaxFlow/KSP implementations. - - The default CPU backend delegates to in-process algorithm implementations. - All execution flows through this interface via an Algorithms façade. -*/ -#pragma once - -#include -#include -#include -#include -#include - -#include "netgraph/core/strict_multidigraph.hpp" -#include "netgraph/core/shortest_paths.hpp" -#include "netgraph/core/max_flow.hpp" -#include "netgraph/core/options.hpp" - -namespace netgraph::core { - -// GraphHandle: opaque handle to a backend-owned graph. -// Uses shared_ptr for automatic lifetime management. -struct GraphHandle { - std::shared_ptr graph {}; -}; - -class Backend { -public: - virtual ~Backend() noexcept = default; - - // Prepares a backend-specific graph handle from an existing graph reference. - // - // The CPU backend creates a non-owning shared_ptr with a no-op deleter, so the - // caller must keep `g` alive for as long as the handle is used. To hand the - // handle shared ownership instead, construct it directly: GraphHandle{my_sp}. - // - // Arguments: - // g: The source graph to wrap. - // - // Returns: - // A GraphHandle containing the backend-specific graph representation. - [[nodiscard]] virtual GraphHandle build_graph(const StrictMultiDiGraph& g) = 0; - - // Computes shortest paths from a source node. - // - // Arguments: - // gh: The graph handle. - // src: The source node ID. - // opts: Configuration options for SPF (e.g., multipath, edge selection). - // - // Returns: - // A pair containing: - // - A vector of costs (distances) from src to all nodes. - // - A predecessor DAG encoding the shortest paths. - [[nodiscard]] virtual std::pair, PredDAG> spf( - const GraphHandle& gh, NodeId src, const SpfOptions& opts) = 0; - - // Destination-limited search returning dst's cost and the predecessor DAG - // without the N-sized distance vector; see shortest_paths_dag() in - // shortest_paths.hpp. opts.dst is ignored (dst is the argument). The default - // implementation derives the result from spf(), so backends that do not - // specialise it stay correct. - [[nodiscard]] virtual std::pair spf_dag( - const GraphHandle& gh, NodeId src, NodeId dst, const SpfOptions& opts) { - SpfOptions o = opts; - o.dst = dst; - auto res = spf(gh, src, o); - const Cost cost = (dst >= 0 && static_cast(dst) < res.first.size()) - ? res.first[static_cast(dst)] - : std::numeric_limits::max(); - return {cost, std::move(res.second)}; - } - - // Computes shortest paths from every node to dst (reverse SPF); see - // shortest_paths_to() in shortest_paths.hpp. Returns distances to dst and a - // forward-oriented predecessor DAG valid for placement from any node toward dst. - [[nodiscard]] virtual std::pair, PredDAG> spf_to( - const GraphHandle& gh, NodeId dst, const SpfToOptions& opts) = 0; - - // Computes maximum flow between a source and destination node. - // - // Arguments: - // gh: The graph handle. - // src: The source node ID. - // dst: The destination node ID. - // opts: Configuration options for max flow (e.g., algorithm, capacity constraints). - // - // Returns: - // A pair containing: - // - The total flow amount. - // - A FlowSummary struct with detailed results. - [[nodiscard]] virtual std::pair max_flow( - const GraphHandle& gh, NodeId src, NodeId dst, const MaxFlowOptions& opts) = 0; - - // Computes K-shortest paths between a source and destination node. - // - // Arguments: - // gh: The graph handle. - // src: The source node ID. - // dst: The destination node ID. - // opts: Configuration options for KSP (e.g., K, cost constraints). - // - // Returns: - // A vector of pairs, each representing a path: - // - A vector of costs along the path. - // - A predecessor DAG representing the path. - [[nodiscard]] virtual std::vector, PredDAG>> ksp( - const GraphHandle& gh, NodeId src, NodeId dst, const KspOptions& opts) = 0; - - // Computes maximum flow for a batch of source-destination pairs. - // - // Arguments: - // gh: The graph handle. - // pairs: A vector of (source, destination) pairs. - // opts: Common configuration options for all pairs. - // node_masks: Optional vector of node masks, one per pair. - // edge_masks: Optional vector of edge masks, one per pair. - // - // Returns: - // A vector of FlowSummary structs, one for each pair. - [[nodiscard]] virtual std::vector batch_max_flow( - const GraphHandle& gh, - const std::vector>& pairs, - const MaxFlowOptions& opts, - const std::vector>& node_masks = {}, - const std::vector>& edge_masks = {}) = 0; - - // Performs sensitivity analysis to identify edges that constrain flow. - // - // Arguments: - // gh: The graph handle. - // src: The source node ID. - // dst: The destination node ID. - // opts: Configuration options. - // - // Returns: - // A vector of pairs (EdgeId, FlowDelta) for edges whose removal reduces - // total flow, where FlowDelta is how much flow is LOST without that edge. - // This is a criticality measure, not the gain from relaxing capacity. - [[nodiscard]] virtual std::vector> sensitivity_analysis( - const GraphHandle& gh, NodeId src, NodeId dst, const MaxFlowOptions& opts) = 0; -}; - -using BackendPtr = std::shared_ptr; - -[[nodiscard]] BackendPtr make_cpu_backend(); - -} // namespace netgraph::core diff --git a/include/netgraph/core/constants.hpp b/include/netgraph/core/constants.hpp deleted file mode 100644 index 5099619..0000000 --- a/include/netgraph/core/constants.hpp +++ /dev/null @@ -1,13 +0,0 @@ -/* Numeric thresholds shared by SPF and flow placement. */ -#pragma once - -#include "netgraph/core/types.hpp" - -namespace netgraph::core { - -// Global numeric thresholds used across SPF and flow placement -inline constexpr Cap kMinCap = static_cast(1.0 / 4096.0); // minimum effective remaining capacity -inline constexpr Flow kMinFlow = static_cast(1.0 / 4096.0); // minimum meaningful flow when augmenting -inline constexpr double kEpsilon = 1e-12; // numeric clamp - -} // namespace netgraph::core diff --git a/include/netgraph/core/flow.hpp b/include/netgraph/core/flow.hpp deleted file mode 100644 index c956186..0000000 --- a/include/netgraph/core/flow.hpp +++ /dev/null @@ -1,28 +0,0 @@ -/* FlowRecord — immutable identity + current path DAG and placed amount. */ -#pragma once - -#include -#include - -#include "netgraph/core/shortest_paths.hpp" -#include "netgraph/core/types.hpp" - -namespace netgraph::core { - -// FlowRecord is a logical unit of routed demand. It stores identity, current -// path (as a PredDAG), path cost, and the volume placed so far. It does not -// mutate any graph state; placement is performed via FlowGraph. -struct FlowRecord { - FlowIndex index {}; - NodeId src { -1 }; - NodeId dst { -1 }; - PredDAG dag {}; - Cost cost { 0 }; - double placed_flow { 0.0 }; - - FlowRecord() noexcept = default; - FlowRecord(FlowIndex idx, NodeId src, NodeId dst, PredDAG d, Cost c) - : index(idx), src(src), dst(dst), dag(std::move(d)), cost(c), placed_flow(0.0) {} -}; - -} // namespace netgraph::core diff --git a/include/netgraph/core/flow_graph.hpp b/include/netgraph/core/flow_graph.hpp deleted file mode 100644 index 1e90fd6..0000000 --- a/include/netgraph/core/flow_graph.hpp +++ /dev/null @@ -1,94 +0,0 @@ -/* FlowGraph manages per-flow edge allocations over FlowState. */ -#pragma once - -#include -#include -#include -#include -#include - -#include "netgraph/core/flow_state.hpp" -#include "netgraph/core/shortest_paths.hpp" -#include "netgraph/core/strict_multidigraph.hpp" -#include "netgraph/core/types.hpp" - -namespace netgraph::core { - -// FlowGraph manages per-flow edge allocations over a StrictMultiDiGraph. -// Composes FlowState for residual/aggregate edge flow management. -class FlowGraph { -public: - explicit FlowGraph(const StrictMultiDiGraph& g); - ~FlowGraph() noexcept = default; - - // Copies and moves take a FRESH uid (and version 0). uid must be unique per - // reachable instance: a shared uid would let two objects that diverge after - // copying present equal StateStamps over different residual content, and - // FlowPolicy's memo fast path would then serve a DAG cached for the other - // object's state. Moved-from objects stay valid and mutable, so moves must - // not share the uid either; the one-time cache miss this costs is nothing. - FlowGraph(const FlowGraph& other); - FlowGraph& operator=(const FlowGraph& other); - FlowGraph(FlowGraph&& other) noexcept; - FlowGraph& operator=(FlowGraph&& other) noexcept; - - // Views - [[nodiscard]] std::span capacity_view() const noexcept { return fs_.capacity_view(); } - [[nodiscard]] std::span residual_view() const noexcept { return fs_.residual_view(); } - [[nodiscard]] std::span edge_flow_view() const noexcept { return fs_.edge_flow_view(); } - - // Access underlying graph (const) - [[nodiscard]] const StrictMultiDiGraph& graph() const noexcept { return *g_; } - - // Apply placement and record per-edge allocations for this flow. Returns placed - // amount. `drops`, when given, receives the per-edge dropped volume of an - // EqualBalancedLossy placement (see FlowState::place_on_dag); other placements - // leave it untouched. Dropped volume is not part of the ledger: it never - // occupied an edge, so removing the flow reverts only what was carried. - [[nodiscard]] Flow place(const FlowIndex& idx, NodeId src, NodeId dst, - const PredDAG& dag, Flow amount, - FlowPlacement placement, - std::vector>* drops = nullptr); - - // Remove a specific flow, reverting its edge allocations from the ledger. - void remove(const FlowIndex& idx); - - // Remove all flows belonging to a given flowClass. - void remove_by_class(FlowClass flowClass); - - // Reset all state to initial capacity and clear ledger. - void reset() noexcept; - - // Inspect: return a copy of the flow's edges and amounts. - [[nodiscard]] std::vector> get_flow_edges(const FlowIndex& idx) const; - // Whether the ledger holds this flow index (placed and not removed). - [[nodiscard]] bool has_flow(const FlowIndex& idx) const noexcept { return ledger_.find(idx) != ledger_.end(); } - - // Monotonic stamp identifying the exact residual state of this FlowGraph. - // uid is process-unique per instance (never reused across FlowGraph - // lifetimes); version bumps on every mutation that can change residuals. - // Equal stamps from two observations therefore guarantee identical residual - // content, which FlowPolicy uses to elide repeated identical SPF runs. - struct StateStamp { - std::uint64_t uid; - std::uint64_t version; - bool operator==(const StateStamp&) const noexcept = default; - }; - [[nodiscard]] StateStamp state_stamp() const noexcept { return {uid_, version_}; } - -// Reconstruct single path for this flow from ledger. -// Returns empty vector if flow uses multipath/proportional splitting. - [[nodiscard]] std::vector get_flow_path(const FlowIndex& idx) const; - -private: - const StrictMultiDiGraph* g_ {nullptr}; - FlowState fs_; - // Per-flow ledger: stores only edges with non-zero flow - std::unordered_map>, FlowIndexHash> ledger_; - // See state_stamp(). fs_ is private and every mutation path (place, remove, - // remove_by_class, reset) bumps version_, so the stamp cannot go stale. - std::uint64_t uid_ {0}; - std::uint64_t version_ {0}; -}; - -} // namespace netgraph::core diff --git a/include/netgraph/core/flow_policy.hpp b/include/netgraph/core/flow_policy.hpp deleted file mode 100644 index c968178..0000000 --- a/include/netgraph/core/flow_policy.hpp +++ /dev/null @@ -1,261 +0,0 @@ -/* FlowPolicy manages flows for a single demand. */ -#pragma once - -#include -#include -#include -#include -#include -#include -#include - -#include "netgraph/core/flow.hpp" -#include "netgraph/core/flow_graph.hpp" -#include "netgraph/core/algorithms.hpp" -#include "netgraph/core/options.hpp" -#include "netgraph/core/types.hpp" - -namespace netgraph::core { - -enum class PathAlg : std::int32_t { SPF = 1 }; - -// Execution context with algorithms and graph handle -struct ExecutionContext { - std::shared_ptr algorithms; - GraphHandle graph; - - ExecutionContext(std::shared_ptr algs, const GraphHandle& gh) noexcept - : algorithms(std::move(algs)), graph(gh) {} -}; - -// Configuration struct for FlowPolicy behavior -struct FlowPolicyConfig { - PathAlg path_alg { PathAlg::SPF }; - FlowPlacement flow_placement { FlowPlacement::Proportional }; - EdgeSelection selection { EdgeSelection{} }; - bool require_capacity { true }; // Require edges to have capacity (software-defined/traffic engineering). - // Set false for cost-only routing (traditional IP/IGP shortest-paths). - bool multipath { true }; // Enable individual flows to split across multiple equal-cost paths. - // When true: each flow uses a DAG containing all equal-cost paths (hash-based ECMP). - // When false: each flow uses a single path (tunnel-based ECMP, MPLS LSP semantics). - int min_flow_count { 1 }; - std::optional max_flow_count { std::nullopt }; - std::optional max_path_cost { std::nullopt }; - std::optional max_path_cost_factor { std::nullopt }; - bool shortest_path { false }; - bool reoptimize_flows_on_each_placement { false }; - int max_no_progress_iterations { 100 }; - int max_total_iterations { 10000 }; - bool diminishing_returns_enabled { true }; - int diminishing_returns_window { 8 }; - double diminishing_returns_epsilon_frac { 1e-3 }; - std::span node_mask {}; // Optional node mask for failure exclusions (True = include) - std::span edge_mask {}; // Optional edge mask for failure exclusions (True = include) -}; - -// FlowPolicy manages flow creation, placement, reoptimization for a single demand -class FlowPolicy { -public: - // Constructor accepting configuration struct - FlowPolicy(const ExecutionContext& ctx, const FlowPolicyConfig& cfg) - : ctx_(ctx), - path_alg_(cfg.path_alg), flow_placement_(cfg.flow_placement), selection_(cfg.selection), - require_capacity_(cfg.require_capacity), multipath_(cfg.multipath), shortest_path_(cfg.shortest_path), - min_flow_count_(cfg.min_flow_count), max_flow_count_(cfg.max_flow_count), max_path_cost_(cfg.max_path_cost), - max_path_cost_factor_(cfg.max_path_cost_factor), reoptimize_flows_on_each_placement_(cfg.reoptimize_flows_on_each_placement), - max_no_progress_iterations_(cfg.max_no_progress_iterations), max_total_iterations_(cfg.max_total_iterations), - diminishing_returns_enabled_(cfg.diminishing_returns_enabled), diminishing_returns_window_(cfg.diminishing_returns_window), - diminishing_returns_epsilon_frac_(cfg.diminishing_returns_epsilon_frac) - { - // Validate mask sizes early for clearer error messages - const auto* g = ctx_.graph.graph.get(); - if (g) { - if (!cfg.node_mask.empty() && cfg.node_mask.size() != static_cast(g->num_nodes())) { - throw std::invalid_argument("FlowPolicy: node_mask length mismatch"); - } - if (!cfg.edge_mask.empty() && cfg.edge_mask.size() != static_cast(g->num_edges())) { - throw std::invalid_argument("FlowPolicy: edge_mask length mismatch"); - } - } - - // Copy mask data if provided - if (!cfg.node_mask.empty()) { - node_mask_storage_.reset(new bool[cfg.node_mask.size()]); - std::copy(cfg.node_mask.begin(), cfg.node_mask.end(), node_mask_storage_.get()); - node_mask_ = std::span(node_mask_storage_.get(), cfg.node_mask.size()); - } - if (!cfg.edge_mask.empty()) { - edge_mask_storage_.reset(new bool[cfg.edge_mask.size()]); - std::copy(cfg.edge_mask.begin(), cfg.edge_mask.end(), edge_mask_storage_.get()); - edge_mask_ = std::span(edge_mask_storage_.get(), cfg.edge_mask.size()); - } - } - - ~FlowPolicy() noexcept = default; - - [[nodiscard]] int flow_count() const noexcept { return static_cast(flows_.size()); } - [[nodiscard]] double placed_demand() const noexcept; - - // Core operations. - // - // A FlowPolicy manages the flows of a SINGLE demand. Once it holds flows, calling - // place_demand() with a different (src, dst) throws std::invalid_argument, because - // the round-robin loop routes using the src/dst stored on the existing flow records. - // Use one policy per demand, or call remove_demand() first to retarget it. - // - // `fg` must wrap the same StrictMultiDiGraph as the policy's own graph handle; - // placing onto a FlowGraph built from a different graph is rejected. - // - // Returns (placed, remaining). NOTE for EqualBalanced: when the equalizing - // rebalance runs, `placed` is the policy's TOTAL placed demand (cumulative - // across calls, since rebalancing re-places previously placed volume too) and - // `placed + remaining` can therefore exceed this call's `volume`. Use - // placed_demand() deltas for strict per-call accounting. - [[nodiscard]] std::pair place_demand(FlowGraph& fg, - NodeId src, NodeId dst, - FlowClass flowClass, - double volume, - std::optional target_per_flow = std::nullopt, - std::optional min_flow = std::nullopt); - - // Re-places the currently placed volume so each flow carries ~target_per_flow. - // (src, dst) must match the demand this policy already manages. - [[nodiscard]] std::pair rebalance_demand(FlowGraph& fg, - NodeId src, NodeId dst, - FlowClass flowClass, - double target_per_flow); - - void remove_demand(FlowGraph& fg); - - [[nodiscard]] const std::unordered_map& flows() const noexcept { return flows_; } - - // Pin this policy's demand to explicit path bundles (MPLS-style routing). - // - // One flow is created per USABLE bundle at the next placement, in supply order, - // each permanently bound to its own bundle. A single-path bundle (e.g. from - // make_path_dag / PredDAG.from_edges or ksp) models a strict-ERO LSP: any failed - // hop takes it down. A multi-walk bundle (e.g. an SPF DAG) models a pinned DAG / - // SR-TE-style policy that renormalizes over its surviving walks. - // - // Each bundle is validated against the policy's graph (shape, id ranges, - // edge-endpoint consistency, acyclicity, and an src->dst walk), then pruned - // against the policy's node/edge masks. A bundle with no surviving src->dst walk - // is DOWN: it creates no flow and carries nothing (a pinned path does not - // reroute around failures). Each flow's cost is the min-cost src->dst walk of - // its PRUNED bundle, computed from the graph's edge costs. - // - // With static paths configured the policy neither creates additional flows nor - // reoptimizes: max_path_cost, max_path_cost_factor, min_flow_count and - // reoptimize_flows_on_each_placement are inert. EqualBalanced spreads over the - // usable (up) bundles only and equalizes the carried volume per bundle; - // EqualBalancedLossy offers each up bundle an equal - // share and lets it carry what fits (no equalizing rebalance), so placed is the - // delivered total. flow_count() reports the usable count U; the supplied count - // N is the caller's, so down LSPs = N - flow_count(). - // - // Throws std::invalid_argument if bundles is empty, the policy already holds - // flows (remove_demand() first), shortest_path=true is configured (single- - // augmentation IP semantics contradict pinned multi-LSP placement), src/dst is - // invalid or src == dst, a user-supplied max_flow_count differs from - // bundles.size(), or any bundle is malformed. Calling again before placement - // replaces the previous pinning. - void set_static_paths(NodeId src, NodeId dst, std::vector bundles); - -private: - // Helpers - // Throws std::invalid_argument if fg wraps a different graph than this policy, or if - // (src, dst) differs from the demand already managed here. `what` names the caller. - void check_demand_target(const FlowGraph& fg, NodeId src, NodeId dst, const char* what) const; - // Placement core: target computation, flow creation, round-robin placement, and - // the optional post-placement reoptimization. The public place_demand() wraps it - // with the iterative EqualBalanced rebalance rounds. - [[nodiscard]] std::pair place_demand_body(FlowGraph& fg, - NodeId src, NodeId dst, - FlowClass flowClass, - double volume, - std::optional target_per_flow, - std::optional min_flow); - [[nodiscard]] std::optional> get_path_bundle(const FlowGraph& fg, - NodeId src, NodeId dst, - std::optional min_flow); - [[nodiscard]] FlowRecord* create_flow(FlowGraph& fg, NodeId src, NodeId dst, FlowClass flowClass, - std::optional min_flow); - [[nodiscard]] FlowRecord* reoptimize_flow(FlowGraph& fg, const FlowIndex& idx, double headroom); - // Next fresh index for this policy; throws if the FlowGraph already holds it - // (another policy managing the same (src, dst, flowClass) on this FlowGraph). - [[nodiscard]] FlowIndex next_flow_index(const FlowGraph& fg, NodeId src, NodeId dst, FlowClass flowClass); - - // Config - ExecutionContext ctx_; - PathAlg path_alg_ { PathAlg::SPF }; - FlowPlacement flow_placement_ { FlowPlacement::Proportional }; - EdgeSelection selection_ { EdgeSelection{} }; - bool require_capacity_ { true }; - bool multipath_ { true }; - bool shortest_path_ { false }; - int min_flow_count_ { 1 }; - std::optional max_flow_count_ {}; - std::optional max_path_cost_ {}; - std::optional max_path_cost_factor_ {}; - bool reoptimize_flows_on_each_placement_ { false }; - int max_no_progress_iterations_ { 100 }; - int max_total_iterations_ { 10000 }; - bool diminishing_returns_enabled_ { true }; - int diminishing_returns_window_ { 8 }; - double diminishing_returns_epsilon_frac_ { 1e-3 }; - - // Mask storage and views for failure exclusions - std::unique_ptr node_mask_storage_; - std::unique_ptr edge_mask_storage_; - std::span node_mask_ {}; - std::span edge_mask_ {}; - - // State - std::unordered_map flows_; - Cost best_path_cost_ { std::numeric_limits::max() }; - FlowId next_flow_id_ { 0 }; - - // Memo of recent raw SPF results computed by get_path_bundle(), used only in - // EqualBalanced mode. SPF is a pure function of (graph, selection, multipath, - // node_mask, src, dst, residual, edge_mask); in EB mode everything except - // (src, dst, residual, min_flow) is fixed for the lifetime of the policy (the - // min_flow-derived edge mask is Proportional-only), so those four ARE the key. - // EB placement and rebalance rounds re-request bundles against residual - // content that repeats -- 94% of SPF calls in a measured place/rebalance - // cycle were exact input repeats, largely remove+place round-trips that - // restore identical bytes -- and the memo elides those calls without changing - // any output. Matching is exact: a (uid, version) StateStamp equality is the - // fast path (unchanged FlowGraph implies unchanged content), with a full - // residual memcmp as the content path (catches the round-trips). Proportional - // mode measured 0% repeats, so it skips the memo and pays nothing. Cost gates - // and best_path_cost_ updates still run on every call, hit or miss: they - // depend on mutable policy state. - struct SpfMemoEntry { - NodeId src = -1, dst = -1; - bool with_residual = false; - FlowGraph::StateStamp stamp {0, 0}; - std::vector residual; // key copy; empty when SPF ran residual-blind - bool has_min_flow = false; // toggles require_residual; the value is Proportional-only - PredDAG dag; - Cost dst_cost = 0; - }; - // MRU order: front = most recent. Entry count adapts to graph size so the - // memo stays under ~512 KiB per policy regardless of edge count. - static constexpr std::size_t kSpfMemoMaxEntries = 24; - static constexpr std::size_t kSpfMemoMaxBytes = 512 * 1024; - std::vector spf_memo_; - - // Static paths (optional): usable (mask-pruned) bundles, one flow per bundle. - struct StaticBundle { - PredDAG dag; // pruned to mask-surviving entries - Cost cost {0}; // min-cost src->dst walk within the pruned bundle - }; - NodeId static_src_ {-1}; - NodeId static_dst_ {-1}; - int static_supplied_count_ {0}; // N as supplied; usable U = static_bundles_.size() - std::vector static_bundles_; - - [[nodiscard]] bool has_static_paths() const noexcept { return !static_bundles_.empty() || static_supplied_count_ > 0; } -}; - -} // namespace netgraph::core diff --git a/include/netgraph/core/flow_state.hpp b/include/netgraph/core/flow_state.hpp deleted file mode 100644 index 6fe9c20..0000000 --- a/include/netgraph/core/flow_state.hpp +++ /dev/null @@ -1,112 +0,0 @@ -/* - FlowState — per-edge residual/flow tracking and placement helpers. - Implements proportional (WCMP-like) and equal-balanced (ECMP) placements - over shortest-path (Dijkstra) predecessor DAGs. - ECMP = Equal-Cost Multi-Path; WCMP = Weighted-Cost Multi-Path. -*/ -#pragma once - -#include -#include -#include -#include - -#include "netgraph/core/shortest_paths.hpp" -#include "netgraph/core/strict_multidigraph.hpp" -#include "netgraph/core/types.hpp" -#include "netgraph/core/max_flow.hpp" - -namespace netgraph::core { - -// FlowState maintains per-edge residual capacity and per-edge placed flow for a -// given immutable StrictMultiDiGraph. It places flow on a predecessor DAG (PredDAG) -// using either proportional or equal-balanced strategies, updating internal -// residuals deterministically. -class FlowState { -public: - explicit FlowState(const StrictMultiDiGraph& g); - FlowState(const StrictMultiDiGraph& g, std::span residual_init); - ~FlowState() noexcept = default; - - // Reset residual to the graph's initial capacities and clear edge_flow. - void reset() noexcept; - void reset(std::span residual_init); - - // Views over internal buffers (length == g.num_edges()). - [[nodiscard]] std::span capacity_view() const noexcept { return g_->capacity_view(); } - [[nodiscard]] std::span residual_view() const noexcept { return residual_; } - [[nodiscard]] std::span edge_flow_view() const noexcept { return edge_flow_; } - - // Access underlying graph (const). - [[nodiscard]] const StrictMultiDiGraph& graph() const noexcept { return *g_; } - - // Mutating placement along a given PredDAG tier between src and dst. - // requested_flow may be +inf. Returns the amount actually placed. - // - // EqualBalanced semantics here are *single-pass ECMP admission*: - // - Use the supplied DAG and fixed equal per-edge splits per (u->v) group. - // - Compute one global scale: min_g cap_rev[g] / assigned[g], - // where cap_rev[g] = min_edge_residual(g) * |E_g| - // (enforces equal per-edge shares), - // assigned[g] = unit-demand load on group g under equal splits. - // - Place once and return. The split set is every DAG edge with capacity, - // so a member filled since the DAG was built yields scale 0 and nothing - // is placed; progression past a full member comes from recomputing the - // DAG with a residual-aware SPF, not from re-invoking this on a stale one. - // - // EqualBalancedLossy splits over the same capacity-based set without scaling: - // each edge carries min(share, residual), the excess is dropped, and the - // returned amount is what reaches dst. When `drops` is given it receives one - // (edge, dropped) entry per edge that dropped flow. Drops are recorded only - // for finite requested_flow; an infinite request fills every source edge and - // records no drops. Other placements never write to `drops`. - [[nodiscard]] Flow place_on_dag(NodeId src, NodeId dst, - const PredDAG& dag, - Flow requested_flow, - FlowPlacement placement, - // Optional trace collector to record per-edge allocations applied by this call - std::vector>* trace = nullptr, - // Optional collector for per-edge dropped volume (EqualBalancedLossy only) - std::vector>* drops = nullptr); - - // Convenience: run repeated placements until exhaustion (or single tier when - // shortest_path=true). Returns total placed flow. Uses internal residual. - // - // require_capacity: Whether to require edges to have capacity. - // - true (default): Routes adapt to residuals (SDN/TE behavior). - // - false: Routes based on costs only (IP/IGP behavior). - // - // NOTE: With FlowPlacement::EqualBalanced + require_capacity=true, this behaves as a - // progressive "fill": it recomputes the SPF DAG after each saturation. That is *not* - // the single-pass ECMP admission used by place_on_dag(). For IP ECMP, use - // require_capacity=false + shortest_path=true + EqualBalanced. - [[nodiscard]] Flow place_max_flow(NodeId src, NodeId dst, - FlowPlacement placement, - bool shortest_path = false, - bool require_capacity = true, - std::span node_mask = {}, - std::span edge_mask = {}); - - // Compute min-cut (edges crossing from reachable set S to unreachable set T) - // based on reachability in the current residual graph. - // Reachability starts from source s; forward arcs allowed if residual > kMinCap, - // reverse arcs allowed if flow > kMinFlow. - [[nodiscard]] MinCut compute_min_cut(NodeId src, - std::span node_mask = {}, - std::span edge_mask = {}) const; - - // Apply or revert a set of edge flow allocations directly. - // When add==true, treats each (eid, flow) as additional placed flow on the edge. - // When add==false, removes placed flow (reverts allocations), clamping to [0, capacity]. - // NOTE: residual is recomputed as capacity - edge_flow, so for a FlowState built with - // a custom residual_init this discards the initial offset. Entries with flow <= 0 and - // out-of-range edge ids are ignored. - void apply_deltas(std::span> deltas, bool add) noexcept; - -private: - const StrictMultiDiGraph* g_ {nullptr}; - std::vector residual_; - std::vector edge_flow_; -}; - -} // namespace netgraph::core diff --git a/include/netgraph/core/k_shortest_paths.hpp b/include/netgraph/core/k_shortest_paths.hpp deleted file mode 100644 index 0af8f20..0000000 --- a/include/netgraph/core/k_shortest_paths.hpp +++ /dev/null @@ -1,26 +0,0 @@ -/* Yen-like K-shortest paths with SPF-compatible outputs per-path. */ -#pragma once - -#include -#include - -#include "netgraph/core/strict_multidigraph.hpp" -#include "netgraph/core/shortest_paths.hpp" -#include "netgraph/core/types.hpp" - -namespace netgraph::core { - -// Compute up to k shortest paths from s to t (Yen-like) and return -// SPF-compatible outputs per path: (distances, predecessor DAG). -// Distances are Cost[N] (int64); unreachable entries are set to -// std::numeric_limits::max(). PredDAG encodes one concrete path with a -// single parent per node along the path; other nodes have no parents. -// Deterministic tie-breaking across equal-cost edges uses compacted edge order. -[[nodiscard]] std::vector, PredDAG>> k_shortest_paths( - const StrictMultiDiGraph& g, NodeId src, NodeId dst, - int k, std::optional max_cost_factor, - bool unique, - std::span node_mask = {}, - std::span edge_mask = {}); - -} // namespace netgraph::core diff --git a/include/netgraph/core/max_flow.hpp b/include/netgraph/core/max_flow.hpp index 347b30d..2fc7052 100644 --- a/include/netgraph/core/max_flow.hpp +++ b/include/netgraph/core/max_flow.hpp @@ -1,7 +1,9 @@ -/* Max-flow utility APIs with summaries and batch evaluation. */ +/* The one integer minimum-cost flow core (APPROACH 2.2). */ #pragma once #include +#include +#include #include #include @@ -10,80 +12,50 @@ namespace netgraph::core { -struct MinCut { std::vector edges; }; - -struct FlowSummary { - Flow total_flow {0.0}; - MinCut min_cut {}; - // Parallel arrays: costs[i] carries placed flow flows[i]. Costs are ascending and - // unique; flows are raw volumes (not normalized). - // - // Entries from the SPF tier loop are the total cost of the shortest-path DAG used. - // Entries produced by the residual completion phase are MARGINAL costs: the cost of - // the augmenting path's forward edges minus the cost of the flow it cancelled. Such - // an entry need not correspond to any traversable s-t path, so treat costs[] as a - // cost-weighted breakdown of the total flow rather than a list of path costs. - std::vector costs; - std::vector flows; - std::vector edge_flows; // filled if requested - // Optional large arrays; populated only when requested via API flags - std::vector residual_capacity; // length == g.num_edges() - std::vector reachable_nodes; // length == g.num_nodes(); 0/1 flags +// Potential of a vertex that no Dijkstra run of the call settled. Potentials are otherwise +// non-negative path costs, so the sentinel never collides. +inline constexpr Cost kNeverReached = -1; +// Upper bound of the internal value cap; also the capacity of the virtual source and sink arcs, +// strictly above every attainable value (below 2^62), so a virtual arc never saturates. +inline constexpr Amount kMaxFlowCap = INT64_MAX - 1; + +// Operation counters of one call: diagnostics for the performance gate's drift check, identical +// across identical calls, never an oracle. Virtual vertices and arcs count like real ones. +struct FlowCoreCounters { + std::int64_t dijkstra_runs {0}; // every run, the final failed one included + std::int64_t dijkstra_pops {0}; // heap pops, stale ones included + std::int64_t settled {0}; // vertices finalised, summed over runs + std::int64_t arc_scans {0}; // arcs examined by Dijkstra, Dinic BFS and DFS advance + std::int64_t dinic_phases {0}; // BFS level builds, the last unsuccessful one of a tier included + std::int64_t augmentations {0}; // DFS paths pushed }; -// Computes maximum flow from src to dst. -// -// The SPF tier loop augments along forward shortest-path DAGs only. For -// FlowPlacement::Proportional with require_capacity=true and shortest_path=false a -// residual completion phase then augments with reverse arcs, so the result is a true -// maximum flow and summary.min_cut satisfies max-flow/min-cut duality. -// -// The other configurations are placement models rather than max-flow computations and -// may report less than the maximum: EqualBalanced models single-pass ECMP admission, -// require_capacity=false models fixed cost-only IP routing, and shortest_path=true -// restricts flow to the first cost tier. -[[nodiscard]] std::pair -calc_max_flow(const StrictMultiDiGraph& g, NodeId src, NodeId dst, - FlowPlacement placement, bool shortest_path, - bool require_capacity, - bool with_edge_flows, - bool with_reachable, - bool with_residuals, - std::span node_mask = {}, - std::span edge_mask = {}); - -[[nodiscard]] std::vector -batch_max_flow(const StrictMultiDiGraph& g, - const std::vector>& pairs, - FlowPlacement placement, bool shortest_path, - bool require_capacity, - bool with_edge_flows, - bool with_reachable, - bool with_residuals, - const std::vector>& node_masks = {}, - const std::vector>& edge_masks = {}); +// The private payload of _place_global_units. +struct FlowCoreResult { + Amount placed {0}; + // The marginal cost curve: (cost, units) with costs strictly increasing and units positive. + std::vector> cost_distribution {}; + std::vector edge_flows {}; // per edge, id order + // Edges with positive view from the full residual closure of the real sources to its + // complement, ascending id; nullopt while the destinations stay connected (a cap stopped the + // solve) and on an input cap of 0. + std::optional> min_cut {}; + std::vector potentials {}; // per node; kNeverReached when never settled + FlowCoreCounters counters {}; +}; -// Performs sensitivity analysis to identify edges that constrain flow. -// -// Computes baseline flow (using full max-flow or shortest-path-only mode), -// then tests removing each saturated edge to measure its criticality. -// -// Arguments: -// g: The input graph. -// src, dst: Source and destination nodes. -// placement: Flow placement strategy. -// shortest_path: If true, uses single-pass shortest-path flow (IP/IGP mode). -// If false, uses full iterative max-flow (SDN/TE mode). -// require_capacity: If true, excludes saturated edges from routing. -// node_mask, edge_mask: Optional masks to exclude nodes/edges. -// -// Returns: -// Pairs of (EdgeId, FlowDelta) for edges whose removal reduces flow. -[[nodiscard]] std::vector> -sensitivity_analysis(const StrictMultiDiGraph& g, NodeId src, NodeId dst, - FlowPlacement placement, bool shortest_path, - bool require_capacity, - std::span node_mask = {}, - std::span edge_mask = {}); +// Minimum-cost flow of the maximum value attainable under `cap` from the source set to the +// destination set over the eligible edges, on the capacities `view` (units, id order). Every +// solve starts from zero flow. `cap` nullopt is uncapped; an input cap of 0 returns the zero +// payload without running a search. Throws std::invalid_argument before any work for a view of +// the wrong length or with an entry outside [0, capacity_units[e]], an empty or out-of-range +// endpoint set, a vertex in both sets, a cap outside [0, 2^63 - 2], or a foreign Eligibility. +// Excluded endpoints are skipped. +[[nodiscard]] FlowCoreResult place_global_units(const StrictMultiDiGraph& g, + std::span view, + std::span sources, + std::span destinations, + const Eligibility* elig, + std::optional cap); } // namespace netgraph::core diff --git a/include/netgraph/core/options.hpp b/include/netgraph/core/options.hpp deleted file mode 100644 index c084e4b..0000000 --- a/include/netgraph/core/options.hpp +++ /dev/null @@ -1,50 +0,0 @@ -/* Option structs for algorithm invocations to keep interfaces concise. */ -#pragma once - -#include -#include - -#include "netgraph/core/types.hpp" - -namespace netgraph::core { - -struct SpfOptions { - bool multipath { true }; - EdgeSelection selection {}; - std::optional dst {}; - std::span residual {}; - std::span node_mask {}; - std::span edge_mask {}; -}; - -// Options for shortest_paths_to (reverse SPF toward one destination). See -// shortest_paths.hpp for the semantics of fanout_edges. -struct SpfToOptions { - bool multipath { true }; - EdgeSelection selection {}; - std::span residual {}; - std::span node_mask {}; - std::span edge_mask {}; - std::span fanout_edges {}; -}; - -struct KspOptions { - int k { 1 }; - std::optional max_cost_factor {}; - bool unique { true }; - std::span node_mask {}; - std::span edge_mask {}; -}; - -struct MaxFlowOptions { - FlowPlacement placement { FlowPlacement::Proportional }; - bool shortest_path { false }; - bool require_capacity { true }; // Require edges to have capacity (SDN/TE). Set false for cost-only routing (IP/IGP) - bool with_edge_flows { false }; - bool with_reachable { false }; - bool with_residuals { false }; - std::span node_mask {}; - std::span edge_mask {}; -}; - -} // namespace netgraph::core diff --git a/include/netgraph/core/profiling.hpp b/include/netgraph/core/profiling.hpp index 40eb9ea..19b9287 100644 --- a/include/netgraph/core/profiling.hpp +++ b/include/netgraph/core/profiling.hpp @@ -12,12 +12,8 @@ When disabled (default), each instrumented scope costs one cached-bool check. // ... function body ... } -From Python: - import netgraph_core as ngc - # Run with NGRAPH_CORE_PROFILE=1 python script.py - if ngc.profiling_enabled(): - ngc.profiling_dump() # Print stats to stderr - ngc.profiling_reset() # Clear stats +From Python: the accessors return with the diagnostics module of the streamlined API +(sub-PR 1d); until then the statistics are reachable from C++ only. */ #pragma once diff --git a/include/netgraph/core/shortest_paths.hpp b/include/netgraph/core/shortest_paths.hpp deleted file mode 100644 index 5c86a93..0000000 --- a/include/netgraph/core/shortest_paths.hpp +++ /dev/null @@ -1,170 +0,0 @@ -/* Shortest paths (Dijkstra) with multipath predecessor DAG support. */ -#pragma once - -#include -#include -#include -#include - -#include "netgraph/core/strict_multidigraph.hpp" -#include "netgraph/core/types.hpp" - -namespace netgraph::core { - -// PredDAG (Predecessor Directed Acyclic Graph): compact representation of the equal-cost -// shortest paths from a source node. Stored in CSR format for efficiency. -// -// The DAG is always acyclic. With strictly positive edge costs it captures *every* -// equal-cost shortest path. Zero-cost edges are the one exception: a predecessor is -// recorded only while the child is still unsettled, so among nodes that are mutually -// reachable at equal distance the settle order decides which alternatives are kept. -// Without that rule a zero-cost pair u<->v would record each node as the other's -// parent, making the structure cyclic and path enumeration non-terminating. -// -// For each node v, predecessors are stored in parents[parent_offsets[v]:parent_offsets[v+1]] -// with corresponding EdgeIds in via_edges[parent_offsets[v]:parent_offsets[v+1]]. -// Multiple parallel edges are represented by multiple entries with the same parent. -// parent_offsets has length N+1 (like CSR row pointers). -struct PredDAG { - std::vector parent_offsets; // Length N+1 (CSR row pointers) - std::vector parents; // Predecessor node IDs - std::vector via_edges; // EdgeId used to reach node from predecessor -}; - -// Frontier queue used by shortest_paths / shortest_paths_to. Both queues pop in -// the same (cost, -bottleneck, node) order and produce bit-identical results; -// the bucket queue is faster when the graph's costs fit a bounded number of -// buckets (max_cost / gcd(costs) + 1 <= 65536), and the heap is used otherwise. -// Auto (the default) applies that rule, or the process-wide override -// NGRAPH_CORE_SPF_QUEUE=heap|bucket read once from the environment. Heap forces -// the reference binary heap; Bucket prefers the bucket queue when the graph is -// eligible. This parameter exists for tests and benchmarks; the Python bindings -// do not expose it. -enum class SpfQueue { Auto, Heap, Bucket }; - -// Compute shortest paths from src using Dijkstra's algorithm. -// Returns (distances, predecessor_dag) where distances[v] is the shortest cost to reach v -// (or inf if unreachable), and predecessor_dag encodes the shortest path structure. -// -// Parameters: -// - dst: if provided, algorithm may exit early once destination is reached -// - multipath: if true, keep all equal-cost predecessors; if false, keep one per node -// (in single-path mode, ties are broken by preferring higher bottleneck capacity) -// - selection: edge selection policy (multi-edge, capacity filtering, tie-breaking) -// - residual: if provided, use these capacities instead of graph's original capacities. -// Passing a non-empty 'residual' span forces capacity gating: -// edges with residual < kMinCap are excluded from exploration. -// For EB semantics this means re-running SPF on updated residuals -// will remove saturated next-hops and therefore *change* the fixed -// equal-split set (progressive behavior). -// - node_mask: if provided, node_mask[v]==true means node v is allowed (false excludes it). -// If the source node is masked (node_mask[src]==false), returns an empty predecessor DAG -// with all distances at infinity, as no traversal can begin from an excluded source. -// - edge_mask: if provided, edge_mask[e]==true means edge e is allowed (false excludes it) -[[nodiscard]] std::pair, PredDAG> -shortest_paths(const StrictMultiDiGraph& g, NodeId src, - std::optional dst, - bool multipath, - const EdgeSelection& selection, - std::span residual = {}, - std::span node_mask = {}, - std::span edge_mask = {}, - SpfQueue queue = SpfQueue::Auto); - -// Destination-limited search that returns only what placement needs: the cost -// of dst (INT64_MAX when unreachable, or when src/dst is invalid or masked) and -// the predecessor DAG. Semantics and the DAG are exactly those of -// shortest_paths(g, src, dst, ...); only the N-sized distance vector is not -// materialised (the DAG is still indexed by node id). Used by max-flow, -// FlowPolicy and KSP, which inspect no other distance. -[[nodiscard]] std::pair -shortest_paths_dag(const StrictMultiDiGraph& g, NodeId src, NodeId dst, - bool multipath, - const EdgeSelection& selection, - std::span residual = {}, - std::span node_mask = {}, - std::span edge_mask = {}, - SpfQueue queue = SpfQueue::Auto); - -// Build the SPF-compatible (distances, PredDAG) pair for one concrete path given -// as node/edge sequences (nodes[i] -> nodes[i+1] via edges[i]; so nodes.size() == -// edges.size() + 1, or a single node with no edges). Distances are cumulative edge -// costs along the path, INT64_MAX elsewhere; the DAG stores one parent per visited -// node. Inputs are trusted (no validation) -- used by KSP's result conversion. -[[nodiscard]] std::pair, PredDAG> -path_to_pred_dag(const StrictMultiDiGraph& g, - std::span nodes, - std::span edges); - -// Build a single-path PredDAG from a contiguous edge sequence (the missing -// constructor for operator-defined explicit paths; PredDAGs from SPF/KSP work -// directly wherever a PredDAG is accepted). -// -// Throws std::invalid_argument if edges is empty, any id is out of range, the -// sequence is not contiguous (dst(e_i) != src(e_{i+1})), or the walk repeats a -// node (a repeated node cannot be represented as one-parent-per-node, and a -// pinned path is a simple path by definition). -[[nodiscard]] PredDAG make_path_dag(const StrictMultiDiGraph& g, - std::span edges); - -// Build a PredDAG for one explicit route whose hops may each use several -// parallel edges: hops[i] lists the edges of hop i, all joining the same -// (u, v) pair, and hop i's head is hop i+1's tail. Every listed edge becomes a -// parent entry of its hop's head node, in the given order, so placement splits -// across the bundle exactly as it does on an SPF multipath DAG. make_path_dag -// is the single-edge-per-hop case. Throws std::invalid_argument on an empty -// route or hop, an id out of range, an edge repeated within a hop, hop edges -// that are not parallel, non-contiguous hops, or a repeated node. -[[nodiscard]] PredDAG make_hops_dag(const StrictMultiDiGraph& g, - std::span> hops); - -// Enumerate concrete paths represented by a PredDAG from src to dst. -// Each path is returned as a sequence of (node_id, (edge_ids...)) pairs ending with (dst, ()). -// When split_parallel_edges=false, parallel edges per hop are grouped in the tuple. -// When true, the cartesian product over parallel edges is enumerated, yielding one -// concrete single-edge path per combination; cap it with max_paths. -// Enumeration is exponential in the DAG's branching, so pass max_paths on wide ECMP DAGs. -// NOTE: this function does not release the Python GIL when called through the bindings. -[[nodiscard]] std::vector>>> -resolve_to_paths(const PredDAG& dag, NodeId src, NodeId dst, - bool split_parallel_edges = false, - std::optional max_paths = std::nullopt); - -} // namespace netgraph::core - -namespace netgraph::core { - -// Compute shortest paths *to* dst from every node (Dijkstra over the in-adjacency). -// Returns (distances, dag) where distances[u] is the cost of a shortest u -> dst walk -// (INT64_MAX if none) and dag is a forward-oriented PredDAG: parents[v] holds (u, e) -// for every edge e = u -> v that lies on a shortest u -> dst walk. Unlike a DAG from -// shortest_paths(), which is rooted at one source, this DAG is valid for placement -// from *any* node toward dst, and every walk it contains from a node u to dst has -// cost distances[u]. It is the union of the per-source shortest-path DAGs toward dst. -// -// Parameters mirror shortest_paths(): multipath keeps every equal-cost successor per -// node (false keeps one, preferring higher bottleneck capacity toward dst); -// selection, residual, node_mask and edge_mask apply as there. Zero-cost edges get -// the same acyclicity guard (a successor is recorded only while its node is -// unsettled). -// -// fanout_edges: edges to add to the DAG regardless of cost, modelling an origin -// whose first hop is decided by a traffic split rather than by routing (e.g. a -// pseudo source whose demand originates evenly at every attached real source). -// Each edge u -> v is added as a parent entry of v when it passes the masks and -// capacity gate and v has a finite distance; entries the SPF already recorded are -// not duplicated, and distances[u] is set to cost(e) + distances[v] if u was -// unreachable. Every fanout edge must leave a node that has no incoming DAG entry, -// so the result stays acyclic; a violating edge throws std::invalid_argument, as -// does an out-of-range edge id. -[[nodiscard]] std::pair, PredDAG> -shortest_paths_to(const StrictMultiDiGraph& g, NodeId dst, - bool multipath, - const EdgeSelection& selection, - std::span residual = {}, - std::span node_mask = {}, - std::span edge_mask = {}, - std::span fanout_edges = {}, - SpfQueue queue = SpfQueue::Auto); - -} // namespace netgraph::core diff --git a/include/netgraph/core/strict_multidigraph.hpp b/include/netgraph/core/strict_multidigraph.hpp index 42531bc..d34b722 100644 --- a/include/netgraph/core/strict_multidigraph.hpp +++ b/include/netgraph/core/strict_multidigraph.hpp @@ -1,4 +1,4 @@ -/* Immutable directed multigraph with CSR and reverse CSR adjacency. */ +/* The one graph type: an immutable directed multigraph on integer units (APPROACH 3). */ #pragma once #include @@ -9,80 +9,114 @@ namespace netgraph::core { -// Edge identifiers: -// - EdgeId: index into the graph's internal edge arrays (0-based). -// Edges are reordered during construction by (cost, src, dst) for deterministic traversal. -// - External edge IDs (ext_edge_ids): optional user-provided identifiers (e.g., your database IDs) -// that are reordered alongside edges, allowing results to reference edges using stable, -// application-level IDs instead of internal EdgeId indices. +class Eligibility; +// Converts a physical value to units of the resolution q, rounding half up. The only way a +// physical number enters Core. Throws std::invalid_argument for NaN, infinity, a negative value, +// a result at or above the domain bound 2^62, or a resolution that is not a power of two in +// [2^-32, 2^32]. +[[nodiscard]] Amount to_units(double x, double resolution); +// Units to a physical double (x * q): exact below 2^53 units. +[[nodiscard]] double as_float(Amount x, double resolution); + +// Edges are given in input order. The public EdgeId of an edge is its rank in the stable order +// (src, cost, dst, input): the out-edges of u are exactly the ids [row_offsets()[u], +// row_offsets()[u + 1]) in traversal order (cost, dst, input), and input_index()[e] is the input +// position of e. Every other per-edge array is in id order. The reverse CSR lists the in-edges +// of v in (cost, src, input) order. +// +// The constructor validates in this order and reports the first defect (std::invalid_argument): +// the resolution (a power of two in [2^-32, 2^32]); the unit values (every capacity in +// [0, 2^62)); the array shapes (num_nodes >= 0, equal lengths, at most INT32_MAX edges); zero +// or negative costs (costs are positive, rule 28); the remaining bounds (endpoints in +// [0, num_nodes), then the sum of all capacities and the sum of all costs below 2^62, so no path +// cost overflows int64). This is APPROACH 3's order (revision 25); a language binding validates +// in the same order before narrowing any value, so the reported defect does not depend on the +// representation of the input. +// +// A copy is a new graph: same ids and input map, a new uid. Uids come from one process-wide +// 64-bit counter and are never reused. class StrictMultiDiGraph { public: - // Builds a graph from parallel edge arrays, which must all have the same length. - // Edges are reordered by (cost, src, dst); ext_edge_ids is permuted alongside them. - // Self-loops and duplicate (src, dst) pairs are permitted (this is a multigraph). - // - // Throws std::invalid_argument or std::out_of_range if: - // - the arrays differ in length, or ext_edge_ids is neither empty nor that length - // - num_nodes < 0, or any src/dst falls outside [0, num_nodes) - // - any capacity < 0, or any cost < 0 - // - the number of edges exceeds INT32_MAX - // - the TOTAL of all edge costs reaches 2^62. SPF accumulates path costs as int64 - // and uses INT64_MAX as the unreachable sentinel, so a larger total could wrap - // negative and silently corrupt results. - [[nodiscard]] static StrictMultiDiGraph from_arrays( - std::int32_t num_nodes, - std::span src, - std::span dst, - std::span capacity, - std::span cost, - std::span ext_edge_ids = {}); + StrictMultiDiGraph(std::int32_t num_nodes, + std::span src, + std::span dst, + std::span capacity_units, + std::span cost, + double resolution); + StrictMultiDiGraph(const StrictMultiDiGraph& other); + StrictMultiDiGraph(StrictMultiDiGraph&& other) noexcept; + StrictMultiDiGraph& operator=(const StrictMultiDiGraph&) = delete; + StrictMultiDiGraph& operator=(StrictMultiDiGraph&&) = delete; ~StrictMultiDiGraph() noexcept = default; + [[nodiscard]] std::uint64_t uid() const noexcept { return uid_; } + [[nodiscard]] double resolution() const noexcept { return resolution_; } [[nodiscard]] std::int32_t num_nodes() const noexcept { return num_nodes_; } - [[nodiscard]] std::int32_t num_edges() const noexcept { return static_cast(edges_); } - - [[nodiscard]] std::span capacity_view() const noexcept { return capacity_; } - [[nodiscard]] std::span cost_view() const noexcept { return cost_; } - // Cost summary recorded at construction (0 for a graph without edges): - // the largest edge cost, and the gcd of all edge costs (0 when every cost is - // 0). SPF uses them to size its bucket queue; see shortest_paths.cpp. + [[nodiscard]] std::int32_t num_edges() const noexcept { + return static_cast(capacity_.size()); + } + [[nodiscard]] std::span capacity_units() const noexcept { return capacity_; } + [[nodiscard]] std::span cost() const noexcept { return cost_; } + [[nodiscard]] std::span edge_src() const noexcept { return src_; } + [[nodiscard]] std::span edge_dst() const noexcept { return dst_; } + // Full scans at construction; 0 on a graph without edges. [[nodiscard]] Cost max_cost() const noexcept { return max_cost_; } [[nodiscard]] Cost cost_gcd() const noexcept { return cost_gcd_; } - [[nodiscard]] std::span edge_src_view() const noexcept { return src_; } - [[nodiscard]] std::span edge_dst_view() const noexcept { return dst_; } - [[nodiscard]] std::span ext_edge_ids_view() const noexcept { return ext_edge_ids_; } - // CSR (Compressed Sparse Row) adjacency: for each node u, outgoing edges are in - // col_indices[row_offsets[u]:row_offsets[u+1]] with corresponding EdgeIds in adj_edge_index. - [[nodiscard]] std::span row_offsets_view() const noexcept { return row_offsets_; } - [[nodiscard]] std::span col_indices_view() const noexcept { return col_indices_; } - [[nodiscard]] std::span adj_edge_index_view() const noexcept { return adj_edge_index_; } - // Reverse CSR (incoming edges): for each node v, incoming edges are in - // in_col_indices[in_row_offsets[v]:in_row_offsets[v+1]] with EdgeIds in in_adj_edge_index. - [[nodiscard]] std::span in_row_offsets_view() const noexcept { return in_row_offsets_; } - [[nodiscard]] std::span in_col_indices_view() const noexcept { return in_col_indices_; } - [[nodiscard]] std::span in_adj_edge_index_view() const noexcept { return in_adj_edge_index_; } + // id -> input position. The inverse permutation is the caller's (one O(E) scatter). + [[nodiscard]] std::span input_index() const noexcept { return input_index_; } + // Forward CSR offsets (N + 1 entries, [0] = 0, [N] = E): the id ranges of the out-edges. + [[nodiscard]] std::span row_offsets() const noexcept { return row_offsets_; } + // Reverse CSR: the in-edges of v are in_adj_edge_index()[in_row_offsets()[v] .. + // in_row_offsets()[v + 1]) (public EdgeIds); the reverse column is + // edge_src()[in_adj_edge_index()[slot]]. + [[nodiscard]] std::span in_row_offsets() const noexcept { return in_row_offsets_; } + [[nodiscard]] std::span in_adj_edge_index() const noexcept { return in_adj_edge_index_; } + + // Static eligibility from excluded ids (duplicates and unsorted lists accepted, out-of-range + // ids rejected). Empty lists mean all allowed (uid 0). + [[nodiscard]] Eligibility eligibility(std::span excluded_nodes = {}, + std::span excluded_edges = {}) const; private: - // Core storage (edges may be reordered in compact form) + std::uint64_t uid_ {0}; + double resolution_ {1.0}; std::int32_t num_nodes_ {0}; - std::size_t edges_ {0}; Cost max_cost_ {0}; Cost cost_gcd_ {0}; - std::vector capacity_ {}; + std::vector capacity_ {}; std::vector cost_ {}; std::vector src_ {}; std::vector dst_ {}; - std::vector ext_edge_ids_ {}; // External edge IDs (reordered with edges) - - // CSR adjacency for deterministic traversal (always built deterministically) + std::vector input_index_ {}; std::vector row_offsets_ {}; - std::vector col_indices_ {}; - std::vector adj_edge_index_ {}; // map CSR entry -> EdgeId - // Reverse CSR (incoming adjacency) std::vector in_row_offsets_ {}; - std::vector in_col_indices_ {}; std::vector in_adj_edge_index_ {}; }; +// Immutable, graph-bound static eligibility: dense node and edge flags. An edge is allowed when +// it is not excluded and both of its endpoints are allowed. +class Eligibility { +public: + [[nodiscard]] std::uint64_t graph_uid() const noexcept { return graph_uid_; } + // 0 for the all-allowed value; a fresh uid otherwise. + [[nodiscard]] std::uint64_t uid() const noexcept { return uid_; } + [[nodiscard]] std::span node_allowed() const noexcept { return node_allowed_; } + [[nodiscard]] std::span edge_allowed() const noexcept { return edge_allowed_; } + [[nodiscard]] bool node_ok(NodeId v) const noexcept { + return node_allowed_[static_cast(v)] != 0; + } + [[nodiscard]] bool edge_ok(EdgeId e) const noexcept { + return edge_allowed_[static_cast(e)] != 0; + } + +private: + friend class StrictMultiDiGraph; + Eligibility() = default; + std::uint64_t graph_uid_ {0}; + std::uint64_t uid_ {0}; + std::vector node_allowed_ {}; + std::vector edge_allowed_ {}; +}; + } // namespace netgraph::core diff --git a/include/netgraph/core/types.hpp b/include/netgraph/core/types.hpp index 10c5236..af7efd5 100644 --- a/include/netgraph/core/types.hpp +++ b/include/netgraph/core/types.hpp @@ -1,106 +1,23 @@ -/* Core type aliases and helper structs. */ +/* Core type aliases and the bounds of the units domain (APPROACH 2.1). */ #pragma once #include -#include namespace netgraph::core { -// Node and edge identifiers are signed 32-bit integers. +// Node and edge identifiers are signed 32-bit integers. An EdgeId is the rank of the edge in +// the stable order (src, cost, dst, input) of its graph (layout B'); the input position of an +// edge is StrictMultiDiGraph::input_index()[e]. using NodeId = std::int32_t; using EdgeId = std::int32_t; -using Cost = std::int64_t; // Path cost (64-bit for large accumulations) -using Cap = double; // Edge capacity -using Flow = double; // Flow amount (same unit as capacity) - -using FlowClass = std::int32_t; // Flow priority/class bucket -using FlowId = std::int64_t; // Unique flow identifier - -// FlowIndex uniquely identifies a flow: (src, dst, class, id). -// Used as a key in unordered_map. -struct FlowIndex { - NodeId src; - NodeId dst; - FlowClass flowClass; // Priority bucket (e.g., for QoS classes) - FlowId flowId; // Unique ID within a FlowPolicy - friend bool operator==(const FlowIndex& a, const FlowIndex& b) noexcept { - return a.src==b.src && a.dst==b.dst && a.flowClass==b.flowClass && a.flowId==b.flowId; - } -}; - -// Hash function for FlowIndex (enables use in std::unordered_map). -struct FlowIndexHash { - std::size_t operator()(const FlowIndex& k) const noexcept { - std::size_t h = 0; - auto combine = [&h](std::size_t v) { - // Hash combine formula - h ^= v + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2); - }; - combine(std::hash{}(k.src)); - combine(std::hash{}(k.dst)); - combine(std::hash{}(k.flowClass)); - combine(std::hash{}(k.flowId)); - return h; - } -}; - -// Flow placement strategy for distributing demand across multiple paths. -// -// IMPORTANT SEMANTICS: -// -// - EqualBalanced models *ECMP admission on a fixed SPF DAG* ("one-shot ECMP"). -// We compute a single global scale so that no edge is oversubscribed under -// equal per-edge splits within each (u->v) group, place once, and stop. -// If you keep injecting after the first bottleneck saturates, you would -// change the split set (or oversubscribe and drop), which is intentionally -// out of scope for EqualBalanced. -// -// - Proportional may be used iteratively (e.g., for max-flow). -// -// - EqualBalanced takes its split set from the DAG edges that have *capacity* -// (the topology's next-hop set) and admits with one global scale computed -// from residual headroom. A member filled since the DAG was built therefore -// blocks admission (scale 0): a forwarding table does not react to load. -// Callers that want to progress past a full member recompute the DAG with a -// residual-aware SPF, as place_max_flow and FlowPolicy do. -// -// - EqualBalancedLossy splits over the same next-hop set without a scale: -// every edge carries min(share, residual) and the excess is dropped, -// deficits propagate downstream, and the placed amount is the volume that -// reaches dst. This is best-effort hash-ECMP forwarding; the dropped share -// per edge is available through the drop trace of place_on_dag. -enum class FlowPlacement { - Proportional = 1, // Distribute flow proportionally to residual capacity (like ECMP with weights) - EqualBalanced = 2, // Equal split over the topology next-hop set, one lossless admission scale - EqualBalancedLossy = 3 // Equal split over the topology next-hop set; excess over residual is dropped -}; - -// Tie-breaking rule when multiple equal-cost edges exist between the same (u,v) pair. -enum class EdgeTieBreak { - Deterministic = 1, // Use edge order from graph construction (reproducible) - PreferHigherResidual = 2 // Prefer the edge with the higher priority value (EdgePriority) -}; - -// Per-edge value that ranks equal-cost alternatives: the PreferHigherResidual -// edge tie-break and the node-level bottleneck ordering (equal-cost nodes -// settle in descending order of the smallest value along their path). It does -// not affect which edges are eligible: capacity gating always uses the residual. -enum class EdgePriority { - Residual = 1, // residual capacity when a residual is supplied, otherwise capacity (default) - Capacity = 2, // static capacity, even when a residual is supplied - Headroom = 3 // residual / capacity: the fraction of the edge still free (1 without a residual) -}; - -// Edge selection policy for shortest path algorithms. -struct EdgeSelection { - // multi_edge: if true, keep all equal-cost parallel edges per (u,v) pair. - // if false, select one edge per (u,v) using tie_break rule. - bool multi_edge { true }; - // require_capacity: if true, only consider edges with residual capacity > kMinCap. - bool require_capacity { false }; - EdgeTieBreak tie_break { EdgeTieBreak::Deterministic }; - // priority: which per-edge value tie_break and the bottleneck ordering rank by. - EdgePriority priority { EdgePriority::Residual }; -}; +// Edge and path costs: positive integers, never converted. +using Cost = std::int64_t; +// An amount: an integer number of units of the graph's resolution. Capacities, views, flows and +// requests are amounts; there is no finer grid. +using Amount = std::int64_t; + +// Exclusive bound of the units domain: every capacity, the sum of all capacities and the sum of +// all edge costs stay below 2^62. +inline constexpr std::int64_t kDomainLimit = std::int64_t{1} << 62; } // namespace netgraph::core diff --git a/python/netgraph_core/__init__.py b/python/netgraph_core/__init__.py index f586db7..222da2d 100644 --- a/python/netgraph_core/__init__.py +++ b/python/netgraph_core/__init__.py @@ -3,6 +3,12 @@ All public symbols are re-exported from the compiled ``_netgraph_core`` module so downstream users import everything from a single place. Typing shims supplied in ``_docs.py`` keep editors happy without affecting runtime behavior. + +The streamlined Core is being rebuilt in sub-PRs (``dev/design/2026-10-streamline``): +this build carries the graph on integer units (``StrictMultiDiGraph``, +``Eligibility``, ``to_units``, ``as_float``) and the private flow-core entry +``_netgraph_core._place_global_units``; the ledger, policies, searches and +evaluators return in the following sub-PRs. """ # pyright: reportMissingImports=false @@ -11,26 +17,10 @@ from typing import TYPE_CHECKING from _netgraph_core import ( - Algorithms, - Backend, - EdgePriority, - EdgeSelection, - EdgeTieBreak, - FlowGraph, - FlowIndex, - FlowPlacement, - FlowPolicy, - FlowPolicyConfig, - FlowState, - FlowSummary, - Graph, - MinCut, - PathAlg, - PredDAG, + Eligibility, StrictMultiDiGraph, - profiling_dump, - profiling_enabled, - profiling_reset, + as_float, + to_units, ) __version__ = version("netgraph-core") @@ -42,48 +32,16 @@ # because it turns "unchecked" into "confidently wrong". if TYPE_CHECKING: # pragma: no cover - typing-only from ._docs import ( # noqa: I001 - Algorithms as Algorithms, - Backend as Backend, - EdgePriority as EdgePriority, - EdgeSelection as EdgeSelection, - EdgeTieBreak as EdgeTieBreak, - FlowGraph as FlowGraph, - FlowIndex as FlowIndex, - FlowPlacement as FlowPlacement, - FlowPolicy as FlowPolicy, - FlowPolicyConfig as FlowPolicyConfig, - FlowState as FlowState, - FlowSummary as FlowSummary, - Graph as Graph, - MinCut as MinCut, - PathAlg as PathAlg, - PredDAG as PredDAG, + Eligibility as Eligibility, StrictMultiDiGraph as StrictMultiDiGraph, - profiling_dump as profiling_dump, - profiling_enabled as profiling_enabled, - profiling_reset as profiling_reset, + as_float as as_float, + to_units as to_units, ) __all__ = [ "__version__", "StrictMultiDiGraph", - "FlowGraph", - "Graph", - "EdgePriority", - "EdgeSelection", - "EdgeTieBreak", - "PathAlg", - "FlowPlacement", - "FlowPolicy", - "FlowPolicyConfig", - "PredDAG", - "FlowIndex", - "FlowState", - "MinCut", - "FlowSummary", - "Backend", - "Algorithms", - "profiling_enabled", - "profiling_dump", - "profiling_reset", + "Eligibility", + "to_units", + "as_float", ] diff --git a/python/netgraph_core/_docs.py b/python/netgraph_core/_docs.py index 9eed25a..bc52196 100644 --- a/python/netgraph_core/_docs.py +++ b/python/netgraph_core/_docs.py @@ -4,12 +4,12 @@ compiled extension. The goal is to provide type hints and help text. TODO: replace this hand-written stub with generated `.pyi` (e.g. via -pybind11-stubgen) to prevent drift from the runtime bindings. +pybind11-stubgen) to prevent drift from the runtime bindings (Phase 5). """ from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar, Optional, Sequence +from typing import TYPE_CHECKING, Sequence, overload if TYPE_CHECKING: # only for typing; runtime comes from extension import numpy as np # type: ignore[reportMissingImports] @@ -17,961 +17,165 @@ # to prevent circular imports during type checking. -class EdgeTieBreak: - """Tie-break rule among equal-cost parallel edges (pybind11 enum). +@overload +def to_units(x: float, resolution: float) -> int: ... +@overload +def to_units(x: "Sequence[float] | np.ndarray", resolution: float) -> "np.ndarray": ... +def to_units(x, resolution): # type: ignore[no-untyped-def] + """Convert physical value(s) to integer units of the resolution, rounding half up. - PREFER_HIGHER_RESIDUAL ranks edges by the value EdgeSelection.priority - selects (the residual by default); DETERMINISTIC uses the edge order from - graph construction. - """ - - DETERMINISTIC: ClassVar[EdgeTieBreak] - PREFER_HIGHER_RESIDUAL: ClassVar[EdgeTieBreak] - __members__: ClassVar[dict[str, EdgeTieBreak]] - - def __init__(self, value: int) -> None: ... - - @property - def name(self) -> str: ... - @property - def value(self) -> int: ... - - -class EdgePriority: - """Per-edge value that ranks equal-cost alternatives (pybind11 enum). - - It drives the PREFER_HIGHER_RESIDUAL edge tie-break and the node-level - ordering (equal-cost nodes settle in descending order of the smallest value - along their path). It never changes which edges are eligible: capacity - gating always uses the residual. - - RESIDUAL: residual capacity when a residual is supplied, otherwise the - static capacity. The default and the historical behaviour. - CAPACITY: the static capacity even when a residual is supplied. - HEADROOM: residual / capacity, the fraction of the edge still free - (1 for every edge when no residual is supplied, 0 for zero capacity). - """ - - RESIDUAL: ClassVar[EdgePriority] - CAPACITY: ClassVar[EdgePriority] - HEADROOM: ClassVar[EdgePriority] - __members__: ClassVar[dict[str, EdgePriority]] - - def __init__(self, value: int) -> None: ... - - -class EdgeSelection: - """Edge selection policy. The constructor is keyword-only. - - priority selects the per-edge value that tie_break and the bottleneck - ordering rank by (see EdgePriority); the default keeps today's behaviour. - """ - - multi_edge: bool - require_capacity: bool - tie_break: EdgeTieBreak - priority: EdgePriority - - def __init__( - self, - *, - multi_edge: bool = True, - require_capacity: bool = False, - tie_break: EdgeTieBreak = ..., - priority: EdgePriority = ..., - ) -> None: ... - - -class FlowPlacement: - """How to place flow across equal-cost predecessors during augmentation. - - PROPORTIONAL (WCMP-like): Distributes flow proportionally to available capacity. - May be used iteratively (e.g., for max-flow). - - EQUAL_BALANCED (ECMP): Single-pass admission on a fixed shortest-path DAG (Dijkstra). - The split set is the DAG's edges with capacity (the topology's next-hop - set). Computes one global scale so no edge is oversubscribed under - equal per-edge splits, places once, and stops; a member filled since - the DAG was computed drives the scale to 0, because a forwarding table - does not react to load. Progress past a full member by recomputing the - DAG with a residual-aware SPF (place_max_flow and FlowPolicy do). - ECMP = Equal-Cost Multi-Path; WCMP = Weighted-Cost Multi-Path. - - EQUAL_BALANCED_LOSSY: Equal split over the same capacity-based set with no - scaling: each edge carries min(share, residual) and drops the excess, - deficits propagate downstream, and the placed amount is what reaches - the destination. Models best-effort hash-ECMP forwarding; per-edge - drops are available from FlowGraph.place_with_drops. - """ - - PROPORTIONAL: ClassVar[FlowPlacement] - EQUAL_BALANCED: ClassVar[FlowPlacement] - EQUAL_BALANCED_LOSSY: ClassVar[FlowPlacement] - __members__: ClassVar[dict[str, FlowPlacement]] - - def __init__(self, value: int) -> None: ... - - @property - def name(self) -> str: ... - @property - def value(self) -> int: ... + The only way physical numbers enter Core: ``to_units(x, q) = floor(x / q + 1/2)``. + Args: + x: A physical float, or a 1-D array of them. + resolution: The unit ``q``, a power of two in ``[2**-32, 2**32]``. -class PredDAG: - """Compact predecessor DAG representation. + Returns: + An ``int`` for a scalar, an ``int64`` array for an array. - Arrays are int32; offsets has length N+1. + Raises: + ValueError: an invalid resolution (checked first, also for an empty array); + NaN, infinity or a negative value (no unit value); a result at or above + ``2**62`` units (the domain bound). """ - - parent_offsets: np.ndarray - parents: np.ndarray - via_edges: np.ndarray - - @staticmethod - def from_hops( - graph: "StrictMultiDiGraph", hops: "Sequence[Sequence[int]]" - ) -> "PredDAG": - """Build a PredDAG for one explicit route with parallel edges per hop. - - hops[i] lists the edge ids of hop i, which must all join the same node - pair; consecutive hops must connect and the walk must be a simple - path. Every listed edge becomes a parent entry of its hop's head node, - so placement splits across the bundle exactly as on an SPF multipath - DAG (a 4 x 10 bundle carries 40, where from_edges with one edge - carries 10). from_edges is the one-edge-per-hop case. - - Raises: - ValueError: If the route or a hop is empty, an id is out of range, - an edge is repeated within a hop, a hop's edges are not - parallel, hops do not connect, or the walk revisits a node. - """ - ... - - @staticmethod - def from_edges(graph: "StrictMultiDiGraph", edges: "Sequence[int]") -> "PredDAG": - """Build a single-path PredDAG from a contiguous edge-id sequence. - - The missing constructor for operator-defined explicit paths; PredDAGs - returned by Algorithms.spf/ksp work directly wherever a PredDAG is - accepted (FlowState.place_on_dag, FlowGraph.place, - FlowPolicy.set_static_paths). - - Raises: - ValueError: If edges is empty, an id is out of range, consecutive - edges do not connect, or the walk revisits a node (a pinned - path must be a simple path). - """ - ... - - def resolve_to_paths( - self, - src: int, - dst: int, - *, - split_parallel_edges: bool = False, - max_paths: Optional[int] = None, - ) -> list[tuple[tuple[int, tuple[int, ...]], ...]]: ... - - -class PathAlg: - SPF: ClassVar[PathAlg] - __members__: ClassVar[dict[str, PathAlg]] - - def __init__(self, value: int) -> None: ... - - @property - def name(self) -> str: ... - @property - def value(self) -> int: ... - - -class Backend: - """Backend for algorithm execution (typing stub only).""" - - @staticmethod - def cpu() -> "Backend": - """Create a CPU backend.""" - ... + ... -class Graph: - """Opaque backend graph handle produced by Algorithms.build_graph. +@overload +def as_float(x: int, resolution: float) -> float: ... +@overload +def as_float(x: "Sequence[int] | np.ndarray", resolution: float) -> "np.ndarray": ... +def as_float(x, resolution): # type: ignore[no-untyped-def] + """Convert integer units to physical value(s): ``x * q``, correctly rounded. - Keeps the underlying StrictMultiDiGraph alive for as long as it is used. + Exact below ``2**53`` units. Returns a ``float`` for an int of any size and a + ``float64`` array for an integer array or sequence. The resolution is validated first + (also for an empty array); a result beyond the finite doubles raises ValueError. """ - - @property - def num_nodes(self) -> int: ... - @property - def num_edges(self) -> int: ... + ... -class FlowGraph: - """Flow graph for multi-commodity flow tracking. +class Eligibility: + """Immutable, graph-bound static eligibility: dense node and edge flags. - Views return read-only NumPy arrays referencing internal C++ memory. - The FlowGraph must remain alive while views are in use. + Built by ``StrictMultiDiGraph.eligibility(excluded_nodes, excluded_edges)``. An + edge is allowed when it is not excluded and both of its endpoints are allowed. + Passing it to a call of another graph raises ValueError. """ - def __init__(self, graph: "StrictMultiDiGraph") -> None: ... - - def capacity_view(self) -> "np.ndarray": - """Return read-only float64 view of edge capacities.""" + def graph_uid(self) -> int: + """uid of the graph this value belongs to.""" ... - def residual_view(self) -> "np.ndarray": - """Return read-only float64 view of residual capacities.""" + def uid(self) -> int: + """0 for the all-allowed value, a fresh uid otherwise.""" ... - def edge_flow_view(self) -> "np.ndarray": - """Return read-only float64 view of placed flows.""" + def node_allowed(self) -> "np.ndarray": + """bool[N] read-only view.""" ... - @property - def graph(self) -> "StrictMultiDiGraph": ... - - def place( - self, - index: "FlowIndex", - src: int, - dst: int, - dag: "PredDAG", - amount: float, - flow_placement: FlowPlacement = ..., - ) -> float: ... - - def place_with_drops( - self, - index: "FlowIndex", - src: int, - dst: int, - dag: "PredDAG", - amount: float, - flow_placement: FlowPlacement = ..., - ) -> tuple[float, list[tuple[int, float]]]: - """Like place(), returning (placed, drops). - - `drops` lists (edge_id, dropped_volume) for every edge that dropped - flow under EQUAL_BALANCED_LOSSY; other placements return an empty - list. Dropped volume never enters the ledger, so remove() reverts only - what was carried. - """ + def edge_allowed(self) -> "np.ndarray": + """bool[E] read-only view.""" ... - def remove(self, index: "FlowIndex") -> None: ... - def remove_by_class(self, cls: int) -> None: ... - def reset(self) -> None: ... - def get_flow_edges(self, index: "FlowIndex") -> list[tuple[int, float]]: ... - def get_flow_path(self, index: "FlowIndex") -> list[int]: ... - - -class FlowState: - """Flow state tracker for a single-commodity flow problem. - - Tracks per-edge residual capacity and flow placement for a graph. - **Lifetime & Memory Safety:** - - Views (capacity_view, residual_view, edge_flow_view) return read-only - NumPy arrays that directly reference internal C++ memory (zero-copy). - - The FlowState must remain alive while these views are in use. - - Arrays passed as 'residual' to __init__ or reset() are **copied** internally. - - Masks passed to compute_min_cut() are **copied** before releasing GIL (thread-safe). - - **Array Requirements:** - - All arrays must be 1-D, C-contiguous, and have correct dtype (float64 for - residual, bool for masks). - - Mask lengths must match num_nodes/num_edges exactly or TypeError is raised. +class StrictMultiDiGraph: + """Immutable directed multigraph on integer units (layout B'). + + Edges are given in input order. The public EdgeId of an edge is its rank in the + stable order ``(src, cost, dst, input)``: the out-edges of node ``u`` are the ids + ``[row_offsets()[u], row_offsets()[u + 1])``, and ``input_index()[e]`` is the input + position of edge ``e`` (the inverse permutation is the caller's). Every other + per-edge array is in id order. Accessors return read-only NumPy views that keep the + graph alive. """ def __init__( - self, graph: "StrictMultiDiGraph", residual: "Optional[np.ndarray]" = None - ) -> None: - """Initialize FlowState with a graph and optional residual capacities. - - Args: - graph: The network graph (kept alive automatically) - residual: Optional 1-D float64 array of initial residual capacities. - If not provided, uses graph capacities. **Array is copied.** - - Raises: - TypeError: If residual has wrong dtype, ndim, or length. - """ - ... - - def reset(self, residual: "Optional[np.ndarray]" = None) -> None: - """Reset flow state to initial capacities. - - Args: - residual: Optional 1-D float64 array. **Array is copied.** - """ - ... - - def capacity_view(self) -> "np.ndarray": - """Return read-only float64 view of edge capacities (zero-copy).""" - ... - - def residual_view(self) -> "np.ndarray": - """Return read-only float64 view of residual capacities (zero-copy).""" - ... - - def edge_flow_view(self) -> "np.ndarray": - """Return read-only float64 view of placed flows (zero-copy).""" - ... - - def place_on_dag( - self, - src: int, - dst: int, - dag: "PredDAG", - requested_flow: float = float("inf"), - flow_placement: FlowPlacement = ..., - ) -> float: - """Place flow along a predecessor DAG. - - EqualBalanced is **single-pass ECMP admission** on the provided DAG: - we compute one global scale so no edge is oversubscribed under equal per-edge - splits, apply it once, and return. The split set is the topology's - next-hop set, so a member already full yields scale 0; progress past it - by recomputing the DAG with residuals. EQUAL_BALANCED_LOSSY forwards - best-effort over the same set (see FlowPlacement). - - Returns: - Amount of flow actually placed (may be less than requested). - """ - ... - - def place_max_flow( self, - src: int, - dst: int, - flow_placement: FlowPlacement = ..., - shortest_path: bool = False, - require_capacity: bool = True, - *, - node_mask: "Optional[np.ndarray]" = None, - edge_mask: "Optional[np.ndarray]" = None, - ) -> float: - """Place maximum flow from src to dst. - - Args: - require_capacity: Whether to require edges to have capacity. - - True (default): Routes adapt to residuals (SDN/TE, progressive fill). - - False: Routes based on costs only (IP/IGP, fixed routing). - node_mask: Optional 1-D bool array (True=allowed). Copied for thread safety. - edge_mask: Optional 1-D bool array (True=allowed). Copied for thread safety. - - For *IP-style ECMP* max-flow, use: require_capacity=False, shortest_path=True, - flow_placement=EQUAL_BALANCED. - - Caution: with require_capacity=True + EQUAL_BALANCED, this method iterates shortest-path + - placement (progressive fill), which differs from single-pass ECMP admission. - - Returns: - Total flow placed. - """ - ... - - def compute_min_cut( - self, - src: int, - *, - node_mask: "Optional[np.ndarray]" = None, - edge_mask: "Optional[np.ndarray]" = None, - ) -> "MinCut": - """Compute minimum cut from source based on current residual. + num_nodes: int, + src: "Sequence[int] | np.ndarray", + dst: "Sequence[int] | np.ndarray", + capacity_units: "Sequence[int] | np.ndarray", + cost: "Sequence[int] | np.ndarray", + resolution: float, + ) -> None: + """Build a graph from parallel edge arrays in input order. Args: - src: Source node for reachability analysis - node_mask: Optional 1-D bool array of length num_nodes. **Copied for thread safety.** - edge_mask: Optional 1-D bool array of length num_edges. **Copied for thread safety.** - - Returns: - MinCut object with edges in the cut set. + num_nodes: Node count; every src/dst must lie in ``[0, num_nodes)``. + src: Source node ids (integers). + dst: Destination node ids (integers). + capacity_units: Capacities as integer units of ``resolution``, each in + ``[0, 2**62)``; physical capacities go through ``to_units`` first. + cost: Positive integer costs (``cost >= 1``). + resolution: The unit ``q``, a power of two in ``[2**-32, 2**32]``. Raises: - TypeError: If masks have wrong dtype, ndim, or length. + ValueError: On the first defect, in this order: the resolution, the unit + values, the array shapes, zero or negative costs, the remaining bounds + (endpoints in range, then the capacity and cost sums below ``2**62``). + Integers are read without narrowing, so the reported defect does not + depend on how the input is represented; floats or bools in an integer + array are rejected, never truncated. """ ... - -class StrictMultiDiGraph: - """Immutable directed multigraph with CSR and reverse-CSR adjacency. - - Edges are reordered by (cost, src, dst) at construction, so EdgeId values are - positions in that canonical order rather than the caller's input order. Use - `ext_edge_ids` to map results back to caller-side identifiers. - - Every `*_view()` method returns a fresh, writable COPY of the internal buffer, - so mutating the result cannot violate the graph's immutability. (This differs - from `FlowState`/`FlowGraph` views, which are live read-only views.) - """ - - @staticmethod - def from_arrays( - num_nodes: int, - src: "np.ndarray", - dst: "np.ndarray", - capacity: "np.ndarray", - cost: "np.ndarray", - ext_edge_ids: "np.ndarray", - ) -> "StrictMultiDiGraph": - """Build a graph from parallel edge arrays (all must be 1-D, C-contiguous). - - Args: - num_nodes: Node count; every src/dst must lie in [0, num_nodes). - src: int32[E] source node ids. - dst: int32[E] destination node ids. - capacity: float64[E] capacities, each >= 0. - cost: int64[E] costs, each >= 0. Their TOTAL must stay below 2**62, - since SPF accumulates path costs as int64. - ext_edge_ids: int64[E] caller-side ids, permuted alongside the edges. - - Raises: - TypeError: On a wrong dtype, a non-contiguous array, or a wrong shape. - ValueError: On a negative capacity/cost, a node id out of range, a - length mismatch, or a total cost at or above 2**62. - """ + def uid(self) -> int: + """Process-unique id, never reused.""" ... + def resolution(self) -> float: ... def num_nodes(self) -> int: ... def num_edges(self) -> int: ... def max_cost(self) -> int: - """Largest edge cost (0 for a graph without edges). - - Shortest-path searches use a bucket queue when - ``max_cost() // cost_gcd() + 1 <= 65536`` and a binary heap otherwise. - Cost scales that exceed the limit (for example latencies in nanoseconds) - can be divided by a common factor before building the graph to stay on - the faster route; results are identical either way. - """ + """Largest edge cost (0 for a graph without edges).""" ... def cost_gcd(self) -> int: - """Greatest common divisor of all edge costs (0 when every cost is 0).""" - ... - - def capacity_view(self) -> "np.ndarray": - """Copy of per-edge capacities, float64[E].""" + """Greatest common divisor of all edge costs (0 for a graph without edges).""" ... - def cost_view(self) -> "np.ndarray": - """Copy of per-edge costs, int64[E].""" + def capacity_units(self) -> "np.ndarray": + """int64[E] capacities in units, id order.""" ... - def edge_src_view(self) -> "np.ndarray": - """Copy of per-edge source node ids, int32[E].""" + def cost(self) -> "np.ndarray": + """int64[E] costs, id order.""" ... - def edge_dst_view(self) -> "np.ndarray": - """Copy of per-edge destination node ids, int32[E].""" + def edge_src(self) -> "np.ndarray": + """int32[E] source node per edge.""" ... - def ext_edge_ids_view(self) -> "np.ndarray": - """Copy of caller-supplied external edge ids, int64[E].""" + def edge_dst(self) -> "np.ndarray": + """int32[E] destination node per edge.""" ... - def row_offsets_view(self) -> "np.ndarray": - """CSR row offsets over outgoing edges, int32[num_nodes + 1].""" + def input_index(self) -> "np.ndarray": + """int32[E]: id -> input position.""" ... - def col_indices_view(self) -> "np.ndarray": - """CSR neighbour node ids for outgoing edges, int32[E].""" + def row_offsets(self) -> "np.ndarray": + """int32[N + 1] forward CSR offsets (id ranges of the out-edges).""" ... - def adj_edge_index_view(self) -> "np.ndarray": - """EdgeId for each CSR outgoing entry, int32[E].""" + def in_row_offsets(self) -> "np.ndarray": + """int32[N + 1] reverse CSR offsets.""" ... - def in_row_offsets_view(self) -> "np.ndarray": - """Reverse-CSR row offsets over incoming edges, int32[num_nodes + 1].""" + def in_adj_edge_index(self) -> "np.ndarray": + """int32[E] in-edges per node in (cost, src, input) order, as EdgeIds.""" ... - def in_col_indices_view(self) -> "np.ndarray": - """Reverse-CSR predecessor node ids, int32[E].""" - ... - - def in_adj_edge_index_view(self) -> "np.ndarray": - """EdgeId for each reverse-CSR entry, int32[E].""" - ... - - -class FlowIndex: - """Identity of one flow: (src, dst, flowClass, flowId). - - The constructor takes exactly four POSITIONAL arguments (the binding - declares no argument names), and the attributes are read-only. - """ - - def __init__(self, src: int, dst: int, flowClass: int, flowId: int, /) -> None: ... - @property - def src(self) -> int: ... - @property - def dst(self) -> int: ... - @property - def flowClass(self) -> int: ... - @property - def flowId(self) -> int: ... - - -class FlowPolicyConfig: - """Configuration for FlowPolicy behavior. - - Key Parameters: - multipath: Controls whether individual flows split across multiple equal-cost paths. - - True (default): Hash-based ECMP - each flow uses a DAG with ALL equal-cost paths. - Flow volume is split across these paths according to flow_placement strategy. - This models router ECMP behavior where packets are hashed across next-hops. - - False: Tunnel-based ECMP - each flow uses a SINGLE path (one tunnel/LSP). - Multiple flows can share the same path. This models MPLS LSP semantics where - each LSP follows one specific path, and ECMP means balancing ACROSS LSPs. - """ - - path_alg: PathAlg - flow_placement: FlowPlacement - selection: EdgeSelection - require_capacity: bool - multipath: bool - min_flow_count: int - max_flow_count: Optional[int] - max_path_cost: Optional[int] - max_path_cost_factor: Optional[float] - shortest_path: bool - reoptimize_flows_on_each_placement: bool - max_no_progress_iterations: int - max_total_iterations: int - diminishing_returns_enabled: bool - diminishing_returns_window: int - diminishing_returns_epsilon_frac: float - - def __init__( + def eligibility( self, - *, - path_alg: PathAlg = ..., - flow_placement: FlowPlacement = ..., - selection: EdgeSelection = ..., - require_capacity: bool = True, - multipath: bool = True, - min_flow_count: int = 1, - max_flow_count: Optional[int] = None, - max_path_cost: Optional[int] = None, - max_path_cost_factor: Optional[float] = None, - shortest_path: bool = False, - reoptimize_flows_on_each_placement: bool = False, - max_no_progress_iterations: int = 100, - max_total_iterations: int = 10000, - diminishing_returns_enabled: bool = True, - diminishing_returns_window: int = 8, - diminishing_returns_epsilon_frac: float = 1e-3, - ) -> None: - """All parameters are keyword-only; the no-argument form is also valid.""" - ... - + excluded_nodes: "Sequence[int] | np.ndarray" = (), + excluded_edges: "Sequence[int] | np.ndarray" = (), + ) -> Eligibility: + """Static eligibility from excluded node ids and EdgeIds. -class FlowPolicy: - """Flow policy for demand placement. - - Dynamic policies may refresh the DAG per round using residual-aware - shortest paths, progressively pruning saturated next-hops (traffic- - engineering style), which differs from one-shot ECMP admission. - - A policy can instead be pinned to explicit path bundles with - set_static_paths(); see that method for the pinned semantics. - - Args: - algorithms: Algorithms instance (kept alive by FlowPolicy) - graph: Graph handle (kept alive by FlowPolicy) - config: FlowPolicyConfig with all policy parameters - node_mask: Optional 1-D bool array (True=allowed). **Copied for thread safety.** - edge_mask: Optional 1-D bool array (True=allowed). **Copied for thread safety.** - - Raises: - TypeError: If masks have wrong dtype, ndim, or length. - """ - - def __init__( - self, - algorithms: "Algorithms", - graph: "Graph", - config: "FlowPolicyConfig", - *, - node_mask: "Optional[np.ndarray]" = None, - edge_mask: "Optional[np.ndarray]" = None, - ) -> None: ... - - def flow_count(self) -> int: ... - def placed_demand(self) -> float: ... - def place_demand( - self, - flow_graph: "FlowGraph", - src: int, - dst: int, - flowClass: int, - volume: float, - target_per_flow: Optional[float] = None, - min_flow: Optional[float] = None, - ) -> tuple[float, float]: - """Place `volume` of demand; returns (placed, remaining). - - Raises: - ValueError: If this policy already manages a different (src, dst) - pair (call remove_demand() first, or use one policy per - demand), or if `flow_graph` wraps a different - StrictMultiDiGraph than the policy's own graph handle. - - EQUAL_BALANCED note: when the equalizing rebalance runs, `placed` is - the policy's TOTAL placed demand (cumulative across calls, because - rebalancing re-places previously placed volume too), so - `placed + remaining` can exceed this call's `volume`. Use - placed_demand() deltas for strict per-call accounting. EB placement is - also not incremental: each call's volume defines the per-flow target, - so a second, smaller call may place nothing. + Duplicates and unsorted lists are accepted; out-of-range ids raise ValueError. + Empty lists mean all allowed (uid 0). """ ... - - def rebalance_demand( - self, - flow_graph: "FlowGraph", - src: int, - dst: int, - flowClass: int, - target: float, - ) -> tuple[float, float]: ... - - def remove_demand(self, flow_graph: "FlowGraph") -> None: - """Remove all of this policy's flows, releasing its (src, dst) binding.""" - ... - - def set_static_paths(self, src: int, dst: int, paths: "Sequence[PredDAG]") -> None: - """Pin this policy's demand to explicit path bundles (MPLS-style). - - One flow is created per USABLE bundle at the next placement, in supply - order, each permanently bound to its own bundle. A single-path bundle - (from PredDAG.from_edges or ksp) models a strict-ERO LSP: any failed - hop takes it down. A multi-walk bundle (an SPF DAG) models a pinned - DAG / SR-TE-style policy that renormalizes over surviving walks. - - Bundles are validated against the policy's graph, then pruned against - the policy's node/edge masks; a bundle with no surviving src->dst walk - is DOWN and creates no flow (pinned paths do not reroute around - failures). Each flow's cost is the min-cost src->dst walk of its - PRUNED bundle. EqualBalanced spreads over the usable (up) bundles - only; down LSPs = len(paths) - flow_count(). - - With static paths configured the policy neither creates additional - flows nor reoptimizes: max_path_cost, max_path_cost_factor, - min_flow_count and reoptimize_flows_on_each_placement are inert. - Placement is not incremental across place_demand calls for - EqualBalanced (each call's volume defines the per-flow target). - - Raises: - ValueError: If paths is empty; the policy already holds flows - (call remove_demand() first); shortest_path=True is - configured; src/dst is out of range or equal; a user-supplied - max_flow_count differs from len(paths); or a bundle is - malformed (wrong shape, out-of-range ids, an edge that does - not connect its parent to its node in this graph, a cycle, or - no src->dst walk). Calling again before placement replaces - the previous pinning. - """ - ... - - @property - def flows(self) -> dict[tuple[int, int, int, int], tuple[int, int, int, float]]: ... - - -class MinCut: - edges: "np.ndarray" # int32[K], copy of the cut edge ids - - -class FlowSummary: - total_flow: float - min_cut: MinCut - # Cost-weighted breakdown of total_flow, ascending and unique. Entries from - # the SPF tier loop are shortest-path-DAG costs; entries from the residual - # completion phase are MARGINAL costs (forward edge costs minus the cost of - # the flow they cancel) and need not match any traversable src->dst path. - costs: "np.ndarray" # int64[K] - flows: "np.ndarray" # float64[K] - edge_flows: "np.ndarray" - residual_capacity: "np.ndarray" - reachable_nodes: "np.ndarray" - - -class Algorithms: - """Core graph algorithms with thread-safe bindings. - - **Thread Safety & Array Handling:** - - All input arrays (residual, node_mask, edge_mask) are **copied** before - releasing the GIL, ensuring thread safety even if Python code mutates - the arrays concurrently. - - All arrays must be 1-D, C-contiguous, with correct dtype. - - Mask lengths must match num_nodes/num_edges exactly or TypeError is raised. - - **Lifetime Management:** - - Algorithms instances must be kept alive while Graph handles derived from - them are in use (automatic via Python reference counting in normal usage). - """ - - def __init__(self, backend: "Backend") -> None: ... - - def build_graph(self, graph: "StrictMultiDiGraph") -> "Graph": - """Build a graph handle from StrictMultiDiGraph. - - The graph object is kept alive automatically. - """ - ... - - def spf( - self, - graph: "Graph", - src: int, - dst: Optional[int] = None, - *, - selection: Optional[EdgeSelection] = None, - residual: Optional["np.ndarray"] = None, - node_mask: Optional["np.ndarray"] = None, - edge_mask: Optional["np.ndarray"] = None, - multipath: bool = True, - dtype: str = "float64", - ) -> tuple["np.ndarray", "PredDAG"]: - """Shortest path first algorithm. - - Args: - graph: Graph handle - src: Source node - dst: Optional destination (if None, compute from source to all) - selection: Edge selection policy - residual: Optional 1-D float64 array of residuals. **Copied for thread safety.** - node_mask: Optional 1-D bool mask (length num_nodes). **Copied for thread safety.** - True = node allowed, False = node excluded. - **If source node is masked (False), returns empty DAG with all distances at infinity.** - edge_mask: Optional 1-D bool mask (length num_edges). **Copied for thread safety.** - True = edge allowed, False = edge excluded. - multipath: Whether to track multiple equal-cost paths - dtype: "float64" (inf for unreachable) or "int64" (max for unreachable) - - Returns: - (distances, predecessor_dag) - - Note: - When the source node is masked out (node_mask[src] == False), the algorithm - immediately returns an empty predecessor DAG with all distances set to infinity, - as no traversal can begin from an excluded source. - - Raises: - TypeError: If arrays have wrong dtype, ndim, or length - (residual must have length num_edges; masks must match - num_nodes / num_edges). - ValueError: If src/dst out of range. - """ - ... - - def spf_to( - self, - graph: "Graph", - dst: int, - *, - selection: Optional[EdgeSelection] = None, - residual: Optional["np.ndarray"] = None, - node_mask: Optional["np.ndarray"] = None, - edge_mask: Optional["np.ndarray"] = None, - multipath: bool = True, - fanout_edges: Optional[Sequence[int]] = None, - dtype: str = "float64", - ) -> tuple["np.ndarray", "PredDAG"]: - """Shortest paths from every node *to* dst (reverse SPF). - - Returns ``(distances_to_dst, dag)``. ``distances_to_dst[u]`` is the cost - of a shortest ``u -> dst`` walk (inf / int64 max if none). The DAG is - forward-oriented, so it works anywhere a PredDAG is accepted, and is - valid for placement from *any* node toward ``dst``: every walk it - holds from a node ``u`` to ``dst`` costs ``distances_to_dst[u]``. It is - the union of the per-source shortest-path DAGs toward ``dst``. - - Args: - graph: Graph handle - dst: Destination node - selection: Edge selection policy (as for spf) - residual: Optional 1-D float64 array of residuals (copied); forces - capacity gating as for spf - node_mask: Optional 1-D bool mask (length num_nodes), copied - edge_mask: Optional 1-D bool mask (length num_edges), copied - multipath: Keep every equal-cost successor per node; False keeps - one, preferring higher bottleneck capacity toward dst - fanout_edges: Edge ids added to the DAG regardless of cost, for an - origin whose first hop is a traffic split rather than a - routing decision (a pseudo source fanning out over every - attached real source). Each edge is added when it passes the - masks and capacity gate and its head has a finite distance; - its tail must have no incoming DAG entry - dtype: "float64" (inf for unreachable) or "int64" (max for unreachable) - - Raises: - TypeError: If arrays have wrong dtype, ndim, or length. - ValueError: If dst or a fanout edge id is out of range, or a - fanout edge leaves a node that already has an incoming DAG - entry. - """ - ... - - def ksp( - self, - graph: "Graph", - src: int, - dst: int, - *, - k: int, - max_cost_factor: Optional[float] = None, - unique: bool = True, - node_mask: Optional["np.ndarray"] = None, - edge_mask: Optional["np.ndarray"] = None, - dtype: str = "float64", - ) -> list[tuple["np.ndarray", "PredDAG"]]: - """K shortest paths algorithm. - - Args: - k: Number of paths to find - max_cost_factor: Optional maximum cost factor relative to shortest - node_mask: Optional 1-D bool mask. **Copied for thread safety.** - edge_mask: Optional 1-D bool mask. **Copied for thread safety.** - - Returns: - List of (distances, dag) tuples, up to k paths. - - Note: - Tie-breaking is deterministic by edge ID. EdgeSelection policy is not used. - """ - ... - - def max_flow( - self, - graph: "Graph", - src: int, - dst: int, - *, - flow_placement: FlowPlacement = ..., - shortest_path: bool = False, - require_capacity: bool = True, - with_edge_flows: bool = False, - with_reachable: bool = False, - with_residuals: bool = False, - node_mask: Optional["np.ndarray"] = None, - edge_mask: Optional["np.ndarray"] = None, - ) -> tuple[float, "FlowSummary"]: - """Maximum flow algorithm. - - Behavior depends on require_capacity parameter: - - - require_capacity=True (default): "True max-flow" - require edges to have capacity, - exclude saturated links (SDN/TE behavior). Uses all available paths. - - - require_capacity=False: "IP ECMP" - routes based on costs only (IP/IGP behavior). - Routes computed once from topology, ignoring capacity. Models OSPF/IS-IS routing. - - Common configurations: - - 1. True max-flow (SDN/TE): - require_capacity=True, shortest_path=False - - 2. IP ECMP max-flow: - require_capacity=False, shortest_path=True, flow_placement=EQUAL_BALANCED - - 3. IP WCMP max-flow: - require_capacity=False, shortest_path=True, flow_placement=PROPORTIONAL - - Args: - require_capacity: Whether to require edges to have capacity. - shortest_path: Whether to restrict to lowest-cost tier only. - flow_placement: How to split flow across parallel edges (ECMP vs WCMP). - node_mask: Optional 1-D bool mask. **Copied for thread safety.** - edge_mask: Optional 1-D bool mask. **Copied for thread safety.** - - Returns: - (total_flow, flow_summary) - """ - ... - - def batch_max_flow( - self, - graph: "Graph", - pairs: "np.ndarray", - *, - node_masks: Optional[list["np.ndarray"]] = None, - edge_masks: Optional[list["np.ndarray"]] = None, - flow_placement: FlowPlacement = ..., - shortest_path: bool = False, - require_capacity: bool = True, - with_edge_flows: bool = False, - with_reachable: bool = False, - with_residuals: bool = False, - ) -> list[FlowSummary]: - """Batch maximum flow for multiple (src,dst) pairs. - - Args: - pairs: int32 array of shape [B, 2] with (src, dst) pairs - node_masks: Optional list of B bool masks. **Each copied for thread safety.** - edge_masks: Optional list of B bool masks. **Each copied for thread safety.** - require_capacity: If True, exclude saturated edges (SDN/TE). If False, route by cost only (IP/IGP). - - Returns: - List of FlowSummary objects, one per pair. - """ - ... - - def sensitivity_analysis( - self, - graph: "Graph", - src: int, - dst: int, - *, - flow_placement: FlowPlacement = ..., - shortest_path: bool = False, - require_capacity: bool = True, - node_mask: Optional["np.ndarray"] = None, - edge_mask: Optional["np.ndarray"] = None, - ) -> list[tuple[int, float]]: - """Sensitivity analysis to identify critical edges that constrain flow. - - Computes baseline flow, then tests removing each saturated edge to - measure how much the total flow would be reduced. - - The `shortest_path` parameter controls the routing semantics: - - - shortest_path=False (default): Full max-flow analysis (SDN/TE mode). - Identifies edges critical for achieving maximum possible flow. - - - shortest_path=True: Shortest-path-only analysis (IP/IGP mode). - Identifies edges critical for flow under ECMP routing. Edges on - unused longer paths are not reported as critical. - - Args: - graph: Graph handle - src: Source node - dst: Destination node - flow_placement: Flow placement strategy - shortest_path: If True, use single-pass shortest-path flow (IP/IGP). - If False, use full iterative max-flow (SDN/TE). - require_capacity: If True, exclude saturated edges from routing. - node_mask: Optional 1-D bool mask. **Copied for thread safety.** - edge_mask: Optional 1-D bool mask. **Copied for thread safety.** - - Returns: - List of (edge_id, flow_delta) tuples. Each edge_id is an edge - whose removal would reduce total flow by flow_delta. - """ - ... - - -def profiling_enabled() -> bool: - """True if profiling is enabled (NGRAPH_CORE_PROFILE=1 in the environment).""" - ... - - -def profiling_dump() -> None: - """Print collected profiling statistics to stderr.""" - ... - - -def profiling_reset() -> None: - """Clear all collected profiling statistics.""" - ... diff --git a/src/cpu_backend.cpp b/src/cpu_backend.cpp deleted file mode 100644 index fd35c66..0000000 --- a/src/cpu_backend.cpp +++ /dev/null @@ -1,146 +0,0 @@ -/* - CPU Backend — thin adapter that delegates to in-process algorithms. -*/ -#include "netgraph/core/backend.hpp" -#include "netgraph/core/k_shortest_paths.hpp" -#include "netgraph/core/max_flow.hpp" -#include "netgraph/core/shortest_paths.hpp" - -#include - -namespace netgraph::core { - -namespace { -class CpuBackend final : public Backend { -public: - GraphHandle build_graph(const StrictMultiDiGraph& g) override { - // Create a non-owning shared_ptr with no-op deleter; lifetime is managed by caller - return GraphHandle{ std::shared_ptr(&g, [](const StrictMultiDiGraph*){}) }; - } - - std::pair, PredDAG> spf( - const GraphHandle& gh, NodeId src, const SpfOptions& opts) override { - const StrictMultiDiGraph& g = *gh.graph; - // Validate mask lengths strictly; mismatches are user errors. - // NOTE: this check is deliberately duplicated with the one inside - // shortest_paths(): the backend layer produces a caller-facing message, and the - // algorithm layer must stand on its own because it is also part of the public - // C++ API and callable without going through a Backend. - if (!opts.node_mask.empty() && opts.node_mask.size() != static_cast(g.num_nodes())) { - throw std::invalid_argument("CpuBackend::spf: node_mask length mismatch"); - } - if (!opts.edge_mask.empty() && opts.edge_mask.size() != static_cast(g.num_edges())) { - throw std::invalid_argument("CpuBackend::spf: edge_mask length mismatch"); - } - // Forward spans directly (already validated above) - return netgraph::core::shortest_paths(g, src, opts.dst, opts.multipath, opts.selection, - opts.residual, opts.node_mask, opts.edge_mask); - } - - std::pair spf_dag( - const GraphHandle& gh, NodeId src, NodeId dst, const SpfOptions& opts) override { - const StrictMultiDiGraph& g = *gh.graph; - return netgraph::core::shortest_paths_dag(g, src, dst, opts.multipath, opts.selection, - opts.residual, opts.node_mask, opts.edge_mask); - } - - std::pair, PredDAG> spf_to( - const GraphHandle& gh, NodeId dst, const SpfToOptions& opts) override { - const StrictMultiDiGraph& g = *gh.graph; - if (!opts.node_mask.empty() && opts.node_mask.size() != static_cast(g.num_nodes())) { - throw std::invalid_argument("CpuBackend::spf_to: node_mask length mismatch"); - } - if (!opts.edge_mask.empty() && opts.edge_mask.size() != static_cast(g.num_edges())) { - throw std::invalid_argument("CpuBackend::spf_to: edge_mask length mismatch"); - } - return netgraph::core::shortest_paths_to(g, dst, opts.multipath, opts.selection, - opts.residual, opts.node_mask, opts.edge_mask, - opts.fanout_edges); - } - - std::pair max_flow( - const GraphHandle& gh, NodeId src, NodeId dst, const MaxFlowOptions& opts) override { - const StrictMultiDiGraph& g = *gh.graph; - // Validate mask lengths strictly. - if (!opts.node_mask.empty() && opts.node_mask.size() != static_cast(g.num_nodes())) { - throw std::invalid_argument("CpuBackend::max_flow: node_mask length mismatch"); - } - if (!opts.edge_mask.empty() && opts.edge_mask.size() != static_cast(g.num_edges())) { - throw std::invalid_argument("CpuBackend::max_flow: edge_mask length mismatch"); - } - // Forward spans directly (already validated above) - return netgraph::core::calc_max_flow( - g, src, dst, - opts.placement, opts.shortest_path, - opts.require_capacity, - opts.with_edge_flows, - opts.with_reachable, - opts.with_residuals, - opts.node_mask, opts.edge_mask); - } - - std::vector, PredDAG>> ksp( - const GraphHandle& gh, NodeId src, NodeId dst, const KspOptions& opts) override { - const StrictMultiDiGraph& g = *gh.graph; - if (opts.k <= 0) { return {}; } - // Validate mask lengths strictly. - if (!opts.node_mask.empty() && opts.node_mask.size() != static_cast(g.num_nodes())) { - throw std::invalid_argument("CpuBackend::ksp: node_mask length mismatch"); - } - if (!opts.edge_mask.empty() && opts.edge_mask.size() != static_cast(g.num_edges())) { - throw std::invalid_argument("CpuBackend::ksp: edge_mask length mismatch"); - } - // Forward spans directly (already validated above) - return netgraph::core::k_shortest_paths(g, src, dst, opts.k, opts.max_cost_factor, - opts.unique, opts.node_mask, opts.edge_mask); - } - - std::vector batch_max_flow( - const GraphHandle& gh, - const std::vector>& pairs, - const MaxFlowOptions& opts, - const std::vector>& node_masks, - const std::vector>& edge_masks) override { - const StrictMultiDiGraph& g = *gh.graph; - // Validate batch mask lengths strictly. - for (const auto& span : node_masks) { - if (!span.empty() && span.size() != static_cast(g.num_nodes())) { - throw std::invalid_argument("CpuBackend::batch_max_flow: node_mask length mismatch"); - } - } - for (const auto& span : edge_masks) { - if (!span.empty() && span.size() != static_cast(g.num_edges())) { - throw std::invalid_argument("CpuBackend::batch_max_flow: edge_mask length mismatch"); - } - } - // Forward spans directly (already validated above) - return netgraph::core::batch_max_flow(g, pairs, - opts.placement, opts.shortest_path, - opts.require_capacity, - opts.with_edge_flows, opts.with_reachable, opts.with_residuals, - node_masks, edge_masks); - } - - std::vector> sensitivity_analysis( - const GraphHandle& gh, NodeId src, NodeId dst, const MaxFlowOptions& opts) override { - const StrictMultiDiGraph& g = *gh.graph; - // Validate mask lengths strictly. - if (!opts.node_mask.empty() && opts.node_mask.size() != static_cast(g.num_nodes())) { - throw std::invalid_argument("CpuBackend::sensitivity_analysis: node_mask length mismatch"); - } - if (!opts.edge_mask.empty() && opts.edge_mask.size() != static_cast(g.num_edges())) { - throw std::invalid_argument("CpuBackend::sensitivity_analysis: edge_mask length mismatch"); - } - return netgraph::core::sensitivity_analysis(g, src, dst, - opts.placement, opts.shortest_path, - opts.require_capacity, - opts.node_mask, opts.edge_mask); - } -}; -} // namespace - -BackendPtr make_cpu_backend() { - return std::make_shared(); -} - -} // namespace netgraph::core diff --git a/src/flow_graph.cpp b/src/flow_graph.cpp deleted file mode 100644 index 44ac4ae..0000000 --- a/src/flow_graph.cpp +++ /dev/null @@ -1,185 +0,0 @@ -/* - FlowGraph — authoritative flow ledger layered over FlowState. - - Tracks per-flow edge allocations to support exact removal and path inspection, - while delegating residual and aggregate flow management to FlowState. -*/ -#include "netgraph/core/flow_graph.hpp" -#include "netgraph/core/constants.hpp" - -#include -#include -#include - -namespace netgraph::core { - -namespace { -// Process-unique instance ids for state_stamp(); never reused, so a stamp -// taken from one FlowGraph can never match a different (or later) instance. -std::uint64_t fresh_flow_graph_uid() noexcept { - static std::atomic next_uid{1}; - return next_uid.fetch_add(1, std::memory_order_relaxed); -} -} // namespace - -FlowGraph::FlowGraph(const StrictMultiDiGraph& g) - : g_(&g), fs_(g), uid_(fresh_flow_graph_uid()) { -} - -// See the header: copies and moves must not share the source's uid, or two -// diverging objects could present equal stamps over different residuals. -FlowGraph::FlowGraph(const FlowGraph& other) - : g_(other.g_), fs_(other.fs_), ledger_(other.ledger_), - uid_(fresh_flow_graph_uid()), version_(0) { -} - -FlowGraph& FlowGraph::operator=(const FlowGraph& other) { - if (this != &other) { - g_ = other.g_; - fs_ = other.fs_; - ledger_ = other.ledger_; - uid_ = fresh_flow_graph_uid(); - version_ = 0; - } - return *this; -} - -FlowGraph::FlowGraph(FlowGraph&& other) noexcept - : g_(other.g_), fs_(std::move(other.fs_)), ledger_(std::move(other.ledger_)), - uid_(fresh_flow_graph_uid()), version_(0) { -} - -FlowGraph& FlowGraph::operator=(FlowGraph&& other) noexcept { - if (this != &other) { - g_ = other.g_; - fs_ = std::move(other.fs_); - ledger_ = std::move(other.ledger_); - uid_ = fresh_flow_graph_uid(); - version_ = 0; - } - return *this; -} - -Flow FlowGraph::place(const FlowIndex& idx, NodeId src, NodeId dst, - const PredDAG& dag, Flow amount, - FlowPlacement placement, - std::vector>* drops) { - if (amount <= 0.0) return 0.0; - ++version_; // residuals may change from here on - - // Get or create ledger entry for this flow. The ledger tracks per-edge - // cumulative amounts contributed by this flow (not just last placement). - // We append fresh deltas to the existing bucket, then coalesce by EdgeId. - auto& bucket = ledger_[idx]; - - // Delegate placement to FlowState, which returns the actual placed flow and - // populates bucket with per-edge allocations (EdgeId, Flow) pairs. - Flow placed = fs_.place_on_dag(src, dst, dag, amount, placement, &bucket, drops); - - // Coalesce and filter: merge duplicate EdgeIds. Keep any positive totals - // (do not drop sub-kMinFlow amounts) to preserve exact reversibility. - if (!bucket.empty()) { - std::vector> compact; - compact.reserve(bucket.size()); - // Sort by EdgeId to group duplicates together. - std::sort(bucket.begin(), bucket.end(), [](auto const& a, auto const& b){ return a.first < b.first; }); - // Merge consecutive entries with the same EdgeId. - for (std::size_t i=0;i 0.0) compact.emplace_back(e, static_cast(sum)); - i=j; - } - bucket.swap(compact); // replace bucket with compacted version - } - - // Clean up if no meaningful flow was placed. - if (bucket.empty() && placed < kMinFlow) { - ledger_.erase(idx); - return 0.0; - } - return placed; -} - -void FlowGraph::remove(const FlowIndex& idx) { - auto it = ledger_.find(idx); - if (it == ledger_.end()) return; // flow not found - ++version_; - const auto& deltas = it->second; - // Revert this flow's allocations from the FlowState by subtracting them. - if (!deltas.empty()) { - fs_.apply_deltas(deltas, /*add=*/false); // false = subtract - } - ledger_.erase(it); -} - -void FlowGraph::remove_by_class(FlowClass flowClass) { - std::vector to_rm; - to_rm.reserve(ledger_.size()); - for (auto const& kv : ledger_) if (kv.first.flowClass == flowClass) to_rm.push_back(kv.first); - for (auto const& idx : to_rm) remove(idx); -} - -void FlowGraph::reset() noexcept { - ++version_; - fs_.reset(); - ledger_.clear(); -} - -std::vector> FlowGraph::get_flow_edges(const FlowIndex& idx) const { - auto it = ledger_.find(idx); - if (it == ledger_.end()) return {}; - return it->second; -} - -std::vector FlowGraph::get_flow_path(const FlowIndex& idx) const { - auto it = ledger_.find(idx); - if (it == ledger_.end()) return {}; - const auto& deltas = it->second; - - // Reconstruct a simple path from the flow's edge allocations. - // This only succeeds if the flow forms a single path (not a DAG). - - // Step 1: Build adjacency map from edges with positive flow. - std::unordered_map>> adj; - // Build adjacency list from allocations using cached src/dst. - for (auto const& pr : deltas) { - if (pr.second < kEpsilon) continue; - auto eid = static_cast(pr.first); - NodeId u = g_->edge_src_view()[eid]; NodeId v = g_->edge_dst_view()[eid]; - adj[u].emplace_back(v, pr.first); - } - - // Step 2: Find starting node (out-degree == 1, in-degree == 0). - std::unordered_map indeg; - for (auto const& kv : adj) - for (auto const& pr : kv.second) - indeg[pr.first]++; - - NodeId start = -1; - for (auto const& kv : adj) { - // Candidate: has exactly one outgoing edge and zero incoming edges. - if (kv.second.size() == 1) { - if (indeg.find(kv.first) == indeg.end()) { - start = kv.first; - break; - } - } - } - if (start < 0) return {}; // no clear start node - - // Step 3: Walk the path from start, collecting edges. - std::vector path; - NodeId cur = start; - std::size_t guard = 0; // prevent infinite loops - while (adj.count(cur)) { - if (adj[cur].size() != 1) return {}; // branching detected, not a simple path - auto [nxt, eid] = adj[cur][0]; - path.push_back(eid); - cur = nxt; - if (++guard > static_cast(g_->num_edges())) return {}; // cycle detected - } - return path; -} - -} // namespace netgraph::core diff --git a/src/flow_policy.cpp b/src/flow_policy.cpp deleted file mode 100644 index b6c0d83..0000000 --- a/src/flow_policy.cpp +++ /dev/null @@ -1,850 +0,0 @@ -/* - FlowPolicy — manages path selection and flow placement for a single demand. - - Responsibilities: - - Select shortest-path predecessors (SPF) per policy via `shortest_paths`. - - Create and track flows with their current DAG and path cost. - - Place volumes using `FlowPlacement` (Proportional or EqualBalanced). - - Optionally re-optimize flows and enforce path-cost constraints. - - Notes: - - "Residual-aware" SPF considers current remaining capacity (or a per-flow - threshold) when exploring edges. - - Edge selection (multipath vs single-path) is handled natively by the SPF algorithm. -*/ -#include "netgraph/core/flow_policy.hpp" -#include "netgraph/core/constants.hpp" -#include "netgraph/core/algorithms.hpp" -#include "netgraph/core/options.hpp" -#include "netgraph/core/profiling.hpp" - -#include -#include -#include -#include -#include - -namespace netgraph::core { - -namespace { -// The equal-balanced placements share FlowPolicy's per-flow target and DAG -// refresh logic (and, except for the lossy mode, the equalizing rebalance in -// place_demand); they differ only inside FlowState::place_on_dag. -constexpr bool is_equal_balanced(FlowPlacement p) noexcept { - return p == FlowPlacement::EqualBalanced || p == FlowPlacement::EqualBalancedLossy; -} -} // namespace - -/* Reject uses that would silently produce a wrong answer: - - a FlowGraph wrapping a different graph than the policy routes on (SPF would run - on one topology while flow is placed on another); - - a (src, dst) pair different from the demand this policy already manages (the - round-robin loop routes using src/dst from the existing flow records). */ -void FlowPolicy::check_demand_target(const FlowGraph& fg, NodeId src, NodeId dst, - const char* what) const { - const auto* policy_graph = ctx_.graph.graph.get(); - if (policy_graph != nullptr && policy_graph != &fg.graph()) { - throw std::invalid_argument( - std::string("FlowPolicy::") + what + - ": the FlowGraph wraps a different StrictMultiDiGraph than this policy; " - "paths would be selected on one topology and placed on another"); - } - if (!flows_.empty()) { - const auto& existing = flows_.begin()->second; - if (existing.src != src || existing.dst != dst) { - throw std::invalid_argument( - std::string("FlowPolicy::") + what + - ": this policy already manages a demand for a different (src, dst) pair; " - "use a separate FlowPolicy per demand or call remove_demand() first"); - } - } -} - -double FlowPolicy::placed_demand() const noexcept { - double s = 0.0; - for (auto const& kv : flows_) s += kv.second.placed_flow; - return s; -} - -/* Compute an SPF predecessor DAG and its destination cost under the current - policy configuration. Honors static paths, residual capacity, and edge/node masks. */ -std::optional> FlowPolicy::get_path_bundle(const FlowGraph& fg, - NodeId src, NodeId dst, - std::optional min_flow) { - // With static paths, flows are created directly from the pinned bundles in - // place_demand_body and never reoptimized, so no dynamic bundle exists to hand - // out. Callers reaching here with static paths configured get nothing. - if (has_static_paths()) return std::nullopt; - if (path_alg_ != PathAlg::SPF) return std::nullopt; - - // Use configured selection for per-adjacency edge behavior (multi-edge, tie-breaking). - EdgeSelection sel = selection_; - - // Enforce semantic consistency between multipath and multi_edge: - // - Tunnel mode (multipath=false): force single edge per hop for true single-path semantics - // - Hash-ECMP with EqualBalanced: use all equal-cost edges to maximize fanout - if (!multipath_) { - sel.multi_edge = false; - } else if (is_equal_balanced(flow_placement_)) { - sel.multi_edge = true; - } - - // Respect capacity requirements from both config sources - sel.require_capacity = (selection_.require_capacity || require_capacity_); - // Decide whether we need residual-aware SPF. - // Residual awareness is controlled by require_capacity_: - // - require_capacity=true: Require edges to have capacity, routes adapt to residuals (SDN/TE behavior) - // - require_capacity=false: Routes based on costs only (IP/IGP behavior) - // Passing a residual to SPF forces capacity-aware selection, so a cost-only - // policy must not pass one even when an equal-balanced per-flow target is - // set; the target only shapes how much is requested per flow, not the route. - const bool require_residual = require_capacity_; - const auto residual = fg.residual_view(); - - // Edge mask: combine user-provided mask with minimum residual capacity threshold. - // For Proportional mode with min_flow threshold and max_flow_count limit, we filter edges - // by minimum capacity to ensure paths can accommodate the required flow. This prevents - // selecting low-capacity paths when limited to a single flow (max_flow_count=1). - // For EqualBalanced mode, we skip per-edge thresholds since group-based semantics mean - // per-edge thresholds can over-prune; capacity gating via residual + kMinCap is sufficient. - std::unique_ptr combined_edge_mask; - std::span final_edge_mask; - - if (require_residual && min_flow.has_value() && !is_equal_balanced(flow_placement_)) { - // Need to filter by min_flow threshold for Proportional mode - combined_edge_mask.reset(new bool[residual.size()]); - double thr = *min_flow; - - if (!edge_mask_.empty()) { - // Combine user mask with min_flow mask: both must be true - for (std::size_t i=0; i(residual[i]) >= thr); - } - } else { - // Only min_flow mask - for (std::size_t i=0; i(residual[i]) >= thr; - } - } - final_edge_mask = std::span(combined_edge_mask.get(), residual.size()); - } else if (!edge_mask_.empty()) { - // Only user-provided mask - final_edge_mask = edge_mask_; - } - - SpfOptions opts; - opts.multipath = multipath_; // Use configured multipath value (enables/disables flow splitting across equal-cost paths) - opts.selection = sel; - opts.dst = dst; - opts.residual = require_residual ? residual : std::span(); - opts.node_mask = node_mask_; // Use user-provided node mask - opts.edge_mask = final_edge_mask; - // dist.size() == num_nodes() for every SPF result, so the range check does - // not depend on the SPF output; hoisting it lets the memo skip the call. - if (dst < 0 || dst >= ctx_.graph.graph->num_nodes()) return std::nullopt; - - PredDAG dag; - Cost dst_cost; - const bool use_memo = (is_equal_balanced(flow_placement_)); - const auto stamp = fg.state_stamp(); - const bool with_residual = !opts.residual.empty(); - std::size_t hit_idx = spf_memo_.size(); - if (use_memo) { - for (std::size_t i = 0; i < spf_memo_.size(); ++i) { - const auto& e = spf_memo_[i]; - // min_flow's VALUE never reaches SPF in EB mode (the value-derived edge - // mask is Proportional-only); only has_value() matters, via - // require_residual. Keying on the value caused misses whenever rebalance - // rounds adjusted the per-flow target against unchanged residuals. - if (e.src != src || e.dst != dst || e.with_residual != with_residual || - e.has_min_flow != min_flow.has_value()) { - continue; - } - // Fast path: same FlowGraph, no mutation since the entry was stored. - // Content path: byte-identical residuals (rebalance remove+place - // round-trips restore content while the version keeps advancing). - const bool same = !with_residual || e.stamp == stamp || - (e.residual.size() == opts.residual.size() && - std::memcmp(e.residual.data(), opts.residual.data(), - opts.residual.size() * sizeof(Cap)) == 0); - if (same) { hit_idx = i; break; } - } - } - if (hit_idx < spf_memo_.size()) { - dag = spf_memo_[hit_idx].dag; - dst_cost = spf_memo_[hit_idx].dst_cost; - // MRU: move the hit to the front so hot entries stay cheap to find. - if (hit_idx != 0) { - std::rotate(spf_memo_.begin(), spf_memo_.begin() + hit_idx, - spf_memo_.begin() + hit_idx + 1); - } - } else { - auto res = ctx_.algorithms->spf_dag(ctx_.graph, src, dst, opts); - dag = std::move(res.second); - dst_cost = res.first; - if (use_memo) { - SpfMemoEntry entry; - entry.src = src; - entry.dst = dst; - entry.with_residual = with_residual; - entry.stamp = stamp; - entry.residual.assign(opts.residual.begin(), opts.residual.end()); - entry.has_min_flow = min_flow.has_value(); - entry.dag = dag; - entry.dst_cost = dst_cost; - const std::size_t entry_bytes = - entry.residual.size() * sizeof(Cap) + - entry.dag.parent_offsets.size() * sizeof(std::int32_t) + - entry.dag.parents.size() * (sizeof(NodeId) + sizeof(EdgeId)) + - sizeof(SpfMemoEntry); - const std::size_t cap = std::clamp( - kSpfMemoMaxBytes / std::max(entry_bytes, 1), 1, - kSpfMemoMaxEntries); - spf_memo_.insert(spf_memo_.begin(), std::move(entry)); - if (spf_memo_.size() > cap) spf_memo_.resize(cap); - } - } - if (dst_cost < best_path_cost_) best_path_cost_ = dst_cost; - - // Enforce path cost constraints: - // 1. In shortest_path mode, only allow paths with cost equal to best discovered cost. - // This prevents the policy from stepping up to higher-cost tiers incrementally. - if (shortest_path_ && dst_cost > best_path_cost_) { - return std::nullopt; - } - - // 2. Check absolute and relative path cost limits. - // max_path_cost: absolute upper bound on path cost. - // max_path_cost_factor: relative multiplier on best path cost (e.g. 1.5 = allow 50% longer). - if (max_path_cost_.has_value() || max_path_cost_factor_.has_value()) { - const Cost absmax = max_path_cost_.value_or(std::numeric_limits::max()); - // Apply the relative bound only when a best cost exists: multiplying the - // INT64_MAX "no best yet" sentinel by the factor and casting back is - // undefined behavior (the product exceeds the int64 range). - Cost factor_bound = std::numeric_limits::max(); - if (max_path_cost_factor_.has_value() && - best_path_cost_ != std::numeric_limits::max()) { - const double prod = static_cast(best_path_cost_) * *max_path_cost_factor_; - if (prod < static_cast(std::numeric_limits::max())) { - factor_bound = static_cast(prod); - } - } - if (dst_cost > std::min(absmax, factor_bound)) return std::nullopt; - } - // Ensure there is at least one predecessor for dst - if (static_cast(dst) >= dag.parent_offsets.size()-1) return std::nullopt; - if (dag.parent_offsets[static_cast(dst)] == dag.parent_offsets[static_cast(dst)+1]) return std::nullopt; - // Return DAG and cost as-is; placement logic decides proportional vs equal-balanced behavior. - return std::make_optional(std::make_pair(std::move(dag), dst_cost)); -} - -FlowIndex FlowPolicy::next_flow_index(const FlowGraph& fg, NodeId src, NodeId dst, FlowClass flowClass) { - FlowIndex idx{src, dst, flowClass, next_flow_id_++}; - if (fg.has_flow(idx)) { - throw std::invalid_argument( - "FlowPolicy: flow id " + std::to_string(idx.flowId) + " for (" + std::to_string(src) + ", " + - std::to_string(dst) + ", class " + std::to_string(flowClass) + ") already exists in the FlowGraph; " - "another policy manages the same demand on this FlowGraph"); - } - return idx; -} - -/* Create a new flow using the current path bundle. Returns nullptr if no - admissible path is available given constraints. */ -FlowRecord* FlowPolicy::create_flow(FlowGraph& fg, NodeId src, NodeId dst, FlowClass flowClass, - std::optional min_flow) { - FlowIndex idx = next_flow_index(fg, src, dst, flowClass); - - // Request a path DAG from shortest paths algorithm. - auto pb = get_path_bundle(fg, src, dst, min_flow); - if (!pb.has_value()) return nullptr; // No admissible path found - - // Destructure the returned pair (DAG, cost). - auto [dag, cost] = std::move(pb.value()); - - // Create flow record and insert into flows_ map. - FlowRecord f(idx, src, dst, std::move(dag), cost); - auto [it, ok] = flows_.emplace(idx, std::move(f)); // emplace returns (iterator, success_flag) - (void)ok; // suppress unused variable warning - return &it->second; -} - -/* Re-select a path for an existing flow, requesting at least (current+headroom) - residual. On failure, restores the flow on its original DAG. - - Reoptimization is useful when a flow's current path becomes suboptimal due to - network changes or when seeking additional capacity. */ -FlowRecord* FlowPolicy::reoptimize_flow(FlowGraph& fg, const FlowIndex& idx, double headroom) { - // Pinned means pinned: a static-path flow is never rerouted. (Deliberate - // divergence from the original Python port, where reoptimization could - // silently move a pinned flow onto an SPF path.) Must precede any - // remove/re-place churn below. - if (has_static_paths()) return nullptr; - auto it = flows_.find(idx); - if (it == flows_.end()) return nullptr; - FlowRecord& cur = it->second; - const double current = cur.placed_flow; - const double new_min = current + headroom; - - // Temporarily remove this flow's deltas from the graph to compute a path - // based on available capacity (excluding this flow's own usage). - fg.remove(idx); - auto pb = get_path_bundle(fg, cur.src, cur.dst, new_min); - if (!pb.has_value()) { - // Reoptimization failed: restore flow on original DAG. - Flow placed = fg.place(idx, cur.src, cur.dst, cur.dag, current, flow_placement_); - cur.placed_flow = placed; // may be slightly less if capacity changed; acceptable - return nullptr; - } - - // Reoptimization succeeded: update flow to use new DAG. - auto [dag, cost] = std::move(pb.value()); - cur.dag = std::move(dag); - cur.cost = cost; - Flow placed = fg.place(idx, cur.src, cur.dst, cur.dag, current, flow_placement_); - cur.placed_flow = placed; - return &cur; -} - -/* Place `volume` of demand according to the policy. When `target_per_flow` - is provided (e.g., during rebalancing), each flow aims for that target. - Returns (total_placed, leftover). */ -std::pair FlowPolicy::place_demand(FlowGraph& fg, - NodeId src, NodeId dst, - FlowClass flowClass, - double volume, - std::optional target_per_flow, - std::optional min_flow) { - NGRAPH_PROFILE_SCOPE("place_demand"); - - check_demand_target(fg, src, dst, "place_demand"); - - auto [total_placed, remaining] = place_demand_body(fg, src, dst, flowClass, volume, - target_per_flow, min_flow); - - // For EQUAL_BALANCED placement, rebalance flows to maintain equal volumes. - // - // Iterative on purpose: the previous implementation recursed - // place_demand -> rebalance_demand -> place_demand until balanced, and with many - // pinned bundles of heterogeneous capacity the depth grows like - // U * ln(imbalance / kMinFlow) -- a stack-overflow risk on worker threads. Each - // round below performs exactly what one recursion level performed (re-place the - // currently placed volume at the equal-share target), and the recursion's return - // value telescoped to (placed_demand(), pre-rebalance leftover + volume lost in - // rebalancing), which is reproduced after the loop. - // Lossy placement is best-effort: each flow is offered its share and carries - // what fits, so equalizing what was *carried* would contradict the model - // (it would throttle the healthy flows down to the congested one). - if (is_equal_balanced(flow_placement_) && - flow_placement_ != FlowPlacement::EqualBalancedLossy && !flows_.empty()) { - // Restore the reoptimize flag even if a round throws (bad_alloc is the only - // realistic thrower here); otherwise the policy would stay permanently - // non-reoptimizing. - struct ReoptRestore { - bool& flag; - bool prev; - ~ReoptRestore() { flag = prev; } - } reopt_restore{reoptimize_flows_on_each_placement_, reoptimize_flows_on_each_placement_}; - reoptimize_flows_on_each_placement_ = false; - const double pre_rounds_placed = placed_demand(); - bool rebalanced = false; - // Backstop only; each round strictly reduces the imbalance in practice. - constexpr int kMaxRebalanceRounds = 65536; - for (int round = 0; round < kMaxRebalanceRounds && !flows_.empty(); ++round) { - const double target_eq = placed_demand() / static_cast(flows_.size()); - bool unbalanced = false; - for (auto const& kv : flows_) { - if (std::abs(target_eq - kv.second.placed_flow) >= kMinFlow) { unbalanced = true; break; } - } - if (!unbalanced) break; - rebalanced = true; - const double vol = placed_demand(); - remove_demand(fg); - (void)place_demand_body(fg, src, dst, flowClass, vol, target_eq, std::nullopt); - } - if (rebalanced) { - const double final_placed = placed_demand(); - remaining += pre_rounds_placed - final_placed; // volume shed while rebalancing - total_placed = final_placed; - } - } - return { total_placed, remaining }; -} - -/* Placement core; see place_demand for the public contract. */ -std::pair FlowPolicy::place_demand_body(FlowGraph& fg, - NodeId src, NodeId dst, - FlowClass flowClass, - double volume, - std::optional target_per_flow, - std::optional min_flow) { - const bool is_static = has_static_paths(); - - // Compute target flow per flow-record. - // target: the volume to place per flow (or globally if target_per_flow is unset). - // per_target: refined target for EqualBalanced mode (considers source capacity). - double target = target_per_flow.value_or(volume); - double per_target = target; - - // EqualBalanced divisor: the number of flows the volume is split across. For a - // pinned policy that is the number of USABLE bundles (a head-end hashes over up - // LSPs only); dynamically it is the configured max_flow_count. - int eb_divisor = 0; - if (is_equal_balanced(flow_placement_)) { - if (is_static) eb_divisor = static_cast(static_bundles_.size()); - else if (max_flow_count_.has_value()) eb_divisor = *max_flow_count_; - } - - // For EqualBalanced, compute a per-flow target based on available source - // capacity and the requested volume, divided by the number of flows. - if (eb_divisor > 0) { - const auto& g = fg.graph(); - auto row = g.row_offsets_view(); - auto aei = g.adj_edge_index_view(); - auto residual = fg.residual_view(); - - // Compute total residual capacity on edges leaving src. - double src_cap = 0.0; - if (src >= 0 && src < g.num_nodes()) { - auto s = static_cast(row[static_cast(src)]); - auto e = static_cast(row[static_cast(src)+1]); - for (std::size_t j = s; j < e; ++j) { - auto eid = static_cast(aei[j]); - src_cap += static_cast(residual[eid]); - } - } - // Per-flow target: min of requested volume and source capacity, per flow. - double per_req = target / static_cast(eb_divisor); - double per_src = src_cap / static_cast(eb_divisor); - per_target = std::max(kMinFlow, std::min(per_req, per_src)); - } - - // Initialize flows if none exist yet. - if (flows_.empty()) { - if (is_static) { - // Pinned bundles: one flow per usable bundle, in supply order, each bound to - // its own (pruned) DAG and post-prune cost. - if (src != static_src_ || dst != static_dst_) { - throw std::invalid_argument( - "Source and destination nodes of static paths do not match demand."); - } - for (auto const& b : static_bundles_) { - FlowIndex idx = next_flow_index(fg, src, dst, flowClass); - flows_.emplace(idx, FlowRecord(idx, src, dst, b.dag, b.cost)); - } - } else { - // Dynamic paths: seed initial flows. - int initial = min_flow_count_; - if (max_flow_count_.has_value()) { - initial = std::min(initial, *max_flow_count_); - } - auto min_req = (is_equal_balanced(flow_placement_) && max_flow_count_.has_value()) - ? std::optional(per_target) - : min_flow; - // Seeding places no flow, so residuals do not change between iterations and - // every create_flow() here would recompute the identical SPF. Compute the - // bundle once and copy it into each seeded flow. - if (initial > 0) { - if (auto pb = get_path_bundle(fg, src, dst, min_req)) { - for (int i = 0; i < initial; ++i) { - FlowIndex idx = next_flow_index(fg, src, dst, flowClass); - FlowRecord f(idx, src, dst, pb->first, pb->second); - flows_.emplace(idx, std::move(f)); - } - } - } - } - } - // Round-robin placement: iterate over flows and try to place volume on each. - std::deque q; - for (auto const& kv : flows_) q.push_back(kv.first); - if (is_static) { - // Pinned bundles have a documented precedence: bundle-supply order. Flow ids - // are assigned in that order, so sorting makes the visit order deterministic - // across platforms (unordered_map iteration order is not). - std::sort(q.begin(), q.end(), - [](const FlowIndex& a, const FlowIndex& b) { return a.flowId < b.flowId; }); - } - double total_placed = 0.0; - int no_progress = 0; // counter for consecutive iterations with no progress - int iters = 0; - - // Diminishing-returns tracking for early exit. - std::deque recent; - const double initial_request = volume; - - while (volume >= kMinFlow && !q.empty()) { - FlowIndex cur_idx = q.front(); q.pop_front(); - auto it_cur = flows_.find(cur_idx); - if (it_cur == flows_.end()) continue; // flow removed during iteration - FlowRecord* f = &it_cur->second; - - // Must have a DAG to place; skip otherwise. - if (f->dag.parent_offsets.empty()) { - ++no_progress; - if (no_progress>=max_no_progress_iterations_) break; - continue; - } - // Refresh DAG based on current residuals for dynamic path selection. - // This prunes saturated next-hops and updates path selection. - // For multipath flows, this tracks saturated edges within the DAG. - // For tunnel flows, this allows different tunnels to discover different paths - // as residuals change, enabling natural fan-out across equal-cost paths. - if (is_equal_balanced(flow_placement_) && !is_static) { - if (auto pb = get_path_bundle(fg, f->src, f->dst, std::optional(per_target))) { - f->dag = std::move(pb->first); - f->cost = pb->second; - } - } - double need; - if (target_per_flow.has_value()) { - // When a per-flow target is specified (e.g., during rebalancing), cap by remaining per-flow target. - need = std::max(0.0, target - f->placed_flow); - } else if (eb_divisor > 0) { - // For EqualBalanced, request only the remaining deficit toward per-target for this flow. - need = std::max(0.0, per_target - f->placed_flow); - } else { - // Default behavior uses the global target amount. - need = target; - } - const double request = std::min(need, volume); - Flow placed = fg.place(f->index, f->src, f->dst, f->dag, request, flow_placement_); - f->placed_flow += placed; - volume -= placed; - total_placed += placed; - ++iters; - // IP-like mode: perform a single augmentation over the current SPF DAG - if (shortest_path_) { - break; - } - // Track recent placements. For a pinned policy, arm the window only once every - // flow has been visited: with more bundles than the window, the early rounds of - // small per-flow placements must not starve the bundles not yet visited. - if (diminishing_returns_enabled_ && - (!is_static || iters >= static_cast(flows_.size()))) { - recent.push_back(placed); - if (static_cast(recent.size()) > diminishing_returns_window_) recent.pop_front(); - if (static_cast(recent.size()) == diminishing_returns_window_) { - double sum_recent = 0.0; for (double x : recent) sum_recent += x; - const double threshold = std::max(kMinFlow, diminishing_returns_epsilon_frac_ * initial_request); - if (sum_recent < threshold) { - break; // graceful cutoff - } - } - } - if (placed < kMinFlow) { - ++no_progress; if (no_progress>=max_no_progress_iterations_) break; - } else { - no_progress = 0; - } - // A pinned policy neither grows its flow set nor reoptimizes: the pinned-ness - // guard is explicit, never inferred from flow-count arithmetic. - if (!is_static) { - if (is_equal_balanced(flow_placement_)) { - if (max_flow_count_.has_value()) { - // Bounded EB: add flows up to configured maximum. - if (static_cast(flows_.size()) < *max_flow_count_) { - if (auto* nf = create_flow(fg, src, dst, flowClass, std::optional(per_target))) q.push_back(nf->index); - } - } else { - // Unbounded EB: rely on a single flow to equalize over the DAG. - // Do not create additional flows implicitly. - } - } else { - if (target - f->placed_flow >= kMinFlow) { - if (!max_flow_count_ || static_cast(flows_.size()) < *max_flow_count_) { - if (auto* nf = create_flow(fg, src, dst, flowClass, std::nullopt)) q.push_back(nf->index); - } else { - if (auto* rf = reoptimize_flow(fg, f->index, kMinFlow)) q.push_back(rf->index); - } - } - } - } - if (iters >= max_total_iterations_) break; - } - - // Reoptimize all flows after placement if enabled (no-op for pinned policies: - // reoptimize_flow returns immediately when static paths are configured). - if (reoptimize_flows_on_each_placement_) { - for (auto& kv : flows_) { - (void)reoptimize_flow(fg, kv.first, kMinFlow); - } - } - return { total_placed, volume }; -} - -/* Rebalance existing placed demand such that each flow carries approximately - `target_per_flow`. Internally removes and re-places the same total volume. */ -std::pair FlowPolicy::rebalance_demand(FlowGraph& fg, - NodeId src, NodeId dst, - FlowClass flowClass, - double target_per_flow) { - // Must run before remove_demand() empties flows_, or the check inside - // place_demand() would have nothing left to compare against and would - // silently retarget this policy's volume onto a different node pair. - check_demand_target(fg, src, dst, "rebalance_demand"); - double vol = placed_demand(); - remove_demand(fg); - return place_demand(fg, src, dst, flowClass, vol, target_per_flow, std::nullopt); -} - -/* Remove all placed flows for this policy from the FlowGraph and reset - per-flow placed volumes. */ -void FlowPolicy::remove_demand(FlowGraph& fg) { - for (auto const& kv : flows_) { - fg.remove(kv.first); - } - flows_.clear(); - best_path_cost_ = std::numeric_limits::max(); -} - -namespace { - -/* Validate one pinned bundle against the graph, prune it against the policy's - masks, and compute its post-prune min cost. Throws std::invalid_argument on a - malformed bundle; returns nullopt for a structurally valid bundle that has no - surviving src->dst walk under the masks (a DOWN LSP). */ -std::optional> -prepare_static_bundle(const StrictMultiDiGraph& g, const PredDAG& dag, - NodeId src, NodeId dst, - std::span node_mask, - std::span edge_mask) { - const auto N = static_cast(g.num_nodes()); - const auto E = static_cast(g.num_edges()); - const auto& off = dag.parent_offsets; - const auto& par = dag.parents; - const auto& via = dag.via_edges; - - // Shape: CSR offsets over N nodes, entry arrays sized by the final offset. - if (off.size() != N + 1 || off.front() != 0) { - throw std::invalid_argument("static path bundle: parent_offsets must have length num_nodes + 1 and start at 0"); - } - for (std::size_t v = 0; v + 1 < off.size(); ++v) { - if (off[v] > off[v + 1]) { - throw std::invalid_argument("static path bundle: parent_offsets must be non-decreasing"); - } - } - const auto entries = static_cast(off.back()); - if (par.size() != entries || via.size() != entries) { - throw std::invalid_argument("static path bundle: parents/via_edges size must equal parent_offsets.back()"); - } - - // Entries: ids in range, and each via edge must actually connect parent -> child - // in THIS graph. Without the endpoint check, a well-shaped DAG built for a - // different graph (or with shuffled edge ids) would silently place flow on - // arbitrary edges. - const auto esrc = g.edge_src_view(); - const auto edst = g.edge_dst_view(); - for (std::size_t v = 0; v < N; ++v) { - for (auto i = static_cast(off[v]); i < static_cast(off[v + 1]); ++i) { - const auto pnode = par[i]; - const auto e = via[i]; - if (pnode < 0 || static_cast(pnode) >= N) { - throw std::invalid_argument("static path bundle: parent node id out of range"); - } - if (e < 0 || static_cast(e) >= E) { - throw std::invalid_argument("static path bundle: via edge id out of range"); - } - if (esrc[static_cast(e)] != pnode || - edst[static_cast(e)] != static_cast(v)) { - throw std::invalid_argument( - "static path bundle: via edge does not connect its parent to its node in " - "this graph (bundle built for a different graph?)"); - } - } - } - - // Acyclicity (Kahn over parent -> child entries). - { - std::vector indeg(N, 0); - for (std::size_t v = 0; v < N; ++v) { - indeg[v] = static_cast(off[v + 1] - off[v]); - } - std::vector stack; - stack.reserve(N); - for (std::size_t v = 0; v < N; ++v) { - if (indeg[v] == 0) stack.push_back(static_cast(v)); - } - std::size_t seen = 0; - // Child adjacency: parent -> list of children, derived on the fly. - std::vector> children(N); - for (std::size_t v = 0; v < N; ++v) { - for (auto i = static_cast(off[v]); i < static_cast(off[v + 1]); ++i) { - children[static_cast(par[i])].push_back(static_cast(v)); - } - } - while (!stack.empty()) { - auto u = stack.back(); stack.pop_back(); - ++seen; - for (auto v : children[static_cast(u)]) { - if (--indeg[static_cast(v)] == 0) stack.push_back(v); - } - } - if (seen != N) { - throw std::invalid_argument("static path bundle: predecessor structure contains a cycle"); - } - } - - // Structural src->dst connectivity ignoring masks: a bundle that never had an - // src->dst walk is malformed for this demand, distinct from being down. - auto backward_reaches_src = [&](auto&& admit_entry) { - std::vector reach(N, 0); - std::vector bfs; - bfs.push_back(dst); - reach[static_cast(dst)] = 1; - for (std::size_t head = 0; head < bfs.size(); ++head) { - const auto v = static_cast(bfs[head]); - for (auto i = static_cast(off[v]); i < static_cast(off[v + 1]); ++i) { - if (!admit_entry(i)) continue; - const auto pnode = par[i]; - if (!reach[static_cast(pnode)]) { - reach[static_cast(pnode)] = 1; - bfs.push_back(pnode); - } - } - } - return reach[static_cast(src)] != 0; - }; - if (!backward_reaches_src([](std::size_t) { return true; })) { - throw std::invalid_argument("static path bundle: no src->dst walk exists in the bundle"); - } - - // Prune against the policy's masks (failure exclusions), then re-check - // connectivity: no surviving walk means the LSP is DOWN. - const bool use_nm = !node_mask.empty(); - const bool use_em = !edge_mask.empty(); - auto entry_up = [&](std::size_t i) { - const auto pnode = static_cast(par[i]); - const auto e = static_cast(via[i]); - if (use_em && !edge_mask[e]) return false; - if (use_nm && !node_mask[pnode]) return false; - return true; - }; - auto node_up = [&](NodeId v) { return !use_nm || node_mask[static_cast(v)]; }; - if (!node_up(src) || !node_up(dst)) return std::nullopt; - - PredDAG pruned; - pruned.parent_offsets.assign(N + 1, 0); - for (std::size_t v = 0; v < N; ++v) { - std::int32_t c = 0; - if (node_up(static_cast(v))) { - for (auto i = static_cast(off[v]); i < static_cast(off[v + 1]); ++i) { - if (entry_up(i)) ++c; - } - } - pruned.parent_offsets[v + 1] = pruned.parent_offsets[v] + c; - } - pruned.parents.resize(static_cast(pruned.parent_offsets.back())); - pruned.via_edges.resize(static_cast(pruned.parent_offsets.back())); - for (std::size_t v = 0; v < N; ++v) { - auto w = static_cast(pruned.parent_offsets[v]); - if (!node_up(static_cast(v))) continue; - for (auto i = static_cast(off[v]); i < static_cast(off[v + 1]); ++i) { - if (!entry_up(i)) continue; - pruned.parents[w] = par[i]; - pruned.via_edges[w] = via[i]; - ++w; - } - } - - const auto& poff = pruned.parent_offsets; - { - std::vector reach(N, 0); - std::vector bfs; - bfs.push_back(dst); - reach[static_cast(dst)] = 1; - for (std::size_t head = 0; head < bfs.size(); ++head) { - const auto v = static_cast(bfs[head]); - for (auto i = static_cast(poff[v]); i < static_cast(poff[v + 1]); ++i) { - const auto pnode = pruned.parents[i]; - if (!reach[static_cast(pnode)]) { - reach[static_cast(pnode)] = 1; - bfs.push_back(pnode); - } - } - } - if (!reach[static_cast(src)]) return std::nullopt; // DOWN - } - - // Cost: min-cost src->dst walk within the PRUNED bundle, so the flow reports the - // metric of a walk it can actually use. DP in a topological order of the pruned - // DAG (acyclicity established above; pruning cannot introduce cycles). - Cost best = std::numeric_limits::max(); - { - const auto costs = g.cost_view(); - std::vector dist(N, std::numeric_limits::max()); - dist[static_cast(src)] = 0; - std::vector indeg(N, 0); - std::vector>> children(N); - for (std::size_t v = 0; v < N; ++v) { - indeg[v] = static_cast(poff[v + 1] - poff[v]); - for (auto i = static_cast(poff[v]); i < static_cast(poff[v + 1]); ++i) { - children[static_cast(pruned.parents[i])].emplace_back( - static_cast(v), pruned.via_edges[i]); - } - } - std::vector stack; - for (std::size_t v = 0; v < N; ++v) { - if (indeg[v] == 0) stack.push_back(static_cast(v)); - } - while (!stack.empty()) { - auto u = stack.back(); stack.pop_back(); - const auto du = dist[static_cast(u)]; - for (auto [v, e] : children[static_cast(u)]) { - if (du != std::numeric_limits::max()) { - const Cost cand = du + costs[static_cast(e)]; - if (cand < dist[static_cast(v)]) dist[static_cast(v)] = cand; - } - if (--indeg[static_cast(v)] == 0) stack.push_back(v); - } - } - best = dist[static_cast(dst)]; - } - return std::make_optional(std::make_pair(std::move(pruned), best)); -} - -} // namespace - -/* See flow_policy.hpp for the contract. */ -void FlowPolicy::set_static_paths(NodeId src, NodeId dst, std::vector bundles) { - const auto* g = ctx_.graph.graph.get(); - if (g == nullptr) { - throw std::invalid_argument("FlowPolicy::set_static_paths: policy has no graph"); - } - if (!flows_.empty()) { - throw std::invalid_argument( - "FlowPolicy::set_static_paths: policy already holds flows; call remove_demand() first"); - } - if (bundles.empty()) { - throw std::invalid_argument("FlowPolicy::set_static_paths: bundles must be non-empty"); - } - if (shortest_path_) { - throw std::invalid_argument( - "FlowPolicy::set_static_paths: incompatible with shortest_path=true (single-" - "augmentation IP semantics contradict pinned multi-LSP placement)"); - } - const auto N = g->num_nodes(); - if (src < 0 || src >= N || dst < 0 || dst >= N || src == dst) { - throw std::invalid_argument("FlowPolicy::set_static_paths: src/dst out of range or equal"); - } - // Validate the USER's max_flow_count against the supplied bundle count. The - // configured value is never overwritten: the usable (up) count U drives the - // per-flow math at placement time, and N - flow_count() is the down-LSP count. - if (max_flow_count_.has_value() && *max_flow_count_ != static_cast(bundles.size())) { - throw std::invalid_argument("If set, max_flow_count must be equal to the number of static paths."); - } - - std::vector usable; - usable.reserve(bundles.size()); - for (auto const& dag : bundles) { - if (auto prepared = prepare_static_bundle(*g, dag, src, dst, node_mask_, edge_mask_)) { - usable.push_back(StaticBundle{std::move(prepared->first), prepared->second}); - } - } - static_src_ = src; - static_dst_ = dst; - static_supplied_count_ = static_cast(bundles.size()); - static_bundles_ = std::move(usable); -} - -} // namespace netgraph::core diff --git a/src/flow_state.cpp b/src/flow_state.cpp deleted file mode 100644 index b95caff..0000000 --- a/src/flow_state.cpp +++ /dev/null @@ -1,674 +0,0 @@ -/* - FlowState — residual capacities and placement over a fixed graph. - - Maintains per-edge residual capacity and cumulative edge flows. Supports - three placement strategies when pushing flow along an SPF DAG: - - Proportional: distribute flow proportionally to residual capacity, - processing nodes in topological order from source to destination. - - EqualBalanced: *single-pass ECMP admission* on a fixed DAG. The split set - is the DAG's edges with capacity (the topology's next-hop set); we - compute a single global scale so no edge is oversubscribed, then return. - A member filled since the DAG was built yields scale 0: a forwarding - table does not react to load. place_max_flow() progresses by recomputing - the DAG with a residual-aware SPF. - - EqualBalancedLossy: equal split over the same capacity-based set with no - scaling; each edge carries min(share, residual) and drops the excess, - deficits propagate downstream, and the placed amount is what reaches - dst (best-effort hash-ECMP forwarding). -*/ -#include "netgraph/core/flow_state.hpp" -#include "netgraph/core/shortest_paths.hpp" -#include "netgraph/core/constants.hpp" -#include "netgraph/core/profiling.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace netgraph::core { -namespace { - -// Dinic-style edge for the reversed residual graph (used in Proportional placement). -// Each edge u->v represents a group of parallel forward edges with aggregated capacity. -struct DinicEdge { - std::int32_t to; // destination node - std::int32_t rev; // index of reverse edge in adj[to] - double cap; // current residual capacity - double init_cap; // initial capacity (snapshot at augmentation start) - std::int32_t group; // index into groups array (-1 if reverse edge) -}; - -// Workspace for Dinic-like augmentations on the reversed residual graph. -// We reverse the DAG (dst becomes source) to enable topological flow placement. -struct FlowWorkspace { - std::vector> adj; // reversed residual graph - std::vector level; // BFS level for level graph - std::vector it; // DFS iteration pointer per node - - void reset(std::int32_t n) { - adj.assign(static_cast(n), {}); - level.assign(static_cast(n), -1); - it.assign(static_cast(n), 0); - } - void add_edge(std::int32_t u, std::int32_t v, double c, std::int32_t group_idx) { - // Add forward edge u->v and its reverse v->u (with zero initial capacity). - // Forward edge stores the group index for later proportional distribution. - DinicEdge a{v, static_cast(adj[static_cast(v)].size()), c, c, group_idx}; - DinicEdge b{u, static_cast(adj[static_cast(u)].size()), 0.0, 0.0, -1}; - adj[static_cast(u)].push_back(a); - adj[static_cast(v)].push_back(b); - } - bool bfs(std::int32_t s, std::int32_t t) { - std::fill(level.begin(), level.end(), -1); - std::queue q; - level[static_cast(s)] = 0; - q.push(s); - while (!q.empty()) { - auto u = q.front(); q.pop(); - for (auto const& e : adj[static_cast(u)]) { - if (e.cap >= kMinCap && level[static_cast(e.to)] < 0) { - level[static_cast(e.to)] = level[static_cast(u)] + 1; - q.push(e.to); - } - } - } - return level[static_cast(t)] >= 0; - } - double dfs(std::int32_t u, std::int32_t t, double f) { - if (u == t) return f; - for (auto& i = it[static_cast(u)]; i < static_cast(adj[static_cast(u)].size()); ++i) { - DinicEdge& e = adj[static_cast(u)][static_cast(i)]; - if (e.cap < kMinCap) continue; - if (level[static_cast(e.to)] != level[static_cast(u)] + 1) continue; - double d = dfs(e.to, t, std::min(f, e.cap)); - if (d >= kMinFlow) { - e.cap -= d; - DinicEdge& r = adj[static_cast(e.to)][static_cast(e.rev)]; - r.cap += d; - return d; - } - } - return 0.0; - } -}; - -// Edges from parent u to child v grouped by (u,v) for proportional/EB logic. -// In the DAG, parent u is a predecessor of child v on a shortest path. -struct EdgeGroup { - std::int32_t from; // child v (destination of grouped edges) - std::int32_t to; // parent u (source of grouped edges) - // Underlying forward edges u->v (parallel edges) as a slice of GroupSet::eids. - // Storing a slice rather than a std::vector keeps group building allocation-free; - // rebuilding groups after every augmentation used to dominate place_on_dag. - std::int32_t eid_begin {0}; - std::int32_t eid_count {0}; - Cap sum_cap {0.0}; // sum of residual capacities for Proportional placement - Cap min_cap {0.0}; // min residual capacity for EqualBalanced placement -}; - -// Groups plus the arena backing their edge slices, and the scratch used to build -// them. One instance is reused across every rebuild within a place_on_dag call so -// the buffers keep their capacity. -struct GroupSet { - std::vector groups; - std::vector eids; // arena: group gi owns [eid_begin, +eid_count) - std::vector reach; // scratch: backward reachability from t - std::vector uniq; // scratch: distinct parents of one node - std::vector bfs; // scratch: BFS queue - - [[nodiscard]] std::span edges_of(const EdgeGroup& gr) const noexcept { - return std::span(eids.data() + gr.eid_begin, - static_cast(gr.eid_count)); - } -}; - -// Build grouped edges by (parent u, child v) that can reach destination t. -// Membership is decided by residual (edges that can still carry flow) for -// Proportional placement, or by capacity when members_by_capacity is set: every -// DAG edge with capacity is a member and sum_cap/min_cap still reflect the -// current residual, so a member filled since the DAG was built contributes -// min_cap = 0. The equal-balanced placements use the latter to model a -// forwarding table that does not react to load. -static void build_groups_residual(const StrictMultiDiGraph& g, - const PredDAG& dag, NodeId t, - const std::vector& residual, - bool members_by_capacity, - GroupSet& gs) { - const auto capacity = g.capacity_view(); - gs.groups.clear(); - gs.eids.clear(); - const auto& offsets = dag.parent_offsets; - const auto& parents = dag.parents; - const auto& via = dag.via_edges; - const auto N = g.num_nodes(); - // Compute reachability: BFS backward from destination t to identify nodes on SPF DAG. - gs.reach.assign(static_cast(N), 0); - if (t >= 0 && t < N) { - gs.bfs.clear(); - gs.bfs.push_back(t); - gs.reach[static_cast(t)] = 1; - for (std::size_t head = 0; head < gs.bfs.size(); ++head) { - const auto v = gs.bfs[head]; - // Iterate over v's predecessors (parents in the DAG). - std::size_t s = static_cast(offsets[static_cast(v)]); - std::size_t e = static_cast(offsets[static_cast(v + 1)]); - for (std::size_t i = s; i < e; ++i) { - auto u = parents[i]; - if (!gs.reach[static_cast(u)]) { gs.reach[static_cast(u)] = 1; gs.bfs.push_back(u); } - } - } - } - // For each reachable node v, group its incoming DAG edges by parent node u. - // This creates one group per (u, v) pair, aggregating parallel edges. - // Two passes per node (collect distinct parents, then gather each parent's edges) - // keep every group's edges contiguous in the arena without any allocation. - for (std::int32_t v = 0; v < N; ++v) { - if (!gs.reach[static_cast(v)]) continue; - const std::size_t s = static_cast(offsets[static_cast(v)]); - const std::size_t e = static_cast(offsets[static_cast(v + 1)]); - // Pass 1: distinct parents, in first-appearance order (matches the previous - // by_parent ordering, so emitted groups keep the same order as before). - gs.uniq.clear(); - for (std::size_t i = s; i < e; ++i) { - const auto u = parents[i]; - bool found = false; - for (auto pu : gs.uniq) { if (pu == u) { found = true; break; } } - if (!found) gs.uniq.push_back(u); - } - // Pass 2: gather each parent's admissible parallel edges contiguously. - for (auto u : gs.uniq) { - EdgeGroup gr; - gr.from = v; gr.to = u; - gr.eid_begin = static_cast(gs.eids.size()); - gr.sum_cap = static_cast(0.0); gr.min_cap = std::numeric_limits::infinity(); - for (std::size_t i = s; i < e; ++i) { - if (parents[i] != u) continue; - const auto eid0 = via[i]; - const Cap c = residual[static_cast(eid0)]; - const Cap gate = members_by_capacity ? capacity[static_cast(eid0)] : c; - if (gate >= kMinCap) { - gs.eids.push_back(eid0); - gr.sum_cap += c; - gr.min_cap = std::min(gr.min_cap, c); - } - } - gr.eid_count = static_cast(gs.eids.size()) - gr.eid_begin; - if (gr.min_cap == std::numeric_limits::infinity()) gr.min_cap = static_cast(0.0); - if (gr.eid_count > 0) gs.groups.push_back(gr); - // else: nothing was appended, so the arena is already back at eid_begin. - } - } -} - -// Construct reversed residual graph for Dinic BFS/DFS using group capacities. -static void build_reversed_residual(FlowWorkspace& ws, std::int32_t N, const std::vector& groups) { - ws.reset(N); - for (std::int32_t idx = 0; idx < static_cast(groups.size()); ++idx) { - const auto& gr = groups[static_cast(idx)]; - if (gr.sum_cap >= kMinCap) { - ws.add_edge(gr.from, gr.to, gr.sum_cap, idx); - } - } -} - -// Note: equal-balanced logic is implemented inline in place_on_dag to avoid -// maintaining a separate assignment routine. This keeps behavior explicit and -// consistent with the documented reference behavior. - -} // namespace - -FlowState::FlowState(const StrictMultiDiGraph& g) : g_(&g) { - residual_.assign(static_cast(g.num_edges()), 0.0); - edge_flow_.assign(static_cast(g.num_edges()), 0.0); - // initialize residual to capacity - auto cap = g.capacity_view(); - for (std::size_t i=0;i residual_init) : g_(&g) { - if (static_cast(g.num_edges()) != residual_init.size()) { - throw std::invalid_argument("FlowState: residual_init length must equal num_edges"); - } - residual_.assign(residual_init.begin(), residual_init.end()); - edge_flow_.assign(static_cast(g.num_edges()), 0.0); -} - -void FlowState::reset() noexcept { - auto cap = g_->capacity_view(); - for (std::size_t i=0;i residual_init) { - if (residual_init.size() != residual_.size()) { - throw std::invalid_argument("FlowState::reset: residual_init length must equal num_edges"); - } - std::copy(residual_init.begin(), residual_init.end(), residual_.begin()); - std::fill(edge_flow_.begin(), edge_flow_.end(), 0.0); -} - -Flow FlowState::place_on_dag(NodeId src, NodeId dst, const PredDAG& dag, - Flow requested_flow, FlowPlacement placement, - std::vector>* trace, - std::vector>* drops) { - NGRAPH_PROFILE_SCOPE("place_on_dag"); - const auto N = g_->num_nodes(); - if (src < 0 || src >= N || dst < 0 || dst >= N || src == dst) return 0.0; - - // The equal-balanced placements model a forwarding table that does not - // react to load: their split set is every DAG edge with capacity, full or not. - const bool fixed_members = (placement != FlowPlacement::Proportional); - - // Build groups using current residual. `gs` is reused across rebuilds so its - // buffers keep their capacity for the whole call. - GroupSet gs; - build_groups_residual(*g_, dag, dst, residual_, fixed_members, gs); - const auto& groups = gs.groups; - - Flow placed = static_cast(0.0); - double remaining = static_cast(requested_flow); - - if (placement == FlowPlacement::Proportional) { - // Proportional placement: use Dinic-like augmentation on reversed DAG. - // We reverse the DAG (dst -> src) so flow propagates topologically from dst. - FlowWorkspace ws; - build_reversed_residual(ws, N, groups); - while (remaining > kMinFlow && ws.bfs(dst, src)) { - std::fill(ws.it.begin(), ws.it.end(), 0); - Flow pushed_layer = static_cast(0.0); - while (true) { - double pushed = ws.dfs(dst, src, remaining); - if (pushed < kMinFlow) break; - pushed_layer += static_cast(pushed); - remaining -= pushed; - if (remaining <= kMinFlow) break; - } - if (pushed_layer < kMinFlow) break; - placed += pushed_layer; - - // Distribute the pushed flow onto the underlying parallel edges. - // For each group, divide the flow proportionally to residual capacity. - // This ensures fair load balancing across parallel edges. - for (std::size_t u = 0; u < ws.adj.size(); ++u) { - for (const auto& e : ws.adj[u]) { - if (e.group < 0) continue; // skip reverse edges - double sent = e.init_cap - e.cap; if (sent < kMinFlow) continue; - const auto& gr = groups[static_cast(e.group)]; - // Guard against division by zero when group capacity is numerically zero. - double denom = gr.sum_cap > kMinCap ? gr.sum_cap : 1.0; - // Proportional split: each edge gets share = sent * (edge_residual / sum_residual). - for (auto eid : gs.edges_of(gr)) { - Cap base = residual_[static_cast(eid)]; - double share = sent * (static_cast(base) / denom); - edge_flow_[static_cast(eid)] += static_cast(share); - residual_[static_cast(eid)] = std::max(static_cast(0.0), static_cast(base - share)); - if (trace) { trace->emplace_back(eid, static_cast(share)); } - } - } - } - // Rebuild groups for next tier using updated residual - build_groups_residual(*g_, dag, dst, residual_, /*members_by_capacity=*/false, gs); - build_reversed_residual(ws, N, groups); - } - } else if (placement == FlowPlacement::EqualBalancedLossy) { - // EqualBalancedLossy: best-effort hash-ECMP forwarding. Every node splits - // what it received equally over its outgoing member edges; each edge carries - // min(share, residual) and drops the rest, so a deficit propagates downstream - // and the placed amount is what arrives at dst. No global scale. - const bool record_drops = (drops != nullptr) && std::isfinite(static_cast(requested_flow)); - - std::vector> succ(static_cast(N)); - for (std::size_t gi = 0; gi < groups.size(); ++gi) { - const auto& gr = groups[gi]; - if (gr.eid_count == 0) continue; - succ[static_cast(gr.to)].push_back(gi); // u -> v (group index) - } - - // Reachability from src over the member graph; nodes outside it never see flow. - std::vector reach(static_cast(N), 0); - { - std::queue q; q.push(src); reach[static_cast(src)] = 1; - while (!q.empty()) { - auto u = q.front(); q.pop(); - for (auto gi : succ[static_cast(u)]) { - auto v = groups[gi].from; - if (!reach[static_cast(v)]) { reach[static_cast(v)] = 1; q.push(v); } - } - } - } - if (!reach[static_cast(dst)]) return static_cast(0.0); - - // Per-node fan-out (number of member edges) for the equal per-edge split. - std::vector node_split(static_cast(N), 0); - std::vector indeg(static_cast(N), 0); - for (std::size_t u = 0; u < succ.size(); ++u) { - if (!reach[u]) continue; - int s = 0; - for (auto gi : succ[u]) { - s += static_cast(groups[gi].eid_count); - auto v = static_cast(groups[gi].from); - if (reach[v]) indeg[v] += 1; - } - node_split[u] = s; - } - - // Kahn's algorithm carrying actual volumes. Nodes are released once every - // parent has forwarded, so inflow[u] is final when u is processed. - std::queue q; - std::vector inflow(static_cast(N), 0.0); - q.push(src); - inflow[static_cast(src)] = static_cast(requested_flow); - while (!q.empty()) { - auto u = q.front(); q.pop(); - const double f_in = inflow[static_cast(u)]; - const int split = node_split[static_cast(u)]; - // Even a node that received nothing must release its children, or a - // reconvergent child stays blocked behind an unreachable parent. - const double per_edge = (split > 0 && f_in >= kEpsilon) ? f_in / static_cast(split) : 0.0; - for (auto gi : succ[static_cast(u)]) { - const auto& gr = groups[gi]; - if (per_edge > 0.0) { - for (auto eid : gs.edges_of(gr)) { - const auto ei = static_cast(eid); - const double res = static_cast(residual_[ei]); - const double carried = std::max(0.0, std::min(per_edge, res)); - if (carried > 0.0) { - edge_flow_[ei] += static_cast(carried); - residual_[ei] = static_cast(std::max(0.0, res - carried)); - inflow[static_cast(gr.from)] += carried; - if (trace) trace->emplace_back(eid, static_cast(carried)); - } - const double dropped = per_edge - carried; - if (record_drops && dropped >= kEpsilon) drops->emplace_back(eid, static_cast(dropped)); - } - } - auto v = static_cast(gr.from); - if (reach[v] && --indeg[v] == 0) q.push(static_cast(v)); - } - } - placed = static_cast(inflow[static_cast(dst)]); - } else { - // EqualBalanced placement: split flow equally across parallel edges, with - // topological accumulation to correctly handle reconvergent DAGs. Every - // group with capacity stays in the split, so a full member drives the - // global scale to 0. - - // Build forward adjacency from parent u to child v for each group and - // compute aggregated reverse capacities per group. - std::vector> succ(static_cast(N)); - std::vector rev_cap(groups.size(), 0.0); - for (std::size_t gi = 0; gi < groups.size(); ++gi) { - const auto& gr = groups[gi]; - if (gr.eid_count == 0) continue; - // EB: group admissible total = min_edge_residual * |edges| - const double cap_rev = static_cast(gr.min_cap) * static_cast(gr.eid_count); - succ[static_cast(gr.to)].push_back(gi); // u -> v (group index) - rev_cap[gi] = cap_rev >= kMinCap ? cap_rev : 0.0; - } - - // Compute reachability from src on this succ graph (to ignore disconnected parts). - std::vector reach(static_cast(N), 0); - if (src >= 0 && src < N) { - std::queue q; q.push(src); reach[static_cast(src)] = 1; - while (!q.empty()) { - auto u = q.front(); q.pop(); - for (auto gi : succ[static_cast(u)]) { - auto v = groups[gi].from; - if (!reach[static_cast(v)]) { reach[static_cast(v)] = 1; q.push(v); } - } - } - } - - // Precompute total outgoing per-edge count per node for equal split. - std::vector node_split(static_cast(N), 0); - for (std::size_t u = 0; u < succ.size(); ++u) { - if (!reach[u]) continue; - int s = 0; - for (auto gi : succ[u]) s += static_cast(groups[gi].eid_count); - node_split[u] = s; - } - - // Topological accumulation: indegree counts over reachable subgraph. - std::vector indeg(static_cast(N), 0); - for (std::size_t u = 0; u < succ.size(); ++u) { - if (!reach[u]) continue; - for (auto gi : succ[u]) { - auto v = static_cast(groups[gi].from); - if (reach[v]) indeg[v] += 1; - } - } - - // Kahn's algorithm over reachable nodes starting from src. - std::queue q; - std::vector inflow(static_cast(N), 0.0); - std::vector assigned(groups.size(), 0.0); - if (src >= 0 && src < N && reach[static_cast(src)]) { - q.push(src); - inflow[static_cast(src)] = 1.0; // unit flow - } - - while (!q.empty()) { - auto u = q.front(); q.pop(); - double f_in = inflow[static_cast(u)]; - if (f_in < kEpsilon) continue; - int split = node_split[static_cast(u)]; - if (split <= 0) continue; - for (auto gi : succ[static_cast(u)]) { - const auto& gr = groups[gi]; - if (gr.eid_count == 0) continue; - // Group share proportional to number of edges (equal per-edge split). - double push = f_in * (static_cast(gr.eid_count) / static_cast(split)); - if (push < kEpsilon) continue; - assigned[gi] += push; - auto v = static_cast(gr.from); - inflow[v] += push; - // Decrement indegree and enqueue child when all parents processed. - if (--indeg[v] == 0) q.push(static_cast(v)); - } - } - - // Early exit if destination is not reachable with capacity. - // With require_capacity=false, SPF includes zero-capacity edges in the DAG, - // but those edges are filtered out during group building. If no path with - // capacity reaches the destination, return 0. - if (inflow[static_cast(dst)] < kEpsilon) { - return static_cast(0.0); - } - - // Single-pass ECMP admission: scale the unit assignment by the smallest - // per-group headroom so that no edge is oversubscribed under *fixed equal - // per-edge splits*. Any further injection with the same splits would violate - // the saturated group(s). - double ratio = std::numeric_limits::infinity(); - for (std::size_t gi = 0; gi < groups.size(); ++gi) { - if (assigned[gi] > 0.0) { - double r = rev_cap[gi] / assigned[gi]; - if (r < ratio) ratio = r; - } - } - if (!std::isfinite(ratio)) ratio = 0.0; - - // Place up to requested amount. - Flow use = static_cast(std::min(ratio, static_cast(remaining))); - if (use >= kMinFlow) { - placed += use; - // Apply scaled group assignments equally over parallel edges. - for (std::size_t gi = 0; gi < groups.size(); ++gi) { - const auto& gr = groups[gi]; if (gr.eid_count == 0) continue; - double flow_scaled = assigned[gi] * static_cast(use); - if (flow_scaled < kMinFlow) continue; - double per_edge = flow_scaled / static_cast(gr.eid_count); - for (auto eid : gs.edges_of(gr)) { - edge_flow_[static_cast(eid)] += static_cast(per_edge); - double base = static_cast(residual_[static_cast(eid)]); - residual_[static_cast(eid)] = static_cast(std::max(0.0, base - per_edge)); - if (trace) trace->emplace_back(eid, static_cast(per_edge)); - } - } - } - } - return placed; -} - -Flow FlowState::place_max_flow(NodeId src, NodeId dst, FlowPlacement placement, - bool shortest_path, bool require_capacity, - std::span node_mask, - std::span edge_mask) { - // require_capacity controls routing behavior: - // - true: Require edges to have capacity, exclude saturated links (SDN/TE, progressive) - // - false: Routes based on costs only, ignore capacity (IP/IGP, fixed routing) - // - // NOTE: With FlowPlacement::EqualBalanced + require_capacity=true, this behaves as a - // progressive "fill". For IP ECMP, use require_capacity=false + shortest_path=true. - - // Validate mask lengths - if (!node_mask.empty() && node_mask.size() != static_cast(g_->num_nodes())) { - throw std::invalid_argument("FlowState::place_max_flow: node_mask length mismatch"); - } - if (!edge_mask.empty() && edge_mask.size() != static_cast(g_->num_edges())) { - throw std::invalid_argument("FlowState::place_max_flow: edge_mask length mismatch"); - } - - Flow total = static_cast(0.0); - // With require_capacity=false the search ignores residuals, so its inputs - // are identical on every pass: compute it once and reuse the result. - std::pair path_result; - bool have_path_result = false; - while (true) { - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = require_capacity; - sel.tie_break = EdgeTieBreak::Deterministic; - if (require_capacity || !have_path_result) { - path_result = shortest_paths_dag(*g_, src, dst, /*multipath=*/true, sel, - require_capacity ? residual_ : std::span{}, - node_mask, edge_mask); - have_path_result = true; - } - const PredDAG& dag = path_result.second; - if (static_cast(dst) >= dag.parent_offsets.size()-1 || dag.parent_offsets[static_cast(dst)] == dag.parent_offsets[static_cast(dst)+1]) { - break; - } - Flow placed = place_on_dag(src, dst, dag, std::numeric_limits::infinity(), placement); - if (placed < kMinFlow) break; - total += placed; - if (shortest_path) break; - } - return total; -} - -MinCut FlowState::compute_min_cut(NodeId src, std::span node_mask, std::span edge_mask) const { - MinCut out; - const auto N = g_->num_nodes(); - - // Early return if source is out of range - if (!(src >= 0 && src < N)) return out; - - // Validate mask lengths - if (!node_mask.empty() && node_mask.size() != static_cast(N)) { - throw std::invalid_argument("FlowState::compute_min_cut: node_mask length mismatch"); - } - if (!edge_mask.empty() && edge_mask.size() != static_cast(g_->num_edges())) { - throw std::invalid_argument("FlowState::compute_min_cut: edge_mask length mismatch"); - } - - const auto row = g_->row_offsets_view(); - const auto col = g_->col_indices_view(); - const auto aei = g_->adj_edge_index_view(); - const auto in_row = g_->in_row_offsets_view(); - const auto in_col = g_->in_col_indices_view(); - const auto in_aei = g_->in_adj_edge_index_view(); - const bool use_node_mask = !node_mask.empty(); - const bool use_edge_mask = !edge_mask.empty(); - - // Early return if source is masked out - if (use_node_mask && !node_mask[static_cast(src)]) return out; - - std::vector visited(static_cast(N), 0); - std::queue q; - visited[static_cast(src)] = 1; - q.push(src); - while (!q.empty()) { - auto u = q.front(); q.pop(); - if (use_node_mask && !node_mask[static_cast(u)]) continue; - // Forward residual arcs - auto start = static_cast(row[static_cast(u)]); - auto end = static_cast(row[static_cast(u)+1]); - for (std::size_t j = start; j < end; ++j) { - auto v = static_cast(col[j]); - auto eid = static_cast(aei[j]); - if (use_edge_mask && !edge_mask[eid]) continue; - if (use_node_mask && !node_mask[static_cast(v)]) continue; - if (residual_[eid] > kMinCap && !visited[static_cast(v)]) { - visited[static_cast(v)] = 1; - q.push(v); - } - } - // Reverse residual arcs - auto rs = static_cast(in_row[static_cast(u)]); - auto re = static_cast(in_row[static_cast(u)+1]); - for (std::size_t j = rs; j < re; ++j) { - auto w = static_cast(in_col[j]); - auto eid = static_cast(in_aei[j]); - if (use_edge_mask && !edge_mask[eid]) continue; - if (use_node_mask && !node_mask[static_cast(w)]) continue; - // Reverse arcs exist where THIS state has placed cancellable flow. Using - // edge_flow_ (not capacity - residual) keeps the traversal correct when - // the state was constructed with a custom residual_init, where - // capacity - residual also counts pre-existing usage this state never - // placed and cannot cancel. - if (edge_flow_[eid] > kMinFlow && !visited[static_cast(w)]) { - visited[static_cast(w)] = 1; - q.push(w); - } - } - } - // Collect cut edges - for (std::int32_t u = 0; u < N; ++u) { - if (!visited[static_cast(u)]) continue; - if (use_node_mask && !node_mask[static_cast(u)]) continue; - auto s3 = static_cast(row[static_cast(u)]); - auto e3 = static_cast(row[static_cast(u)+1]); - for (std::size_t j = s3; j < e3; ++j) { - auto v = static_cast(col[j]); - if (use_node_mask && !node_mask[static_cast(v)]) continue; - if (visited[static_cast(v)]) continue; - auto eid = static_cast(aei[j]); - if (use_edge_mask && !edge_mask[eid]) continue; - if (residual_[eid] <= kMinCap) { - out.edges.push_back(static_cast(eid)); - } - } - } - return out; -} - -void FlowState::apply_deltas(std::span> deltas, bool add) noexcept { - const auto cap = g_->capacity_view(); - for (const auto& pr : deltas) { - std::size_t eid = static_cast(pr.first); - double df = static_cast(pr.second); - if (df <= 0.0) continue; - if (eid >= residual_.size()) continue; - // base_res is not needed; use base_flow and cap directly - double base_flow = edge_flow_[eid]; - if (add) { - edge_flow_[eid] = static_cast(base_flow + df); - double new_res = std::max(0.0, static_cast(cap[eid]) - edge_flow_[eid]); - residual_[eid] = static_cast(new_res); - } else { - // Clamp flow into [0, cap] and set residual accordingly. - double unclamped = base_flow - df; - double clamped = std::min(static_cast(cap[eid]), std::max(0.0, unclamped)); - edge_flow_[eid] = static_cast(clamped); - residual_[eid] = static_cast(std::max(0.0, static_cast(cap[eid]) - clamped)); - } - } -} - -} // namespace netgraph::core diff --git a/src/k_shortest_paths.cpp b/src/k_shortest_paths.cpp deleted file mode 100644 index 8146a03..0000000 --- a/src/k_shortest_paths.cpp +++ /dev/null @@ -1,349 +0,0 @@ -/* - k_shortest_paths — Yen-like enumeration with deterministic tie-breaking. - - Returns SPF-compatible outputs for each path: full distance array and a - PredDAG that encodes a single concrete path as single-parent predecessors. -*/ -#include "netgraph/core/k_shortest_paths.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -// Use multipath SPF DAG for spur enumeration -#include "netgraph/core/shortest_paths.hpp" - -namespace netgraph::core { - -namespace { -struct Path { - std::vector nodes; - std::vector edges; - Cost cost{0}; -}; -struct Candidate { - Cost cost; - std::vector nodes; - std::vector edges; - bool operator>(const Candidate& o) const { return cost > o.cost; } -}; - -struct VectorHash { size_t operator()(const std::vector& v) const noexcept { - size_t h = 1469598103934665603ull; - for (auto x : v) { h ^= static_cast(x + 0x9e3779b97f4a7c15ull); h *= 1099511628211ull; } - return h; -}}; -using PathSet = std::unordered_set, VectorHash>; - - -static std::optional dijkstra_single(const StrictMultiDiGraph& g, NodeId s, NodeId t, - const std::vector* node_mask, - const std::vector* edge_mask) { - const auto row = g.row_offsets_view(); - const auto col = g.col_indices_view(); - const auto aei = g.adj_edge_index_view(); - const auto cost = g.cost_view(); - const int N = g.num_nodes(); - if (s < 0 || t < 0 || s >= N || t >= N) return std::nullopt; - auto ok_node = [&](std::int32_t v){ return (!node_mask) || (*node_mask)[static_cast(v)] != 0; }; - auto ok_edge = [&](std::size_t e){ return (!edge_mask) || (*edge_mask)[e] != 0; }; - if (!ok_node(s) || !ok_node(t)) return std::nullopt; - std::vector dist(static_cast(N), std::numeric_limits::max()); - std::vector parent(static_cast(N), -1); - std::vector via(static_cast(N), -1); - using QItem = std::pair; - auto cmp = [](const QItem& a, const QItem& b){ return a.first > b.first; }; - std::priority_queue, decltype(cmp)> pq(cmp); - dist[static_cast(s)] = static_cast(0); - pq.emplace(static_cast(0), s); - while (!pq.empty()) { - auto [d_u, u] = pq.top(); pq.pop(); - if (d_u > dist[static_cast(u)]) continue; - if (u == t) break; - auto start = static_cast(row[static_cast(u)]); - auto end = static_cast(row[static_cast(u)+1]); - std::size_t i = start; - while (i < end) { - std::int32_t v = col[i]; - // Skip neighbor group if node masked - if (!ok_node(v)) { std::size_t j=i; while (jv (min cost; tie-breaker: smallest edge id) - Cost min_edge_cost = std::numeric_limits::max(); - std::int32_t best_eid = -1; - std::size_t j = i; - for (; j < end && col[j] == v; ++j) { - auto eidx = static_cast(aei[j]); - if (!ok_edge(eidx)) continue; - Cost ec = static_cast(cost[eidx]); - if (ec < min_edge_cost || (ec == min_edge_cost && static_cast(eidx) < best_eid)) { - min_edge_cost = ec; best_eid = static_cast(eidx); - } - } - if (best_eid >= 0) { - Cost nd = static_cast(d_u + min_edge_cost); - auto vi = static_cast(v); - if (nd < dist[vi]) { dist[vi] = nd; parent[vi] = u; via[vi] = best_eid; pq.emplace(nd, v); } - } - i = j; - } - } - if (dist[static_cast(t)] == std::numeric_limits::max()) return std::nullopt; - // Reconstruct - std::vector nodes_rev; - std::vector edges_rev; - for (std::int32_t v = t; v != s; v = parent[static_cast(v)]) { - if (v < 0) return std::nullopt; - nodes_rev.push_back(v); - edges_rev.push_back(via[static_cast(v)]); - } - Path p; - p.nodes.reserve(nodes_rev.size() + 1); - p.nodes.push_back(s); - for (auto it = nodes_rev.rbegin(); it != nodes_rev.rend(); ++it) p.nodes.push_back(*it); - p.edges.reserve(edges_rev.size()); - for (auto it = edges_rev.rbegin(); it != edges_rev.rend(); ++it) p.edges.push_back(*it); - p.cost = dist[static_cast(t)]; - return p; -} - -// Enumerate shortest spur paths from spur->t using the PredDAG produced by -// shortest_paths(spur, ..., multipath=true), in deterministic DFS order. -// All spur paths in the DAG share the same (shortest) cost, and at most -// max_count candidates can ever be accepted downstream, so enumeration stops -// after emitting max_count paths. Without the bound this walk materializes -// every equal-cost path — exponential in ECMP fan-out (2^k paths on a k-stage -// ladder). When dedup is supplied (unique mode), paths whose full edge -// sequence (prefix + spur segment) was already visited are skipped without -// consuming the bound, so the cap cannot starve fresh candidates. -static void dfs_spur_paths(NodeId spur, NodeId v, - const std::vector& off, - const std::vector& parents, - const std::vector& via, - std::vector& nodes_rev, - std::vector& edges_rev, - std::vector>& out_nodes, - std::vector>& out_edges, - std::size_t max_count, - const std::vector* prefix_edges, - const PathSet* dedup) { - if (out_nodes.size() >= max_count) return; - if (v == spur) { - // build forward - std::vector n; n.reserve(nodes_rev.size() + 1); - n.push_back(spur); - for (auto it = nodes_rev.rbegin(); it != nodes_rev.rend(); ++it) n.push_back(*it); - std::vector e; e.reserve(edges_rev.size()); - for (auto it = edges_rev.rbegin(); it != edges_rev.rend(); ++it) e.push_back(*it); - if (dedup != nullptr) { - std::vector full; - full.reserve(prefix_edges->size() + e.size()); - full.insert(full.end(), prefix_edges->begin(), prefix_edges->end()); - full.insert(full.end(), e.begin(), e.end()); - if (dedup->find(full) != dedup->end()) return; // already visited; free the slot - } - out_nodes.push_back(std::move(n)); - out_edges.push_back(std::move(e)); - return; - } - auto s = off[static_cast(v)]; - auto e = off[static_cast(v) + 1]; - for (std::int32_t i = s; i < e; ++i) { - if (out_nodes.size() >= max_count) return; - auto u = parents[static_cast(i)]; - auto eid = via[static_cast(i)]; - edges_rev.push_back(static_cast(eid)); - nodes_rev.push_back(v); - dfs_spur_paths(spur, u, off, parents, via, nodes_rev, edges_rev, out_nodes, out_edges, - max_count, prefix_edges, dedup); - nodes_rev.pop_back(); - edges_rev.pop_back(); - } -} - -} // namespace - -std::vector, PredDAG>> k_shortest_paths( - const StrictMultiDiGraph& g, NodeId src, NodeId dst, - int k, std::optional max_cost_factor, - bool unique, - std::span node_mask, - std::span edge_mask) { - std::vector paths; - if (k <= 0) return {}; - if (src < 0 || dst < 0 || src >= g.num_nodes() || dst >= g.num_nodes()) return {}; - - if (!node_mask.empty() && node_mask.size() != static_cast(g.num_nodes())) { - throw std::invalid_argument("k_shortest_paths: node_mask length mismatch"); - } - if (!edge_mask.empty() && edge_mask.size() != static_cast(g.num_edges())) { - throw std::invalid_argument("k_shortest_paths: edge_mask length mismatch"); - } - - // Base shortest path - std::vector node_mask_vec; - std::vector edge_mask_vec; - const std::vector* nm_ptr = nullptr; - const std::vector* em_ptr = nullptr; - if (node_mask.size() == static_cast(g.num_nodes())) { - node_mask_vec.assign(static_cast(g.num_nodes()), static_cast(1)); - for (std::size_t i=0;i(g.num_edges())) { - edge_mask_vec.assign(static_cast(g.num_edges()), static_cast(1)); - for (std::size_t i=0;i(p0->cost); - double max_cost = std::numeric_limits::infinity(); - if (max_cost_factor && *max_cost_factor > 0.0) { - max_cost = best_cost * (*max_cost_factor); - } - if (p0->cost <= max_cost) paths.push_back(*p0); - // A max_cost_factor below 1.0 puts the ceiling under the shortest path itself, so - // nothing was admitted. Return now: the k > 1 loop below opens with paths.back(), - // which is undefined behaviour on an empty vector. (The k == 1 branch would have - // masked this, so the guard must sit ahead of it.) - if (paths.empty()) return {}; - if (k == 1) { - // Convert and return - std::vector, PredDAG>> items; - items.reserve(paths.size()); - for (auto const& P : paths) { - items.push_back(path_to_pred_dag(g, P.nodes, P.edges)); - } - return items; - } - - // Candidate heap across spur deviations - auto cost_view = g.cost_view(); - std::priority_queue, std::greater> B; - auto path_signature = [&](const std::vector& edges){ return edges; }; - PathSet visited; - visited.insert(path_signature(p0->edges)); - - for (int i = 1; i < k; ++i) { - const Path& last = paths.back(); - // Precompute prefix cumulative costs along last path to avoid re-summing - std::vector prefix_cost; - prefix_cost.assign(last.nodes.size(), 0); - for (std::size_t idx = 1; idx < last.nodes.size(); ++idx) { - // edge at idx-1 - auto e = last.edges[idx - 1]; - prefix_cost[idx] = prefix_cost[idx - 1] + static_cast(cost_view[static_cast(e)]); - } - // Spur node positions 0..len-2 - for (std::size_t j = 0; j + 1 < last.nodes.size(); ++j) { - std::int32_t spur_node = last.nodes[j]; - // Build masks: exclude prefix nodes [0..j-1] - std::vector node_mask_local; - node_mask_local.assign(static_cast(g.num_nodes()), static_cast(1)); - for (std::size_t r = 0; r < j; ++r) node_mask_local[static_cast(last.nodes[r])] = 0; - if (nm_ptr) { - for (std::size_t idx=0; idx edge_mask_local; - edge_mask_local.assign(static_cast(g.num_edges()), static_cast(1)); - for (auto const& P : paths) { - if (P.nodes.size() > j && std::equal(P.nodes.begin(), P.nodes.begin() + static_cast(j + 1), last.nodes.begin())) { - // Exclude edge at position j for this path - if (P.edges.size() > j) { - edge_mask_local[static_cast(P.edges[j])] = 0; - } - } - } - if (em_ptr) { - for (std::size_t idx=0; idx t - std::unique_ptr nm_buf2; - std::unique_ptr em_buf2; - std::span nm, em; - if (!node_mask_local.empty()) { - nm_buf2 = std::unique_ptr(new bool[static_cast(g.num_nodes())]); - for (std::size_t idx = 0; idx < static_cast(g.num_nodes()); ++idx) nm_buf2[idx] = (node_mask_local[idx] != 0); - nm = std::span(nm_buf2.get(), static_cast(g.num_nodes())); - } - if (!edge_mask_local.empty()) { - em_buf2 = std::unique_ptr(new bool[static_cast(g.num_edges())]); - for (std::size_t idx = 0; idx < static_cast(g.num_edges()); ++idx) em_buf2[idx] = (edge_mask_local[idx] != 0); - em = std::span(em_buf2.get(), static_cast(g.num_edges())); - } - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = false; sel.tie_break = EdgeTieBreak::Deterministic; - auto [spur_cost, dag_spur] = shortest_paths_dag(g, spur_node, dst, /*multipath=*/true, sel, std::span(), nm, em); - // If no spur parents for t, skip - if (static_cast(dst + 1) >= dag_spur.parent_offsets.size() || - dag_spur.parent_offsets[static_cast(dst)] == dag_spur.parent_offsets[static_cast(dst + 1)]) { - continue; - } - // Every spur path in the DAG costs spur_cost; skip the whole family if - // the resulting candidates would exceed the cost ceiling. - if (static_cast(prefix_cost[j]) + static_cast(spur_cost) > max_cost) { - continue; - } - // At most (k - accepted) further paths can ever be accepted, so that many - // fresh candidates from this spur family suffice. - const std::size_t want = static_cast(k) - paths.size(); - std::vector prefix_edges(last.edges.begin(), - last.edges.begin() + static_cast(j)); - std::vector nodes_rev; - std::vector edges_rev; - std::vector> spur_nodes_list; - std::vector> spur_edges_list; - dfs_spur_paths(spur_node, dst, dag_spur.parent_offsets, dag_spur.parents, dag_spur.via_edges, - nodes_rev, edges_rev, spur_nodes_list, spur_edges_list, - want, &prefix_edges, unique ? &visited : nullptr); - for (std::size_t si = 0; si < spur_nodes_list.size(); ++si) { - const auto& spur_nodes = spur_nodes_list[si]; - const auto& spur_edges = spur_edges_list[si]; - std::vector cand_nodes; - std::vector cand_edges; - cand_nodes.reserve(j + spur_nodes.size()); - cand_edges.reserve(j + spur_edges.size()); - for (std::size_t r = 0; r < j; ++r) cand_nodes.push_back(last.nodes[r]); - for (auto n : spur_nodes) cand_nodes.push_back(n); - for (std::size_t r = 0; r < j; ++r) cand_edges.push_back(last.edges[r]); - for (auto e : spur_edges) cand_edges.push_back(e); - // Compute candidate cost as prefix_cost[j] + sum(spur_edges) - Cost cand_cost = prefix_cost[j]; - for (auto e : spur_edges) cand_cost += static_cast(cost_view[static_cast(e)]); - if (cand_cost > max_cost) continue; - auto sig = path_signature(cand_edges); - if (unique && visited.find(sig) != visited.end()) continue; - visited.insert(sig); - B.push(Candidate{cand_cost, std::move(cand_nodes), std::move(cand_edges)}); - } - } - // Pick next best candidate not exceeding threshold - if (B.empty()) break; - bool accepted = false; - while (!B.empty()) { - auto c = B.top(); B.pop(); - if (c.cost <= max_cost) { - paths.push_back(Path{std::move(c.nodes), std::move(c.edges), c.cost}); - accepted = true; - break; - } - } - if (!accepted) break; - } - // Convert to SPF-compatible outputs - std::vector, PredDAG>> items; - items.reserve(paths.size()); - for (auto const& P : paths) { - items.push_back(path_to_pred_dag(g, P.nodes, P.edges)); - } - return items; -} - -} // namespace netgraph::core diff --git a/src/max_flow.cpp b/src/max_flow.cpp index 10eef37..13913d5 100644 --- a/src/max_flow.cpp +++ b/src/max_flow.cpp @@ -1,488 +1,465 @@ /* - calc_max_flow — incremental max-flow via repeated SPF tiers over residuals. - - Uses FlowState to track residuals and place flow along SPF predecessor DAGs. - Accumulates total flow, optional per-edge flows, cost distribution, and - derives a min-cut from the final residual graph. - - Algorithm: Iterative augmentation using Successive Shortest Path on residual graphs, - pushing flow across full ECMP/WCMP DAGs at each step. + The one integer minimum-cost flow core (APPROACH 2.2). + + One objective on every view: minimum cost for the maximum value attainable under the value + cap. Successive shortest paths with potentials and blocking flows. Each tier runs Dijkstra on + reduced costs cost(e) + pi(u) - pi(v) to completion over the residual network reachable from + the virtual source s* (through the virtual sink t* and onward), adds the distances to the + potentials of the vertices it settled, then drains the zero-reduced-cost admissible subgraph + by Dinic phases (BFS levels, DFS with current-arc pointers) until no admissible augmenting + path remains or the cap is reached; then the next tier. Positive original costs do not + exclude zero-reduced-cost residual cycles, hence the repeated phases. The solve ends when a + Dijkstra does not reach t* or the cap is reached; there is no completion phase. + + Arc identity: every eligible edge is its own arc pair (forward with the view capacity, + reverse with the flow). At a node the forward arcs of its out-edges and the reverse arcs of + its in-edges are visited in one sequence by ascending EdgeId (a self-loop's forward arc before + its reverse arc); at a real endpoint its virtual arc comes first; s* visits its arcs by + ascending source NodeId and t* by ascending destination NodeId. Virtual arcs cost 0 and carry + 2^63 - 2 units, above every attainable value. Every solve starts from zero flow, so the zero + potentials are valid (all costs are positive). + + Dijkstra's queue is a radix heap: keys pop in increasing order and entries with equal keys pop + in a fixed order (last pushed first within the lowest bucket), so the settle order, and with it + every counter, is a function of the input alone. Equal-key order affects only the counters, + never a distance, a potential or an augmenting path. The solve runs to the cap or to + disconnection; there is no clamp of the cap (APPROACH 2.2, revision 25). + + Arithmetic stays in int64: every true residual distance is the cost of a simple residual path, + below the cost sum bound 2^62; potentials are such distances; a path's bottleneck contains a + real arc (sources and destinations are disjoint), so every amount is below 2^62. */ #include "netgraph/core/max_flow.hpp" -#include "netgraph/core/shortest_paths.hpp" -#include "netgraph/core/flow_state.hpp" -#include "netgraph/core/constants.hpp" #include -#include -#include -#include -#include -#include +#include +#include #include -#include -#include -#include -#include -#include -#include +#include +#include namespace netgraph::core { namespace { -std::size_t thread_budget_from_env(const char* env_name, std::size_t candidate_count) { - if (candidate_count <= 1) { - return 1; - } +constexpr Cost kInfDist = std::numeric_limits::max(); +constexpr Amount kUncapped = std::numeric_limits::max(); - const char* env = std::getenv(env_name); - if (env != nullptr && env[0] != '\0') { - char* end = nullptr; - unsigned long parsed = std::strtoul(env, &end, 10); - if (end != env) { - if (parsed == 0ul) { - return 1; - } - return std::min(static_cast(parsed), candidate_count); - } +// Sorted, duplicate-free endpoint set; throws on an empty set or an id out of range. +std::vector endpoint_set(std::span nodes, std::int32_t n, const char* name) { + if (nodes.empty()) { + throw std::invalid_argument(std::string(name) + ": the endpoint set is empty"); } - - const auto hw = std::thread::hardware_concurrency(); - const auto budget = hw > 0 ? static_cast(hw) : 1u; - return std::min(budget, candidate_count); -} - -std::optional> -evaluate_sensitivity_candidate(const StrictMultiDiGraph& g, - NodeId src, - NodeId dst, - FlowPlacement placement, - bool shortest_path, - bool require_capacity, - Flow baseline_flow, - std::span node_mask, - EdgeId eid, - bool* local_mask, - std::size_t mask_size) { - local_mask[static_cast(eid)] = false; - - auto [new_flow, _] = calc_max_flow( - g, src, dst, placement, - shortest_path, - require_capacity, - /*with_edge_flows=*/false, - /*with_reachable=*/false, - /*with_residuals=*/false, - node_mask, std::span(local_mask, mask_size)); - - local_mask[static_cast(eid)] = true; - - double delta = baseline_flow - new_flow; - if (delta > kMinFlow) { - return std::pair{eid, delta}; + std::vector out(nodes.begin(), nodes.end()); + for (NodeId v : out) { + if (v < 0 || v >= n) { + throw std::invalid_argument(std::string(name) + ": node " + std::to_string(v) + + " out of node range [0, " + std::to_string(n) + ")"); + } } - return std::nullopt; + std::sort(out.begin(), out.end()); + out.erase(std::unique(out.begin(), out.end()), out.end()); + return out; } -} // namespace - -std::pair -calc_max_flow(const StrictMultiDiGraph& g, NodeId src, NodeId dst, - FlowPlacement placement, bool shortest_path, - bool require_capacity, - bool with_edge_flows, - bool with_reachable, - bool with_residuals, - std::span node_mask, std::span edge_mask) { - FlowSummary summary; - const auto N = g.num_nodes(); - if (src < 0 || src >= N || dst < 0 || dst >= N || src == dst) { - return {0.0, std::move(summary)}; +// Monotone priority queue for Dijkstra on non-negative integer keys (a radix heap): a key goes +// to the bucket of the highest bit in which it differs from the last popped key, and a pop +// redistributes the first non-empty bucket below its minimum. Entries are (key, vertex); equal +// keys pop in a fixed order, so every run is a function of its input. +class RadixHeap { +public: + void clear() { + for (auto& b : buckets_) b.clear(); + last_ = 0; + size_ = 0; } - if (!node_mask.empty() && node_mask.size() != static_cast(g.num_nodes())) { - throw std::invalid_argument("calc_max_flow: node_mask length mismatch"); + [[nodiscard]] bool empty() const noexcept { return size_ == 0; } + void push(Cost key, std::int32_t v) { + buckets_[bucket(key)].emplace_back(key, v); + ++size_; } - if (!edge_mask.empty() && edge_mask.size() != static_cast(g.num_edges())) { - throw std::invalid_argument("calc_max_flow: edge_mask length mismatch"); - } - const bool use_node_mask = !node_mask.empty(); - const bool use_edge_mask = !edge_mask.empty(); - if ((use_node_mask && !node_mask[static_cast(src)]) || - (use_node_mask && !node_mask[static_cast(dst)])) { - return {0.0, std::move(summary)}; + std::pair pop() { + if (buckets_[0].empty()) { + std::size_t i = 1; + while (buckets_[i].empty()) ++i; + auto& from = buckets_[i]; + Cost lowest = from.front().first; + for (const auto& entry : from) lowest = std::min(lowest, entry.first); + last_ = lowest; + for (const auto& entry : from) buckets_[bucket(entry.first)].push_back(entry); + from.clear(); + } + const auto top = buckets_[0].back(); + buckets_[0].pop_back(); + --size_; + return top; } - // Use FlowState for residual tracking and per-edge placement - FlowState fs(g); - Flow total = static_cast(0.0); - std::vector> cost_dist; // (cost, flow) - // Iterate tiers: SPF over current residual or costs only - // require_capacity=true: Require edges to have capacity, exclude saturated links (SDN/TE behavior) - // require_capacity=false: Routes based on costs only, ignore capacity (IP/IGP behavior) - // With require_capacity=false the search ignores residuals, so its inputs - // are identical on every tier: compute it once and reuse the result. - std::pair path_result; - bool have_path_result = false; - while (true) { - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = require_capacity; - sel.tie_break = EdgeTieBreak::Deterministic; - if (require_capacity || !have_path_result) { - path_result = shortest_paths_dag( - g, src, dst, - /*multipath=*/true, - sel, - require_capacity ? fs.residual_view() : std::span{}, - use_node_mask ? node_mask : std::span{}, - use_edge_mask ? edge_mask : std::span{}); - have_path_result = true; +private: + [[nodiscard]] std::size_t bucket(Cost key) const noexcept { + return static_cast( + std::bit_width(static_cast(key) ^ static_cast(last_))); + } + std::array>, 65> buckets_ {}; + Cost last_ {0}; + std::size_t size_ {0}; +}; + +// The residual network of one solve: real vertices 0..N-1, s* = N, t* = N + 1. Arcs are stored +// as slots in adjacency order (the slots of u are [off_[u], off_[u + 1])), each with its head, +// its cost, its residual and the slot of its partner arc. +class Solver { +public: + Solver(const StrictMultiDiGraph& g, std::span view, const std::vector& srcs, + const std::vector& dsts, const Eligibility* elig) + : g_(g), n_(g.num_nodes() + 2), s_(g.num_nodes()), t_(g.num_nodes() + 1) { + const auto num_edges = static_cast(g.num_edges()); + const auto edge_src = g.edge_src(); + const auto edge_dst = g.edge_dst(); + const auto cost = g.cost(); + active_.reserve(num_edges); + for (std::size_t e = 0; e < num_edges; ++e) { + if (elig == nullptr || elig->edge_ok(static_cast(e))) active_.push_back(static_cast(e)); } - const PredDAG& dag = path_result.second; - - // No path if t has no parents in DAG - if (static_cast(dst) >= dag.parent_offsets.size() - 1 || - dag.parent_offsets[static_cast(dst)] == dag.parent_offsets[static_cast(dst) + 1]) { - break; + for (NodeId s : srcs) { + if (elig == nullptr || elig->node_ok(s)) srcs_.push_back(s); } - - Cost path_cost = path_result.first; - Flow placed = fs.place_on_dag(src, dst, dag, std::numeric_limits::infinity(), placement); - - if (placed < kMinFlow) break; - total += placed; - // Merge by exact cost (integer) - bool merged = false; - for (auto& pr : cost_dist) { if (pr.first == path_cost) { pr.second += placed; merged = true; break; } } - if (!merged) cost_dist.emplace_back(path_cost, placed); - if (shortest_path) break; + for (NodeId d : dsts) { + if (elig == nullptr || elig->node_ok(d)) dsts_.push_back(d); + } + const auto n = static_cast(n_); + off_.assign(n + 1, 0); + auto deg = [&](std::int32_t v) { ++off_[static_cast(v) + 1]; }; + for (EdgeId e : active_) { + deg(edge_src[static_cast(e)]); + deg(edge_dst[static_cast(e)]); + } + for (NodeId s : srcs_) { deg(s); deg(s_); } + for (NodeId d : dsts_) { deg(d); deg(t_); } + for (std::size_t v = 0; v < n; ++v) off_[v + 1] += off_[v]; + const auto num_slots = static_cast(off_[n]); + head_.resize(num_slots); + rev_.resize(num_slots); + cost_.resize(num_slots); + res_.resize(num_slots); + std::vector pos(off_.begin(), off_.end() - 1); + auto take = [&](std::int32_t v) { return pos[static_cast(v)]++; }; + auto link = [&](std::int32_t a, std::int32_t to_a, Amount cap, Cost w, std::int32_t b, std::int32_t to_b) { + const auto ai = static_cast(a); + const auto bi = static_cast(b); + head_[ai] = to_a; + rev_[ai] = b; + cost_[ai] = w; + res_[ai] = cap; + head_[bi] = to_b; + rev_[bi] = a; + cost_[bi] = -w; + res_[bi] = 0; + }; + // Virtual arcs first at the real endpoints, then the real arcs by ascending EdgeId (forward + // slot at the tail, reverse slot at the head), then the slots of s* and t*. + std::vector src_rev(srcs_.size()), dst_fwd(dsts_.size()); + for (std::size_t i = 0; i < srcs_.size(); ++i) src_rev[i] = take(srcs_[i]); + for (std::size_t i = 0; i < dsts_.size(); ++i) dst_fwd[i] = take(dsts_[i]); + edge_slot_.resize(active_.size()); + for (std::size_t k = 0; k < active_.size(); ++k) { + const auto ei = static_cast(active_[k]); + const NodeId u = edge_src[ei]; + const NodeId v = edge_dst[ei]; + const std::int32_t fwd = take(u); + const std::int32_t bwd = take(v); + link(fwd, v, view[ei], cost[ei], bwd, u); + edge_slot_[k] = fwd; + } + for (std::size_t i = 0; i < srcs_.size(); ++i) link(take(s_), srcs_[i], kMaxFlowCap, 0, src_rev[i], s_); + for (std::size_t i = 0; i < dsts_.size(); ++i) link(dst_fwd[i], t_, kMaxFlowCap, 0, take(t_), dsts_[i]); + pi_.assign(n, 0); + ever_.assign(n, 0); + dist_.resize(n); + level_.resize(n); + reach_.assign(n, 0); + cur_.resize(n); } - // Completion phase. The tier loop above augments only along forward SPF DAGs - // and can never cancel an earlier placement, so it may terminate below the - // true maximum (its own min-cut then contradicts total_flow). Finish with - // BFS augmentation on the full residual graph, where traversing an edge - // backwards returns previously placed flow. Only meaningful for Proportional - // max-flow semantics: EqualBalanced models ECMP admission and - // require_capacity=false models fixed-cost IP routing, both of which are - // placement models rather than max-flow computations. - if (placement == FlowPlacement::Proportional && !shortest_path && require_capacity) { - const auto row = g.row_offsets_view(); - const auto col = g.col_indices_view(); - const auto aei = g.adj_edge_index_view(); - const auto irow = g.in_row_offsets_view(); - const auto icol = g.in_col_indices_view(); - const auto iaei = g.in_adj_edge_index_view(); - const auto costv = g.cost_view(); - const auto residual = fs.residual_view(); - const auto eflow = fs.edge_flow_view(); - std::vector prev_node(static_cast(N)); - std::vector prev_edge(static_cast(N)); - std::vector prev_fwd(static_cast(N)); - std::vector seen(static_cast(N)); - std::vector> fwd_deltas, rev_deltas; - while (true) { - std::fill(seen.begin(), seen.end(), 0); - std::queue bfs; - seen[static_cast(src)] = 1; - bfs.push(src); - bool found = false; - while (!bfs.empty() && !found) { - auto u = bfs.front(); bfs.pop(); - auto us = static_cast(u); - // Forward residual arcs u -> v. - for (auto j = static_cast(row[us]); j < static_cast(row[us + 1]); ++j) { - auto v = static_cast(col[j]); - auto eid = static_cast(aei[j]); - if (use_edge_mask && !edge_mask[eid]) continue; - if (use_node_mask && !node_mask[v]) continue; - if (seen[v] || residual[eid] <= kMinCap) continue; - seen[v] = 1; prev_node[v] = u; prev_edge[v] = static_cast(eid); prev_fwd[v] = 1; - if (static_cast(v) == dst) { found = true; break; } - bfs.push(static_cast(v)); - } - if (found) break; - // Reverse residual arcs u -> w along edges w -> u carrying flow. - for (auto j = static_cast(irow[us]); j < static_cast(irow[us + 1]); ++j) { - auto w = static_cast(icol[j]); - auto eid = static_cast(iaei[j]); - if (use_edge_mask && !edge_mask[eid]) continue; - if (use_node_mask && !node_mask[w]) continue; - if (seen[w] || eflow[eid] <= kMinFlow) continue; - seen[w] = 1; prev_node[w] = u; prev_edge[w] = static_cast(eid); prev_fwd[w] = 0; - if (static_cast(w) == dst) { found = true; break; } - bfs.push(static_cast(w)); - } - } - if (!found) break; - // Bottleneck and marginal path cost (forward costs minus cancelled costs). - double bottleneck = std::numeric_limits::infinity(); - Cost path_cost = 0; - for (auto v = dst; v != src; v = prev_node[static_cast(v)]) { - auto vs = static_cast(v); - auto eid = static_cast(prev_edge[vs]); - if (prev_fwd[vs]) { - bottleneck = std::min(bottleneck, static_cast(residual[eid])); - path_cost += costv[eid]; - } else { - bottleneck = std::min(bottleneck, static_cast(eflow[eid])); - path_cost -= costv[eid]; + FlowCoreResult run(std::span view, Amount limit) { + FlowCoreResult out; + auto& c = out.counters; + Amount total = 0; + while (total < limit) { + if (!dijkstra(c)) break; + const Cost tier_cost = pi_[static_cast(t_)] - pi_[static_cast(s_)]; + const Amount before = total; + while (total < limit) { + if (!build_levels(c)) break; + std::copy(off_.begin(), off_.end() - 1, cur_.begin()); + while (total < limit) { + if (!find_path(c)) break; + Amount amount = limit - total; + for (std::int32_t a : path_) amount = std::min(amount, res_[static_cast(a)]); + for (std::int32_t a : path_) { + res_[static_cast(a)] -= amount; + res_[static_cast(rev_[static_cast(a)])] += amount; + } + total += amount; + ++c.augmentations; } } - if (!(bottleneck >= kMinFlow)) break; - fwd_deltas.clear(); rev_deltas.clear(); - for (auto v = dst; v != src; v = prev_node[static_cast(v)]) { - auto vs = static_cast(v); - if (prev_fwd[vs]) fwd_deltas.emplace_back(prev_edge[vs], static_cast(bottleneck)); - else rev_deltas.emplace_back(prev_edge[vs], static_cast(bottleneck)); + // A successful Dijkstra reaches t* over arcs with positive residual and zero reduced cost + // along its shortest paths, so the drain moves at least one unit. + if (total == before) { + throw std::logic_error("flow core: a successful Dijkstra drained nothing"); } - fs.apply_deltas(fwd_deltas, /*add=*/true); - fs.apply_deltas(rev_deltas, /*add=*/false); - total += static_cast(bottleneck); - bool merged2 = false; - for (auto& pr : cost_dist) { if (pr.first == path_cost) { pr.second += bottleneck; merged2 = true; break; } } - if (!merged2) cost_dist.emplace_back(path_cost, static_cast(bottleneck)); + out.cost_distribution.emplace_back(tier_cost, total - before); } - } + out.placed = total; - summary.total_flow = total; - if (with_edge_flows) { - auto ef = fs.edge_flow_view(); - summary.edge_flows.assign(ef.begin(), ef.end()); - } - if (with_residuals) { - auto res = fs.residual_view(); - summary.residual_capacity.resize(static_cast(g.num_edges())); - for (std::size_t i = 0; i < summary.residual_capacity.size(); ++i) { - summary.residual_capacity[i] = static_cast(res[i]); + const auto num_edges = static_cast(g_.num_edges()); + out.edge_flows.assign(num_edges, 0); + for (std::size_t k = 0; k < active_.size(); ++k) { + const auto fwd = static_cast(edge_slot_[k]); + out.edge_flows[static_cast(active_[k])] = res_[static_cast(rev_[fwd])]; } - } - if (!cost_dist.empty()) { - std::sort(cost_dist.begin(), cost_dist.end(), [](auto const& a, auto const& b){ return a.first < b.first; }); - summary.costs.reserve(cost_dist.size()); - summary.flows.reserve(cost_dist.size()); - for (auto const& pr : cost_dist) { summary.costs.push_back(pr.first); summary.flows.push_back(pr.second); } - } - // Min-Cut: Set of edges crossing from the reachable set (S) to the unreachable set (T). - // Computed by finding all nodes reachable from src in the residual graph. - if (total >= kMinFlow) { - auto mc = fs.compute_min_cut(src, node_mask, edge_mask); - summary.min_cut = mc; - if (with_reachable) { - summary.reachable_nodes.assign(static_cast(g.num_nodes()), 0u); - auto residual = fs.residual_view(); - auto eflows = fs.edge_flow_view(); - const bool reach_use_node_mask = use_node_mask; - const bool reach_use_edge_mask = use_edge_mask; - const auto N = static_cast(g.num_nodes()); - std::vector stack; - stack.reserve(N); - stack.push_back(src); - while (!stack.empty()) { - auto n = static_cast(stack.back()); - stack.pop_back(); - if (summary.reachable_nodes[n]) continue; - if (reach_use_node_mask && !node_mask[n]) continue; - summary.reachable_nodes[n] = 1u; - auto ro = g.row_offsets_view(); - auto ci = g.col_indices_view(); - auto ae = g.adj_edge_index_view(); - auto s = static_cast(ro[n]); - auto e = static_cast(ro[n+1]); - for (std::size_t p = s; p < e; ++p) { - auto v = static_cast(ci[p]); - auto eid = static_cast(ae[p]); - if (reach_use_edge_mask && !edge_mask[eid]) continue; - if (reach_use_node_mask && !node_mask[v]) continue; - if (residual[eid] > kMinCap && !summary.reachable_nodes[v]) { - stack.push_back(static_cast(v)); - } + // Full residual closure seeded at the real sources (reverse and virtual arcs included). + std::vector seen(static_cast(n_), 0); + std::vector queue; + queue.reserve(static_cast(n_)); + for (NodeId s : srcs_) { + seen[static_cast(s)] = 1; + queue.push_back(s); + } + for (std::size_t qi = 0; qi < queue.size(); ++qi) { + const auto u = static_cast(queue[qi]); + for (auto p = off_[u]; p < off_[u + 1]; ++p) { + const auto pi = static_cast(p); + const auto v = static_cast(head_[pi]); + if (res_[pi] > 0 && seen[v] == 0) { + seen[v] = 1; + queue.push_back(static_cast(v)); } - auto iro = g.in_row_offsets_view(); - auto ici = g.in_col_indices_view(); - auto iae = g.in_adj_edge_index_view(); - auto rs = static_cast(iro[n]); - auto re = static_cast(iro[n+1]); - for (std::size_t p = rs; p < re; ++p) { - auto u = static_cast(ici[p]); - auto eid = static_cast(iae[p]); - if (reach_use_edge_mask && !edge_mask[eid]) continue; - if (reach_use_node_mask && !node_mask[u]) continue; - if (eflows[eid] > kMinFlow && !summary.reachable_nodes[u]) { - stack.push_back(static_cast(u)); - } + } + } + if (seen[static_cast(t_)] == 0) { + const auto edge_src = g_.edge_src(); + const auto edge_dst = g_.edge_dst(); + std::vector cut; + for (EdgeId e : active_) { + const auto ei = static_cast(e); + if (seen[static_cast(edge_src[ei])] != 0 && + seen[static_cast(edge_dst[ei])] == 0 && view[ei] > 0) { + cut.push_back(e); } } + out.min_cut = std::move(cut); + } + out.potentials.resize(static_cast(g_.num_nodes())); + for (std::size_t v = 0; v < out.potentials.size(); ++v) { + out.potentials[v] = ever_[v] != 0 ? pi_[v] : kNeverReached; } + return out; } - return {summary.total_flow, std::move(summary)}; -} -std::vector -batch_max_flow(const StrictMultiDiGraph& g, - const std::vector>& pairs, - FlowPlacement placement, bool shortest_path, - bool require_capacity, - bool with_edge_flows, - bool with_reachable, - bool with_residuals, - const std::vector>& node_masks, - const std::vector>& edge_masks) { - // Validate all masks upfront - for (const auto& nm : node_masks) { - if (!nm.empty() && nm.size() != static_cast(g.num_nodes())) { - throw std::invalid_argument("batch_max_flow: node_mask length mismatch"); +private: + // Dijkstra on reduced costs to completion; potentials of the settled vertices updated. + // Returns whether t* was reached. + bool dijkstra(FlowCoreCounters& c) { + ++c.dijkstra_runs; + std::fill(dist_.begin(), dist_.end(), kInfDist); + settled_.clear(); + heap_.clear(); + dist_[static_cast(s_)] = 0; + heap_.push(0, s_); + while (!heap_.empty()) { + const auto [du, ui] = heap_.pop(); + ++c.dijkstra_pops; + const auto u = static_cast(ui); + if (du != dist_[u]) continue; + settled_.push_back(ui); + ++c.settled; + const Cost base = du + pi_[u]; + const auto end = off_[u + 1]; + c.arc_scans += end - off_[u]; + for (auto p = off_[u]; p < end; ++p) { + const auto pi = static_cast(p); + if (res_[pi] <= 0) continue; + const auto v = static_cast(head_[pi]); + const Cost nd = base + cost_[pi] - pi_[v]; + if (nd < dist_[v]) { + dist_[v] = nd; + heap_.push(nd, static_cast(v)); + } + } } - } - for (const auto& em : edge_masks) { - if (!em.empty() && em.size() != static_cast(g.num_edges())) { - throw std::invalid_argument("batch_max_flow: edge_mask length mismatch"); + for (std::int32_t v : settled_) { + const auto vi = static_cast(v); + pi_[vi] += dist_[vi]; + ever_[vi] = 1; } + return dist_[static_cast(t_)] != kInfDist; } - std::vector out(pairs.size()); - auto run_range = [&](std::size_t begin, std::size_t end) { - for (std::size_t i = begin; i < end; ++i) { - auto pr = pairs[i]; - std::span nm = (i < node_masks.size() ? node_masks[i] : std::span{}); - std::span em = (i < edge_masks.size() ? edge_masks[i] : std::span{}); - auto [val, summary] = calc_max_flow(g, pr.first, pr.second, - placement, shortest_path, - require_capacity, - with_edge_flows, - with_reachable, - with_residuals, - nm, em); - out[i] = std::move(summary); + // BFS levels over admissible arcs (positive residual, zero reduced cost). Counts one phase. + // Only vertices that can reach t* over admissible arcs are levelled: every vertex on a + // shortest admissible path from s* to such a vertex reaches t* too, so their levels equal the + // levels of a full BFS, and the other vertices are dead ends that the DFS would only enter and + // leave. The forward search also stops once t*'s level is complete. The augmenting paths are + // therefore the ones a full BFS gives. + bool build_levels(FlowCoreCounters& c) { + const auto t = static_cast(t_); + // Reverse search from t*: the arc into v from head_[p] is the partner of v's slot p. + ++mark_epoch_; + queue_.clear(); + reach_[t] = mark_epoch_; + queue_.push_back(t_); + for (std::size_t qi = 0; qi < queue_.size(); ++qi) { + const auto v = static_cast(queue_[qi]); + const Cost pv = pi_[v]; + const auto end = off_[v + 1]; + c.arc_scans += end - off_[v]; + for (auto p = off_[v]; p < end; ++p) { + const auto pi = static_cast(p); + const auto u = static_cast(head_[pi]); + if (reach_[u] == mark_epoch_) continue; + const auto in = static_cast(rev_[pi]); + if (res_[in] > 0 && cost_[in] + pi_[u] == pv) { + reach_[u] = mark_epoch_; + queue_.push_back(static_cast(u)); + } + } } - }; - // Pairs are independent over an immutable graph; evaluate chunks in parallel. - // Thread count from NGRAPH_CORE_BATCH_THREADS env or hardware concurrency. - const auto thread_budget = thread_budget_from_env("NGRAPH_CORE_BATCH_THREADS", pairs.size()); - if (thread_budget <= 1) { - run_range(0, pairs.size()); - return out; - } - // Claim pairs one at a time from a shared counter rather than handing each worker a - // fixed contiguous chunk. Per-pair cost varies by orders of magnitude (a dense - // cross-fabric pair versus a disconnected one), so static chunking leaves workers - // idle whenever the expensive pairs happen to land in the same chunk. - std::atomic next_pair{0}; - auto run_dynamic = [&]() { - for (;;) { - const std::size_t i = next_pair.fetch_add(1, std::memory_order_relaxed); - if (i >= pairs.size()) break; - run_range(i, i + 1); + std::fill(level_.begin(), level_.end(), -1); + ++c.dinic_phases; + const auto s = static_cast(s_); + if (reach_[s] != mark_epoch_) return false; + queue_.clear(); + level_[s] = 0; + queue_.push_back(s_); + for (std::size_t qi = 0; qi < queue_.size(); ++qi) { + const auto u = static_cast(queue_[qi]); + if (level_[t] >= 0 && level_[u] >= level_[t]) break; + const Cost pu = pi_[u]; + const auto next = level_[u] + 1; + const auto end = off_[u + 1]; + c.arc_scans += end - off_[u]; + for (auto p = off_[u]; p < end; ++p) { + const auto pi = static_cast(p); + const auto v = static_cast(head_[pi]); + if (level_[v] < 0 && reach_[v] == mark_epoch_ && res_[pi] > 0 && cost_[pi] + pu == pi_[v]) { + level_[v] = next; + queue_.push_back(static_cast(v)); + } + } } - }; - std::vector> futures; - futures.reserve(thread_budget); - for (std::size_t w = 0; w < thread_budget; ++w) { - futures.emplace_back(std::async(std::launch::async, run_dynamic)); + return level_[t] >= 0; } - for (auto& f : futures) f.get(); - return out; -} -std::vector> -sensitivity_analysis(const StrictMultiDiGraph& g, NodeId src, NodeId dst, - FlowPlacement placement, bool shortest_path, - bool require_capacity, - std::span node_mask, - std::span edge_mask) { - // Step 1: Baseline analysis to identify saturated edges - // Uses shortest_path mode to match the routing semantics being analyzed. - auto [baseline_flow, summary] = calc_max_flow( - g, src, dst, placement, - shortest_path, - require_capacity, - /*with_edge_flows=*/false, - /*with_reachable=*/false, - /*with_residuals=*/true, - node_mask, edge_mask); - - if (baseline_flow < kMinFlow || summary.residual_capacity.empty()) { - return {}; - } - - std::vector candidates; - // Identify saturated edges (residual <= kMinCap) - // Only consider edges that are not already masked out - for (EdgeId eid = 0; eid < static_cast(summary.residual_capacity.size()); ++eid) { - if (!edge_mask.empty() && !edge_mask[eid]) continue; // Already masked - if (summary.residual_capacity[eid] <= kMinCap) { - candidates.push_back(eid); + // One augmenting path s* -> t* in the level graph into path_ (current-arc pointers kept: an + // arc that is pushed through stays current; a dead end advances its parent's pointer). + bool find_path(FlowCoreCounters& c) { + stack_.clear(); + path_.clear(); + stack_.push_back(s_); + while (!stack_.empty()) { + const auto u = static_cast(stack_.back()); + if (static_cast(u) == t_) return true; + bool advanced = false; + const auto end = off_[u + 1]; + const auto next = level_[u] + 1; + const Cost pu = pi_[u]; + while (cur_[u] < end) { + ++c.arc_scans; + const auto pi = static_cast(cur_[u]); + const auto v = static_cast(head_[pi]); + if (res_[pi] > 0 && level_[v] == next && cost_[pi] + pu == pi_[v]) { + stack_.push_back(static_cast(v)); + path_.push_back(static_cast(pi)); + advanced = true; + break; + } + ++cur_[u]; + } + if (!advanced) { + stack_.pop_back(); + if (!path_.empty()) { + path_.pop_back(); + ++cur_[static_cast(stack_.back())]; + } + } } + return false; } - if (candidates.empty()) { - return {}; - } + const StrictMultiDiGraph& g_; + std::int32_t n_; + std::int32_t s_; + std::int32_t t_; + std::vector active_ {}; + std::vector srcs_ {}; + std::vector dsts_ {}; + std::vector off_ {}; + std::vector head_ {}; + std::vector rev_ {}; + std::vector cost_ {}; + std::vector res_ {}; + std::vector edge_slot_ {}; + std::vector pi_ {}; + std::vector ever_ {}; + std::vector dist_ {}; + std::vector settled_ {}; + RadixHeap heap_ {}; + std::vector level_ {}; + std::vector reach_ {}; + std::uint32_t mark_epoch_ {0}; + std::vector queue_ {}; + std::vector cur_ {}; + std::vector stack_ {}; + std::vector path_ {}; +}; - // Prepare mutable mask buffer - const auto N = static_cast(g.num_edges()); - std::unique_ptr test_mask_buf(new bool[N]); - if (edge_mask.empty()) { - std::fill(test_mask_buf.get(), test_mask_buf.get() + N, true); - } else { - std::copy(edge_mask.begin(), edge_mask.end(), test_mask_buf.get()); - } - std::vector> results; - results.reserve(candidates.size()); +} // namespace - // Step 2: Iterate candidates, testing flow reduction when each is removed - const auto thread_budget = thread_budget_from_env("NGRAPH_CORE_SENSITIVITY_THREADS", candidates.size()); - if (thread_budget <= 1) { - for (EdgeId eid : candidates) { - auto maybe_result = evaluate_sensitivity_candidate( - g, src, dst, placement, - shortest_path, require_capacity, - baseline_flow, node_mask, - eid, test_mask_buf.get(), N); - if (maybe_result.has_value()) { - results.push_back(*maybe_result); - } +FlowCoreResult place_global_units(const StrictMultiDiGraph& g, + std::span view, + std::span sources, + std::span destinations, + const Eligibility* elig, + std::optional cap) { + const auto num_edges = static_cast(g.num_edges()); + if (view.size() != num_edges) { + throw std::invalid_argument("view has length " + std::to_string(view.size()) + ", expected " + + std::to_string(num_edges) + " (one entry per edge)"); + } + const auto capacity = g.capacity_units(); + for (std::size_t e = 0; e < num_edges; ++e) { + if (view[e] < 0 || view[e] > capacity[e]) { + throw std::invalid_argument("view[" + std::to_string(e) + "] = " + std::to_string(view[e]) + + " is outside [0, capacity_units[" + std::to_string(e) + "] = " + + std::to_string(capacity[e]) + "]"); } - return results; } - - const auto chunk_size = (candidates.size() + thread_budget - 1) / thread_budget; - std::vector>>> futures; - futures.reserve(thread_budget); - - for (std::size_t begin = 0; begin < candidates.size(); begin += chunk_size) { - const std::size_t end = std::min(begin + chunk_size, candidates.size()); - futures.emplace_back(std::async(std::launch::async, [&, begin, end]() { - std::unique_ptr local_mask_buf(new bool[N]); - if (!edge_mask.empty()) { - std::copy(edge_mask.begin(), edge_mask.end(), local_mask_buf.get()); - } else { - std::fill(local_mask_buf.get(), local_mask_buf.get() + N, true); - } - - std::vector> local_results; - local_results.reserve(end - begin); - for (std::size_t idx = begin; idx < end; ++idx) { - auto maybe_result = evaluate_sensitivity_candidate( - g, src, dst, placement, - shortest_path, require_capacity, - baseline_flow, node_mask, - candidates[idx], local_mask_buf.get(), N); - if (maybe_result.has_value()) { - local_results.push_back(*maybe_result); - } - } - return local_results; - })); + const auto srcs = endpoint_set(sources, g.num_nodes(), "sources"); + const auto dsts = endpoint_set(destinations, g.num_nodes(), "destinations"); + for (std::size_t i = 0, k = 0; i < srcs.size() && k < dsts.size();) { + if (srcs[i] == dsts[k]) { + throw std::invalid_argument("node " + std::to_string(srcs[i]) + + " is in both endpoint sets: sources and destinations must not overlap"); + } + if (srcs[i] < dsts[k]) ++i; else ++k; } - - for (auto& future : futures) { - auto local_results = future.get(); - results.insert(results.end(), local_results.begin(), local_results.end()); + if (cap.has_value() && (*cap < 0 || *cap > kMaxFlowCap)) { + throw std::invalid_argument("cap " + std::to_string(*cap) + " is outside [0, 2^63 - 2]"); } - - return results; + if (elig != nullptr && elig->graph_uid() != g.uid()) { + throw std::invalid_argument("foreign Eligibility: built for another graph"); + } + if (cap.has_value() && *cap == 0) { + // The input cap 0 runs nothing: the prescribed zero payload. + FlowCoreResult out; + out.edge_flows.assign(num_edges, 0); + out.potentials.assign(static_cast(g.num_nodes()), kNeverReached); + return out; + } + Solver solver(g, view, srcs, dsts, elig); + return solver.run(view, cap.value_or(kUncapped)); } } // namespace netgraph::core diff --git a/src/shortest_paths.cpp b/src/shortest_paths.cpp deleted file mode 100644 index 080074e..0000000 --- a/src/shortest_paths.cpp +++ /dev/null @@ -1,1245 +0,0 @@ -/* Path enumeration from PredDAG (resolve_to_paths). */ -#include "netgraph/core/shortest_paths.hpp" -#include "netgraph/core/constants.hpp" -#include "netgraph/core/profiling.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace netgraph::core { - -static inline void group_parents(const PredDAG& dag, NodeId v, - std::vector>>& out) { - out.clear(); - const auto start = static_cast(dag.parent_offsets[static_cast(v)]); - const auto end = static_cast(dag.parent_offsets[static_cast(v) + 1]); - if (start >= end) return; - // Collect entries, grouping by parent id - // PredDAG does not guarantee sorted by parent; aggregate via map-like linear scan - for (std::size_t i = start; i < end; ++i) { - auto p = dag.parents[i]; - auto e = dag.via_edges[i]; - // find or append group - bool found = false; - for (auto& pr : out) { - if (pr.first == p) { pr.second.push_back(e); found = true; break; } - } - if (!found) out.emplace_back(p, std::vector{e}); - } -} - -std::pair, PredDAG> -path_to_pred_dag(const StrictMultiDiGraph& g, - std::span nodes, - std::span edges) { - const auto cost_view = g.cost_view(); - std::vector dist(static_cast(g.num_nodes()), - std::numeric_limits::max()); - PredDAG dag; - dag.parent_offsets.assign(static_cast(g.num_nodes() + 1), 0); - if (!nodes.empty()) { - dist[static_cast(nodes.front())] = 0; - for (std::size_t i = 1; i < nodes.size(); ++i) { - auto u = nodes[i - 1]; auto v = nodes[i]; auto e = edges[i - 1]; - dist[static_cast(v)] = - dist[static_cast(u)] + cost_view[static_cast(e)]; - dag.parent_offsets[static_cast(v + 1)] = 1; - } - for (std::size_t v = 1; v < dag.parent_offsets.size(); ++v) - dag.parent_offsets[v] += dag.parent_offsets[v - 1]; - dag.parents.resize(static_cast(dag.parent_offsets.back())); - dag.via_edges.resize(static_cast(dag.parent_offsets.back())); - for (std::size_t i = 1; i < nodes.size(); ++i) { - auto v = nodes[i]; - auto base = static_cast(dag.parent_offsets[static_cast(v)]); - dag.parents[base] = nodes[i - 1]; - dag.via_edges[base] = edges[i - 1]; - } - } - return {std::move(dist), std::move(dag)}; -} - -namespace { - -// Shared builder for explicit routes (make_path_dag, make_hops_dag): validates -// the hop sequence and lays the entries out in PredDAG form. -PredDAG build_route_dag(const char* what, const StrictMultiDiGraph& g, - std::span> hops) { - const std::string who(what); - if (hops.empty()) throw std::invalid_argument(who + ": route must be non-empty"); - const auto N = g.num_nodes(); - const auto E = static_cast(g.num_edges()); - const auto esrc = g.edge_src_view(); - const auto edst = g.edge_dst_view(); - std::vector seen(static_cast(N), 0); - PredDAG dag; - dag.parent_offsets.assign(static_cast(N) + 1, 0); - NodeId prev = -1; - for (std::size_t i = 0; i < hops.size(); ++i) { - const auto& hop = hops[i]; - if (hop.empty()) throw std::invalid_argument(who + ": hop " + std::to_string(i) + " is empty"); - NodeId hu = -1, hv = -1; - for (std::size_t k = 0; k < hop.size(); ++k) { - const auto e = hop[k]; - if (e < 0 || static_cast(e) >= E) { - throw std::invalid_argument(who + ": edge id out of range"); - } - for (std::size_t m = 0; m < k; ++m) { - if (hop[m] == e) throw std::invalid_argument(who + ": edge " + std::to_string(e) + " repeated in hop " + std::to_string(i)); - } - const auto u = esrc[static_cast(e)]; - const auto v = edst[static_cast(e)]; - if (k == 0) { hu = u; hv = v; } - else if (u != hu || v != hv) { - throw std::invalid_argument(who + ": edges of hop " + std::to_string(i) + " are not parallel (they must join the same node pair)"); - } - } - if (i == 0) { - seen[static_cast(hu)] = 1; - } else if (hu != prev) { - throw std::invalid_argument( - who + ": hops are not contiguous (edge source does not match the previous hop's destination)"); - } - if (seen[static_cast(hv)]) { - throw std::invalid_argument(who + ": route revisits a node (must be a simple path)"); - } - seen[static_cast(hv)] = 1; - prev = hv; - dag.parent_offsets[static_cast(hv) + 1] = static_cast(hop.size()); - } - for (std::size_t v = 1; v < dag.parent_offsets.size(); ++v) dag.parent_offsets[v] += dag.parent_offsets[v - 1]; - dag.parents.resize(static_cast(dag.parent_offsets.back())); - dag.via_edges.resize(static_cast(dag.parent_offsets.back())); - for (const auto& hop : hops) { - const auto hu = esrc[static_cast(hop[0])]; - const auto hv = edst[static_cast(hop[0])]; - auto base = static_cast(dag.parent_offsets[static_cast(hv)]); - for (std::size_t k = 0; k < hop.size(); ++k) { - dag.parents[base + k] = hu; - dag.via_edges[base + k] = hop[k]; - } - } - return dag; -} - -} // namespace - -PredDAG make_path_dag(const StrictMultiDiGraph& g, std::span edges) { - std::vector> hops; - hops.reserve(edges.size()); - for (auto e : edges) hops.push_back({e}); - return build_route_dag("make_path_dag", g, hops); -} - -PredDAG make_hops_dag(const StrictMultiDiGraph& g, std::span> hops) { - return build_route_dag("make_hops_dag", g, hops); -} - -std::vector>>> -resolve_to_paths(const PredDAG& dag, NodeId src, NodeId dst, - bool split_parallel_edges, - std::optional max_paths) { - std::vector>>> paths; - if (src == dst) { - // Trivial path: ((src, ())) - std::vector>> p; - p.emplace_back(src, std::vector{}); - paths.push_back(std::move(p)); - return paths; - } - if (static_cast(dst) >= dag.parent_offsets.size() - 1) return paths; - if (dag.parent_offsets[static_cast(dst)] == dag.parent_offsets[static_cast(dst) + 1]) return paths; - - // Iterative DFS stack: each frame holds current node and index into its parent-groups. - struct Frame { NodeId node; std::size_t idx; std::vector>> groups; }; - std::vector stack; - stack.reserve(16); - // on_path[v] marks nodes on the current DFS stack. A well-formed PredDAG is - // acyclic, but zero-cost edges (or a caller-supplied DAG) can contain cycles; - // without this guard the enumeration below would walk them forever. - std::vector on_path(dag.parent_offsets.size() > 0 ? dag.parent_offsets.size() - 1 : 0, 0); - // start from dst - Frame start; start.node = dst; start.idx = 0; group_parents(dag, dst, start.groups); - stack.push_back(std::move(start)); - on_path[static_cast(dst)] = 1; - - std::vector>> current; // reversed path accum - - while (!stack.empty()) { - auto& top = stack.back(); - if (top.idx >= top.groups.size()) { - // backtrack - on_path[static_cast(top.node)] = 0; - stack.pop_back(); - if (!current.empty()) current.pop_back(); - continue; - } - auto [parent, edges] = top.groups[top.idx++]; - // Skip parents already on the current path (cycle in the input DAG). - if (parent != src && on_path[static_cast(parent)]) continue; - current.emplace_back(top.node, std::move(edges)); - if (parent == src) { - // reached src; build forward segments: for each hop prev->next store (next, edges) - std::vector>> segments; - segments.reserve(current.size()); - for (auto it = current.rbegin(); it != current.rend(); ++it) { - segments.emplace_back(it->first, it->second); - } - // Build path tuples: (src, edges for src->n1), (n1, edges for n1->n2), ..., (dst, ()) - std::vector>> path; - path.reserve(segments.size() + 1); - if (!segments.empty()) { - // src element - path.emplace_back(src, segments[0].second); - // intermediate elements (attach next hop's edges to current node) - for (std::size_t j = 1; j < segments.size(); ++j) { - path.emplace_back(segments[j - 1].first, segments[j].second); - } - // dst element - path.emplace_back(segments.back().first, std::vector{}); - } else { - // Degenerate: src==dst should be handled earlier, but keep form - path.emplace_back(src, std::vector{}); - } - if (!split_parallel_edges) { - paths.push_back(std::move(path)); - } else { - // expand cartesian product over edge sets excluding last (dst has empty edges) - // collect ranges - std::vector idxs(path.size(), 0); - // Enumerate over all elements except the final dst (which has empty edges) - const std::size_t start_i = 0; - const std::size_t end_i = path.size() - 2; // last index before dst - // initialize counters - bool done = false; - while (!done) { - // build one concrete path - std::vector>> concrete; - concrete.reserve(path.size()); - for (std::size_t i = start_i; i <= end_i; ++i) { - const auto& node = path[i].first; - const auto& eds = path[i].second; - if (!eds.empty()) { - std::size_t sel = std::min(idxs[i], eds.size() - 1); - concrete.emplace_back(node, std::vector{ eds[sel] }); - } else { - concrete.emplace_back(node, std::vector{}); - } - } - // append dst with empty edges - concrete.emplace_back(path.back().first, std::vector{}); - paths.push_back(std::move(concrete)); - if (max_paths && static_cast(paths.size()) >= *max_paths) return paths; - // increment counters (mixed radix) - std::ptrdiff_t k = static_cast(end_i); - while (k >= static_cast(start_i)) { - if (path[static_cast(k)].second.empty()) { --k; continue; } - idxs[static_cast(k)]++; - if (idxs[static_cast(k)] < path[static_cast(k)].second.size()) break; - idxs[static_cast(k)] = 0; - if (k == static_cast(start_i)) { done = true; break; } - --k; - } - if (k < static_cast(start_i)) done = true; - } - } - if (max_paths && static_cast(paths.size()) >= *max_paths) return paths; - current.pop_back(); - continue; - } - // descend - Frame next; next.node = parent; next.idx = 0; group_parents(dag, parent, next.groups); - if (next.groups.empty()) { - // dead end, backtrack - current.pop_back(); - continue; - } - on_path[static_cast(parent)] = 1; - stack.push_back(std::move(next)); - } - - return paths; -} - -} // namespace netgraph::core - -/* - Dijkstra shortest path algorithm with capacity-aware tie-breaking. - - Features: - - Residual-aware traversal: uses dynamic residuals if provided, otherwise static capacity - - Multipath mode: collects all equal-cost predecessor edges per node - - Single-path mode: selects one edge per adjacency using: - * Edge-level tie-breaking for parallel edges (PreferHigherResidual or Deterministic) - * Node-level tie-breaking for equal-cost nodes (prefers higher bottleneck capacity) - - Early exit when specific destination is reached - - Frontier queue. The reference queue is a binary heap on (cost, -bottleneck, - node). When the graph's costs fit a bounded number of buckets - (W = max_cost / gcd(costs) + 1 <= kMaxBucketWidth) the search uses a monotone - bucket queue instead: Dijkstra pops costs in non-decreasing order and every - push is at least the cost being popped, so with W cyclic buckets each bucket - holds exactly one cost value at a time. The active bucket is itself a small - heap on (-bottleneck, node), so the pop sequence is identical to the heap's, - including zero-cost pushes into the active bucket, single-path re-pushes, - stale entries and destination early exit. The two queues therefore produce - bit-identical results; ShortestPaths.QueueDifferential_* enforce that. - - Scratch state lives in a thread-local workspace that is reset after each - search: only the touched nodes when few were touched, the whole arrays - otherwise. This removes the O(N) per-query initialisation that dominated small - destination-limited searches on large graphs. A workspace holds no pointer - into any graph, residual or mask; see ForwardWorkspace for what it retains - between searches and when it shrinks. -*/ -namespace netgraph::core { - -namespace { - -constexpr Cost kInf = std::numeric_limits::max(); -constexpr std::size_t kMaxBucketWidth = std::size_t{1} << 16; - -using QItem = std::tuple; - -// Reference queue: binary heap on (cost, -bottleneck, node). Keys are unique per -// push (a node is re-pushed only with a strictly smaller cost or a strictly -// larger bottleneck), so the pop order is fully determined by the key. -struct HeapQueue { - // Functor, not a function pointer: std::push_heap / pop_heap inline it. - struct Greater { bool operator()(const QItem& a, const QItem& b) const { return a > b; } }; - std::vector h; - void begin(std::size_t, Cost) { h.clear(); } - void end() { h.clear(); } - void drop_storage() { std::vector fresh; h.swap(fresh); } - bool empty() const { return h.empty(); } - Cost top_cost() const { return std::get<0>(h.front()); } - QItem pop() { - std::pop_heap(h.begin(), h.end(), Greater{}); - QItem t = h.back(); h.pop_back(); return t; - } - void push(Cost c, Cap nr, NodeId v) { - h.emplace_back(c, nr, v); - std::push_heap(h.begin(), h.end(), Greater{}); - } -}; - -// Monotone bucket queue with ordered buckets (see the file comment). -struct BucketQueue { - struct Item { Cap neg_res; NodeId node; }; - struct Greater { - bool operator()(const Item& a, const Item& b) const { - return a.neg_res != b.neg_res ? a.neg_res > b.neg_res : a.node > b.node; - } - }; - std::vector> buckets; - std::vector occ; // one bit per non-empty bucket; all zero between searches - std::vector dirty; // occupancy words set during the current search - std::size_t W = 0, cur_b = 0, pending = 0; - std::size_t item_capacity = 0; // sum of the buckets' retained capacities - std::size_t peak_pending = 0; // largest frontier of the current search - Cost cur = 0, stride = 1; - // A frontier of exactly one item is held inline rather than in a bucket, so - // chain-like stretches pay no bucket bookkeeping; a second push promotes it. - bool inline_item = false; - QItem single; - - void begin(std::size_t width, Cost stride_) { - stride = stride_; - W = width; - if (buckets.size() < W) buckets.resize(W); - const std::size_t words = (W + 63) / 64; - if (occ.size() < words) occ.resize(words, 0); // existing words are already zero - cur = 0; cur_b = 0; pending = 0; peak_pending = 0; inline_item = false; - } - // Early exit can leave items behind: clear the buckets of the words this - // search set (O(buckets used), not O(W)). Retained item storage is dropped - // when it exceeds a small multiple of what this search needed, so the - // workspace does not accumulate the high-water marks of every bucket ever - // used across differently shaped graphs. - void end() { - for (auto w : dirty) { - std::uint64_t word = occ[w]; - while (word) { - const std::size_t b = (static_cast(w) << 6) + static_cast(std::countr_zero(word)); - buckets[b].clear(); - word &= word - 1; - } - occ[w] = 0; - } - dirty.clear(); - pending = 0; inline_item = false; - if (item_capacity > 4 * peak_pending + 4096) drop_storage(); - } - void drop_storage() { - for (auto& b : buckets) { std::vector fresh; b.swap(fresh); } - item_capacity = 0; - } - bool empty() const { return pending == 0; } - std::size_t bucket_of(Cost c) const { - const Cost q = stride == 1 ? c : c / stride; - return static_cast(q % static_cast(W)); - } - void push_bucket(Cost c, Cap nr, NodeId v) { - const std::size_t b = bucket_of(c); - auto& vec = buckets[b]; - if (vec.empty()) { - const std::size_t w = b >> 6; - if (occ[w] == 0) dirty.push_back(static_cast(w)); - occ[w] |= (std::uint64_t{1} << (b & 63)); - } - const std::size_t cap_before = vec.capacity(); - vec.push_back({nr, v}); - if (vec.capacity() != cap_before) item_capacity += vec.capacity() - cap_before; - if (b == cur_b) std::push_heap(vec.begin(), vec.end(), Greater{}); - ++pending; - if (pending > peak_pending) peak_pending = pending; - } - void push(Cost c, Cap nr, NodeId v) { - if (pending == 0) { - single = {c, nr, v}; inline_item = true; pending = 1; - if (peak_pending == 0) peak_pending = 1; - return; - } - if (inline_item) { - // Promote the inline item into the buckets. cur is the cost of the last - // popped item, so every pending cost lies in [cur, cur + max_cost] and - // the cyclic bucket invariant holds from cur_b = bucket_of(cur). - inline_item = false; pending = 0; - cur_b = bucket_of(cur); - push_bucket(std::get<0>(single), std::get<1>(single), std::get<2>(single)); - } - push_bucket(c, nr, v); - } - // Advance to the next non-empty bucket when the active one is exhausted. - // Precondition: pending > 0 and no inline item. - void settle_active() { - if (!buckets[cur_b].empty()) return; - std::size_t i = cur_b + 1; if (i == W) i = 0; - std::size_t w = i >> 6; - std::uint64_t word = occ[w] & (~std::uint64_t{0} << (i & 63)); - const std::size_t nwords = (W + 63) / 64; - while (!word) { w = (w + 1 == nwords) ? 0 : w + 1; word = occ[w]; } - const std::size_t f = (w << 6) + static_cast(std::countr_zero(word)); - const std::size_t steps = f > cur_b ? f - cur_b : f + W - cur_b; - cur += static_cast(steps) * stride; cur_b = f; - std::make_heap(buckets[f].begin(), buckets[f].end(), Greater{}); - } - Cost top_cost() { - if (inline_item) return std::get<0>(single); - settle_active(); return cur; - } - QItem pop() { - if (inline_item) { - inline_item = false; pending = 0; - cur = std::get<0>(single); // cur_b keeps the last activation; promotion recomputes it - return single; - } - settle_active(); - auto& vec = buckets[cur_b]; - std::pop_heap(vec.begin(), vec.end(), Greater{}); - const Item it = vec.back(); vec.pop_back(); --pending; - if (vec.empty()) occ[cur_b >> 6] &= ~(std::uint64_t{1} << (cur_b & 63)); - return {cur, it.neg_res, it.node}; - } -}; - -struct BucketPlan { bool eligible = false; std::size_t width = 0; Cost stride = 1; }; - -// The value equal-cost alternatives are ranked by (EdgeSelection::priority). -// `rem` is the residual when one was supplied, otherwise the capacity; the -// default instantiation returns it untouched and never reads `cap`. -template -inline Cap priority_of(Cap rem, std::span cap, std::size_t e) { - if constexpr (P == EdgePriority::Residual) { - (void)cap; (void)e; return rem; - } else if constexpr (P == EdgePriority::Capacity) { - return cap[e]; - } else { - const Cap c = cap[e]; - return c > static_cast(0) ? rem / c : static_cast(0); - } -} - -BucketPlan plan_buckets(const StrictMultiDiGraph& g) { - BucketPlan p; - p.stride = g.cost_gcd() > 0 ? g.cost_gcd() : 1; - const Cost w = g.max_cost() / p.stride + 1; // max_cost < 2^62, no overflow - p.eligible = w <= static_cast(kMaxBucketWidth); - p.width = p.eligible ? static_cast(w) : 0; - return p; -} - -SpfQueue env_queue_override() { - static const SpfQueue q = [] { - const char* v = std::getenv("NGRAPH_CORE_SPF_QUEUE"); - if (!v) return SpfQueue::Auto; - const std::string s(v); - if (s == "heap") return SpfQueue::Heap; - if (s == "bucket") return SpfQueue::Bucket; - return SpfQueue::Auto; - }(); - return q; -} - -bool use_bucket_queue(const StrictMultiDiGraph& g, SpfQueue requested, BucketPlan& plan) { - const SpfQueue q = requested == SpfQueue::Auto ? env_queue_override() : requested; - if (q == SpfQueue::Heap) return false; - plan = plan_buckets(g); - return plan.eligible; -} - -// Shared reset policy: sparse when few nodes were touched, wholesale otherwise. -// The same predicate decides how outputs are materialised, so the two agree. -template -bool sparse_reset(const WS& ws) { return ws.touched.size() * 4 < ws.dist.size(); } - -// Resizes a fixed-size scratch array; returns true when a much larger buffer -// was released because the graph shrank. -template -bool ensure_sized(Vec& v, std::size_t n, typename Vec::value_type init) { - if (v.size() == n) return false; - bool released = false; - if (n * 4 < v.capacity()) { Vec fresh; v.swap(fresh); released = true; } - v.assign(n, init); - return released; -} - -template -void drop(Vec& v) { Vec fresh; v.swap(fresh); } - -// Forward search scratch. Invariant between searches: dist == kInf, -// min_res == 0, pred_head/pred_tail == -1, pred_count == 0, settled == 0 for -// every node; the entry arrays, touched list and queues are empty. -// -// Retention: the node arrays are sized to the current graph and released when -// a graph less than a quarter the size is searched; at that point the entry -// arrays, touched list, heap and bucket storage are released too. Otherwise -// those growable buffers keep their high-water capacity, except that the -// bucket queue drops its item storage when it exceeds a small multiple of the -// last search's peak frontier. The searches are synchronous and do not call -// back into user code, so a workspace is never re-entered on its thread. -struct ForwardWorkspace { - std::vector dist; - std::vector min_res; - std::vector pred_head, pred_tail, pred_count; - std::vector settled; - std::vector ent_parent; - std::vector ent_edge; - std::vector ent_next; - std::vector touched; - std::vector sel_buf; - HeapQueue heap; - BucketQueue bucket; - - // Predecessor lists: pred_head/pred_tail index into the ent_* arrays, whose - // entries are (parent, via_edge) pairs appended in discovery order; pred_count - // is the live length, so the result is sized without walking the lists twice. - void clear_preds(std::size_t v) { pred_head[v] = -1; pred_tail[v] = -1; pred_count[v] = 0; } - void append_pred(std::size_t v, NodeId p, EdgeId e) { - const auto idx = static_cast(ent_parent.size()); - ent_parent.push_back(p); ent_edge.push_back(e); ent_next.push_back(-1); - if (pred_head[v] < 0) pred_head[v] = idx; - else ent_next[static_cast(pred_tail[v])] = idx; - pred_tail[v] = idx; - ++pred_count[v]; - } - - void acquire(std::size_t n) { - const bool shrank = ensure_sized(dist, n, kInf); - ensure_sized(min_res, n, static_cast(0)); - ensure_sized(pred_head, n, -1); - ensure_sized(pred_tail, n, -1); - ensure_sized(pred_count, n, 0); - ensure_sized(settled, n, 0); - if (shrank) { - drop(ent_parent); drop(ent_edge); drop(ent_next); drop(touched); drop(sel_buf); - heap.drop_storage(); bucket.drop_storage(); - } - ent_parent.clear(); ent_edge.clear(); ent_next.clear(); touched.clear(); - } - void release() { - if (sparse_reset(*this)) { - for (auto v : touched) { - const auto i = static_cast(v); - dist[i] = kInf; min_res[i] = 0; pred_head[i] = -1; pred_tail[i] = -1; pred_count[i] = 0; settled[i] = 0; - } - } else { - std::fill(dist.begin(), dist.end(), kInf); - std::fill(min_res.begin(), min_res.end(), static_cast(0)); - std::fill(pred_head.begin(), pred_head.end(), -1); - std::fill(pred_tail.begin(), pred_tail.end(), -1); - std::fill(pred_count.begin(), pred_count.end(), 0); - std::fill(settled.begin(), settled.end(), 0); - } - touched.clear(); - heap.end(); bucket.end(); - } -}; - -// Reverse search scratch; same invariant. has_in_entry[v]: some DAG entry -// u -> v exists; a fanout edge may not leave such a node. -struct ReverseWorkspace { - std::vector dist; - std::vector min_res; - std::vector succ_head, succ_tail; - std::vector settled; - std::vector has_in_entry; - std::vector ent_node; - std::vector ent_edge; - std::vector ent_next; - std::vector touched; - std::vector sel_buf; - HeapQueue heap; - BucketQueue bucket; - - // Successor lists per tail node u: (v, e) entries in the ent_* arrays. - void clear_succs(std::size_t u) { succ_head[u] = -1; succ_tail[u] = -1; } - void append_succ(std::size_t u, NodeId v, EdgeId e) { - const auto idx = static_cast(ent_node.size()); - ent_node.push_back(v); ent_edge.push_back(e); ent_next.push_back(-1); - if (succ_head[u] < 0) succ_head[u] = idx; - else ent_next[static_cast(succ_tail[u])] = idx; - succ_tail[u] = idx; - } - - void acquire(std::size_t n) { - const bool shrank = ensure_sized(dist, n, kInf); - ensure_sized(min_res, n, static_cast(0)); - ensure_sized(succ_head, n, -1); - ensure_sized(succ_tail, n, -1); - ensure_sized(settled, n, 0); - ensure_sized(has_in_entry, n, 0); - if (shrank) { - drop(ent_node); drop(ent_edge); drop(ent_next); drop(touched); drop(sel_buf); - heap.drop_storage(); bucket.drop_storage(); - } - ent_node.clear(); ent_edge.clear(); ent_next.clear(); touched.clear(); - } - void release() { - if (sparse_reset(*this)) { - for (auto v : touched) { - const auto i = static_cast(v); - dist[i] = kInf; min_res[i] = 0; succ_head[i] = -1; succ_tail[i] = -1; - settled[i] = 0; has_in_entry[i] = 0; - } - } else { - std::fill(dist.begin(), dist.end(), kInf); - std::fill(min_res.begin(), min_res.end(), static_cast(0)); - std::fill(succ_head.begin(), succ_head.end(), -1); - std::fill(succ_tail.begin(), succ_tail.end(), -1); - std::fill(settled.begin(), settled.end(), 0); - std::fill(has_in_entry.begin(), has_in_entry.end(), 0); - } - touched.clear(); - heap.end(); bucket.end(); - } -}; - -thread_local ForwardWorkspace tls_forward; -thread_local ReverseWorkspace tls_reverse; - -// Restores the workspace invariant on every exit path, including exceptions. -// Callers must record a node in `touched` before writing its scratch entries, -// so that a throwing push_back cannot leave an unrecorded modification behind. -template -struct WorkspaceLease { - WS& ws; - ~WorkspaceLease() { ws.release(); } -}; - -// The search loop, shared by both queues. Precondition: src is allowed and -// initialised in ws (dist 0, min_res max, touched). -template -void run_forward(Queue& pq, ForwardWorkspace& ws, const StrictMultiDiGraph& g, NodeId src, - std::optional dst, bool multipath, const EdgeSelection& selection, - std::span residual, std::span node_mask, - std::span edge_mask, const BucketPlan& plan) { - const auto N = g.num_nodes(); - const auto row = g.row_offsets_view(); - const auto col = g.col_indices_view(); - const auto aei = g.adj_edge_index_view(); - const auto cost = g.cost_view(); - const auto cap = g.capacity_view(); - // Raw pointers into the fixed-size arrays: they are not resized during the - // search, and hoisting them keeps the compiler from reloading the vector - // bases around every store (the workspace is a thread-local object). - Cost* const dist = ws.dist.data(); - Cap* const min_residual_to_node = ws.min_res.data(); - char* const settled = ws.settled.data(); - - const bool use_node_mask = (node_mask.size() == static_cast(N)); - const bool use_edge_mask = (edge_mask.size() == static_cast(g.num_edges())); - const bool has_residual = (residual.size() == static_cast(g.num_edges())); - const bool require_cap = selection.require_capacity || has_residual; - - // Queue items are (cost, -residual, node): cost (minimize) -> residual - // (maximize) -> node (deterministic). This naturally distributes flows across - // equal-cost paths based on available capacity. - pq.begin(plan.width, plan.stride); - pq.push(static_cast(0), -std::numeric_limits::max(), src); - Cost best_dst_cost = kInf; - bool have_best_dst = false; - const bool early_exit = dst.has_value(); - const NodeId dst_node = dst.value_or(-1); - - // settled[v] is set once v is popped at its final distance. Equal-cost - // predecessor updates are only accepted while v is unsettled: with positive - // edge costs every equal-cost parent is discovered before v settles, and with - // zero-cost edges this guard is what keeps the PredDAG acyclic (previously a - // zero-cost pair u<->v recorded each node as the other's parent). - std::vector& selected_edges = ws.sel_buf; - while (!pq.empty()) { - auto [d_u, neg_res_u, u] = pq.pop(); - if (u < 0 || u >= N) continue; - // Skip stale entries (node already processed at a lower cost). - if (d_u > dist[static_cast(u)]) continue; - // Skip residual-stale entries in single-path mode (same cost but outdated residual). - if (!multipath && d_u == dist[static_cast(u)] && - -neg_res_u < min_residual_to_node[static_cast(u)] - kEpsilon) continue; - settled[static_cast(u)] = 1; - - if (early_exit && u == dst_node && !have_best_dst) { best_dst_cost = d_u; have_best_dst = true; } - if (early_exit && u == dst_node) { - if (pq.empty() || pq.top_cost() > best_dst_cost) break; else continue; - } - - auto start = static_cast(row[static_cast(u)]); - auto end = static_cast(row[static_cast(u)+1]); - std::size_t i = start; - // Process edges grouped by destination node (parallel edges are consecutive in CSR). - // A settled neighbour cannot be improved (costs are non-negative) and no - // longer accepts equal-cost or capacity-tie updates, so its whole parallel - // group is skipped before any edge is examined. - while (i < end) { - NodeId v = col[i]; - if (settled[static_cast(v)] || (use_node_mask && !node_mask[static_cast(v)])) { - std::size_t j_skip = i; while (j_skip < end && col[j_skip] == v) ++j_skip; i = j_skip; continue; - } - - // Select best edge(s) from u to v according to policy. - Cost min_edge_cost = kInf; - selected_edges.clear(); - double best_rem_for_min_cost = -1.0; - std::size_t j = i; - int best_edge_id = -1; - for (; j < end && col[j] == v; ++j) { - auto e = static_cast(aei[j]); - if (use_edge_mask && !edge_mask[e]) continue; - const Cap rem = has_residual ? residual[e] : cap[e]; - if (require_cap && rem < kMinCap) continue; - const Cap pri = priority_of

(rem, cap, e); - const Cost ecost = static_cast(cost[e]); - if (ecost < min_edge_cost) { - min_edge_cost = ecost; - selected_edges.clear(); - if (selection.multi_edge) { - selected_edges.push_back(static_cast(aei[j])); - } else { - best_edge_id = static_cast(e); - best_rem_for_min_cost = static_cast(pri); - } - } else if (ecost == min_edge_cost) { - if (selection.multi_edge) { - selected_edges.push_back(static_cast(aei[j])); - } else { - // tie-break among equal-cost edges - if (selection.tie_break == EdgeTieBreak::PreferHigherResidual) { - if (static_cast(pri) > best_rem_for_min_cost + kEpsilon) { - best_edge_id = static_cast(e); - best_rem_for_min_cost = static_cast(pri); - } else if (std::abs(static_cast(pri) - best_rem_for_min_cost) <= kEpsilon) { - // further tie-break deterministically by smaller edge id - if (best_edge_id < 0 || static_cast(e) < best_edge_id) { - best_edge_id = static_cast(e); - } - } - } else { - // Deterministic: smallest edge id - if (best_edge_id < 0 || static_cast(e) < best_edge_id) { - best_edge_id = static_cast(e); - } - } - } - } - } - if (!selection.multi_edge && best_edge_id >= 0) { - selected_edges.clear(); - selected_edges.push_back(static_cast(best_edge_id)); - } - if (!selected_edges.empty()) { - Cost new_cost = static_cast(d_u + min_edge_cost); - auto v_idx = static_cast(v); - // Bottleneck along the path to v through u (node-level tie-breaking), in - // the selected priority value. - Cap max_edge_residual = static_cast(0); - for (auto edge_id : selected_edges) { - const auto idx = static_cast(edge_id); - const Cap rem = has_residual ? residual[idx] : cap[idx]; - const Cap pri = priority_of

(rem, cap, idx); - if (pri > max_edge_residual) max_edge_residual = pri; - } - Cap path_residual = std::min(min_residual_to_node[static_cast(u)], max_edge_residual); - // Relaxation: shorter path, or equal-cost path with better capacity (single-path mode). - if (new_cost < dist[v_idx] || - (!multipath && new_cost == dist[v_idx] && !settled[v_idx] && - path_residual > min_residual_to_node[v_idx] + kEpsilon)) { - if (dist[v_idx] == kInf) ws.touched.push_back(v); - dist[v_idx] = new_cost; - min_residual_to_node[v_idx] = path_residual; - ws.clear_preds(v_idx); - for (auto sel_e : selected_edges) ws.append_pred(v_idx, u, sel_e); - pq.push(new_cost, -path_residual, v); - } - // Multipath: equal-cost alternative (only while v is unsettled; see above). - // min_residual_to_node is not updated here: all equal-cost paths are kept, - // none is chosen by residual. - else if (multipath && new_cost == dist[v_idx] && !settled[v_idx]) { - for (auto sel_e : selected_edges) ws.append_pred(v_idx, u, sel_e); - } - } - i = j; - } - if (have_best_dst) { if (pq.empty() || pq.top_cost() > best_dst_cost) break; } - } -} - -// Runs the forward search on the chosen queue for one priority. -template -void forward_with(bool bucket, ForwardWorkspace& ws, const StrictMultiDiGraph& g, NodeId src, - std::optional dst, bool multipath, const EdgeSelection& selection, - std::span residual, std::span node_mask, - std::span edge_mask, const BucketPlan& plan) { - if (bucket) run_forward(ws.bucket, ws, g, src, dst, multipath, selection, residual, node_mask, edge_mask, plan); - else run_forward(ws.heap, ws, g, src, dst, multipath, selection, residual, node_mask, edge_mask, plan); -} - -// Materialise the distance vector from the forward workspace. -std::vector materialise_dist(const ForwardWorkspace& ws, std::int32_t N) { - if (!sparse_reset(ws)) return ws.dist; - std::vector dist(static_cast(N), kInf); - for (auto v : ws.touched) dist[static_cast(v)] = ws.dist[static_cast(v)]; - return dist; -} - -// Materialise the PredDAG from the forward workspace. Per-node predecessor -// lists are independent, so visiting only touched nodes yields the same arrays -// as visiting every node. -PredDAG materialise_dag(const ForwardWorkspace& ws, std::int32_t N) { - const bool sparse = sparse_reset(ws); - PredDAG dag; - dag.parent_offsets.assign(static_cast(N+1), 0); - if (sparse) { - for (auto v : ws.touched) dag.parent_offsets[static_cast(v) + 1] = ws.pred_count[static_cast(v)]; - } else { - for (std::int32_t v = 0; v < N; ++v) dag.parent_offsets[static_cast(v) + 1] = ws.pred_count[static_cast(v)]; - } - for (std::size_t k = 1; k < dag.parent_offsets.size(); ++k) - dag.parent_offsets[k] += dag.parent_offsets[k-1]; - dag.parents.resize(static_cast(dag.parent_offsets.back())); - dag.via_edges.resize(static_cast(dag.parent_offsets.back())); - auto fill = [&](std::int32_t v) { - auto base = static_cast(dag.parent_offsets[static_cast(v)]); - std::size_t k = 0; - for (std::int32_t i = ws.pred_head[static_cast(v)]; i >= 0; i = ws.ent_next[static_cast(i)]) { - dag.parents[base+k] = ws.ent_parent[static_cast(i)]; - dag.via_edges[base+k] = ws.ent_edge[static_cast(i)]; - ++k; - } - }; - if (sparse) { for (auto v : ws.touched) fill(v); } - else { for (std::int32_t v = 0; v < N; ++v) fill(v); } - return dag; -} - -PredDAG empty_dag(std::int32_t N) { - PredDAG dag; - dag.parent_offsets.assign(static_cast(N + 1), 0); - return dag; -} - -bool source_allowed(const StrictMultiDiGraph& g, NodeId src, std::span node_mask) { - const auto N = g.num_nodes(); - const bool use_node_mask = (node_mask.size() == static_cast(N)); - return src >= 0 && src < N && (!use_node_mask || node_mask[static_cast(src)]); -} - -// Runs the forward search into the acquired workspace. Precondition: the -// source is allowed (source_allowed) and the lease is held by the caller. -void search_forward(ForwardWorkspace& ws, const StrictMultiDiGraph& g, NodeId src, - std::optional dst, bool multipath, const EdgeSelection& selection, - std::span residual, std::span node_mask, - std::span edge_mask, SpfQueue queue) { - ws.touched.push_back(src); // before the writes: see WorkspaceLease - ws.dist[static_cast(src)] = static_cast(0); - ws.min_res[static_cast(src)] = std::numeric_limits::max(); - BucketPlan plan; - const bool bucket = use_bucket_queue(g, queue, plan); - switch (selection.priority) { - case EdgePriority::Capacity: - forward_with(bucket, ws, g, src, dst, multipath, selection, residual, node_mask, edge_mask, plan); break; - case EdgePriority::Headroom: - forward_with(bucket, ws, g, src, dst, multipath, selection, residual, node_mask, edge_mask, plan); break; - default: - forward_with(bucket, ws, g, src, dst, multipath, selection, residual, node_mask, edge_mask, plan); break; - } -} - -std::pair, PredDAG> -shortest_paths_core(const StrictMultiDiGraph& g, NodeId src, - std::optional dst, - bool multipath, - const EdgeSelection& selection, - std::span residual, - std::span node_mask, - std::span edge_mask, - SpfQueue queue) { - NGRAPH_PROFILE_SCOPE("shortest_paths_core"); - const auto N = g.num_nodes(); - if (!source_allowed(g, src, node_mask)) { - // Source is out of range or masked out: no traversal, return empty DAG. - return {std::vector(static_cast(N), kInf), empty_dag(N)}; - } - ForwardWorkspace& ws = tls_forward; - ws.acquire(static_cast(N)); - WorkspaceLease lease{ws}; - search_forward(ws, g, src, dst, multipath, selection, residual, node_mask, edge_mask, queue); - return {materialise_dist(ws, N), materialise_dag(ws, N)}; -} - -std::pair -shortest_paths_dag_core(const StrictMultiDiGraph& g, NodeId src, NodeId dst, - bool multipath, - const EdgeSelection& selection, - std::span residual, - std::span node_mask, - std::span edge_mask, - SpfQueue queue) { - NGRAPH_PROFILE_SCOPE("shortest_paths_core"); - const auto N = g.num_nodes(); - if (!source_allowed(g, src, node_mask)) return {kInf, empty_dag(N)}; - ForwardWorkspace& ws = tls_forward; - ws.acquire(static_cast(N)); - WorkspaceLease lease{ws}; - search_forward(ws, g, src, dst, multipath, selection, residual, node_mask, edge_mask, queue); - const Cost cost = (dst >= 0 && dst < N) ? ws.dist[static_cast(dst)] : kInf; - return {cost, materialise_dag(ws, N)}; -} - -void validate_spans(const char* what, const StrictMultiDiGraph& g, std::span residual, - std::span node_mask, std::span edge_mask) { - if (!node_mask.empty() && node_mask.size() != static_cast(g.num_nodes())) { - throw std::invalid_argument(std::string(what) + ": node_mask length mismatch"); - } - if (!edge_mask.empty() && edge_mask.size() != static_cast(g.num_edges())) { - throw std::invalid_argument(std::string(what) + ": edge_mask length mismatch"); - } - if (!residual.empty() && residual.size() != static_cast(g.num_edges())) { - throw std::invalid_argument(std::string(what) + ": residual length mismatch"); - } -} - -} // namespace - -std::pair, PredDAG> -shortest_paths(const StrictMultiDiGraph& g, NodeId src, - std::optional dst, - bool multipath, - const EdgeSelection& selection, - std::span residual, - std::span node_mask, - std::span edge_mask, - SpfQueue queue) { - validate_spans("shortest_paths", g, residual, node_mask, edge_mask); - return shortest_paths_core(g, src, dst, multipath, selection, residual, node_mask, edge_mask, queue); -} - -std::pair -shortest_paths_dag(const StrictMultiDiGraph& g, NodeId src, NodeId dst, - bool multipath, - const EdgeSelection& selection, - std::span residual, - std::span node_mask, - std::span edge_mask, - SpfQueue queue) { - validate_spans("shortest_paths_dag", g, residual, node_mask, edge_mask); - return shortest_paths_dag_core(g, src, dst, multipath, selection, residual, node_mask, edge_mask, queue); -} - -} // namespace netgraph::core - -/* - Reverse Dijkstra: shortest paths from every node *to* one destination. - - Mirrors the forward search over the in-adjacency (in_row_offsets / - in_col_indices / in_adj_edge_index): the queue settles nodes by their distance - to dst, edge selection runs per (u -> v) parallel group exactly as in the - forward variant, and node-level tie-breaking in single-path mode prefers the - higher bottleneck capacity toward dst. Successor entries (v, e) are kept per - tail node u while u is unsettled, then bucketed by v into the forward PredDAG - layout that placement consumes. The same two queues apply. -*/ -namespace netgraph::core { - -namespace { - -template -void run_reverse(Queue& pq, ReverseWorkspace& ws, const StrictMultiDiGraph& g, NodeId target, - bool multipath, const EdgeSelection& selection, - std::span residual, std::span node_mask, - std::span edge_mask, const BucketPlan& plan) { - const auto N = g.num_nodes(); - const auto E = g.num_edges(); - const auto irow = g.in_row_offsets_view(); - const auto icol = g.in_col_indices_view(); - const auto iaei = g.in_adj_edge_index_view(); - const auto cost = g.cost_view(); - const auto cap = g.capacity_view(); - Cost* const dist = ws.dist.data(); - Cap* const min_residual_to_dst = ws.min_res.data(); - char* const settled = ws.settled.data(); - char* const has_in_entry = ws.has_in_entry.data(); - - const bool use_node_mask = (node_mask.size() == static_cast(N)); - const bool use_edge_mask = (edge_mask.size() == static_cast(E)); - const bool has_residual = (residual.size() == static_cast(E)); - const bool require_cap = selection.require_capacity || has_residual; - - pq.begin(plan.width, plan.stride); - pq.push(static_cast(0), -std::numeric_limits::max(), target); - std::vector& selected_edges = ws.sel_buf; - - while (!pq.empty()) { - auto [d_v, neg_res_v, v] = pq.pop(); - if (v < 0 || v >= N) continue; - if (d_v > dist[static_cast(v)]) continue; - if (!multipath && d_v == dist[static_cast(v)] && - -neg_res_v < min_residual_to_dst[static_cast(v)] - kEpsilon) continue; - settled[static_cast(v)] = 1; - - // In-edges of v, clustered by tail node u (edges are sorted by (src, dst)). - auto start = static_cast(irow[static_cast(v)]); - auto end = static_cast(irow[static_cast(v)+1]); - std::size_t i = start; - while (i < end) { - NodeId u = icol[i]; - if (settled[static_cast(u)] || (use_node_mask && !node_mask[static_cast(u)])) { - std::size_t j_skip = i; while (j_skip < end && icol[j_skip] == u) ++j_skip; i = j_skip; continue; - } - Cost min_edge_cost = kInf; - selected_edges.clear(); - double best_rem_for_min_cost = -1.0; - std::size_t j = i; - int best_edge_id = -1; - for (; j < end && icol[j] == u; ++j) { - auto e = static_cast(iaei[j]); - if (use_edge_mask && !edge_mask[e]) continue; - const Cap rem = has_residual ? residual[e] : cap[e]; - if (require_cap && rem < kMinCap) continue; - const Cap pri = priority_of

(rem, cap, e); - const Cost ecost = static_cast(cost[e]); - if (ecost < min_edge_cost) { - min_edge_cost = ecost; - selected_edges.clear(); - if (selection.multi_edge) { - selected_edges.push_back(static_cast(iaei[j])); - } else { - best_edge_id = static_cast(e); - best_rem_for_min_cost = static_cast(pri); - } - } else if (ecost == min_edge_cost) { - if (selection.multi_edge) { - selected_edges.push_back(static_cast(iaei[j])); - } else if (selection.tie_break == EdgeTieBreak::PreferHigherResidual) { - if (static_cast(pri) > best_rem_for_min_cost + kEpsilon) { - best_edge_id = static_cast(e); - best_rem_for_min_cost = static_cast(pri); - } else if (std::abs(static_cast(pri) - best_rem_for_min_cost) <= kEpsilon) { - if (best_edge_id < 0 || static_cast(e) < best_edge_id) best_edge_id = static_cast(e); - } - } else { - if (best_edge_id < 0 || static_cast(e) < best_edge_id) best_edge_id = static_cast(e); - } - } - } - if (!selection.multi_edge && best_edge_id >= 0) { - selected_edges.clear(); - selected_edges.push_back(static_cast(best_edge_id)); - } - if (!selected_edges.empty()) { - const Cost new_cost = static_cast(d_v + min_edge_cost); - const auto u_idx = static_cast(u); - Cap max_edge_residual = static_cast(0); - for (auto edge_id : selected_edges) { - const auto idx = static_cast(edge_id); - const Cap rem = has_residual ? residual[idx] : cap[idx]; - const Cap pri = priority_of

(rem, cap, idx); - if (pri > max_edge_residual) max_edge_residual = pri; - } - const Cap path_residual = std::min(min_residual_to_dst[static_cast(v)], max_edge_residual); - if (new_cost < dist[u_idx] || - (!multipath && new_cost == dist[u_idx] && !settled[u_idx] && - path_residual > min_residual_to_dst[u_idx] + kEpsilon)) { - if (dist[u_idx] == kInf) ws.touched.push_back(u); - dist[u_idx] = new_cost; - min_residual_to_dst[u_idx] = path_residual; - ws.clear_succs(u_idx); - for (auto sel_e : selected_edges) ws.append_succ(u_idx, v, sel_e); - has_in_entry[static_cast(v)] = 1; - pq.push(new_cost, -path_residual, u); - } else if (multipath && new_cost == dist[u_idx] && !settled[u_idx]) { - for (auto sel_e : selected_edges) ws.append_succ(u_idx, v, sel_e); - has_in_entry[static_cast(v)] = 1; - } - } - i = j; - } - } -} - -// Runs the reverse search on the chosen queue for one priority. -template -void reverse_with(bool bucket, ReverseWorkspace& ws, const StrictMultiDiGraph& g, NodeId target, - bool multipath, const EdgeSelection& selection, - std::span residual, std::span node_mask, - std::span edge_mask, const BucketPlan& plan) { - if (bucket) run_reverse(ws.bucket, ws, g, target, multipath, selection, residual, node_mask, edge_mask, plan); - else run_reverse(ws.heap, ws, g, target, multipath, selection, residual, node_mask, edge_mask, plan); -} - -std::pair, PredDAG> -shortest_paths_to_core(const StrictMultiDiGraph& g, NodeId target, - bool multipath, - const EdgeSelection& selection, - std::span residual, - std::span node_mask, - std::span edge_mask, - std::span fanout_edges, - SpfQueue queue) { - NGRAPH_PROFILE_SCOPE("shortest_paths_to_core"); - const auto N = g.num_nodes(); - const auto E = g.num_edges(); - const auto esrc = g.edge_src_view(); - const auto edst = g.edge_dst_view(); - const auto cost = g.cost_view(); - const auto cap = g.capacity_view(); - const bool use_node_mask = (node_mask.size() == static_cast(N)); - const bool use_edge_mask = (edge_mask.size() == static_cast(E)); - const bool has_residual = (residual.size() == static_cast(E)); - const bool require_cap = selection.require_capacity || has_residual; - const bool target_allowed = (target >= 0 && target < N && - (!use_node_mask || node_mask[static_cast(target)])); - - ReverseWorkspace& ws = tls_reverse; - ws.acquire(static_cast(N)); - WorkspaceLease lease{ws}; - - if (target_allowed) { - ws.touched.push_back(target); // before the writes: see WorkspaceLease - ws.dist[static_cast(target)] = static_cast(0); - ws.min_res[static_cast(target)] = std::numeric_limits::max(); - BucketPlan plan; - const bool bucket = use_bucket_queue(g, queue, plan); - switch (selection.priority) { - case EdgePriority::Capacity: - reverse_with(bucket, ws, g, target, multipath, selection, residual, node_mask, edge_mask, plan); break; - case EdgePriority::Headroom: - reverse_with(bucket, ws, g, target, multipath, selection, residual, node_mask, edge_mask, plan); break; - default: - reverse_with(bucket, ws, g, target, multipath, selection, residual, node_mask, edge_mask, plan); break; - } - } - - // Forced fan-out entries (see the header). - for (auto e_raw : fanout_edges) { - if (e_raw < 0 || e_raw >= E) { - throw std::invalid_argument("shortest_paths_to: fanout edge id out of range"); - } - const auto e = static_cast(e_raw); - const NodeId u = esrc[e]; - const NodeId v = edst[e]; - if (ws.has_in_entry[static_cast(u)]) { - throw std::invalid_argument( - "shortest_paths_to: a fanout edge must leave a node with no incoming DAG entry " - "(otherwise the DAG could contain a cycle)"); - } - if (use_edge_mask && !edge_mask[e]) continue; - if (use_node_mask && (!node_mask[static_cast(u)] || !node_mask[static_cast(v)])) continue; - const Cap rem = has_residual ? residual[e] : cap[e]; - if (require_cap && rem < kMinCap) continue; - if (ws.dist[static_cast(v)] == kInf) continue; - bool present = false; - for (std::int32_t i = ws.succ_head[static_cast(u)]; i >= 0; i = ws.ent_next[static_cast(i)]) { - if (ws.ent_edge[static_cast(i)] == e_raw) { present = true; break; } - } - if (present) continue; - if (ws.dist[static_cast(u)] == kInf) ws.touched.push_back(u); - ws.append_succ(static_cast(u), v, e_raw); - if (ws.dist[static_cast(u)] == kInf) { - ws.dist[static_cast(u)] = static_cast(cost[e]) + ws.dist[static_cast(v)]; - } - } - - // Bucket successor entries (u -> v via e) by v into the forward PredDAG - // layout. Entries within a v range are ordered by ascending tail node, so a - // sparse pass must visit the touched tails in ascending order. - const bool sparse = sparse_reset(ws); - std::vector dist; - if (sparse) { - std::sort(ws.touched.begin(), ws.touched.end()); - dist.assign(static_cast(N), kInf); - for (auto v : ws.touched) dist[static_cast(v)] = ws.dist[static_cast(v)]; - } else { - dist = ws.dist; - } - PredDAG dag; - dag.parent_offsets.assign(static_cast(N+1), 0); - auto count = [&](std::int32_t u) { - for (std::int32_t i = ws.succ_head[static_cast(u)]; i >= 0; i = ws.ent_next[static_cast(i)]) { - dag.parent_offsets[static_cast(ws.ent_node[static_cast(i)]) + 1] += 1; - } - }; - if (sparse) { for (auto u : ws.touched) count(u); } - else { for (std::int32_t u = 0; u < N; ++u) count(u); } - for (std::size_t k = 1; k < dag.parent_offsets.size(); ++k) dag.parent_offsets[k] += dag.parent_offsets[k-1]; - dag.parents.resize(static_cast(dag.parent_offsets.back())); - dag.via_edges.resize(static_cast(dag.parent_offsets.back())); - std::vector cursor(dag.parent_offsets.begin(), dag.parent_offsets.end() - 1); - auto fill = [&](std::int32_t u) { - for (std::int32_t i = ws.succ_head[static_cast(u)]; i >= 0; i = ws.ent_next[static_cast(i)]) { - const auto v = static_cast(ws.ent_node[static_cast(i)]); - const auto pos = static_cast(cursor[v]++); - dag.parents[pos] = u; - dag.via_edges[pos] = ws.ent_edge[static_cast(i)]; - } - }; - if (sparse) { for (auto u : ws.touched) fill(u); } - else { for (std::int32_t u = 0; u < N; ++u) fill(u); } - return {std::move(dist), std::move(dag)}; -} - -} // namespace - -std::pair, PredDAG> -shortest_paths_to(const StrictMultiDiGraph& g, NodeId dst, - bool multipath, - const EdgeSelection& selection, - std::span residual, - std::span node_mask, - std::span edge_mask, - std::span fanout_edges, - SpfQueue queue) { - validate_spans("shortest_paths_to", g, residual, node_mask, edge_mask); - return shortest_paths_to_core(g, dst, multipath, selection, residual, node_mask, edge_mask, fanout_edges, queue); -} - -} // namespace netgraph::core diff --git a/src/strict_multidigraph.cpp b/src/strict_multidigraph.cpp index 546b5cf..5d3c4f7 100644 --- a/src/strict_multidigraph.cpp +++ b/src/strict_multidigraph.cpp @@ -1,165 +1,285 @@ /* - StrictMultiDiGraph — immutable directed multigraph with deterministic layout. + StrictMultiDiGraph: the one graph type on integer units (APPROACH 2.1, 3). - Construction from arrays validates inputs and builds CSR and reverse CSR - adjacency structures. Edges are reordered by (cost, src, dst) to cluster - equal-cost tiers for deterministic traversal. + Construction validates the inputs in the documented order, then orders the edges by the + stable key (src, cost, dst, input): a counting pass by src places every input position in + its source's bucket (input order kept), and each bucket is sorted by (cost, dst, input). The + rank in that order is the public EdgeId, so the out-edges of u are the id range + [row_offsets[u], row_offsets[u + 1]). The reverse CSR lists the in-edges of v in + (cost, src, input) order: a counting pass by dst over the ids, then each bucket sorted by + (cost, src, id). Within one destination, equal (cost, src) means equal (src, cost, dst), whose + ids are in input order, so (cost, src, id) is (cost, src, input). */ #include "netgraph/core/strict_multidigraph.hpp" #include +#include +#include +#include #include #include +#include +#include namespace netgraph::core { -StrictMultiDiGraph StrictMultiDiGraph::from_arrays( - std::int32_t num_nodes, - std::span src, - std::span dst, - std::span capacity, - std::span cost, - std::span ext_edge_ids) { +namespace { - if (num_nodes < 0) { - throw std::invalid_argument("num_nodes must be >= 0"); +std::atomic uid_counter {0}; + +std::uint64_t next_uid() noexcept { + return uid_counter.fetch_add(1, std::memory_order_relaxed) + 1; +} + +// The exponent k of a resolution q = 2^k with k in [-32, 32]. +int resolution_exponent(double q) { + if (!std::isfinite(q) || q <= 0.0) { + throw std::invalid_argument("resolution must be a power of two in [2^-32, 2^32]"); + } + int exp = 0; + const double mantissa = std::frexp(q, &exp); // q = mantissa * 2^exp, mantissa in [0.5, 1) + const int k = exp - 1; + if (mantissa != 0.5 || k < -32 || k > 32) { + throw std::invalid_argument("resolution must be a power of two in [2^-32, 2^32]"); + } + return k; +} + +} // namespace + +Amount to_units(double x, double resolution) { + const int k = resolution_exponent(resolution); + if (std::isnan(x)) { + throw std::invalid_argument("to_units: NaN has no unit value"); } - if (src.size() != dst.size() || src.size() != capacity.size() || src.size() != cost.size()) { - throw std::invalid_argument("src, dst, capacity, and cost must have the same length"); + if (std::isinf(x)) { + throw std::invalid_argument("to_units: an infinite amount is not finite and has no unit value"); } - if (!ext_edge_ids.empty() && ext_edge_ids.size() != src.size()) { - throw std::invalid_argument("ext_edge_ids must be empty or have the same length as src/dst/capacity/cost"); + if (x < 0.0) { + throw std::invalid_argument("to_units: a negative amount has no unit value"); } - StrictMultiDiGraph g; - g.num_nodes_ = num_nodes; - std::size_t m = src.size(); - if (m > static_cast(std::numeric_limits::max())) { - throw std::invalid_argument("number of edges exceeds INT32_MAX"); + // x / q is exact (q is a power of two) unless it overflows to infinity. + const double y = std::ldexp(x, -k); + constexpr double kTwo52 = 4503599627370496.0; // 2^52: doubles at or above it are integers + constexpr double kLimit = 4611686018427387904.0; // 2^62 + double r; + if (y >= kTwo52) { + r = y; + } else { + const double f = std::floor(y); + r = (y - f >= 0.5) ? f + 1.0 : f; // y - f is exact below 2^52: round half up + } + if (!(r < kLimit)) { + throw std::invalid_argument( + "to_units: the amount is at or above the bound of the units domain (2^62 units)"); } + return static_cast(r); +} - // Invariants: ids within [0, num_nodes), non-negative weights. - // SPF accumulates path costs as int64 without overflow checks and uses - // INT64_MAX as the unreachable sentinel, so the total of all edge costs - // (an upper bound on any simple path, and on one reverse-edge bounce) must - // stay below 2^62 or accumulated costs could wrap negative and silently - // corrupt results. - constexpr std::uint64_t kMaxTotalCost = (std::uint64_t{1} << 62); - std::uint64_t total_cost = 0; +double as_float(Amount x, double resolution) { + const int k = resolution_exponent(resolution); + return std::ldexp(static_cast(x), k); +} + +StrictMultiDiGraph::StrictMultiDiGraph(std::int32_t num_nodes, + std::span src, + std::span dst, + std::span capacity_units, + std::span cost, + double resolution) { + (void)resolution_exponent(resolution); + // The unit values. + for (std::size_t i = 0; i < capacity_units.size(); ++i) { + const Amount c = capacity_units[i]; + if (c < 0 || c >= kDomainLimit) { + throw std::invalid_argument("capacity_units[" + std::to_string(i) + "] = " + std::to_string(c) + + " is outside the units domain [0, 2^62)"); + } + } + // The array shapes. + if (num_nodes < 0) { + throw std::invalid_argument("num_nodes must be >= 0 (got " + std::to_string(num_nodes) + ")"); + } + const std::size_t m = src.size(); + if (dst.size() != m || capacity_units.size() != m || cost.size() != m) { + throw std::invalid_argument("src, dst, capacity_units and cost must have the same length (got " + + std::to_string(src.size()) + ", " + std::to_string(dst.size()) + ", " + + std::to_string(capacity_units.size()) + ", " + + std::to_string(cost.size()) + ")"); + } + if (m > static_cast(std::numeric_limits::max())) { + throw std::invalid_argument("the number of edges exceeds INT32_MAX (array length)"); + } + // Zero or negative costs (rule 28). Cost max_cost = 0; Cost cost_gcd = 0; for (std::size_t i = 0; i < m; ++i) { - if (src[i] < 0 || dst[i] < 0 || src[i] >= num_nodes || dst[i] >= num_nodes) { - throw std::out_of_range("edge index out of range of num_nodes"); - } - if (capacity[i] < 0.0) { - throw std::invalid_argument("capacity must be >= 0"); - } - if (cost[i] < 0) { throw std::invalid_argument("cost must be >= 0"); } - const auto c = static_cast(cost[i]); - if (c >= kMaxTotalCost - total_cost) { - throw std::invalid_argument( - "total edge cost must stay below 2^62: larger accumulated path costs " - "overflow int64 cost arithmetic and silently corrupt results"); + if (cost[i] < 1) { + throw std::invalid_argument("cost[" + std::to_string(i) + "] = " + std::to_string(cost[i]) + + ": costs must be positive (cost >= 1)"); } - total_cost += c; - if (cost[i] > max_cost) max_cost = cost[i]; + max_cost = std::max(max_cost, cost[i]); cost_gcd = std::gcd(cost_gcd, cost[i]); } - g.max_cost_ = max_cost; - g.cost_gcd_ = cost_gcd; - // Gather initial arrays - std::vector src_v(src.begin(), src.end()); - std::vector dst_v(dst.begin(), dst.end()); - std::vector cap_v(capacity.begin(), capacity.end()); - std::vector cost_v(cost.begin(), cost.end()); - std::vector ext_v; - if (!ext_edge_ids.empty()) { - ext_v.assign(ext_edge_ids.begin(), ext_edge_ids.end()); - } - - // Sort edges by (cost, src, dst) to cluster equal-cost tiers for deterministic traversal. - // Create an index array [0, 1, 2, ..., m-1] that we'll permute to define the reordering. - std::vector idx(m); - std::iota(idx.begin(), idx.end(), 0); // iota fills with sequential values - - // Cost-first ordering keeps equal-cost tiers clustered; within the same - // cost, maintain stable source/destination ordering for reproducibility. - // stable_sort preserves relative order of equal elements, ensuring determinism. - // Lambda comparator: [&] captures variables by reference, (a, b) are indices to compare. - std::stable_sort(idx.begin(), idx.end(), [&](std::size_t a, std::size_t b) { - if (cost_v[a] != cost_v[b]) return cost_v[a] < cost_v[b]; - if (src_v[a] != src_v[b]) return src_v[a] < src_v[b]; - return dst_v[a] < dst_v[b]; - }); - - // Reorder all edge arrays using the computed permutation. - // Generic lambda (auto parameters) works with any vector type. - auto apply_perm = [&](auto& out_vec, const auto& in_vec) { - out_vec.resize(m); - for (std::size_t i = 0; i < m; ++i) out_vec[i] = in_vec[idx[i]]; - }; - apply_perm(g.src_, src_v); - apply_perm(g.dst_, dst_v); - apply_perm(g.capacity_, cap_v); - apply_perm(g.cost_, cost_v); - if (!ext_v.empty()) { - apply_perm(g.ext_edge_ids_, ext_v); - } - g.edges_ = m; - - // Build CSR (Compressed Sparse Row) adjacency structure. - // For each node u, row_offsets_[u]:row_offsets_[u+1] gives the range in col_indices_ - // that stores u's outgoing neighbors, and adj_edge_index_ stores the corresponding EdgeIds. - - // Step 1: Count outgoing edges per node (histogram). - g.row_offsets_.assign(static_cast(num_nodes) + 1, 0); + // The remaining bounds. for (std::size_t i = 0; i < m; ++i) { - g.row_offsets_[static_cast(g.src_[i]) + 1]++; + if (src[i] < 0 || src[i] >= num_nodes || dst[i] < 0 || dst[i] >= num_nodes) { + throw std::invalid_argument("edge " + std::to_string(i) + ": endpoint (" + std::to_string(src[i]) + + ", " + std::to_string(dst[i]) + ") out of node range [0, " + + std::to_string(num_nodes) + ")"); + } } - - // Step 2: Convert counts to cumulative offsets (prefix sum). - // row_offsets_[u] is the starting index for node u's adjacency list. - for (std::size_t i = 1; i < g.row_offsets_.size(); ++i) { - g.row_offsets_[i] += g.row_offsets_[i - 1]; + constexpr auto kLimit = static_cast(kDomainLimit); + std::uint64_t cap_sum = 0; + std::uint64_t cost_sum = 0; + for (std::size_t i = 0; i < m; ++i) { + cap_sum += static_cast(capacity_units[i]); // each < 2^62: no wrap before the check + if (cap_sum >= kLimit) { + throw std::invalid_argument("the sum of all capacities reaches the bound of the units domain (2^62 units)"); + } + cost_sum += static_cast(cost[i]); + if (cost_sum >= kLimit) { + throw std::invalid_argument( + "the sum of all edge costs reaches the bound 2^62: path costs would overflow int64"); + } } - // Step 3: Fill col_indices_ and adj_edge_index_ arrays. - g.col_indices_.resize(m); - g.adj_edge_index_.resize(m); - // cursor tracks the current write position for each node's adjacency list. - std::vector cursor = g.row_offsets_; // copy for in-place filling + uid_ = next_uid(); + resolution_ = resolution; + num_nodes_ = num_nodes; + max_cost_ = max_cost; + cost_gcd_ = cost_gcd; + const auto n = static_cast(num_nodes); + + // Ids: rank in (src, cost, dst, input). + row_offsets_.assign(n + 1, 0); + for (std::size_t i = 0; i < m; ++i) ++row_offsets_[static_cast(src[i]) + 1]; + for (std::size_t u = 0; u < n; ++u) row_offsets_[u + 1] += row_offsets_[u]; + input_index_.resize(m); + { + std::vector cursor(row_offsets_.begin(), row_offsets_.end() - 1); + for (std::size_t i = 0; i < m; ++i) { + input_index_[static_cast(cursor[static_cast(src[i])]++)] = + static_cast(i); + } + } + for (std::size_t u = 0; u < n; ++u) { + auto first = input_index_.begin() + row_offsets_[u]; + auto last = input_index_.begin() + row_offsets_[u + 1]; + if (last - first < 2) continue; + std::sort(first, last, [&](std::int32_t a, std::int32_t b) { + const auto ia = static_cast(a); + const auto ib = static_cast(b); + if (cost[ia] != cost[ib]) return cost[ia] < cost[ib]; + if (dst[ia] != dst[ib]) return dst[ia] < dst[ib]; + return a < b; + }); + } + capacity_.resize(m); + cost_.resize(m); + src_.resize(m); + dst_.resize(m); for (std::size_t e = 0; e < m; ++e) { - auto u = g.src_[e]; - auto pos = static_cast(cursor[static_cast(u)]++); - g.col_indices_[pos] = g.dst_[e]; // neighbor node - g.adj_edge_index_[pos] = static_cast(e); // edge index + const auto i = static_cast(input_index_[e]); + capacity_[e] = capacity_units[i]; + cost_[e] = cost[i]; + src_[e] = src[i]; + dst_[e] = dst[i]; } - // Build reverse CSR (incoming adjacency) for predecessor tracking. - // Same CSR construction algorithm, but keyed by destination node instead of source. - // This enables efficient lookup of incoming edges for any node. - // Step 1: Count incoming edges per node. - g.in_row_offsets_.assign(static_cast(num_nodes) + 1, 0); - for (std::size_t i = 0; i < m; ++i) { - g.in_row_offsets_[static_cast(g.dst_[i]) + 1]++; + // Reverse CSR in (cost, src, input) order. + in_row_offsets_.assign(n + 1, 0); + for (std::size_t e = 0; e < m; ++e) ++in_row_offsets_[static_cast(dst_[e]) + 1]; + for (std::size_t v = 0; v < n; ++v) in_row_offsets_[v + 1] += in_row_offsets_[v]; + in_adj_edge_index_.resize(m); + { + std::vector cursor(in_row_offsets_.begin(), in_row_offsets_.end() - 1); + for (std::size_t e = 0; e < m; ++e) { + in_adj_edge_index_[static_cast(cursor[static_cast(dst_[e])]++)] = + static_cast(e); + } } - - // Step 2: Convert counts to cumulative offsets. - for (std::size_t i = 1; i < g.in_row_offsets_.size(); ++i) { - g.in_row_offsets_[i] += g.in_row_offsets_[i - 1]; + for (std::size_t v = 0; v < n; ++v) { + auto first = in_adj_edge_index_.begin() + in_row_offsets_[v]; + auto last = in_adj_edge_index_.begin() + in_row_offsets_[v + 1]; + if (last - first < 2) continue; + std::sort(first, last, [&](EdgeId a, EdgeId b) { + const auto ia = static_cast(a); + const auto ib = static_cast(b); + if (cost_[ia] != cost_[ib]) return cost_[ia] < cost_[ib]; + if (src_[ia] != src_[ib]) return src_[ia] < src_[ib]; + return a < b; + }); } +} - // Step 3: Fill reverse adjacency arrays. - g.in_col_indices_.resize(m); - g.in_adj_edge_index_.resize(m); - std::vector rcursor = g.in_row_offsets_; - for (std::size_t e = 0; e < m; ++e) { - auto v = g.dst_[e]; - auto pos = static_cast(rcursor[static_cast(v)]++); - g.in_col_indices_[pos] = g.src_[e]; // predecessor node - g.in_adj_edge_index_[pos] = static_cast(e); +StrictMultiDiGraph::StrictMultiDiGraph(const StrictMultiDiGraph& other) + : uid_(next_uid()), + resolution_(other.resolution_), + num_nodes_(other.num_nodes_), + max_cost_(other.max_cost_), + cost_gcd_(other.cost_gcd_), + capacity_(other.capacity_), + cost_(other.cost_), + src_(other.src_), + dst_(other.dst_), + input_index_(other.input_index_), + row_offsets_(other.row_offsets_), + in_row_offsets_(other.in_row_offsets_), + in_adj_edge_index_(other.in_adj_edge_index_) {} + +StrictMultiDiGraph::StrictMultiDiGraph(StrictMultiDiGraph&& other) noexcept + : uid_(std::exchange(other.uid_, 0)), + resolution_(other.resolution_), + num_nodes_(std::exchange(other.num_nodes_, 0)), + max_cost_(std::exchange(other.max_cost_, 0)), + cost_gcd_(std::exchange(other.cost_gcd_, 0)), + capacity_(std::move(other.capacity_)), + cost_(std::move(other.cost_)), + src_(std::move(other.src_)), + dst_(std::move(other.dst_)), + input_index_(std::move(other.input_index_)), + row_offsets_(std::move(other.row_offsets_)), + in_row_offsets_(std::move(other.in_row_offsets_)), + in_adj_edge_index_(std::move(other.in_adj_edge_index_)) { + other.row_offsets_.assign(1, 0); + other.in_row_offsets_.assign(1, 0); +} + +Eligibility StrictMultiDiGraph::eligibility(std::span excluded_nodes, + std::span excluded_edges) const { + const auto n = static_cast(num_nodes_); + const auto m = capacity_.size(); + for (NodeId v : excluded_nodes) { + if (v < 0 || static_cast(v) >= n) { + throw std::invalid_argument("excluded node " + std::to_string(v) + " out of node range [0, " + + std::to_string(n) + ")"); + } + } + for (EdgeId e : excluded_edges) { + if (e < 0 || static_cast(e) >= m) { + throw std::invalid_argument("excluded edge " + std::to_string(e) + " out of edge range [0, " + + std::to_string(m) + ")"); + } + } + Eligibility el; + el.graph_uid_ = uid_; + el.node_allowed_.assign(n, 1); + el.edge_allowed_.assign(m, 1); + for (EdgeId e : excluded_edges) el.edge_allowed_[static_cast(e)] = 0; + for (NodeId v : excluded_nodes) { + const auto vi = static_cast(v); + if (el.node_allowed_[vi] == 0) continue; // duplicate + el.node_allowed_[vi] = 0; + for (auto e = row_offsets_[vi]; e < row_offsets_[vi + 1]; ++e) { + el.edge_allowed_[static_cast(e)] = 0; + } + for (auto s = in_row_offsets_[vi]; s < in_row_offsets_[vi + 1]; ++s) { + el.edge_allowed_[static_cast(in_adj_edge_index_[static_cast(s)])] = 0; + } } - return g; + el.uid_ = (excluded_nodes.empty() && excluded_edges.empty()) ? 0 : next_uid(); + return el; } } // namespace netgraph::core diff --git a/tests/cpp/flow_graph_tests.cpp b/tests/cpp/flow_graph_tests.cpp deleted file mode 100644 index bac6bf7..0000000 --- a/tests/cpp/flow_graph_tests.cpp +++ /dev/null @@ -1,332 +0,0 @@ -#include -#include -#include "netgraph/core/flow_graph.hpp" -#include "netgraph/core/shortest_paths.hpp" -#include "netgraph/core/strict_multidigraph.hpp" -#include "test_utils.hpp" - -using namespace netgraph::core; -using namespace netgraph::core::test; - -TEST(FlowGraph, EmptyLedgerInitially) { - auto g = make_line_graph(3); - FlowGraph fg(g); - - FlowIndex idx{0, 2, 0, 0}; - auto edges = fg.get_flow_edges(idx); - - EXPECT_TRUE(edges.empty()); -} - -TEST(FlowGraph, PlacementCreatesEntry) { - auto g = make_line_graph(3); - FlowGraph fg(g); - - // Get shortest path - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, {}, {}, {}); - - FlowIndex idx{0, 2, 0, 0}; - Flow placed = fg.place(idx, 0, 2, dag, 0.5, FlowPlacement::Proportional); - - EXPECT_GT(placed, 0.0); - - auto edges = fg.get_flow_edges(idx); - EXPECT_FALSE(edges.empty()); -} - -TEST(FlowGraph, RemovalDeletesEntry) { - auto g = make_line_graph(3); - FlowGraph fg(g); - - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, {}, {}, {}); - - FlowIndex idx{0, 2, 0, 0}; - Flow placed = fg.place(idx, 0, 2, dag, 0.5, FlowPlacement::Proportional); - EXPECT_GT(placed, 0.0); - - EXPECT_FALSE(fg.get_flow_edges(idx).empty()); - - fg.remove(idx); - - EXPECT_TRUE(fg.get_flow_edges(idx).empty()); -} - -TEST(FlowGraph, RemoveByClassFiltersCorrectly) { - auto g = make_line_graph(3); - FlowGraph fg(g); - - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, {}, {}, {}); - - // Create flows with different classes - FlowIndex idx1{0, 2, 10, 0}; - FlowIndex idx2{0, 2, 20, 1}; - FlowIndex idx3{0, 2, 10, 2}; - - (void)fg.place(idx1, 0, 2, dag, 0.5, FlowPlacement::Proportional); - (void)fg.place(idx2, 0, 2, dag, 0.5, FlowPlacement::Proportional); - (void)fg.place(idx3, 0, 2, dag, 0.5, FlowPlacement::Proportional); - - // Remove flows of class 10 - fg.remove_by_class(10); - - // idx1 and idx3 should be removed (class 10) - EXPECT_TRUE(fg.get_flow_edges(idx1).empty()); - EXPECT_TRUE(fg.get_flow_edges(idx3).empty()); - - // idx2 should remain (class 20) - EXPECT_FALSE(fg.get_flow_edges(idx2).empty()); -} - -TEST(FlowGraph, GetFlowEdgesReturnsDeltas) { - auto g = make_line_graph(3); - FlowGraph fg(g); - - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, {}, {}, {}); - - FlowIndex idx{0, 2, 0, 0}; - Flow placed = fg.place(idx, 0, 2, dag, 0.5, FlowPlacement::Proportional); - - auto edges = fg.get_flow_edges(idx); - - // Should have entries for edges used in the path - EXPECT_GT(edges.size(), 0); - - // Total flow across all edges should match placed amount times path length - double total = 0.0; - for (const auto& [eid, flow] : edges) { - EXPECT_GE(flow, 0.0); - total += flow; - } - EXPECT_NEAR(total, placed * 2, 1e-9); // Two edges in path -} - -TEST(FlowGraph, GetFlowPathForLinearFlow) { - auto g = make_line_graph(3); - FlowGraph fg(g); - - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, {}, {}, {}); - - FlowIndex idx{0, 2, 0, 0}; - Flow placed = fg.place(idx, 0, 2, dag, 0.5, FlowPlacement::Proportional); - EXPECT_GT(placed, 0.0); - - auto path = fg.get_flow_path(idx); - - // Should find a simple path with 2 edges - EXPECT_EQ(path.size(), 2); -} - -TEST(FlowGraph, GetFlowPathForDAGReturnsEmpty) { - // Create a graph with two disjoint equal-cost paths: 0->1->2 and 0->3->2 - // When flow splits proportionally across both paths, get_flow_path should - // return empty (since there's no single simple path) - std::int32_t src[4] = {0, 1, 0, 3}; - std::int32_t dst[4] = {1, 2, 3, 2}; - double cap[4] = {1.0, 1.0, 1.0, 1.0}; - std::int64_t cost[4] = {1, 1, 1, 1}; - - auto g = StrictMultiDiGraph::from_arrays(4, - std::span(src, 4), std::span(dst, 4), - std::span(cap, 4), std::span(cost, 4)); - - FlowGraph fg(g); - - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, {}, {}, {}); - - FlowIndex idx{0, 2, 0, 0}; - // Place 2.0 units - should split across both paths (1.0 each) - Flow placed = fg.place(idx, 0, 2, dag, 2.0, FlowPlacement::Proportional); - EXPECT_GT(placed, 0.0); - - auto path = fg.get_flow_path(idx); - - // get_flow_path should return empty when flow is split across multiple paths - // (it only returns a simple path if one exists) - EXPECT_TRUE(path.empty()) << "Expected empty path for split flow, got " << path.size() << " edges"; -} - -TEST(FlowGraph, CoalescesDuplicateEdgeIds) { - // Create graph with parallel edges - std::int32_t src[3] = {0, 1, 1}; - std::int32_t dst[3] = {1, 2, 2}; - double cap[3] = {10.0, 1.0, 1.0}; - std::int64_t cost[3] = {1, 1, 1}; - - auto g = StrictMultiDiGraph::from_arrays(3, - std::span(src, 3), std::span(dst, 3), - std::span(cap, 3), std::span(cost, 3)); - - FlowGraph fg(g); - - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, {}, {}, {}); - - FlowIndex idx{0, 2, 0, 0}; - (void)fg.place(idx, 0, 2, dag, 2.0, FlowPlacement::Proportional); - - auto edges = fg.get_flow_edges(idx); - - // Check that no EdgeId appears more than once - std::set seen; - for (const auto& [eid, flow] : edges) { - EXPECT_EQ(seen.count(eid), 0) << "Duplicate EdgeId " << eid; - seen.insert(eid); - } -} - -TEST(FlowGraph, LedgerHistory_RemovalRestoresResidual) { - // edges: 0->1, 1->2 (both cap=1.0) - auto g = make_line_graph(3); - FlowGraph fg(g); - - // Build DAG once - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = false; sel.tie_break = EdgeTieBreak::Deterministic; - auto sp = shortest_paths(g, 0, 2, /*multipath=*/true, sel, {}, {}, {}); - const PredDAG& dag = sp.second; - - FlowIndex idx{0, 2, 42, 7}; - - // Two placements on same flow - Flow p1 = fg.place(idx, 0, 2, dag, 0.4, FlowPlacement::Proportional); - Flow p2 = fg.place(idx, 0, 2, dag, 0.5, FlowPlacement::Proportional); - ASSERT_GT(p1, 0.0); - ASSERT_GT(p2, 0.0); - - // Remove the flow (should restore both p1 and p2 if ledger retained history) - fg.remove(idx); - - auto cap = g.capacity_view(); - auto res = fg.residual_view(); - ASSERT_EQ(res.size(), static_cast(g.num_edges())); - - // Expect residual to be fully restored to capacity if removal undid entire history - for (std::size_t i = 0; i < res.size(); ++i) { - EXPECT_NEAR(res[i], cap[i], 1e-9) << "Residual not fully restored at edge " << i; - } -} - -TEST(FlowGraph, LedgerMicroFlows_RemovalRestoresResidual) { - // Split 1.0 unit equally over many parallel edges so per-edge share is < kMinFlow. - const int parallel = 5000; // 1.0 / 5000 < kMinFlow (1/4096) - std::vector src(parallel), dst(parallel); - std::vector cap(parallel, 1.0); - std::vector cost(parallel, 1); - for (int i = 0; i < parallel; ++i) { src[i] = 0; dst[i] = 1; } - auto g = StrictMultiDiGraph::from_arrays(2, src, dst, cap, cost); - - FlowGraph fg(g); - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = false; sel.tie_break = EdgeTieBreak::Deterministic; - auto sp = shortest_paths(g, 0, 1, /*multipath=*/true, sel, {}, {}, {}); - const PredDAG& dag = sp.second; - - FlowIndex idx{0, 1, 1, 1}; - - // Place 1.0 evenly across many parallels; per-edge contribution < kMinFlow - Flow placed = fg.place(idx, 0, 1, dag, 1.0, FlowPlacement::EqualBalanced); - ASSERT_NEAR(placed, 1.0, 1e-9); - - // Ledger must record all deltas, including micro-flows smaller than kMinFlow - auto edges = fg.get_flow_edges(idx); - EXPECT_FALSE(edges.empty()) << "Ledger should record micro-deltas even when per-edge < kMinFlow"; - - // Removing the flow should fully restore residual capacity with no drift - auto capv = g.capacity_view(); - fg.remove(idx); - auto res_after = fg.residual_view(); - ASSERT_EQ(res_after.size(), static_cast(g.num_edges())); - for (std::size_t i = 0; i < res_after.size(); ++i) { - EXPECT_NEAR(res_after[i], capv[i], 1e-9) << "Residual not restored at edge " << i; - } -} - -// state_stamp() uids must be unique per reachable instance. A copy that shared -// the source's uid could diverge by the same number of mutations and then -// present an equal stamp over different residual content, which would let -// FlowPolicy's SPF memo fast path serve a DAG cached for the other object. -// Moved-from FlowGraphs remain valid and mutable, so moves must not share the -// uid either. -TEST(FlowGraphStamp, CopiesAndMovesGetFreshUids) { - auto g = make_square_graph(1); - FlowGraph a(g); - const auto uid_a = a.state_stamp().uid; - - FlowGraph b(a); // copy ctor - EXPECT_NE(b.state_stamp().uid, uid_a); - - FlowGraph c(g); - c = a; // copy assign - EXPECT_NE(c.state_stamp().uid, uid_a); - EXPECT_NE(c.state_stamp().uid, b.state_stamp().uid); - - FlowGraph d(std::move(b)); // move ctor - EXPECT_NE(d.state_stamp().uid, uid_a); - - FlowGraph e(g); - e = std::move(c); // move assign - EXPECT_NE(e.state_stamp().uid, uid_a); -} - -// The end-to-end failure the uid rule prevents: two copies mutated the same -// NUMBER of times (equal versions) but with different content must never -// satisfy the stamp fast path. Equal-version divergence is exactly the case a -// shared uid would get wrong. -TEST(FlowGraphStamp, EqualVersionDivergentCopiesCompareUnequal) { - auto g = make_square_graph(1); // 0->1->2 cheap, 0->3->2 expensive - FlowGraph a(g); - FlowGraph b(a); - - // One mutation each (same version count), different targets. - PredDAG cheap = make_path_dag(g, std::array{0, 1}); - PredDAG dear = make_path_dag(g, std::array{2, 3}); - FlowIndex idx{0, 2, 0, 0}; - (void)a.place(idx, 0, 2, cheap, 0.5, FlowPlacement::Proportional); - (void)b.place(idx, 0, 2, dear, 0.5, FlowPlacement::Proportional); - - EXPECT_FALSE(a.state_stamp() == b.state_stamp()); -} - -TEST(FlowGraph, HopBundleCarriesEveryParallelEdge) { - // Four parallel 10-unit links 0 -> 1, then 1 -> 2 (100). A route pinned to one - // edge carries 10; the same hop pinned as a bundle carries 40. - std::vector src = {0, 0, 0, 0, 1}, dst = {1, 1, 1, 1, 2}; - std::vector cap = {10, 10, 10, 10, 100}; - std::vector cost = {1, 1, 1, 1, 1}; - auto g = StrictMultiDiGraph::from_arrays(3, src, dst, cap, cost); - auto one = make_path_dag(g, std::array{0, 4}); - auto bundle = make_hops_dag(g, std::vector>{{0, 1, 2, 3}, {4}}); - FlowGraph fg(g); - const FlowIndex a{0, 2, 0, 1}, b{0, 2, 0, 2}; - EXPECT_DOUBLE_EQ(fg.place(a, 0, 2, one, 100.0, FlowPlacement::Proportional), 10.0); - fg.remove(a); - EXPECT_DOUBLE_EQ(fg.place(b, 0, 2, bundle, 100.0, FlowPlacement::Proportional), 40.0); - const auto flows = fg.edge_flow_view(); - for (std::size_t e = 0; e < 4; ++e) EXPECT_DOUBLE_EQ(flows[e], 10.0); - EXPECT_DOUBLE_EQ(flows[4], 40.0); -} diff --git a/tests/cpp/flow_policy_tests.cpp b/tests/cpp/flow_policy_tests.cpp deleted file mode 100644 index ac2b499..0000000 --- a/tests/cpp/flow_policy_tests.cpp +++ /dev/null @@ -1,507 +0,0 @@ -#include - -#include "netgraph/core/flow_graph.hpp" -#include "netgraph/core/flow_policy.hpp" -#include "netgraph/core/backend.hpp" -#include "netgraph/core/algorithms.hpp" -#include "netgraph/core/strict_multidigraph.hpp" -#include "netgraph/core/types.hpp" -#include "test_utils.hpp" - -using namespace netgraph::core; - -namespace { - -StrictMultiDiGraph make_square1() { - // Nodes: 0:A, 1:B, 2:C, 3:D - // Edges: - // 0: A->B cap=1 cost=1 - // 1: B->C cap=1 cost=1 - // 2: A->D cap=2 cost=2 - // 3: D->C cap=2 cost=2 - std::int32_t num_nodes = 4; - std::int32_t src_arr[4] = {0, 1, 0, 3}; - std::int32_t dst_arr[4] = {1, 2, 3, 2}; - double cap_arr[4] = {1.0, 1.0, 2.0, 2.0}; - std::int64_t cost_arr[4] = {1, 1, 2, 2}; - std::int64_t ext_arr[4] = {0, 1, 2, 3}; - std::span src(src_arr, 4); - std::span dst(dst_arr, 4); - std::span cap(cap_arr, 4); - std::span cost(cost_arr, 4); - std::span ext(ext_arr, 4); - return StrictMultiDiGraph::from_arrays(num_nodes, src, dst, cap, cost, ext); -} - -StrictMultiDiGraph make_line1() { - // Nodes: 0:A, 1:B, 2:C - // Edges forward: - // A->B cap=5 cost=1 - // B->C cap=1 cost=1 - // B->C cap=3 cost=1 - // B->C cap=7 cost=2 - std::int32_t num_nodes = 3; - std::int32_t src_arr[4] = {0, 1, 1, 1}; - std::int32_t dst_arr[4] = {1, 2, 2, 2}; - double cap_arr[4] = {5.0, 1.0, 3.0, 7.0}; - std::int64_t cost_arr[4] = {1, 1, 1, 2}; - std::int64_t ext_arr[4] = {0, 1, 2, 3}; - std::span src(src_arr, 4); - std::span dst(dst_arr, 4); - std::span cap(cap_arr, 4); - std::span cost(cost_arr, 4); - std::span ext(ext_arr, 4); - return StrictMultiDiGraph::from_arrays(num_nodes, src, dst, cap, cost, ext); -} - -StrictMultiDiGraph make_square3() { - // Nodes: 0:A, 1:B, 2:C, 3:D - // A->B 100@1, B->C 125@1, A->D 75@1, D->C 50@1 - std::int32_t num_nodes = 4; - std::int32_t src_arr[4] = {0, 1, 0, 3}; - std::int32_t dst_arr[4] = {1, 2, 3, 2}; - double cap_arr[4] = {100.0, 125.0, 75.0, 50.0}; - std::int64_t cost_arr[4] = {1, 1, 1, 1}; - std::int64_t ext_arr[4] = {0, 1, 2, 3}; - std::span src(src_arr, 4); - std::span dst(dst_arr, 4); - std::span cap(cap_arr, 4); - std::span cost(cost_arr, 4); - std::span ext(ext_arr, 4); - return StrictMultiDiGraph::from_arrays(num_nodes, src, dst, cap, cost, ext); -} - -void expect_edge_flows_by_uv(const FlowGraph& fg, std::initializer_list> exp) { - const auto& g = fg.graph(); - auto row = g.row_offsets_view(); - auto col = g.col_indices_view(); - auto aei = g.adj_edge_index_view(); - // Build map EdgeId->flow - auto ef = fg.edge_flow_view(); - // Check each expected (u,v,flow) - // When a (u,v) pair appears multiple times, advance through successive edges - // in the adjacency row to validate each parallel edge in order. - std::map, std::size_t> cursor; - for (auto [u, v, expected] : exp) { - bool found = false; - // Scan adjacency row for (u->v) - auto s = static_cast(row[static_cast(u)]); - auto e = static_cast(row[static_cast(u)+1]); - std::size_t start = s; - auto key = std::make_pair(u, v); - if (auto it = cursor.find(key); it != cursor.end()) start = it->second; - for (std::size_t j = start; j < e; ++j) { - if (static_cast(col[j]) == v) { - auto eid = static_cast(aei[j]); - EXPECT_NEAR(static_cast(ef[eid]), expected, 1e-9) << "edge (" << u << "," << v << ")"; - cursor[key] = j + 1; // subsequent matches continue after this position - found = true; - break; - } - } - ASSERT_TRUE(found) << "edge (" << u << "," << v << ") not found"; - } -} - -} - -TEST(FlowPolicyCore, Proportional_SingleDemand_UsesShortestPath) { - auto g = make_square1(); - FlowGraph fg(g); - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = true; sel.tie_break = EdgeTieBreak::Deterministic; - auto be = make_cpu_backend(); auto algs = std::make_shared(be); auto gh = algs->build_graph(g); - ExecutionContext ctx(algs, gh); - FlowPolicyConfig cfg; - cfg.flow_placement = FlowPlacement::Proportional; - cfg.selection = sel; - cfg.require_capacity = true; - FlowPolicy policy(ctx, cfg); - auto res = policy.place_demand(fg, /*src=*/0, /*dst=*/2, /*flowClass=*/0, /*volume=*/1.0); - EXPECT_NEAR(res.first, 1.0, 1e-9); - EXPECT_NEAR(res.second, 0.0, 1e-9); - expect_edge_flows_by_uv(fg, {{0,1,1.0}, {1,2,1.0}, {0,3,0.0}, {3,2,0.0}}); -} - -TEST(FlowPolicyCore, Proportional_SingleDemand_SplitsAcrossTwoEqualCostPaths) { - auto g = make_square1(); - FlowGraph fg(g); - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = true; sel.tie_break = EdgeTieBreak::Deterministic; - auto be = make_cpu_backend(); auto algs = std::make_shared(be); auto gh = algs->build_graph(g); - ExecutionContext ctx(algs, gh); - FlowPolicyConfig cfg; - cfg.flow_placement = FlowPlacement::Proportional; - cfg.selection = sel; - cfg.require_capacity = true; - FlowPolicy policy(ctx, cfg); - auto res = policy.place_demand(fg, 0, 2, 0, 2.0); - EXPECT_NEAR(res.first, 2.0, 1e-9); - EXPECT_NEAR(res.second, 0.0, 1e-9); - expect_edge_flows_by_uv(fg, {{0,1,1.0}, {1,2,1.0}, {0,3,1.0}, {3,2,1.0}}); -} - -TEST(FlowPolicyCore, Proportional_SingleDemand_MaxFlowCount1_UsesSinglePath) { - auto g = make_square1(); - FlowGraph fg(g); - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = true; sel.tie_break = EdgeTieBreak::Deterministic; - auto be = make_cpu_backend(); auto algs = std::make_shared(be); auto gh = algs->build_graph(g); - ExecutionContext ctx(algs, gh); - FlowPolicyConfig cfg; - cfg.flow_placement = FlowPlacement::Proportional; - cfg.selection = sel; - cfg.require_capacity = true; - cfg.multipath = false; - cfg.min_flow_count = 1; - cfg.max_flow_count = 1; - FlowPolicy policy(ctx, cfg); - auto res = policy.place_demand(fg, 0, 2, 0, 2.0); - EXPECT_NEAR(res.first, 2.0, 1e-9); - EXPECT_NEAR(res.second, 0.0, 1e-9); - expect_edge_flows_by_uv(fg, {{0,1,0.0}, {1,2,0.0}, {0,3,2.0}, {3,2,2.0}}); -} - -TEST(FlowPolicyCore, Proportional_SingleDemand_PartialPlacement_InsufficientCapacity) { - auto g = make_square1(); - FlowGraph fg(g); - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = true; sel.tie_break = EdgeTieBreak::Deterministic; - auto be = make_cpu_backend(); auto algs = std::make_shared(be); auto gh = algs->build_graph(g); - ExecutionContext ctx(algs, gh); - FlowPolicyConfig cfg; - cfg.flow_placement = FlowPlacement::Proportional; - cfg.selection = sel; - cfg.require_capacity = true; - FlowPolicy policy(ctx, cfg); - auto res = policy.place_demand(fg, 0, 2, 0, 5.0); - EXPECT_NEAR(res.first, 3.0, 1e-9); - EXPECT_NEAR(res.second, 2.0, 1e-9); - expect_edge_flows_by_uv(fg, {{0,1,1.0}, {1,2,1.0}, {0,3,2.0}, {3,2,2.0}}); -} - -TEST(FlowPolicyCore, EqualBalanced_SingleDemand_FlowCount2_BalancedDistribution) { - auto g = make_line1(); - FlowGraph fg(g); - EdgeSelection sel; sel.multi_edge = false; sel.require_capacity = true; sel.tie_break = EdgeTieBreak::Deterministic; - auto be = make_cpu_backend(); auto algs = std::make_shared(be); auto gh = algs->build_graph(g); - ExecutionContext ctx(algs, gh); - FlowPolicyConfig cfg; - cfg.flow_placement = FlowPlacement::EqualBalanced; - cfg.selection = sel; - cfg.require_capacity = true; - cfg.min_flow_count = 2; - cfg.max_flow_count = 2; - FlowPolicy policy(ctx, cfg); - auto res = policy.place_demand(fg, 0, 2, 0, 7.0); - EXPECT_NEAR(res.first, 4.0, 1e-9); - EXPECT_NEAR(res.second, 3.0, 1e-9); - // A->B: 4, B->C: cap1 gets 1.0, cap3 gets 3.0, cap7(cost2) unused - expect_edge_flows_by_uv(fg, {{0,1,4.0}, {1,2,1.0}, {1,2,3.0}, {1,2,0.0}}); -} - -TEST(FlowPolicyCore, EqualBalanced_SingleDemand_FlowCount3_SaturatesBothPaths) { - // Graph: A->B (cap 100), B->C (cap 125), A->D (cap 75), D->C (cap 50) - // Two equal-cost paths exist with capacities 100 (A->B->C) and 50 (A->D->C). - // EqualBalanced with 3 flows should saturate both paths for a total of 150, - // leaving 50 unplaced out of the 200 requested. - auto g = make_square3(); - FlowGraph fg(g); - EdgeSelection sel; sel.multi_edge = false; sel.require_capacity = true; sel.tie_break = EdgeTieBreak::Deterministic; - auto be = make_cpu_backend(); auto algs = std::make_shared(be); auto gh = algs->build_graph(g); - ExecutionContext ctx(algs, gh); - FlowPolicyConfig cfg; - cfg.flow_placement = FlowPlacement::EqualBalanced; - cfg.selection = sel; - cfg.require_capacity = true; - cfg.min_flow_count = 3; - cfg.max_flow_count = 3; - FlowPolicy policy(ctx, cfg); - auto res = policy.place_demand(fg, 0, 2, 0, 200.0); - EXPECT_NEAR(res.first, 150.0, 1e-9); - EXPECT_NEAR(res.second, 50.0, 1e-9); - // Path A->B->C carries 100, A->D->C carries 50 - expect_edge_flows_by_uv(fg, {{0,1,100.0}, {1,2,100.0}, {0,3,50.0}, {3,2,50.0}}); -} - -TEST(FlowPolicyCore, DiminishingReturnsCutoff) { - auto g = make_line1(); - FlowGraph fg(g); - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = false; sel.tie_break = EdgeTieBreak::Deterministic; - auto be = make_cpu_backend(); auto algs = std::make_shared(be); auto gh = algs->build_graph(g); - ExecutionContext ctx(algs, gh); - FlowPolicyConfig cfg; - cfg.flow_placement = FlowPlacement::EqualBalanced; - cfg.selection = sel; - cfg.require_capacity = true; - cfg.multipath = true; - cfg.min_flow_count = 1; - cfg.max_flow_count = 1000000; - cfg.shortest_path = false; - cfg.reoptimize_flows_on_each_placement = false; - cfg.max_no_progress_iterations = 100; - cfg.max_total_iterations = 10000; - cfg.diminishing_returns_enabled = true; - cfg.diminishing_returns_window = 8; - cfg.diminishing_returns_epsilon_frac = 1e-3; - FlowPolicy policy(ctx, cfg); - auto res = policy.place_demand(fg, 0, 2, 0, 7.0); - EXPECT_GE(res.first, 0.0); - EXPECT_GE(res.second, 0.0); - EXPECT_GT(res.second, 0.0); -} - -TEST(FlowPolicyCore, EqualBalanced_ShortestPath_ECMP3_SaturatesAllEqualCostPaths) { - // Graph: 0->{1,2,3}->4, all caps=5, all costs=1 (ECMP width 3) - // ECMP-like config: EqualBalanced, multi_edge=true, require_capacity=false, shortest_path=true, single flow - std::int32_t num_nodes = 5; - std::int32_t src_arr[6] = {0, 0, 0, 1, 2, 3}; - std::int32_t dst_arr[6] = {1, 2, 3, 4, 4, 4}; - double cap_arr[6] = {5.0, 5.0, 5.0, 5.0, 5.0, 5.0}; - std::int64_t cost_arr[6] = {1, 1, 1, 1, 1, 1}; - std::int64_t ext_arr[6] = {0, 1, 2, 3, 4, 5}; - auto g = StrictMultiDiGraph::from_arrays(num_nodes, - std::span(src_arr, 6), - std::span(dst_arr, 6), - std::span(cap_arr, 6), - std::span(cost_arr, 6), - std::span(ext_arr, 6)); - FlowGraph fg(g); - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = false; sel.tie_break = EdgeTieBreak::Deterministic; - auto be = make_cpu_backend(); auto algs = std::make_shared(be); auto gh = algs->build_graph(g); - ExecutionContext ctx(algs, gh); - FlowPolicyConfig cfg; - cfg.flow_placement = FlowPlacement::EqualBalanced; - cfg.selection = sel; - cfg.require_capacity = false; - cfg.min_flow_count = 1; - cfg.max_flow_count = 1; - cfg.shortest_path = true; - FlowPolicy policy(ctx, cfg); - auto res = policy.place_demand(fg, 0, 4, 0, 100.0); - // Should place sum of shortest-tier capacities = 15.0, and stop - EXPECT_NEAR(res.first, 15.0, 1e-9); - EXPECT_NEAR(res.second, 85.0, 1e-9); - // Equal-balanced split across the three equal-cost paths - expect_edge_flows_by_uv(fg, {{0,1,5.0}, {1,4,5.0}, {0,2,5.0}, {2,4,5.0}, {0,3,5.0}, {3,4,5.0}}); -} - -TEST(FlowPolicyCore, EqualBalanced_ShortestPath_DownstreamSplit_BalancesBottleneck) { - // Graph: 0->1 (cap 10) -> {2,3} (cap 5 each) -> 4, all costs=1 - // Expect placement limited to 10, split 5/5 on downstream equal-cost arcs. - std::int32_t num_nodes = 5; - std::int32_t src_arr[5] = {0, 1, 1, 2, 3}; - std::int32_t dst_arr[5] = {1, 2, 3, 4, 4}; - double cap_arr[5] = {10.0, 5.0, 5.0, 10.0, 10.0}; - std::int64_t cost_arr[5] = {1, 1, 1, 1, 1}; - std::int64_t ext_arr[5] = {0, 1, 2, 3, 4}; - auto g = StrictMultiDiGraph::from_arrays(num_nodes, - std::span(src_arr, 5), - std::span(dst_arr, 5), - std::span(cap_arr, 5), - std::span(cost_arr, 5), - std::span(ext_arr, 5)); - FlowGraph fg(g); - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = false; sel.tie_break = EdgeTieBreak::Deterministic; - auto be = make_cpu_backend(); auto algs = std::make_shared(be); auto gh = algs->build_graph(g); - ExecutionContext ctx(algs, gh); - FlowPolicyConfig cfg; - cfg.flow_placement = FlowPlacement::EqualBalanced; - cfg.selection = sel; - cfg.require_capacity = false; - cfg.min_flow_count = 1; - cfg.max_flow_count = 1; - cfg.shortest_path = true; - FlowPolicy policy(ctx, cfg); - auto res = policy.place_demand(fg, 0, 4, 0, 100.0); - EXPECT_NEAR(res.first, 10.0, 1e-9); - EXPECT_NEAR(res.second, 90.0, 1e-9); - expect_edge_flows_by_uv(fg, {{0,1,10.0}, {1,2,5.0}, {1,3,5.0}, {2,4,5.0}, {3,4,5.0}}); -} - -TEST(FlowPolicyCore, EqualBalanced_ShortestPath_IgnoresHigherCostTier) { - // Graph: - // Shortest tier: 0->1->4, caps 10, costs=1 - // Higher tier: 0->2->4, caps 50, costs=2 - // With shortest_path=True, placement must saturate shortest tier only (10) and stop. - std::int32_t num_nodes = 5; - std::int32_t src_arr[4] = {0, 1, 0, 2}; - std::int32_t dst_arr[4] = {1, 4, 2, 4}; - double cap_arr[4] = {10.0, 10.0, 50.0, 50.0}; - std::int64_t cost_arr[4] = {1, 1, 2, 2}; - std::int64_t ext_arr[4] = {0, 1, 2, 3}; - auto g = StrictMultiDiGraph::from_arrays(num_nodes, - std::span(src_arr, 4), - std::span(dst_arr, 4), - std::span(cap_arr, 4), - std::span(cost_arr, 4), - std::span(ext_arr, 4)); - FlowGraph fg(g); - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = false; sel.tie_break = EdgeTieBreak::Deterministic; - auto be = make_cpu_backend(); auto algs = std::make_shared(be); auto gh = algs->build_graph(g); - ExecutionContext ctx(algs, gh); - FlowPolicyConfig cfg; - cfg.flow_placement = FlowPlacement::EqualBalanced; - cfg.selection = sel; - cfg.require_capacity = false; - cfg.min_flow_count = 1; - cfg.max_flow_count = 1; - cfg.shortest_path = true; - FlowPolicy policy(ctx, cfg); - auto res = policy.place_demand(fg, 0, 4, 0, 100.0); - EXPECT_NEAR(res.first, 10.0, 1e-9); - EXPECT_NEAR(res.second, 90.0, 1e-9); - // Only shortest tier edges should carry flow - expect_edge_flows_by_uv(fg, {{0,1,10.0}, {1,4,10.0}, {0,2,0.0}, {2,4,0.0}}); -} - -// ============================================================================ -// Static paths (pinned path bundles) -// ============================================================================ - -// Each flow binds to ITS OWN bundle in supply order (the original Python -// semantics; the first C++ port bound every flow to bundle[0]). -TEST(FlowPolicyStatic, PerFlowBundleBinding) { - auto g = make_square1(); - FlowGraph fg(g); - auto be = make_cpu_backend(); auto algs = std::make_shared(be); auto gh = algs->build_graph(g); - ExecutionContext ctx(algs, gh); - FlowPolicy policy(ctx, FlowPolicyConfig{}); - - // Edge ids after (cost, src, dst) reordering match construction order here. - EdgeId short_path[2] = {0, 1}; // A->B->C, cost 2, cap 1 - EdgeId long_path[2] = {2, 3}; // A->D->C, cost 4, cap 2 - std::vector bundles; - bundles.push_back(make_path_dag(g, std::span(short_path, 2))); - bundles.push_back(make_path_dag(g, std::span(long_path, 2))); - policy.set_static_paths(0, 2, std::move(bundles)); - - auto [placed, left] = policy.place_demand(fg, 0, 2, 0, 3.0); - EXPECT_NEAR(placed, 3.0, 1e-9); - EXPECT_NEAR(left, 0.0, 1e-9); - ASSERT_EQ(policy.flow_count(), 2); - // Creation order == supply order; costs prove the binding. - std::vector> got; - for (auto const& kv : policy.flows()) got.emplace_back(kv.first.flowId, kv.second.cost); - std::sort(got.begin(), got.end()); - EXPECT_EQ(got[0].second, 2); - EXPECT_EQ(got[1].second, 4); -} - -// A bundle whose every walk is masked out is DOWN: no flow, no volume. -TEST(FlowPolicyStatic, MaskedBundleIsDown) { - using netgraph::core::test::make_bool_mask; - auto g = make_square1(); - FlowGraph fg(g); - auto be = make_cpu_backend(); auto algs = std::make_shared(be); auto gh = algs->build_graph(g); - ExecutionContext ctx(algs, gh); - - auto edge_mask = make_bool_mask(4); - edge_mask[0] = false; // A->B down -> the short bundle has no surviving walk - FlowPolicyConfig cfg; - cfg.edge_mask = std::span(edge_mask.get(), 4); - FlowPolicy policy(ctx, cfg); - - EdgeId short_path[2] = {0, 1}; - EdgeId long_path[2] = {2, 3}; - std::vector bundles; - bundles.push_back(make_path_dag(g, std::span(short_path, 2))); - bundles.push_back(make_path_dag(g, std::span(long_path, 2))); - policy.set_static_paths(0, 2, std::move(bundles)); - - auto [placed, left] = policy.place_demand(fg, 0, 2, 0, 3.0); - EXPECT_EQ(policy.flow_count(), 1); - EXPECT_NEAR(placed, 2.0, 1e-9); // only the long bundle carries - EXPECT_NEAR(left, 1.0, 1e-9); -} - -// Malformed bundles are rejected loudly; a foreign-graph DAG cannot slip through. -TEST(FlowPolicyStatic, ValidationRejectsForeignAndDisconnectedBundles) { - auto g = make_square1(); - auto be = make_cpu_backend(); auto algs = std::make_shared(be); auto gh = algs->build_graph(g); - ExecutionContext ctx(algs, gh); - FlowPolicy policy(ctx, FlowPolicyConfig{}); - - EdgeId short_path[2] = {0, 1}; - auto dag = make_path_dag(g, std::span(short_path, 2)); - - // Connects 0->2, not 1->3: structurally wrong for that demand. - { - std::vector b; b.push_back(dag); - EXPECT_THROW(policy.set_static_paths(1, 3, std::move(b)), std::invalid_argument); - } - // Shuffled via edge: entry no longer connects its parent to its node. - { - PredDAG bad = dag; - bad.via_edges[0] = 3; // D->C edge in place of a parent link - std::vector b; b.push_back(std::move(bad)); - EXPECT_THROW(policy.set_static_paths(0, 2, std::move(b)), std::invalid_argument); - } -} - -// The max_path_cost_factor gate multiplies best_path_cost_ by the factor and casts -// the product back to Cost. best_path_cost_ is only updated when dst is reachable -// (flow_policy.cpp:135), so an UNREACHABLE destination is the one way to reach the -// gate while it still holds the INT64_MAX "no best path yet" sentinel. Before 0.8.0 -// the multiply-and-cast ran unconditionally there, and INT64_MAX * factor is far -// outside the int64 range -- undefined behavior. -// -// No assertion can distinguish fixed from unfixed here: both return "no path" for an -// unreachable destination. The check is the UB itself, so this test exists to give -// UBSan something to instrument. It only has teeth under `make sanitize-test`, which -// runs ctest -- which is why this lives in C++ and not in the Python suite. -TEST(FlowPolicyCostBounds, MaxPathCostFactor_UnreachableDst_KeepsSentinelOutOfTheCast) { - // 0 -> 1, plus an isolated node 2 that cannot be reached from 0. - std::vector src{0}, dst{1}; - std::vector cap{1.0}; - std::vector cost{7}; - auto g = StrictMultiDiGraph::from_arrays(3, src, dst, cap, cost); - FlowGraph fg(g); - - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = true; - sel.tie_break = EdgeTieBreak::Deterministic; - auto be = make_cpu_backend(); auto algs = std::make_shared(be); - auto gh = algs->build_graph(g); - ExecutionContext ctx(algs, gh); - - FlowPolicyConfig cfg; - cfg.flow_placement = FlowPlacement::Proportional; - cfg.selection = sel; - cfg.require_capacity = true; - cfg.max_flow_count = 1; - cfg.min_flow_count = 1; // seeding is what calls get_path_bundle - cfg.max_path_cost_factor = 2.0; - FlowPolicy policy(ctx, cfg); - - // Reaches the gate with best_path_cost_ == INT64_MAX. Nothing can be placed. - auto res = policy.place_demand(fg, /*src=*/0, /*dst=*/2, /*flowClass=*/0, /*volume=*/1.0); - EXPECT_NEAR(res.first, 0.0, 1e-9); - EXPECT_NEAR(res.second, 1.0, 1e-9); -} - -// A very large (but legal) best cost times a large factor overflows the int64 range -// too. The bound must be dropped rather than wrapped: a wrapped negative bound would -// reject the shortest path itself, so a successful placement is what separates the -// guard from the bug. Unlike the sentinel case above, this one IS assertable. -TEST(FlowPolicyCostBounds, MaxPathCostFactor_HugeProduct_DropsBoundInsteadOfWrapping) { - const std::int64_t big = std::int64_t{1} << 60; // total stays below the 2^62 ceiling - std::vector src{0, 1}, dst{1, 2}; - std::vector cap{1.0, 1.0}; - std::vector cost{big, big}; - auto g = StrictMultiDiGraph::from_arrays(3, src, dst, cap, cost); - FlowGraph fg(g); - - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = true; - sel.tie_break = EdgeTieBreak::Deterministic; - auto be = make_cpu_backend(); auto algs = std::make_shared(be); - auto gh = algs->build_graph(g); - ExecutionContext ctx(algs, gh); - - FlowPolicyConfig cfg; - cfg.flow_placement = FlowPlacement::Proportional; - cfg.selection = sel; - cfg.require_capacity = true; - cfg.max_flow_count = 1; - cfg.min_flow_count = 1; - cfg.max_path_cost_factor = 1e9; - FlowPolicy policy(ctx, cfg); - - auto res = policy.place_demand(fg, /*src=*/0, /*dst=*/2, /*flowClass=*/0, /*volume=*/1.0); - EXPECT_NEAR(res.first, 1.0, 1e-9); -} diff --git a/tests/cpp/flow_state_tests.cpp b/tests/cpp/flow_state_tests.cpp deleted file mode 100644 index ac9658f..0000000 --- a/tests/cpp/flow_state_tests.cpp +++ /dev/null @@ -1,589 +0,0 @@ -#include -#include "netgraph/core/flow_state.hpp" -#include -#include "netgraph/core/shortest_paths.hpp" -#include "netgraph/core/strict_multidigraph.hpp" -#include "netgraph/core/constants.hpp" -#include "test_utils.hpp" - -using namespace netgraph::core; -using namespace netgraph::core::test; - -TEST(FlowState, InitialResidualEqualsCapacity) { - auto g = make_line_graph(3); - FlowState fs(g); - - auto residual = fs.residual_view(); - auto capacity = g.capacity_view(); - - for (std::size_t i = 0; i < residual.size(); ++i) { - EXPECT_DOUBLE_EQ(residual[i], capacity[i]); - } -} - -TEST(FlowState, PlacementReducesResidual) { - auto g = make_line_graph(3); - FlowState fs(g); - - // Get shortest path DAG - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, {}, {}, {}); - - auto initial_residual = fs.residual_view()[0]; - - // Place 0.5 units of flow - Flow placed = fs.place_on_dag(0, 2, dag, 0.5, FlowPlacement::Proportional); - - EXPECT_GT(placed, 0.0); - - // Residual should be reduced - auto new_residual = fs.residual_view()[0]; - EXPECT_LT(new_residual, initial_residual); -} - -TEST(FlowState, ProportionalDistribution) { - // Create graph with two parallel edges of different capacities - std::int32_t src[3] = {0, 1, 1}; - std::int32_t dst[3] = {1, 2, 2}; - double cap[3] = {10.0, 3.0, 7.0}; // Total 10 capacity B->C - std::int64_t cost[3] = {1, 1, 1}; - - auto g = StrictMultiDiGraph::from_arrays(3, - std::span(src, 3), std::span(dst, 3), - std::span(cap, 3), std::span(cost, 3)); - - FlowState fs(g); - - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, {}, {}, {}); - - // Place 10 units (should use both B->C edges proportionally) - Flow placed = fs.place_on_dag(0, 2, dag, 10.0, FlowPlacement::Proportional); - - EXPECT_NEAR(placed, 10.0, 1e-9); - - auto flows = fs.edge_flow_view(); - // First edge (A->B) should have 10 - EXPECT_NEAR(flows[0], 10.0, 1e-9); - - // Parallel edges should have flow proportional to capacity: 3 and 7 - EXPECT_NEAR(flows[1], 3.0, 1e-9); - EXPECT_NEAR(flows[2], 7.0, 1e-9); -} - -TEST(FlowState, EqualBalancedDistribution) { - // Same graph as proportional test - std::int32_t src[3] = {0, 1, 1}; - std::int32_t dst[3] = {1, 2, 2}; - double cap[3] = {10.0, 3.0, 7.0}; - std::int64_t cost[3] = {1, 1, 1}; - - auto g = StrictMultiDiGraph::from_arrays(3, - std::span(src, 3), std::span(dst, 3), - std::span(cap, 3), std::span(cost, 3)); - - FlowState fs(g); - - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, {}, {}, {}); - - // Place 6 units with equal-balanced (limited by min capacity * 2 = 6) - Flow placed = fs.place_on_dag(0, 2, dag, 10.0, FlowPlacement::EqualBalanced); - - EXPECT_NEAR(placed, 6.0, 1e-9); - - auto flows = fs.edge_flow_view(); - // Parallel edges should each get 3 (equal split) - EXPECT_NEAR(flows[1], 3.0, 1e-9); - EXPECT_NEAR(flows[2], 3.0, 1e-9); -} - -TEST(FlowState, ZeroCapacityHandling) { - std::int32_t src[1] = {0}; - std::int32_t dst[1] = {1}; - double cap[1] = {0.0}; - std::int64_t cost[1] = {1}; - - auto g = StrictMultiDiGraph::from_arrays(2, - std::span(src, 1), std::span(dst, 1), - std::span(cap, 1), std::span(cost, 1)); - - FlowState fs(g); - - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = true; // Should filter zero capacity - sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, fs.residual_view(), {}, {}); - - // No path should be found due to zero capacity - EXPECT_EQ(dag.parent_offsets[1], dag.parent_offsets[2]); -} - -TEST(FlowState, ApplyDeltasAdditive) { - auto g = make_line_graph(3); - FlowState fs(g); - - std::vector> deltas = {{0, 0.5}, {1, 0.3}}; - - auto initial_flow_0 = fs.edge_flow_view()[0]; - auto initial_residual_0 = fs.residual_view()[0]; - - fs.apply_deltas(deltas, true); // Add - - EXPECT_NEAR(fs.edge_flow_view()[0], initial_flow_0 + 0.5, 1e-9); - EXPECT_NEAR(fs.residual_view()[0], initial_residual_0 - 0.5, 1e-9); -} - -TEST(FlowState, ApplyDeltasSubtractive) { - auto g = make_line_graph(3); - FlowState fs(g); - - // First add some flow - std::vector> deltas = {{0, 0.5}}; - fs.apply_deltas(deltas, true); - - auto flow_after_add = fs.edge_flow_view()[0]; - auto residual_after_add = fs.residual_view()[0]; - - // Subtract the same deltas to reverse the operation - fs.apply_deltas(deltas, false); - - EXPECT_NEAR(fs.edge_flow_view()[0], flow_after_add - 0.5, 1e-9); - EXPECT_NEAR(fs.residual_view()[0], residual_after_add + 0.5, 1e-9); -} - -TEST(FlowState, MinCutComputation) { - // Validates min-cut computation on a saturated flow graph. - // Square has two paths: 0->1->2 (cap 1, cost 2) and 0->3->2 (cap 2, cost 4). - // Max flow is 3.0, and min-cut should separate source from sink with saturated edges. - auto g = make_square_graph(1); - FlowState fs(g); - - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = true; - sel.tie_break = EdgeTieBreak::Deterministic; - - // Saturate max-flow: iteratively place flow until exhausted - Flow total_placed = 0.0; - for (int iter = 0; iter < 10; ++iter) { - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, fs.residual_view(), {}, {}); - // Check if destination is reachable - if (dag.parent_offsets[2] == dag.parent_offsets[3]) break; // No path to node 2 - Flow placed = fs.place_on_dag(0, 2, dag, 10.0, FlowPlacement::Proportional); - if (placed < kMinFlow) break; - total_placed += placed; - } - - // Verify flow was actually placed - ASSERT_GT(total_placed, 0.0) << "No flow placed, test setup is broken"; - - // Compute min-cut from source - auto min_cut = fs.compute_min_cut(0, {}, {}); - - // Min-cut must have at least one edge separating source from sink - EXPECT_GT(min_cut.edges.size(), 0) << "Min-cut must contain saturated edges"; - - // All cut edges should be saturated (residual <= kMinCap) - auto residual = fs.residual_view(); - for (auto eid : min_cut.edges) { - EXPECT_LE(residual[eid], kMinCap) - << "Cut edge " << eid << " not saturated (residual=" << residual[eid] << ")"; - } - - // Verify min-cut capacity equals max-flow (by max-flow min-cut theorem) - double cut_capacity = 0.0; - auto capacity = g.capacity_view(); - for (auto eid : min_cut.edges) { - cut_capacity += capacity[eid]; - } - EXPECT_NEAR(cut_capacity, total_placed, 1e-9) - << "Min-cut capacity should equal max-flow by max-flow min-cut theorem"; -} - -TEST(FlowState, ResetRestoresCapacity) { - auto g = make_line_graph(3); - FlowState fs(g); - - // Place some flow - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, {}, {}, {}); - Flow placed = fs.place_on_dag(0, 2, dag, 0.5, FlowPlacement::Proportional); - EXPECT_GT(placed, 0.0); - - EXPECT_GT(fs.edge_flow_view()[0], 0.0); - - // Reset - fs.reset(); - - // Flow should be zero - EXPECT_DOUBLE_EQ(fs.edge_flow_view()[0], 0.0); - - // Residual should equal capacity - auto residual = fs.residual_view(); - auto capacity = g.capacity_view(); - for (std::size_t i = 0; i < residual.size(); ++i) { - EXPECT_DOUBLE_EQ(residual[i], capacity[i]); - } -} - -TEST(FlowState, CustomInitialResidual) { - auto g = make_line_graph(3); - - // Create custom residual (half capacity) - std::vector custom_residual(g.num_edges()); - auto capacity = g.capacity_view(); - for (std::size_t i = 0; i < custom_residual.size(); ++i) { - custom_residual[i] = capacity[i] * 0.5; - } - - FlowState fs(g, custom_residual); - - auto residual = fs.residual_view(); - for (std::size_t i = 0; i < residual.size(); ++i) { - EXPECT_DOUBLE_EQ(residual[i], custom_residual[i]); - } -} - -TEST(FlowState, InvalidResidualInitThrows) { - auto g = make_line_graph(3); - // residual_init wrong length - std::vector bad(static_cast(g.num_edges() + 1), 1.0); - EXPECT_THROW({ FlowState fs_bad(g, bad); (void)fs_bad; }, std::invalid_argument); -} - -TEST(FlowState, ResetWithInvalidResidualThrows) { - auto g = make_line_graph(3); - FlowState fs(g); - std::vector bad(static_cast(g.num_edges() + 2), 1.0); - EXPECT_THROW({ fs.reset(bad); }, std::invalid_argument); -} - -TEST(FlowState, ApplyDeltasClampsWithinCapacityBounds) { - auto g = make_line_graph(3); - FlowState fs(g); - auto cap = g.capacity_view(); - // Add some flow on edge 0 - std::vector> add = {{0, 0.4}}; - fs.apply_deltas(add, true); - // Subtract more than present; implementation should clamp to zero (no negative flows) - std::vector> sub = {{0, 1.0}}; - fs.apply_deltas(sub, false); - EXPECT_GE(fs.edge_flow_view()[0], 0.0); - EXPECT_LE(fs.edge_flow_view()[0], cap[0]); - EXPECT_NEAR(fs.residual_view()[0], cap[0], 1e-12); -} - -TEST(FlowState, EqualBalancedReconvergentDAGPlacesAll) { - // Graph: 0->1->3 and 0->2->3, all caps=1, costs=1 - std::int32_t src[4] = {0, 1, 0, 2}; - std::int32_t dst[4] = {1, 3, 2, 3}; - double cap[4] = {1.0, 1.0, 1.0, 1.0}; - std::int64_t cost[4] = {1, 1, 1, 1}; - auto g = StrictMultiDiGraph::from_arrays(4, - std::span(src, 4), std::span(dst, 4), - std::span(cap, 4), std::span(cost, 4)); - FlowState fs(g); - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = true; sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, 0, 3, /*multipath=*/true, sel, {}, {}, {}); - Flow placed = fs.place_on_dag(0, 3, dag, std::numeric_limits::infinity(), FlowPlacement::EqualBalanced); - EXPECT_NEAR(placed, 2.0, 1e-9); -} - -TEST(FlowState, EqualBalanced_ECMP3_FirstHopEqualization) { - // Graph with three equal-cost next-hops: 0->{1,2,3}->{4}, all caps=5, costs=1 - std::int32_t src[6] = {0, 0, 0, 1, 2, 3}; - std::int32_t dst[6] = {1, 2, 3, 4, 4, 4}; - double cap[6] = {5.0, 5.0, 5.0, 5.0, 5.0, 5.0}; - std::int64_t cost[6] = {1, 1, 1, 1, 1, 1}; - auto g = StrictMultiDiGraph::from_arrays(5, - std::span(src, 6), std::span(dst, 6), - std::span(cap, 6), std::span(cost, 6)); - FlowState fs(g); - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = true; sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, 0, 4, /*multipath=*/true, sel, {}, {}, {}); - // Request 3 units; expect 1 on each of the three paths - Flow placed = fs.place_on_dag(0, 4, dag, 3.0, FlowPlacement::EqualBalanced); - EXPECT_NEAR(placed, 3.0, 1e-9); - auto ef = fs.edge_flow_view(); - // First-hop equalization - // Edges 0->1, 0->2, 0->3 are the first three edges by construction - EXPECT_NEAR(ef[0], 1.0, 1e-9); - EXPECT_NEAR(ef[1], 1.0, 1e-9); - EXPECT_NEAR(ef[2], 1.0, 1e-9); - // Second hop equalization - EXPECT_NEAR(ef[3], 1.0, 1e-9); - EXPECT_NEAR(ef[4], 1.0, 1e-9); - EXPECT_NEAR(ef[5], 1.0, 1e-9); -} - -TEST(FlowState, EqualBalanced_MinFlowGating_DoesNotPruneValidParallelEdges) { - // Validates that EB placement doesn't prune paths where individual edges have - // residual < requested flow, but the parallel edge group collectively supports it. - // Graph: 0->1 cap 1.2; 1->2 has two parallel edges cap 0.6 each; all costs=1 - // When placing 1.0, individual edges can't carry 1.0, but group of 2×0.6 can. - std::int32_t src[3] = {0, 1, 1}; - std::int32_t dst[3] = {1, 2, 2}; - double cap[3] = {1.2, 0.6, 0.6}; - std::int64_t cost[3] = {1, 1, 1}; - auto g = StrictMultiDiGraph::from_arrays(3, - std::span(src, 3), std::span(dst, 3), - std::span(cap, 3), std::span(cost, 3)); - FlowState fs(g); - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = true; sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, 0, 2, /*multipath=*/true, sel, {}, {}, {}); - // Request 1.0; group capacity is min(1.2, 0.6+0.6) = 1.2, so should place 1.0 - Flow placed = fs.place_on_dag(0, 2, dag, 1.0, FlowPlacement::EqualBalanced); - EXPECT_GE(placed, 1.0 - 1e-9) << "Should place full 1.0 using parallel edge group"; - auto ef = fs.edge_flow_view(); - // Parallel edges should split the flow - EXPECT_NEAR(ef[1] + ef[2], 1.0, 1e-9) << "Parallel edges should carry the flow"; -} - -TEST(FlowState, EqualBalanced_DownstreamSplitEqualization) { - // Downstream equalization: 0->1 (cap 10) then 1->{2,3}->4 (each hop cap 5) - // Expect 10 placed, split 5 and 5 downstream. - std::int32_t src[5] = {0, 1, 1, 2, 3}; - std::int32_t dst[5] = {1, 2, 3, 4, 4}; - double cap[5] = {10.0, 5.0, 5.0, 5.0, 5.0}; - std::int64_t cost[5] = {1, 1, 1, 1, 1}; - auto g = StrictMultiDiGraph::from_arrays(5, - std::span(src, 5), std::span(dst, 5), - std::span(cap, 5), std::span(cost, 5)); - FlowState fs(g); - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = true; sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, 0, 4, /*multipath=*/true, sel, {}, {}, {}); - Flow placed = fs.place_on_dag(0, 4, dag, 10.0, FlowPlacement::EqualBalanced); - EXPECT_NEAR(placed, 10.0, 1e-9); - auto ef = fs.edge_flow_view(); - // First hop carries all 10 - EXPECT_NEAR(ef[0], 10.0, 1e-9); - // Downstream split equalization - EXPECT_NEAR(ef[1], 5.0, 1e-9); // 1->2 - EXPECT_NEAR(ef[2], 5.0, 1e-9); // 1->3 - EXPECT_NEAR(ef[3], 5.0, 1e-9); // 2->4 - EXPECT_NEAR(ef[4], 5.0, 1e-9); // 3->4 -} - -TEST(FlowState, EqualBalanced_ZeroCapacityOnShortestPath_ReturnsZero) { - // With require_capacity=false (true IP/IGP semantics), routing is cost-only. - // If the shortest path has a zero-capacity edge, no flow can be placed. - // Graph: 0->1 (cap 0, cost 1) -> 2 (cap 10, cost 1) [shortest path, but no capacity] - // 0->3 (cap 100, cost 2) -> 2 (cap 100, cost 2) [longer path with capacity] - std::int32_t src[4] = {0, 1, 0, 3}; - std::int32_t dst[4] = {1, 2, 3, 2}; - double cap[4] = {0.0, 10.0, 100.0, 100.0}; // 0->1 has zero capacity! - std::int64_t cost[4] = {1, 1, 2, 2}; - auto g = StrictMultiDiGraph::from_arrays(4, - std::span(src, 4), std::span(dst, 4), - std::span(cap, 4), std::span(cost, 4)); - FlowState fs(g); - - // Build DAG with require_capacity=false (cost-only routing, ignores capacity). - // This simulates true IP/IGP behavior where routes are based purely on cost. - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = false; sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, 0, 2, /*multipath=*/true, sel, {}, {}, {}); - - // Verify SPF found a path (the zero-capacity one is included in the DAG). - // The shortest path 0->1->2 has cost 2, while 0->3->2 has cost 4. - EXPECT_LT(dist[2], std::numeric_limits::max()); - - // Attempt EqualBalanced placement. Since the shortest path has no capacity, - // and we're in single-tier mode (simulated by only having one DAG), - // placement should return 0. - Flow placed = fs.place_on_dag(0, 2, dag, std::numeric_limits::infinity(), FlowPlacement::EqualBalanced); - EXPECT_NEAR(placed, 0.0, 1e-9) << "EqualBalanced should return 0 when shortest path has no capacity"; - - // Verify no edge flows were placed - auto ef = fs.edge_flow_view(); - for (std::size_t i = 0; i < static_cast(g.num_edges()); ++i) { - EXPECT_NEAR(ef[i], 0.0, 1e-9) << "Edge " << i << " should have no flow"; - } -} - -TEST(FlowState, Proportional_ZeroCapacityOnShortestPath_ReturnsZero) { - // With require_capacity=false, if the shortest path has a zero-capacity edge, - // no flow can be placed. Both placements behave consistently. - std::int32_t src[4] = {0, 1, 0, 3}; - std::int32_t dst[4] = {1, 2, 3, 2}; - double cap[4] = {0.0, 10.0, 100.0, 100.0}; - std::int64_t cost[4] = {1, 1, 2, 2}; - auto g = StrictMultiDiGraph::from_arrays(4, - std::span(src, 4), std::span(dst, 4), - std::span(cap, 4), std::span(cost, 4)); - FlowState fs(g); - - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = false; sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, 0, 2, /*multipath=*/true, sel, {}, {}, {}); - - Flow placed = fs.place_on_dag(0, 2, dag, std::numeric_limits::infinity(), FlowPlacement::Proportional); - EXPECT_NEAR(placed, 0.0, 1e-9) << "Proportional should return 0 when shortest path has no capacity"; -} - -TEST(FlowState, RepeatedPlacementAfterResetAndIndependentInstancesStayStable) { - // Exercise the per-instance workspace reuse path: - // 1. repeated placement on one FlowState after reset must be identical - // 2. a second FlowState must remain unaffected by the first instance - std::int32_t src[5] = {0, 1, 1, 2, 3}; - std::int32_t dst[5] = {1, 2, 3, 4, 4}; - double cap[5] = {10.0, 5.0, 5.0, 5.0, 5.0}; - std::int64_t cost[5] = {1, 1, 1, 1, 1}; - auto g = StrictMultiDiGraph::from_arrays(5, - std::span(src, 5), std::span(dst, 5), - std::span(cap, 5), std::span(cost, 5)); - - FlowState fs1(g); - FlowState fs2(g); - - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = true; - sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, 0, 4, /*multipath=*/true, sel, {}, {}, {}); - - Flow placed_first = fs1.place_on_dag(0, 4, dag, 10.0, FlowPlacement::EqualBalanced); - std::vector first_flows(fs1.edge_flow_view().begin(), fs1.edge_flow_view().end()); - EXPECT_NEAR(placed_first, 10.0, 1e-9); - - for (auto flow : fs2.edge_flow_view()) { - EXPECT_NEAR(flow, 0.0, 1e-12); - } - - fs1.reset(); - Flow placed_second = fs1.place_on_dag(0, 4, dag, 10.0, FlowPlacement::EqualBalanced); - EXPECT_NEAR(placed_second, placed_first, 1e-9); - for (std::size_t i = 0; i < first_flows.size(); ++i) { - EXPECT_NEAR(fs1.edge_flow_view()[i], first_flows[i], 1e-9); - } -} - -// --------------------------------------------------------------------------- -// EqualBalanced / EqualBalancedLossy: load-blind hash-ECMP models. -// --------------------------------------------------------------------------- -#include "netgraph/core/flow_graph.hpp" - -namespace { -// 0 -> 1 over two parallel equal-cost edges: edge 0 cap 10, edge 1 cap 100. -StrictMultiDiGraph make_unbalanced_pair() { - std::int32_t src[2] = {0, 0}; - std::int32_t dst[2] = {1, 1}; - double cap[2] = {10.0, 100.0}; - std::int64_t cost[2] = {1, 1}; - return StrictMultiDiGraph::from_arrays(2, - std::span(src, 2), std::span(dst, 2), std::span(cap, 2), std::span(cost, 2)); -} -PredDAG cost_only_dag(const StrictMultiDiGraph& g, NodeId s, NodeId t) { - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = false; sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, s, t, /*multipath=*/true, sel, {}, {}, {}); - return dag; -} -} // namespace - -TEST(FlowState, EqualBalanced_SaturatedMemberBlocksAdmission) { - auto g = make_unbalanced_pair(); - auto dag = cost_only_dag(g, 0, 1); - - FlowState fs(g); - EXPECT_NEAR(fs.place_on_dag(0, 1, dag, 20.0, FlowPlacement::EqualBalanced), 20.0, 1e-9); - EXPECT_NEAR(fs.edge_flow_view()[0], 10.0, 1e-9); - EXPECT_NEAR(fs.edge_flow_view()[1], 10.0, 1e-9); - // The 10-unit member is full; equal hashing of any further demand would lose - // half of it, so nothing more is admitted losslessly on this DAG. - EXPECT_NEAR(fs.place_on_dag(0, 1, dag, 10.0, FlowPlacement::EqualBalanced), 0.0, 1e-9); - EXPECT_NEAR(fs.edge_flow_view()[1], 10.0, 1e-9) << "no flow may leak onto the remaining member"; -} - -TEST(FlowState, EqualBalanced_ProgressesOnlyThroughResidualAwareSpf) { - // On a fresh residual-aware DAG the full member is gone and placement continues. - auto g = make_unbalanced_pair(); - FlowState fs(g); - EXPECT_NEAR(fs.place_on_dag(0, 1, cost_only_dag(g, 0, 1), 20.0, FlowPlacement::EqualBalanced), 20.0, 1e-9); - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = true; sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, 0, 1, /*multipath=*/true, sel, fs.residual_view(), {}, {}); - EXPECT_NEAR(fs.place_on_dag(0, 1, dag, 10.0, FlowPlacement::EqualBalanced), 10.0, 1e-9); - EXPECT_NEAR(fs.edge_flow_view()[1], 20.0, 1e-9); -} - -TEST(FlowState, EqualBalancedLossy_FillAndDropReportsDrops) { - auto g = make_unbalanced_pair(); - auto dag = cost_only_dag(g, 0, 1); - FlowState fs(g); - std::vector> trace, drops; - Flow placed = fs.place_on_dag(0, 1, dag, 100.0, FlowPlacement::EqualBalancedLossy, &trace, &drops); - EXPECT_NEAR(placed, 60.0, 1e-9) << "50 on the 100-unit edge, 10 on the 10-unit edge"; - EXPECT_NEAR(fs.edge_flow_view()[0], 10.0, 1e-9); - EXPECT_NEAR(fs.edge_flow_view()[1], 50.0, 1e-9); - ASSERT_EQ(drops.size(), 1u); - EXPECT_EQ(drops[0].first, 0); - EXPECT_NEAR(drops[0].second, 40.0, 1e-9); - double traced = 0.0; for (auto const& pr : trace) traced += pr.second; - EXPECT_NEAR(traced, 60.0, 1e-9) << "the trace records carried volume only"; - - // A later demand still hashes half onto the saturated member and loses it. - drops.clear(); - EXPECT_NEAR(fs.place_on_dag(0, 1, dag, 10.0, FlowPlacement::EqualBalancedLossy, nullptr, &drops), 5.0, 1e-9); - ASSERT_EQ(drops.size(), 1u); - EXPECT_NEAR(drops[0].second, 5.0, 1e-9); -} - -TEST(FlowState, EqualBalancedLossy_DeficitPropagatesDownstream) { - // 0 -> 1 over edges cap 10 / 100 (cost 1 each), then 1 -> 2 cap 30 (cost 1). - std::int32_t src[3] = {0, 0, 1}; - std::int32_t dst[3] = {1, 1, 2}; - double cap[3] = {10.0, 100.0, 30.0}; - std::int64_t cost[3] = {1, 1, 1}; - auto g = StrictMultiDiGraph::from_arrays(3, - std::span(src, 3), std::span(dst, 3), std::span(cap, 3), std::span(cost, 3)); - auto dag = cost_only_dag(g, 0, 2); - FlowState fs(g); - std::vector> drops; - Flow placed = fs.place_on_dag(0, 2, dag, 100.0, FlowPlacement::EqualBalancedLossy, nullptr, &drops); - // Node 1 receives 60 (40 dropped upstream) and forwards 30 (30 dropped on 1->2). - EXPECT_NEAR(placed, 30.0, 1e-9); - EXPECT_NEAR(fs.edge_flow_view()[2], 30.0, 1e-9); - double total_dropped = 0.0; for (auto const& pr : drops) total_dropped += pr.second; - EXPECT_NEAR(total_dropped, 70.0, 1e-9); -} - -TEST(FlowState, EqualBalancedLossy_InfiniteRequestFillsWithoutDrops) { - auto g = make_unbalanced_pair(); - auto dag = cost_only_dag(g, 0, 1); - FlowState fs(g); - std::vector> drops; - Flow placed = fs.place_on_dag(0, 1, dag, std::numeric_limits::infinity(), - FlowPlacement::EqualBalancedLossy, nullptr, &drops); - EXPECT_NEAR(placed, 110.0, 1e-9); - EXPECT_TRUE(drops.empty()) << "drops are undefined for an infinite offered load"; -} - -TEST(FlowGraph, LossyPlacementIsReversibleAndDropsStayOutOfLedger) { - auto g = make_unbalanced_pair(); - auto dag = cost_only_dag(g, 0, 1); - FlowGraph fg(g); - FlowIndex idx{0, 1, 0, 7}; - std::vector> drops; - EXPECT_NEAR(fg.place(idx, 0, 1, dag, 100.0, FlowPlacement::EqualBalancedLossy, &drops), 60.0, 1e-9); - ASSERT_EQ(drops.size(), 1u); - double ledger = 0.0; for (auto const& pr : fg.get_flow_edges(idx)) ledger += pr.second; - EXPECT_NEAR(ledger, 60.0, 1e-9) << "only carried volume is in the ledger"; - fg.remove(idx); - EXPECT_NEAR(fg.residual_view()[0], 10.0, 1e-9); - EXPECT_NEAR(fg.residual_view()[1], 100.0, 1e-9); - // Non-lossy placements never touch the collector. - drops.clear(); - EXPECT_NEAR(fg.place(idx, 0, 1, dag, 20.0, FlowPlacement::EqualBalanced, &drops), 20.0, 1e-9); - EXPECT_TRUE(drops.empty()); -} diff --git a/tests/cpp/k_shortest_paths_tests.cpp b/tests/cpp/k_shortest_paths_tests.cpp deleted file mode 100644 index 30b1dc6..0000000 --- a/tests/cpp/k_shortest_paths_tests.cpp +++ /dev/null @@ -1,331 +0,0 @@ -#include -#include -#include -#include "netgraph/core/k_shortest_paths.hpp" -#include "netgraph/core/shortest_paths.hpp" -#include "netgraph/core/backend.hpp" -#include "netgraph/core/algorithms.hpp" -#include "netgraph/core/strict_multidigraph.hpp" -#include "test_utils.hpp" - -using namespace netgraph::core; -using namespace netgraph::core::test; - -TEST(KShortestPaths, FindsKPaths) { - auto g = make_square_graph(1); // Has at least 2 paths - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - KspOptions opts; - opts.k = 2; - opts.unique = true; - opts.max_cost_factor = std::nullopt; - - auto paths = algs.ksp(gh, 0, 2, opts); - - // Should find at least 1 path, up to 2 - EXPECT_GT(paths.size(), 0); - EXPECT_LE(paths.size(), 2); -} - -TEST(KShortestPaths, KEqualsZeroReturnsEmpty) { - auto g = make_square_graph(1); - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - KspOptions opts; opts.k = 0; opts.unique = true; - auto paths = algs.ksp(gh, 0, 2, opts); - EXPECT_TRUE(paths.empty()); -} - -TEST(KShortestPaths, NodeMaskBlocksPaths) { - // Verify that node masks prevent path discovery in KSP - auto g = make_line_graph(3); - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - KspOptions opts; opts.k = 5; opts.unique = true; - auto node_mask = make_bool_mask(g.num_nodes()); - node_mask[1] = false; // Block middle node - opts.node_mask = std::span(node_mask.get(), static_cast(g.num_nodes())); - auto paths = algs.ksp(gh, 0, 2, opts); - EXPECT_TRUE(paths.empty()); -} - -TEST(KShortestPaths, UniqueModeFiltering) { - auto g = make_square_graph(1); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - KspOptions opts1; - opts1.k = 10; - opts1.unique = true; - auto paths_unique = algs.ksp(gh, 0, 2, opts1); - - KspOptions opts2; - opts2.k = 10; - opts2.unique = false; - auto paths_non_unique = algs.ksp(gh, 0, 2, opts2); - - // Non-unique mode should find at least as many paths as unique mode - EXPECT_GE(paths_non_unique.size(), paths_unique.size()); -} - -TEST(KShortestPaths, MaxCostFactorLimit) { - auto g = make_square_graph(1); // Has paths of cost 2 and 4 - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - KspOptions opts1; - opts1.k = 10; - opts1.max_cost_factor = 1.5; // Allow up to 1.5x shortest - auto paths_limited = algs.ksp(gh, 0, 2, opts1); - - KspOptions opts2; - opts2.k = 10; - opts2.max_cost_factor = std::nullopt; // No limit - auto paths_unlimited = algs.ksp(gh, 0, 2, opts2); - - // Unlimited should find at least as many paths - EXPECT_GE(paths_unlimited.size(), paths_limited.size()); - - // All limited paths should have reasonable cost (check via distance to destination) - if (!paths_limited.empty()) { - Cost min_cost = paths_limited[0].first[2]; // Cost to destination node 2 - - for (const auto& [dist, dag] : paths_limited) { - Cost path_cost = dist[2]; // Cost to destination - EXPECT_LE(path_cost, min_cost * 1.5 + 1e-9); - } - } -} - -TEST(KShortestPaths, PathsSortedByCost) { - auto g = make_square_graph(1); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - KspOptions opts; - opts.k = 5; - opts.unique = true; - - auto paths = algs.ksp(gh, 0, 2, opts); - - if (paths.size() > 1) { - // Extract costs to destination (node 2) - std::vector costs; - for (const auto& [dist, dag] : paths) { - costs.push_back(dist[2]); // Cost to destination node 2 - } - - // Check sorted - for (std::size_t i = 0; i < costs.size() - 1; ++i) { - EXPECT_LE(costs[i], costs[i + 1] + 1e-9) << "Paths not sorted by cost"; - } - } -} - -TEST(KShortestPaths, DisconnectedReturnsEmpty) { - // Create disconnected graph: 0-1 and 2-3 - std::int32_t src[2] = {0, 2}; - std::int32_t dst[2] = {1, 3}; - double cap[2] = {1.0, 1.0}; - std::int64_t cost[2] = {1, 1}; - - auto g = StrictMultiDiGraph::from_arrays(4, - std::span(src, 2), std::span(dst, 2), - std::span(cap, 2), std::span(cost, 2)); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - KspOptions opts; - opts.k = 5; - - // Try to find path from 0 to 3 (disconnected) - auto paths = algs.ksp(gh, 0, 3, opts); - - EXPECT_EQ(paths.size(), 0); -} - -TEST(KShortestPaths, LooplessPaths) { - // Create a graph with cycles: 0->1->2->3 (forward path) and 1->0, 2->0 (back edges) - std::int32_t src[6] = {0, 1, 2, 1, 0, 2}; - std::int32_t dst[6] = {1, 2, 3, 0, 2, 0}; // Has cycles - double cap[6] = {1.0, 1.0, 1.0, 1.0, 1.0, 1.0}; - std::int64_t cost[6] = {1, 1, 1, 1, 2, 2}; - - auto g = StrictMultiDiGraph::from_arrays(4, - std::span(src, 6), std::span(dst, 6), - std::span(cap, 6), std::span(cost, 6)); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - KspOptions opts; - opts.k = 10; - opts.unique = true; - - auto paths = algs.ksp(gh, 0, 3, opts); - - // KSP should return loop-free paths (Yen's algorithm property) - // Verify by checking that destination is reachable and has finite cost - for (const auto& [dist, dag] : paths) { - // Destination (node 3) should be reachable with finite cost - EXPECT_LT(dist[3], std::numeric_limits::max()) - << "Path to destination should have finite cost (no infinite loops)"; - - // Source (node 0) should have distance 0 - EXPECT_DOUBLE_EQ(dist[0], 0.0) << "Source distance should be 0"; - - // Path cost should be positive (since all edge costs are positive) - EXPECT_GT(dist[3], 0.0) << "Path cost should be positive"; - } - - // Should find at least one path (0->1->2->3 with cost 3) - EXPECT_GT(paths.size(), 0) << "Should find at least one loop-free path"; - - // First path should be shortest (cost 3) - if (!paths.empty()) { - EXPECT_DOUBLE_EQ(paths[0].first[3], 3.0) << "Shortest path should have cost 3"; - } -} - -TEST(KShortestPaths, OutOfOrderNodePredDAGSemantics) { - // Graph topology that forces paths visiting nodes out of numerical order. - // Path 1 (shortest): 0 -> 1 -> 2 (cost 2, in-order) - // Path 2 (longer): 0 -> 3 -> 2 (cost 4, visits node 3 before node 2) - // This was the exact bug scenario: PredDAG fill for path [0,3,2] swapped - // parent entries between nodes 2 and 3 when using a linear index. - auto g = make_square_graph(1); // 0->1->2 (cost 2) vs 0->3->2 (cost 4) - - auto items = k_shortest_paths(g, 0, 2, 5, std::nullopt, true); - - ASSERT_GE(items.size(), 2) << "Should find at least 2 paths"; - - // Verify all PredDAGs have correct semantic structure - for (std::size_t p = 0; p < items.size(); ++p) { - const auto& [dist, dag] = items[p]; - SCOPED_TRACE("Path " + std::to_string(p)); - - expect_pred_dag_valid(dag, g.num_nodes()); - expect_pred_dag_semantically_valid(g, dag, dist); - - // Source must have distance 0 and destination must be reachable - EXPECT_EQ(dist[0], 0); - EXPECT_LT(dist[2], std::numeric_limits::max()); - - // Verify path can be reconstructed via resolve_to_paths - auto concrete = resolve_to_paths(dag, 0, 2, /*split_parallel_edges=*/true); - EXPECT_GE(concrete.size(), 1) << "resolve_to_paths should reconstruct at least one path"; - - // Verify reconstructed path is valid: consecutive nodes connected by claimed edges - for (const auto& path : concrete) { - ASSERT_GE(path.size(), 2); // at least src and dst - EXPECT_EQ(path.front().first, 0) << "Path should start at source"; - EXPECT_EQ(path.back().first, 2) << "Path should end at destination"; - // Check edge connectivity - for (std::size_t i = 0; i + 1 < path.size(); ++i) { - auto from_node = path[i].first; - auto to_node = path[i + 1].first; - const auto& edges = path[i].second; - for (auto eid : edges) { - EXPECT_EQ(g.edge_src_view()[static_cast(eid)], from_node); - EXPECT_EQ(g.edge_dst_view()[static_cast(eid)], to_node); - } - } - } - } -} - -TEST(KShortestPaths, LargerOutOfOrderTopology) { - // Larger graph where multiple k-shortest paths visit nodes out of numerical order. - // Topology (6 nodes): - // 0 -> 5 -> 3 -> 1 (cost 3, severely out-of-order: 0,5,3,1) - // 0 -> 2 -> 4 -> 1 (cost 6, also out-of-order: 0,2,4,1) - // 0 -> 1 (cost 10, direct) - std::int32_t src_arr[7] = {0, 5, 3, 0, 2, 4, 0}; - std::int32_t dst_arr[7] = {5, 3, 1, 2, 4, 1, 1}; - double cap_arr[7] = {1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0}; - std::int64_t cost_arr[7] = {1, 1, 1, 2, 2, 2, 10}; - - auto g = StrictMultiDiGraph::from_arrays(6, - std::span(src_arr, 7), std::span(dst_arr, 7), - std::span(cap_arr, 7), std::span(cost_arr, 7)); - - auto items = k_shortest_paths(g, 0, 1, 5, std::nullopt, true); - - ASSERT_GE(items.size(), 2) << "Should find at least 2 paths"; - - // Verify costs are non-decreasing - for (std::size_t i = 0; i + 1 < items.size(); ++i) { - EXPECT_LE(items[i].first[1], items[i + 1].first[1]) - << "Paths should be sorted by cost to destination"; - } - - // The first path should be 0->5->3->1 (cost 3) - EXPECT_EQ(items[0].first[1], 3) << "Shortest path cost should be 3"; - - // Verify all PredDAGs are semantically correct and can be resolved - for (std::size_t p = 0; p < items.size(); ++p) { - const auto& [dist, dag] = items[p]; - SCOPED_TRACE("Path " + std::to_string(p)); - - expect_pred_dag_valid(dag, g.num_nodes()); - expect_pred_dag_semantically_valid(g, dag, dist); - - // Source must have distance 0 and destination must be reachable - EXPECT_EQ(dist[0], 0); - EXPECT_LT(dist[1], std::numeric_limits::max()); - - auto concrete = resolve_to_paths(dag, 0, 1, true); - EXPECT_GE(concrete.size(), 1) << "Should reconstruct at least one concrete path"; - - // Verify node connectivity in reconstructed paths - for (const auto& path : concrete) { - ASSERT_GE(path.size(), 2); - EXPECT_EQ(path.front().first, 0); - EXPECT_EQ(path.back().first, 1); - // Verify no repeated nodes (simple path) - std::set visited; - for (const auto& [node, edges] : path) { - EXPECT_TRUE(visited.insert(node).second) - << "Node " << node << " appears twice in path (cycle)"; - } - } - } -} - -// Regression: a max_cost_factor below 1.0 puts the cost ceiling under the shortest -// path, so no path is admitted. The k > 1 loop opened with paths.back() on that empty -// vector -- undefined behaviour. The k == 1 early return masked it, so only k >= 2 hit -// it. All k must now agree and return no paths. -TEST(KSP, MaxCostFactorBelowOne_ReturnsEmptyForAllK) { - auto g = make_line_graph(3); - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - for (int k : {1, 2, 5}) { - KspOptions opts; - opts.k = k; - opts.max_cost_factor = 0.5; - auto out = algs.ksp(gh, 0, 2, opts); - EXPECT_TRUE(out.empty()) << "k=" << k << " should admit no path under a sub-1.0 factor"; - } - // A factor of exactly 1.0 still admits the shortest path. - KspOptions ok; - ok.k = 3; - ok.max_cost_factor = 1.0; - EXPECT_EQ(algs.ksp(gh, 0, 2, ok).size(), 1u); -} diff --git a/tests/cpp/masking_tests.cpp b/tests/cpp/masking_tests.cpp index 7af26d3..117ea02 100644 --- a/tests/cpp/masking_tests.cpp +++ b/tests/cpp/masking_tests.cpp @@ -1,646 +1,74 @@ -/** - * Comprehensive tests for masking behavior in NetGraph-Core. - * - * These tests verify: - * 1. Node and edge mask semantics (true = allowed, false = excluded) - * 2. Masked source node handling (should return empty DAG) - * 3. Masked destination node handling - * 4. Mask validation (length checking) - * 5. Combination of node and edge masks - * 6. Interaction with residual capacity gating - * 7. Masking in max_flow and k_shortest_paths - */ - +// Eligibility (APPROACH 4) as the 1a graph builds it: dense node and edge flags from excluded +// ids, graph-bound, uid 0 for the all-allowed value. #include -#include -#include "netgraph/core/shortest_paths.hpp" -#include "netgraph/core/max_flow.hpp" -#include "netgraph/core/k_shortest_paths.hpp" + +#include +#include +#include + #include "netgraph/core/strict_multidigraph.hpp" -#include "netgraph/core/backend.hpp" -#include "netgraph/core/algorithms.hpp" -#include "netgraph/core/options.hpp" -#include "netgraph/core/flow_state.hpp" #include "test_utils.hpp" using namespace netgraph::core; using namespace netgraph::core::test; -// ============================================================================ -// Masked Source Tests -// ============================================================================ - -TEST(MaskingTests, MaskedSourceReturnsEmptyDAG) { - // CRITICAL BUG FIX TEST: When source is masked out, SPF must return empty DAG - // and leave all distances at infinity. - auto g = make_line_graph(3); // 0->1->2 - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - - // Mask out source node 0 - auto node_mask = make_bool_mask(g.num_nodes()); - node_mask[0] = false; - - auto [dist, dag] = shortest_paths( - g, 0, std::nullopt, true, sel, {}, - std::span(node_mask.get(), g.num_nodes()), {}); - - // All distances should remain at infinity - EXPECT_EQ(dist[0], std::numeric_limits::max()); - EXPECT_EQ(dist[1], std::numeric_limits::max()); - EXPECT_EQ(dist[2], std::numeric_limits::max()); - - // DAG should be empty (all offsets zero) - EXPECT_EQ(dag.parent_offsets.size(), static_cast(g.num_nodes() + 1)); - for (const auto& offset : dag.parent_offsets) { - EXPECT_EQ(offset, 0) << "DAG should be empty when source is masked"; - } - EXPECT_EQ(dag.parents.size(), 0); - EXPECT_EQ(dag.via_edges.size(), 0); -} - -TEST(MaskingTests, MaskedSourceWithDestination) { - // Test masked source with explicit destination - auto g = make_line_graph(3); - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - - auto node_mask = make_bool_mask(g.num_nodes()); - node_mask[0] = false; // Mask source - - auto [dist, dag] = shortest_paths( - g, 0, NodeId(2), true, sel, {}, - std::span(node_mask.get(), g.num_nodes()), {}); - - // Destination should be unreachable - EXPECT_EQ(dist[2], std::numeric_limits::max()); - - // DAG should be empty - EXPECT_EQ(dag.parent_offsets.size(), static_cast(g.num_nodes() + 1)); - for (const auto& offset : dag.parent_offsets) { - EXPECT_EQ(offset, 0); - } -} - -TEST(MaskingTests, MaskedSourceInMaxFlow) { - // Test masked source in max_flow algorithm - auto g = make_line_graph(3); - - auto node_mask = make_bool_mask(g.num_nodes()); - node_mask[0] = false; // Mask source - - auto [flow_val, summary] = calc_max_flow( - g, 0, 2, - FlowPlacement::Proportional, false, true, true, false, false, - std::span(node_mask.get(), g.num_nodes()), {}); - - // No flow should be possible - EXPECT_DOUBLE_EQ(flow_val, 0.0); - - // Edge flows may be empty or all zeros when no flow is possible - if (!summary.edge_flows.empty()) { - for (const auto& ef : summary.edge_flows) { - EXPECT_DOUBLE_EQ(ef, 0.0); - } - } -} - -TEST(MaskingTests, MaskedSourceInComputeMinCut) { - // Test masked source in FlowState::compute_min_cut (early return optimization) - auto g = make_triangle_topology(); - FlowState fs(g); - - auto node_mask = make_bool_mask(g.num_nodes()); - node_mask[0] = false; // Mask source - - auto mincut = fs.compute_min_cut( - 0, - std::span(node_mask.get(), g.num_nodes()), - {}); - - // Min cut should be empty when source is masked (early return) - EXPECT_EQ(mincut.edges.size(), 0); -} - -TEST(MaskingTests, OutOfRangeSourceInComputeMinCut) { - // Test early return for out-of-range source in compute_min_cut - auto g = make_triangle_topology(); - FlowState fs(g); - - // Test negative source - auto mincut = fs.compute_min_cut(-1, {}, {}); - EXPECT_EQ(mincut.edges.size(), 0); - - // Test source >= num_nodes - mincut = fs.compute_min_cut(999, {}, {}); - EXPECT_EQ(mincut.edges.size(), 0); -} - -// ============================================================================ -// Masked Destination Tests -// ============================================================================ - -TEST(MaskingTests, MaskedDestinationUnreachable) { - auto g = make_line_graph(3); // 0->1->2 - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - - // Mask out destination node 2 - auto node_mask = make_bool_mask(g.num_nodes()); - node_mask[2] = false; - - auto [dist, dag] = shortest_paths( - g, 0, std::nullopt, true, sel, {}, - std::span(node_mask.get(), g.num_nodes()), {}); - - // Source and node 1 should be reachable - EXPECT_DOUBLE_EQ(dist[0], 0.0); - EXPECT_DOUBLE_EQ(dist[1], 1.0); - - // Destination should be unreachable - EXPECT_EQ(dist[2], std::numeric_limits::max()); - - // Node 2 should have no predecessors - auto start = static_cast(dag.parent_offsets[2]); - auto end = static_cast(dag.parent_offsets[3]); - EXPECT_EQ(start, end) << "Masked node should have no predecessors"; -} - -TEST(MaskingTests, MaskedIntermediateNode) { - auto g = make_line_graph(3); // 0->1->2 - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - - // Mask out intermediate node 1 - auto node_mask = make_bool_mask(g.num_nodes()); - node_mask[1] = false; - - auto [dist, dag] = shortest_paths( - g, 0, NodeId(2), true, sel, {}, - std::span(node_mask.get(), g.num_nodes()), {}); - - // Source reachable - EXPECT_DOUBLE_EQ(dist[0], 0.0); - - // Nodes 1 and 2 should be unreachable (path broken) - EXPECT_EQ(dist[1], std::numeric_limits::max()); - EXPECT_EQ(dist[2], std::numeric_limits::max()); -} - -// ============================================================================ -// Edge Mask Tests -// ============================================================================ - -TEST(MaskingTests, MaskedEdgeBlocksPath) { - auto g = make_line_graph(3); // 0->1->2 - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - - // Mask out first edge (0->1) - auto edge_mask = make_bool_mask(g.num_edges()); - edge_mask[0] = false; - - auto [dist, dag] = shortest_paths( - g, 0, std::nullopt, true, sel, {}, {}, - std::span(edge_mask.get(), g.num_edges())); - - EXPECT_DOUBLE_EQ(dist[0], 0.0); - EXPECT_EQ(dist[1], std::numeric_limits::max()); - EXPECT_EQ(dist[2], std::numeric_limits::max()); -} - -TEST(MaskingTests, MaskedEdgeSelectsAlternativePath) { - // Create graph with two paths: 0->1->3 and 0->2->3 - auto g = make_simple_parallel_paths_topology(); - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - - // Mask out edge 0->1 (first edge) - auto edge_mask = make_bool_mask(g.num_edges()); - edge_mask[0] = false; - - auto [dist, dag] = shortest_paths( - g, 0, NodeId(3), true, sel, {}, {}, - std::span(edge_mask.get(), g.num_edges())); - - // Should still reach destination via alternative path 0->2->3 - EXPECT_DOUBLE_EQ(dist[0], 0.0); - EXPECT_EQ(dist[1], std::numeric_limits::max()); // Node 1 masked - EXPECT_DOUBLE_EQ(dist[2], 1.0); // Via 0->2 - EXPECT_DOUBLE_EQ(dist[3], 2.0); // Via 0->2->3 -} - -TEST(MaskingTests, MultipleEdgesMasked) { - auto g = make_square_graph(2); // Two equal-cost paths - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - - // Mask out multiple edges - auto edge_mask = make_bool_mask(g.num_edges()); - edge_mask[1] = false; // Block one path - - auto [dist, dag] = shortest_paths( - g, 0, std::nullopt, true, sel, {}, {}, - std::span(edge_mask.get(), g.num_edges())); - - // Should still have one path to destination - EXPECT_DOUBLE_EQ(dist[0], 0.0); - EXPECT_GT(dist[2], 0.0); - EXPECT_LT(dist[2], std::numeric_limits::max()); -} - -// ============================================================================ -// Combined Node and Edge Mask Tests -// ============================================================================ - -TEST(MaskingTests, CombinedNodeAndEdgeMasks) { - // Create a grid graph with multiple paths - auto g = make_grid_graph(3, 3); // 3x3 grid - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - - // Mask out some nodes and edges to create specific topology - auto node_mask = make_bool_mask(g.num_nodes()); - node_mask[4] = false; // Mask center node - - auto edge_mask = make_bool_mask(g.num_edges()); - // Find and mask specific edge (e.g., 0->1) - auto edge_src = g.edge_src_view(); - for (std::int32_t e = 0; e < g.num_edges(); ++e) { - if (edge_src[e] == 0 && e == 0) { - edge_mask[e] = false; - } +namespace { + +std::vector flags(std::span s) { return std::vector(s.begin(), s.end()); } + +} // namespace + +TEST(Eligibility, AllAllowedHasUidZero) { + auto g = make_graph(3, {{0, 1, 1, 1}, {1, 2, 1, 1}}); + auto el = g.eligibility(); + EXPECT_EQ(el.uid(), 0u); + EXPECT_EQ(el.graph_uid(), g.uid()); + EXPECT_EQ(flags(el.node_allowed()), (std::vector{1, 1, 1})); + EXPECT_EQ(flags(el.edge_allowed()), (std::vector{1, 1})); +} + +TEST(Eligibility, ExcludedNodeExcludesIncidentEdges) { + // In-edges, out-edges and a self-loop of the excluded node. + auto g = make_graph(4, {{0, 1, 1, 1}, {1, 2, 1, 1}, {2, 3, 1, 1}, {1, 1, 1, 2}, {3, 0, 1, 1}}); + const auto inv = id_of_input(g); + const std::vector xn = {1}; + auto el = g.eligibility(xn, {}); + EXPECT_NE(el.uid(), 0u); + EXPECT_EQ(flags(el.node_allowed()), (std::vector{1, 0, 1, 1})); + auto allowed_in = to_input(g, std::vector(el.edge_allowed().begin(), el.edge_allowed().end())); + EXPECT_EQ(std::vector(allowed_in.begin(), allowed_in.end()), (std::vector{0, 0, 1, 0, 1})); + EXPECT_FALSE(el.edge_ok(inv[0])); + EXPECT_TRUE(el.edge_ok(inv[2])); +} + +TEST(Eligibility, ExcludedEdgesDuplicatesAndOrder) { + auto g = make_graph(3, {{0, 1, 1, 1}, {1, 2, 1, 1}, {0, 2, 1, 5}}); + const std::vector xe = {2, 0, 2}; + const std::vector xn = {2, 2}; + auto el = g.eligibility(xn, xe); + EXPECT_EQ(flags(el.node_allowed()), (std::vector{1, 1, 0})); + for (EdgeId e = 0; e < 3; ++e) { + const bool excluded = e == 0 || e == 2 || g.edge_dst()[static_cast(e)] == 2; + EXPECT_EQ(el.edge_ok(e), !excluded) << e; } - - auto [dist, dag] = shortest_paths( - g, 0, std::nullopt, true, sel, {}, - std::span(node_mask.get(), g.num_nodes()), - std::span(edge_mask.get(), g.num_edges())); - - // Verify source is reachable - EXPECT_DOUBLE_EQ(dist[0], 0.0); - - // Verify masked node is unreachable - EXPECT_EQ(dist[4], std::numeric_limits::max()); - - // DAG should be valid - expect_pred_dag_valid(dag, g.num_nodes()); } -TEST(MaskingTests, NodeMaskOverridesEdgeMask) { - // When both node and edge are masked, node mask takes precedence - // (if a node is masked, all its edges are implicitly unavailable) - auto g = make_line_graph(3); - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - - auto node_mask = make_bool_mask(g.num_nodes()); - node_mask[1] = false; // Mask node 1 - - auto edge_mask = make_bool_mask(g.num_edges()); - // Even if edges are allowed, masked node should block them - // (all edges true) - - auto [dist, dag] = shortest_paths( - g, 0, std::nullopt, true, sel, {}, - std::span(node_mask.get(), g.num_nodes()), - std::span(edge_mask.get(), g.num_edges())); - - // Node 1 and beyond should be unreachable - EXPECT_DOUBLE_EQ(dist[0], 0.0); - EXPECT_EQ(dist[1], std::numeric_limits::max()); - EXPECT_EQ(dist[2], std::numeric_limits::max()); -} - -// ============================================================================ -// Mask Interaction with Residual Capacity -// ============================================================================ - -TEST(MaskingTests, MaskWithResidualCapacity) { - auto g = make_line_graph(4); // 0->1->2->3 - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = true; // Enable residual filtering - - // Set residual capacity - std::vector residual(g.num_edges(), 1.0); - residual[1] = 0.0; // Zero capacity on edge 1->2 - - // Also mask out edge 2->3 - auto edge_mask = make_bool_mask(g.num_edges()); - edge_mask[2] = false; - - auto [dist, dag] = shortest_paths( - g, 0, std::nullopt, true, sel, residual, {}, - std::span(edge_mask.get(), g.num_edges())); - - // Should reach node 1 but not beyond - EXPECT_DOUBLE_EQ(dist[0], 0.0); - EXPECT_DOUBLE_EQ(dist[1], 1.0); - EXPECT_EQ(dist[2], std::numeric_limits::max()); // Blocked by residual - EXPECT_EQ(dist[3], std::numeric_limits::max()); // Blocked by mask -} - -TEST(MaskingTests, ResidualAndNodeMaskCombined) { - auto g = make_simple_parallel_paths_topology(); // Two parallel paths - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = true; - - // Zero capacity on one path - std::vector residual(g.num_edges(), 1.0); - residual[0] = 0.0; // Block 0->1 - - // Mask intermediate node on other path - auto node_mask = make_bool_mask(g.num_nodes()); - node_mask[2] = false; // Block 0->2->3 path - - auto [dist, dag] = shortest_paths( - g, 0, NodeId(3), true, sel, residual, - std::span(node_mask.get(), g.num_nodes()), {}); - - // Destination should be unreachable (both paths blocked) - EXPECT_EQ(dist[3], std::numeric_limits::max()); -} - -// ============================================================================ -// K-Shortest Paths with Masks -// ============================================================================ - -TEST(MaskingTests, KSPWithNodeMask) { - auto g = make_square_graph(2); // Two equal-cost paths - - // Mask out one intermediate node - auto node_mask = make_bool_mask(g.num_nodes()); - node_mask[1] = false; // Block path through node 1 - - auto results = k_shortest_paths( - g, 0, 2, 2, std::nullopt, true, - std::span(node_mask.get(), g.num_nodes()), {}); - - // Should find at most 1 path (through node 3) - EXPECT_LE(results.size(), 1); - - if (results.size() > 0) { - const auto& [dist, dag] = results[0]; - // Destination should be reachable via alternate path - EXPECT_GT(dist[2], 0.0); - EXPECT_LT(dist[2], std::numeric_limits::max()); - } -} - -TEST(MaskingTests, KSPWithEdgeMask) { - auto g = make_square_graph(2); // Two equal-cost paths - - // Mask out edges to eliminate one path - auto edge_mask = make_bool_mask(g.num_edges()); - edge_mask[0] = false; // Block first edge - - auto results = k_shortest_paths( - g, 0, 2, 2, std::nullopt, true, {}, - std::span(edge_mask.get(), g.num_edges())); - - // Should find remaining path(s) - EXPECT_EQ(results.size(), 1u); - - for (const auto& [dist, dag] : results) { - if (dist[2] < std::numeric_limits::max()) { - expect_pred_dag_valid(dag, g.num_nodes()); - } - } -} - -// ============================================================================ -// Max Flow with Masks -// ============================================================================ - -TEST(MaskingTests, MaxFlowWithNodeMask) { - auto g = make_simple_parallel_paths_topology(); - - // Mask out one intermediate node - auto node_mask = make_bool_mask(g.num_nodes()); - node_mask[1] = false; // Block path through node 1 - - auto [flow_val, summary] = calc_max_flow( - g, 0, 3, - FlowPlacement::Proportional, false, true, true, false, false, - std::span(node_mask.get(), g.num_nodes()), {}); - - // Should get flow only through remaining path - EXPECT_GT(flow_val, 0.0); - EXPECT_LE(flow_val, 1.0); // Only one path available - - // Validate constraints - validate_capacity_constraints(g, summary); - validate_flow_conservation(g, summary, 0, 3); -} - -TEST(MaskingTests, MaxFlowWithEdgeMask) { - auto g = make_triangle_topology(); - - // Mask out direct edge 0->2 - auto edge_src = g.edge_src_view(); - auto edge_dst = g.edge_dst_view(); - auto edge_mask = make_bool_mask(g.num_edges()); - - for (std::int32_t e = 0; e < g.num_edges(); ++e) { - if (edge_src[e] == 0 && edge_dst[e] == 2) { - edge_mask[e] = false; - } - } - - auto [flow_val, summary] = calc_max_flow( - g, 0, 2, - FlowPlacement::Proportional, false, true, true, false, false, - {}, - std::span(edge_mask.get(), g.num_edges())); - - // Should get flow only through indirect path 0->1->2 - EXPECT_GT(flow_val, 0.0); - EXPECT_LE(flow_val, 1.0); // Limited by bottleneck - - // Verify masked edge has no flow - for (std::int32_t e = 0; e < g.num_edges(); ++e) { - if (edge_src[e] == 0 && edge_dst[e] == 2) { - EXPECT_DOUBLE_EQ(summary.edge_flows[e], 0.0) - << "Masked edge should have zero flow"; - } - } -} - -TEST(MaskingTests, MaxFlowAllPathsMasked) { - auto g = make_line_graph(3); - - // Mask intermediate node to block all paths - auto node_mask = make_bool_mask(g.num_nodes()); - node_mask[1] = false; - - auto [flow_val, summary] = calc_max_flow( - g, 0, 2, - FlowPlacement::Proportional, false, true, true, false, false, - std::span(node_mask.get(), g.num_nodes()), {}); - - // No flow should be possible - EXPECT_DOUBLE_EQ(flow_val, 0.0); - - // All edge flows should be zero - for (const auto& ef : summary.edge_flows) { - EXPECT_DOUBLE_EQ(ef, 0.0); - } -} - -// ============================================================================ -// Empty Mask Tests (no masking applied) -// ============================================================================ - -TEST(MaskingTests, EmptyNodeMaskAllowsAllNodes) { - auto g = make_line_graph(3); - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - - // Empty mask means no masking - auto [dist, dag] = shortest_paths( - g, 0, std::nullopt, true, sel, {}, {}, {}); - - // All nodes should be reachable - EXPECT_DOUBLE_EQ(dist[0], 0.0); - EXPECT_DOUBLE_EQ(dist[1], 1.0); - EXPECT_DOUBLE_EQ(dist[2], 2.0); -} - -TEST(MaskingTests, EmptyEdgeMaskAllowsAllEdges) { - auto g = make_line_graph(3); - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - - // Empty mask means no masking - auto [dist, dag] = shortest_paths( - g, 0, std::nullopt, true, sel, {}, {}, {}); - - expect_pred_dag_valid(dag, g.num_nodes()); - - // All nodes should have valid paths - for (std::int32_t i = 0; i < g.num_nodes(); ++i) { - EXPECT_LT(dist[i], std::numeric_limits::max()); - } -} - -// ============================================================================ -// All-False Mask Tests -// ============================================================================ - -TEST(MaskingTests, AllNodesMaskedNoReachability) { - auto g = make_line_graph(3); - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - - // Mask all nodes - auto node_mask = make_bool_mask(g.num_nodes(), false); - - auto [dist, dag] = shortest_paths( - g, 0, std::nullopt, true, sel, {}, - std::span(node_mask.get(), g.num_nodes()), {}); - - // All nodes unreachable (including source) - for (std::int32_t i = 0; i < g.num_nodes(); ++i) { - EXPECT_EQ(dist[i], std::numeric_limits::max()); - } - - // DAG should be empty - for (const auto& offset : dag.parent_offsets) { - EXPECT_EQ(offset, 0); - } -} - -TEST(MaskingTests, AllEdgesMaskedNoReachability) { - auto g = make_line_graph(3); - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - - // Mask all edges - auto edge_mask = make_bool_mask(g.num_edges(), false); - - auto [dist, dag] = shortest_paths( - g, 0, std::nullopt, true, sel, {}, {}, - std::span(edge_mask.get(), g.num_edges())); - - // Only source should be reachable - EXPECT_DOUBLE_EQ(dist[0], 0.0); - - // All other nodes unreachable - for (std::int32_t i = 1; i < g.num_nodes(); ++i) { - EXPECT_EQ(dist[i], std::numeric_limits::max()); - } -} - -// ============================================================================ -// Backend (Algorithms API) Mask Tests -// ============================================================================ - -TEST(MaskingTests, AlgorithmsAPIMaskedSource) { - auto backend = make_cpu_backend(); - Algorithms algs(backend); - - auto g = make_line_graph(3); - auto gh = algs.build_graph(g); - - // Mask source - auto node_mask = make_bool_mask(g.num_nodes()); - node_mask[0] = false; - - SpfOptions opts; - opts.multipath = true; - opts.selection.multi_edge = true; - opts.selection.require_capacity = false; - opts.node_mask = std::span(node_mask.get(), g.num_nodes()); - - auto [dist, dag] = algs.spf(gh, 0, opts); - - // All distances should be infinity - for (const auto& d : dist) { - EXPECT_EQ(d, std::numeric_limits::max()); - } - - // DAG should be empty - for (const auto& offset : dag.parent_offsets) { - EXPECT_EQ(offset, 0); - } -} - -TEST(MaskingTests, AlgorithmsAPIMaxFlowWithMask) { - auto backend = make_cpu_backend(); - Algorithms algs(backend); - - auto g = make_simple_parallel_paths_topology(); - auto gh = algs.build_graph(g); - - // Mask one path - auto node_mask = make_bool_mask(g.num_nodes()); - node_mask[1] = false; - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = false; - opts.require_capacity = true; - opts.with_edge_flows = true; - opts.node_mask = std::span(node_mask.get(), g.num_nodes()); - - auto [flow_val, summary] = algs.max_flow(gh, 0, 3, opts); - - // Should get flow through remaining path only - EXPECT_GT(flow_val, 0.0); - EXPECT_LE(flow_val, 1.0); +TEST(Eligibility, DistinctUidsForEqualLists) { + auto g = make_graph(2, {{0, 1, 1, 1}}); + const std::vector xe = {0}; + auto a = g.eligibility({}, xe); + auto b = g.eligibility({}, xe); + EXPECT_NE(a.uid(), b.uid()); + EXPECT_NE(a.uid(), 0u); + EXPECT_EQ(flags(a.edge_allowed()), flags(b.edge_allowed())); +} + +TEST(Eligibility, OutOfRangeIdsRejected) { + auto g = make_graph(2, {{0, 1, 1, 1}}); + const std::vector bad_node = {2}; + const std::vector bad_edge = {1}; + const std::vector neg_node = {-1}; + EXPECT_THROW((void)g.eligibility(bad_node, {}), std::invalid_argument); + EXPECT_THROW((void)g.eligibility({}, bad_edge), std::invalid_argument); + EXPECT_THROW((void)g.eligibility(neg_node, {}), std::invalid_argument); } diff --git a/tests/cpp/max_flow_tests.cpp b/tests/cpp/max_flow_tests.cpp index cf14a92..b49378b 100644 --- a/tests/cpp/max_flow_tests.cpp +++ b/tests/cpp/max_flow_tests.cpp @@ -1,1338 +1,551 @@ -/** - * Comprehensive Max-Flow Algorithm Test Suite - * - * This suite systematically validates max-flow correctness across all parameter - * combinations and network topologies. The test matrix ensures complete coverage - * of algorithm behavior under various configurations. - * - * TEST MATRIX COVERAGE: - * - * Dimension 1: Path Structure - * - Single path - * - Multiple disjoint equal-cost paths (2, 3, 4, 10) - * - Multiple cost tiers (2-tier, 3-tier) - * - Complex topologies (grids, shared bottlenecks) - * - * Dimension 2: Capacity Patterns - * - Equal capacities across paths - * - Asymmetric capacities (1:100, 1:1M ratios) - * - Zero capacity edges - * - Bottleneck scenarios - * - * Dimension 3: Flow Placement - * - PROPORTIONAL: Capacity-weighted distribution - * - EQUAL_BALANCED: Uniform distribution across paths - * - * Dimension 4: Path Selection - * - shortest_path=False: Multi-tier max-flow (use all cost tiers) - * - shortest_path=True: Single-tier flow (lowest cost tier only) - * - * Dimension 5: Optional Features - * - Node/edge masking - * - Batch operations - * - Flow details (edge flows, residuals, min-cut) - * - * INVARIANT PROPERTIES VALIDATED: - * - Flow conservation at intermediate nodes - * - Capacity constraints on all edges - * - Cost distribution consistency - * - Min-cut correctness - */ - +// The flow core of APPROACH 2.2 through place_global_units: the review witnesses (expected +// values derived by hand and from prototype 1, `prototypes/p1_amount/flowcore.py`), the +// certificate in its two branches, input validation, determinism, and randomized graphs +// certified against an exhaustive optimum (tier a) and an independent solver (tier b). #include + +#include +#include +#include +#include +#include +#include +#include + #include "netgraph/core/max_flow.hpp" -#include "netgraph/core/backend.hpp" -#include "netgraph/core/algorithms.hpp" -#include "netgraph/core/strict_multidigraph.hpp" #include "test_utils.hpp" using namespace netgraph::core; using namespace netgraph::core::test; -//============================================================================= -// SECTION 1: BASIC CORRECTNESS - SINGLE PATH SCENARIOS -//============================================================================= - -TEST(MaxFlow, SinglePath_BasicLinear) { - // Validates basic max-flow computation on simplest topology - auto g = make_line_graph(3); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = false; - - auto [total, summary] = algs.max_flow(gh, 0, 2, opts); - - EXPECT_NEAR(total, 1.0, 1e-9) << "Single path with unit capacity"; -} - -TEST(MaxFlow, SinglePath_BottleneckConstraint) { - // Validates that bottleneck edge correctly limits total flow - // Topology: 0→1 (cap 10), 1→2 (cap 2), 2→3 (cap 10) - std::int32_t src[3] = {0, 1, 2}; - std::int32_t dst[3] = {1, 2, 3}; - double cap[3] = {10.0, 2.0, 10.0}; - std::int64_t cost[3] = {1, 1, 1}; - - auto g = StrictMultiDiGraph::from_arrays(4, - std::span(src, 3), std::span(dst, 3), - std::span(cap, 3), std::span(cost, 3)); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - - auto [total, summary] = algs.max_flow(gh, 0, 3, opts); - - EXPECT_NEAR(total, 2.0, 1e-9) << "Flow limited by bottleneck edge capacity"; -} - -TEST(MaxFlow, SinglePath_ShortestPathEquivalence) { - // For single-path networks, shortest_path=True/False should be identical - auto g = make_line_graph(3); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); +namespace { - MaxFlowOptions opts_full; - opts_full.placement = FlowPlacement::Proportional; - opts_full.shortest_path = false; - auto [total_full, _] = algs.max_flow(gh, 0, 2, opts_full); +constexpr Amount kL62m1 = (Amount{1} << 62) - 1; - MaxFlowOptions opts_sp; - opts_sp.placement = FlowPlacement::Proportional; - opts_sp.shortest_path = true; - auto [total_sp, __] = algs.max_flow(gh, 0, 2, opts_sp); - - EXPECT_NEAR(total_full, 1.0, 1e-9); - EXPECT_NEAR(total_sp, 1.0, 1e-9); - EXPECT_NEAR(total_full, total_sp, 1e-9) - << "shortest_path flag should not affect single-path networks"; -} - -TEST(MaxFlow, DegenerateCase_SourceEqualsSink) { - // Validates handling of degenerate case where source = sink - auto g = make_line_graph(3); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - - auto [total, summary] = algs.max_flow(gh, 1, 1, opts); - - EXPECT_NEAR(total, 0.0, 1e-12) << "Flow from node to itself must be zero"; -} - -//============================================================================= -// SECTION 2: EQUAL-COST MULTIPATH (ECMP) - CORE VALIDATION -// -// This section systematically validates that when multiple paths have equal -// cost, the algorithm correctly utilizes ALL paths to maximize throughput. -// This is critical for data center fabrics (Clos, spine-leaf) where ECMP -// load balancing across equal-cost paths is the primary traffic distribution -// mechanism. -//============================================================================= - -TEST(MaxFlow, ECMP_TwoPaths_Proportional_ShortestPath) { - // Validates ECMP with 2 parallel equal-cost paths - // Topology: Two disjoint paths from 0→3, each with capacity 10.0 - // shortest_path=True should saturate both paths (not just one) - auto g = make_n_disjoint_paths(2, 10.0); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = true; - opts.with_edge_flows = true; - - auto [total, summary] = algs.max_flow(gh, 0, 3, opts); - - EXPECT_NEAR(total, 20.0, 1e-9) - << "shortest_path=True must saturate all equal-cost paths"; - - // Verify both paths carry significant flow - EXPECT_GT(summary.edge_flows[0], 5.0) << "Path 1 should be utilized"; - EXPECT_GT(summary.edge_flows[2], 5.0) << "Path 2 should be utilized"; - - validate_capacity_constraints(g, summary); - validate_flow_conservation(g, summary, 0, 3); +// The counters fixed by the normative per-tier order and the augmenting-path sequence, compared +// with prototype 1: dijkstra_runs, dijkstra_pops, settled, dinic_phases, augmentations. arc_scans +// is the implementation's own diagnostic (the level search here first marks the vertices that +// reach t* and stops at t*'s level, so it examines a different number of arcs than the +// prototype's full BFS); it is checked for determinism and presence only. +using Counters = std::vector; +Counters counters_of(const FlowCoreResult& r) { + const auto& c = r.counters; + EXPECT_GE(c.arc_scans, c.dijkstra_runs > 0 ? 1 : 0); + return {c.dijkstra_runs, c.dijkstra_pops, c.settled, c.dinic_phases, c.augmentations}; } -TEST(MaxFlow, ECMP_TwoPaths_Proportional_FullMaxFlow) { - // Same topology, but shortest_path=False for comparison - auto g = make_n_disjoint_paths(2, 10.0); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = false; - opts.with_edge_flows = true; - - auto [total, summary] = algs.max_flow(gh, 0, 3, opts); - - EXPECT_NEAR(total, 20.0, 1e-9) << "Full max-flow should use both paths"; - - validate_capacity_constraints(g, summary); - validate_flow_conservation(g, summary, 0, 3); +std::vector chain_plus_bypasses(Cost factor = 1) { + // Chain 0-1-2-3 of cost 1 with bypasses 0->2 and 1->3 of cost 3, unit capacities. + return {{0, 1, 1, factor}, {1, 2, 1, factor}, {2, 3, 1, factor}, {0, 2, 1, 3 * factor}, {1, 3, 1, 3 * factor}}; } -TEST(MaxFlow, ECMP_TwoPaths_EqualBalanced_ShortestPath) { - // Same topology with EQUAL_BALANCED placement - // Should distribute flow uniformly across both paths - auto g = make_n_disjoint_paths(2, 10.0); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::EqualBalanced; - opts.shortest_path = true; - opts.with_edge_flows = true; - - auto [total, summary] = algs.max_flow(gh, 0, 3, opts); - - EXPECT_NEAR(total, 20.0, 1e-9) << "Equal-balanced should use both paths"; - - // With equal capacities, both paths should have equal flow - EXPECT_NEAR(summary.edge_flows[0], 10.0, 1e-9) << "Path 1 should be saturated"; - EXPECT_NEAR(summary.edge_flows[2], 10.0, 1e-9) << "Path 2 should be saturated"; - - validate_capacity_constraints(g, summary); - validate_flow_conservation(g, summary, 0, 3); +void expect_certified(const StrictMultiDiGraph& g, const std::vector& s, const std::vector& d, + std::optional cap, const FlowCoreResult& r, const std::vector* view = nullptr, + const Eligibility* elig = nullptr, bool tier_a = true) { + std::vector full(g.capacity_units().begin(), g.capacity_units().end()); + const auto& v = view ? *view : full; + const auto fails = certify(g, v, s, d, cap, elig, r, tier_a); + EXPECT_TRUE(fails.empty()) << (fails.empty() ? "" : fails.front()); } -TEST(MaxFlow, ECMP_ThreePaths_Proportional_ShortestPath) { - // Extends ECMP validation to 3 equal-cost paths - auto g = make_n_disjoint_paths(3, 10.0); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = true; - opts.with_edge_flows = true; - - auto [total, summary] = algs.max_flow(gh, 0, 4, opts); - - EXPECT_NEAR(total, 30.0, 1e-9) - << "Should saturate all 3 equal-cost paths"; - - // Each path should carry significant flow (~10.0) - for (int i = 0; i < 3; ++i) { - EXPECT_GT(summary.edge_flows[i * 2], 5.0) - << "Path " << i << " should be utilized"; +std::string error_of(const std::function& fn) { + try { + fn(); + } catch (const std::invalid_argument& e) { + return e.what(); } - - validate_capacity_constraints(g, summary); - validate_flow_conservation(g, summary, 0, 4); -} - -TEST(MaxFlow, ECMP_FourPaths_ClosFabric_Simulation) { - // Simulates 4-spine Clos fabric (common data center topology) - // Each spine provides an equal-cost path with 100 Gbps capacity - auto g = make_n_disjoint_paths(4, 100.0); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = true; - opts.with_edge_flows = true; - - auto [total, summary] = algs.max_flow(gh, 0, 5, opts); - - EXPECT_NEAR(total, 400.0, 1e-9) - << "4-spine Clos: aggregate bandwidth should be 400 Gbps"; - - validate_capacity_constraints(g, summary); - validate_flow_conservation(g, summary, 0, 5); -} - -TEST(MaxFlow, ECMP_TenPaths_ScalabilityValidation) { - // Validates algorithm scalability with many parallel equal-cost paths - auto g = make_n_disjoint_paths(10, 10.0); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = true; - opts.with_edge_flows = true; - - auto [total, summary] = algs.max_flow(gh, 0, 11, opts); - - EXPECT_NEAR(total, 100.0, 1e-9) - << "Should saturate all 10 equal-cost paths"; - - validate_capacity_constraints(g, summary); - validate_flow_conservation(g, summary, 0, 11); -} - -//============================================================================= -// SECTION 3: ASYMMETRIC CAPACITY PATTERNS -// -// Real networks often have paths with very different capacities due to -// heterogeneous equipment, upgrades, or failures. These tests validate -// correct behavior under capacity asymmetry. -//============================================================================= - -TEST(MaxFlow, AsymmetricCapacity_1to100_Proportional) { - // Two equal-cost paths with 100x capacity difference - // Path 1: capacity 1.0, Path 2: capacity 100.0 - std::int32_t src_arr[4] = {0, 1, 0, 2}; - std::int32_t dst_arr[4] = {1, 3, 2, 3}; - double cap_arr[4] = {1.0, 1.0, 100.0, 100.0}; - std::int64_t cost_arr[4] = {1, 1, 1, 1}; // Equal cost - - auto g = StrictMultiDiGraph::from_arrays(4, - std::span(src_arr, 4), std::span(dst_arr, 4), - std::span(cap_arr, 4), std::span(cost_arr, 4)); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = true; - opts.with_edge_flows = true; - - auto [total, summary] = algs.max_flow(gh, 0, 3, opts); - - // Should use both paths despite capacity asymmetry - EXPECT_NEAR(total, 101.0, 1e-9) << "Total flow should be 1.0 + 100.0"; - - // Validate per-edge flows by (u,v) lookup instead of relying on edge indices - auto row = g.row_offsets_view(); - auto col = g.col_indices_view(); - auto aei = g.adj_edge_index_view(); - auto eid_uv = [&](int u, int v) -> std::size_t { - auto s = static_cast(row[static_cast(u)]); - auto e = static_cast(row[static_cast(u) + 1]); - for (std::size_t j = s; j < e; ++j) { - if (static_cast(col[j]) == v) return static_cast(aei[j]); + return ""; +} + +} // namespace + +TEST(FlowCore, QuantisedCapacitiesFamily) { + // Physical 2.5q is 3 units and 0.5q is 1 unit (review-2 R1 at 1a: the quantised network). + const double q = std::ldexp(1.0, -12); + const Amount a = to_units(2.5 * q, q); + const Amount b = to_units(0.5 * q, q); + ASSERT_EQ(a, 3); + ASSERT_EQ(b, 1); + auto g = make_graph(3, {{0, 1, a, 1}, {1, 2, a, 1}, {0, 2, b, 3}}, q); + auto r = solve(g, {0}, {2}, std::nullopt); + EXPECT_EQ(r.placed, 4); + EXPECT_EQ(r.cost_distribution, curve({{2, 3}, {3, 1}})); + ASSERT_TRUE(r.min_cut.has_value()); + EXPECT_EQ(ids_to_input(g, *r.min_cut), (std::vector{0, 2})); + EXPECT_EQ(r.potentials, (std::vector{0, 2, 3})); + EXPECT_EQ(counters_of(r), (Counters{3, 13, 12, 4, 2})); + expect_certified(g, {0}, {2}, std::nullopt, r); +} + +TEST(FlowCore, ChainPlusBypassesCancellation) { + // review-14 flow.chain, review-1 C7: value 2 needs the middle edge cancelled. + auto g = make_graph(4, chain_plus_bypasses()); + auto one = solve(g, {0}, {3}, 1); + EXPECT_EQ(one.placed, 1); + EXPECT_EQ(one.cost_distribution, curve({{3, 1}})); + EXPECT_EQ(to_input(g, one.edge_flows), (std::vector{1, 1, 1, 0, 0})); + EXPECT_FALSE(one.min_cut.has_value()); // review-14 flow.finite_cap: value 1 of a maximum 2 + EXPECT_EQ(counters_of(one), (Counters{1, 8, 6, 1, 1})); + expect_certified(g, {0}, {3}, 1, one); + auto two = solve(g, {0}, {3}, 2); + EXPECT_EQ(two.placed, 2); + EXPECT_EQ(two.cost_distribution, curve({{3, 1}, {5, 1}})); // C(1) = 3, C(2) = 8 + EXPECT_EQ(to_input(g, two.edge_flows), (std::vector{1, 0, 1, 1, 1})); + ASSERT_TRUE(two.min_cut.has_value()); + EXPECT_EQ(ids_to_input(g, *two.min_cut), (std::vector{0, 3})); + EXPECT_EQ(two.potentials, (std::vector{0, 2, 3, 5})); + EXPECT_EQ(counters_of(two), (Counters{2, 14, 12, 3, 2})); + expect_certified(g, {0}, {3}, 2, two); + auto inf = solve(g, {0}, {3}, std::nullopt); + EXPECT_EQ(inf.placed, 2); + EXPECT_EQ(inf.cost_distribution, two.cost_distribution); + EXPECT_EQ(inf.edge_flows, two.edge_flows); + EXPECT_EQ(inf.min_cut, two.min_cut); + EXPECT_EQ(counters_of(inf), (Counters{3, 16, 14, 4, 2})); // the final failed Dijkstra + expect_certified(g, {0}, {3}, std::nullopt, inf); +} + +TEST(FlowCore, ChainPlusBypassesMasksAndCostFactors) { + // The middle edge disabled by a zero view or excluded by Eligibility: curve [(4, 2)]. + auto g = make_graph(4, chain_plus_bypasses()); + const auto inv = id_of_input(g); + std::vector view(g.capacity_units().begin(), g.capacity_units().end()); + view[static_cast(inv[1])] = 0; + auto zero = solve(g, {0}, {3}, std::nullopt, &view); + EXPECT_EQ(zero.cost_distribution, curve({{4, 2}})); + EXPECT_EQ(to_input(g, zero.edge_flows), (std::vector{1, 0, 1, 1, 1})); + EXPECT_EQ(zero.potentials, (std::vector{0, 1, 3, 4})); + expect_certified(g, {0}, {3}, std::nullopt, zero, &view); + const std::vector excluded = {inv[1]}; + auto el = g.eligibility({}, excluded); + auto masked = solve(g, {0}, {3}, std::nullopt, nullptr, &el); + EXPECT_EQ(masked.cost_distribution, curve({{4, 2}})); + EXPECT_EQ(masked.edge_flows, zero.edge_flows); + EXPECT_EQ(masked.min_cut, zero.min_cut); + expect_certified(g, {0}, {3}, std::nullopt, masked, nullptr, &el); + // Excluding node 1 leaves 0 -> 2 -> 3 only. + const std::vector xn = {1}; + auto el_node = g.eligibility(xn, {}); + auto node_masked = solve(g, {0}, {3}, std::nullopt, nullptr, &el_node); + EXPECT_EQ(node_masked.cost_distribution, curve({{4, 1}})); + EXPECT_EQ(node_masked.potentials[1], kNeverReached); + expect_certified(g, {0}, {3}, std::nullopt, node_masked, nullptr, &el_node); + // Scaling every cost by a factor scales the curve and the potentials; the flows, the cut and + // the operation counters are unchanged. + for (Cost factor : {2, 7, 1000003}) { + auto gf = make_graph(4, chain_plus_bypasses(factor)); + auto base = solve(g, {0}, {3}, std::nullopt); + auto scaled = solve(gf, {0}, {3}, std::nullopt); + ASSERT_EQ(scaled.cost_distribution.size(), base.cost_distribution.size()); + for (std::size_t i = 0; i < base.cost_distribution.size(); ++i) { + EXPECT_EQ(scaled.cost_distribution[i].first, factor * base.cost_distribution[i].first); + EXPECT_EQ(scaled.cost_distribution[i].second, base.cost_distribution[i].second); } - ADD_FAILURE() << "Edge (" << u << "->" << v << ") not found"; - return static_cast(-1); - }; - auto e01 = eid_uv(0, 1); - auto e13 = eid_uv(1, 3); - auto e02 = eid_uv(0, 2); - auto e23 = eid_uv(2, 3); - ASSERT_NE(e01, static_cast(-1)); - ASSERT_NE(e13, static_cast(-1)); - ASSERT_NE(e02, static_cast(-1)); - ASSERT_NE(e23, static_cast(-1)); - // Small path saturated - EXPECT_NEAR(summary.edge_flows[e01], 1.0, 1e-9) - << "Low-capacity first hop should be fully utilized"; - EXPECT_NEAR(summary.edge_flows[e13], 1.0, 1e-9) - << "Low-capacity second hop should be fully utilized"; - // Large path saturated - EXPECT_NEAR(summary.edge_flows[e02], 100.0, 1e-9) - << "High-capacity first hop should be fully utilized"; - EXPECT_NEAR(summary.edge_flows[e23], 100.0, 1e-9) - << "High-capacity second hop should be fully utilized"; - - validate_capacity_constraints(g, summary); - validate_flow_conservation(g, summary, 0, 3); -} - -TEST(MaxFlow, AsymmetricCapacity_1to1M_NumericalStability) { - // Extreme capacity asymmetry: 1M:1 ratio - // Tests numerical stability of proportional placement - std::int32_t src_arr[4] = {0, 1, 0, 2}; - std::int32_t dst_arr[4] = {1, 3, 2, 3}; - double cap_arr[4] = {1.0, 1.0, 1000000.0, 1000000.0}; - std::int64_t cost_arr[4] = {1, 1, 1, 1}; - - auto g = StrictMultiDiGraph::from_arrays(4, - std::span(src_arr, 4), std::span(dst_arr, 4), - std::span(cap_arr, 4), std::span(cost_arr, 4)); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = true; - opts.with_edge_flows = true; - - auto [total, summary] = algs.max_flow(gh, 0, 3, opts); - - // Should handle extreme asymmetry without numerical issues - EXPECT_NEAR(total, 1000001.0, 1e-6) << "Should use both paths"; - - // Small path saturated - EXPECT_NEAR(summary.edge_flows[0], 1.0, 1e-9) - << "Small path should be saturated"; - - validate_capacity_constraints(g, summary); - validate_flow_conservation(g, summary, 0, 3); -} - -TEST(MaxFlow, AsymmetricCapacity_1to100_EqualBalanced) { - // Equal-balanced placement with asymmetric capacities - // Should be limited by minimum capacity across paths - std::int32_t src_arr[4] = {0, 1, 0, 2}; - std::int32_t dst_arr[4] = {1, 3, 2, 3}; - double cap_arr[4] = {1.0, 1.0, 100.0, 100.0}; - std::int64_t cost_arr[4] = {1, 1, 1, 1}; - - auto g = StrictMultiDiGraph::from_arrays(4, - std::span(src_arr, 4), std::span(dst_arr, 4), - std::span(cap_arr, 4), std::span(cost_arr, 4)); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::EqualBalanced; - opts.shortest_path = true; - opts.with_edge_flows = true; - - auto [total, summary] = algs.max_flow(gh, 0, 3, opts); - - // Equal-balanced: both paths get same flow, limited by min capacity - EXPECT_NEAR(total, 2.0, 1e-9) - << "Equal-balanced: 1.0 per path × 2 paths = 2.0"; - - // Both paths should have equal flow - EXPECT_NEAR(summary.edge_flows[0], 1.0, 1e-9) << "Path 1 saturated at min"; - EXPECT_NEAR(summary.edge_flows[2], 1.0, 1e-9) << "Path 2 limited to min"; - - validate_capacity_constraints(g, summary); - validate_flow_conservation(g, summary, 0, 3); -} - -TEST(MaxFlow, EqualBalanced_NodeMask_ReducesECMPWidth) { - // Two equal-cost paths 0->1->3 and 0->2->3 with cap=5 each. - // Mask out node 2; EB should place only on path via 1, limited by 5. - std::int32_t src_arr[4] = {0, 1, 0, 2}; - std::int32_t dst_arr[4] = {1, 3, 2, 3}; - double cap_arr[4] = {5.0, 5.0, 5.0, 5.0}; - std::int64_t cost_arr[4] = {1, 1, 1, 1}; - auto g = StrictMultiDiGraph::from_arrays(4, - std::span(src_arr, 4), std::span(dst_arr, 4), - std::span(cap_arr, 4), std::span(cost_arr, 4)); - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - MaxFlowOptions opts; - opts.placement = FlowPlacement::EqualBalanced; - opts.shortest_path = false; - opts.with_edge_flows = true; - // Node mask: disable node 2 - auto node_mask_vec = make_bool_mask(g.num_nodes()); - node_mask_vec[2] = false; - opts.node_mask = std::span(node_mask_vec.get(), static_cast(g.num_nodes())); - auto [total, summary] = algs.max_flow(gh, 0, 3, opts); - EXPECT_NEAR(total, 5.0, 1e-9) << "Only unmasked path should carry flow"; - // Validate that only path via 1 carries 5 - auto row = g.row_offsets_view(); - auto col = g.col_indices_view(); - auto aei = g.adj_edge_index_view(); - auto eid_uv = [&](int u, int v) -> std::size_t { - auto s = static_cast(row[static_cast(u)]); - auto e = static_cast(row[static_cast(u) + 1]); - for (std::size_t j = s; j < e; ++j) { - if (static_cast(col[j]) == v) return static_cast(aei[j]); + for (std::size_t v = 0; v < base.potentials.size(); ++v) { + EXPECT_EQ(scaled.potentials[v], factor * base.potentials[v]); } - return static_cast(-1); - }; - auto e01 = eid_uv(0, 1); - auto e13 = eid_uv(1, 3); - auto e02 = eid_uv(0, 2); - auto e23 = eid_uv(2, 3); - ASSERT_NE(e01, static_cast(-1)); - ASSERT_NE(e13, static_cast(-1)); - ASSERT_NE(e02, static_cast(-1)); - ASSERT_NE(e23, static_cast(-1)); - EXPECT_NEAR(summary.edge_flows[e01], 5.0, 1e-9); - EXPECT_NEAR(summary.edge_flows[e13], 5.0, 1e-9); - EXPECT_NEAR(summary.edge_flows[e02], 0.0, 1e-9); - EXPECT_NEAR(summary.edge_flows[e23], 0.0, 1e-9); - validate_capacity_constraints(g, summary); - validate_flow_conservation(g, summary, 0, 3); -} -TEST(MaxFlow, ZeroCapacityPath_CorrectFiltering) { - // One path with zero capacity should be ignored - // Tests correct residual capacity filtering - std::int32_t src_arr[4] = {0, 1, 0, 2}; - std::int32_t dst_arr[4] = {1, 3, 2, 3}; - double cap_arr[4] = {10.0, 10.0, 0.0, 0.0}; // Path 2 has zero capacity - std::int64_t cost_arr[4] = {1, 1, 1, 1}; - - auto g = StrictMultiDiGraph::from_arrays(4, - std::span(src_arr, 4), std::span(dst_arr, 4), - std::span(cap_arr, 4), std::span(cost_arr, 4)); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = true; - opts.with_edge_flows = true; - - auto [total, summary] = algs.max_flow(gh, 0, 3, opts); - - EXPECT_NEAR(total, 10.0, 1e-9) - << "Should route all flow via non-zero capacity path"; - - validate_capacity_constraints(g, summary); - validate_flow_conservation(g, summary, 0, 3); -} - -//============================================================================= -// SECTION 4: COST TIER VALIDATION -// -// Networks often have paths with different costs (distance, latency, monetary). -// The shortest_path parameter controls whether to use only lowest-cost paths -// or to utilize higher-cost paths for additional capacity. -//============================================================================= - -TEST(MaxFlow, TwoCostTiers_ShortestPath_SingleTierOnly) { - // Validates that shortest_path=True uses only lowest-cost tier - // Tier 1: cost=1, capacity=5 - // Tier 2: cost=2, capacity=100 - auto g = make_tiered_graph(5.0, 100.0); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = true; - opts.with_edge_flows = true; - - auto [total, summary] = algs.max_flow(gh, 0, 3, opts); - - // Should use ONLY cost=1 tier, NOT cost=2 tier - EXPECT_NEAR(total, 5.0, 1e-9) - << "shortest_path=True: use only lowest-cost tier"; - - // Tier 1 (cost=1) should be saturated - EXPECT_NEAR(summary.edge_flows[0], 5.0, 1e-9) - << "Lowest-cost path should be saturated"; - - // Tier 2 (cost=2) should NOT be used - EXPECT_NEAR(summary.edge_flows[2], 0.0, 1e-9) - << "Higher-cost tier should not be used"; - - // Verify cost distribution contains only one tier - EXPECT_EQ(summary.costs.size(), 1) << "Only one cost tier used"; - EXPECT_EQ(summary.costs[0], 2) << "Total cost = 2 (sum of 2 edges @ cost 1)"; - - validate_capacity_constraints(g, summary); - validate_flow_conservation(g, summary, 0, 3); -} - -TEST(MaxFlow, TwoCostTiers_FullMaxFlow_AllTiers) { - // Validates that shortest_path=False uses all available tiers - auto g = make_tiered_graph(5.0, 100.0); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = false; // Full max-flow - opts.with_edge_flows = true; - - auto [total, summary] = algs.max_flow(gh, 0, 3, opts); - - // Should use BOTH tiers - EXPECT_NEAR(total, 105.0, 1e-9) - << "Full max-flow should use all tiers (5 + 100 = 105)"; - - // Both tiers should be saturated - EXPECT_NEAR(summary.edge_flows[0], 5.0, 1e-9) << "Tier 1 saturated"; - EXPECT_NEAR(summary.edge_flows[2], 100.0, 1e-9) << "Tier 2 saturated"; - - // Verify cost distribution has TWO tiers - EXPECT_EQ(summary.costs.size(), 2) << "Two cost tiers used"; - - validate_capacity_constraints(g, summary); - validate_flow_conservation(g, summary, 0, 3); -} - -TEST(MaxFlow, EqualCost_ShortestVsFull_Equivalent) { - // When all paths have equal cost, shortest_path and full should be identical - auto g = make_n_disjoint_paths(3, 10.0); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts_full; - opts_full.placement = FlowPlacement::Proportional; - opts_full.shortest_path = false; - auto [total_full, _] = algs.max_flow(gh, 0, 4, opts_full); - - MaxFlowOptions opts_sp; - opts_sp.placement = FlowPlacement::Proportional; - opts_sp.shortest_path = true; - auto [total_sp, __] = algs.max_flow(gh, 0, 4, opts_sp); - - EXPECT_NEAR(total_full, total_sp, 1e-9) - << "With all equal-cost paths, both modes should be equivalent"; - EXPECT_NEAR(total_full, 30.0, 1e-9) << "Should saturate all 3 paths"; -} - -TEST(MaxFlow, ThreeCostTiers_ShortestPath_LowestTierOnly) { - // Validates shortest_path with 3+ cost tiers - // Path 1: 0→1→4 (cost=1 per edge, cap=5) - // Path 2: 0→2→4 (cost=2 per edge, cap=10) - // Path 3: 0→3→4 (cost=3 per edge, cap=20) - std::int32_t src_arr[6] = {0, 1, 0, 2, 0, 3}; - std::int32_t dst_arr[6] = {1, 4, 2, 4, 3, 4}; - double cap_arr[6] = {5.0, 5.0, 10.0, 10.0, 20.0, 20.0}; - std::int64_t cost_arr[6] = {1, 1, 2, 2, 3, 3}; - - auto g = StrictMultiDiGraph::from_arrays(5, - std::span(src_arr, 6), std::span(dst_arr, 6), - std::span(cap_arr, 6), std::span(cost_arr, 6)); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = true; - opts.with_edge_flows = true; - - auto [total, summary] = algs.max_flow(gh, 0, 4, opts); - - // Should use ONLY cost=1 tier - EXPECT_NEAR(total, 5.0, 1e-9) - << "Should use only lowest-cost tier (cost=1)"; - - // Only first path should have flow - EXPECT_NEAR(summary.edge_flows[0], 5.0, 1e-9) << "Path 1 (cost=1) saturated"; - EXPECT_NEAR(summary.edge_flows[2], 0.0, 1e-9) << "Path 2 (cost=2) unused"; - EXPECT_NEAR(summary.edge_flows[4], 0.0, 1e-9) << "Path 3 (cost=3) unused"; - - validate_capacity_constraints(g, summary); - validate_flow_conservation(g, summary, 0, 4); -} - -//============================================================================= -// SECTION 5: PLACEMENT MODE COMPARISON -// -// Direct comparison of PROPORTIONAL vs EQUAL_BALANCED behavior under -// various conditions. -//============================================================================= - -TEST(MaxFlow, PlacementModes_EqualCapacity_IdenticalResult) { - // With equal capacities, both modes should give same total flow - auto g = make_n_disjoint_paths(2, 10.0); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts_prop; - opts_prop.placement = FlowPlacement::Proportional; - opts_prop.shortest_path = true; - auto [total_prop, _] = algs.max_flow(gh, 0, 3, opts_prop); - - MaxFlowOptions opts_eq; - opts_eq.placement = FlowPlacement::EqualBalanced; - opts_eq.shortest_path = true; - auto [total_eq, __] = algs.max_flow(gh, 0, 3, opts_eq); - - // Both should saturate both paths - EXPECT_NEAR(total_prop, 20.0, 1e-9); - EXPECT_NEAR(total_eq, 20.0, 1e-9); - EXPECT_NEAR(total_prop, total_eq, 1e-9) - << "With equal capacities, both modes give same total"; -} - -//============================================================================= -// SECTION 6: FLOW PROPERTIES & INVARIANTS -// -// Validates fundamental flow properties: conservation, capacity constraints, -// min-cut correctness, cost distribution consistency. -//============================================================================= - -TEST(MaxFlow, FlowConservation_IntermediateNodes) { - // Validates flow conservation: inflow = outflow at intermediate nodes - auto g = make_square_graph(1); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.with_edge_flows = true; - - auto [total, summary] = algs.max_flow(gh, 0, 2, opts); - - EXPECT_EQ(summary.edge_flows.size(), static_cast(g.num_edges())); - - // Use helper to validate conservation at all intermediate nodes - validate_flow_conservation(g, summary, 0, 2); -} - -TEST(MaxFlow, MinCut_ValidEdgeIdentification) { - // Min-cut should separate source from sink with valid edge IDs - auto g = make_square_graph(1); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - - auto [total, summary] = algs.max_flow(gh, 0, 2, opts); - - // Min cut should contain edges - EXPECT_GT(summary.min_cut.edges.size(), 0) - << "Min-cut must separate source from sink"; - - // All cut edges should have valid IDs - for (auto eid : summary.min_cut.edges) { - EXPECT_GE(eid, 0) << "Edge ID must be non-negative"; - EXPECT_LT(eid, g.num_edges()) << "Edge ID must be in valid range"; + EXPECT_EQ(scaled.edge_flows, base.edge_flows); + EXPECT_EQ(scaled.min_cut, base.min_cut); + EXPECT_EQ(counters_of(scaled), counters_of(base)); + expect_certified(gf, {0}, {3}, std::nullopt, scaled); } } -TEST(MaxFlow, CostDistribution_AccountsForAllFlow) { - // Cost distribution should account for all flow - auto g = make_square_graph(1); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.with_edge_flows = true; - - auto [total, summary] = algs.max_flow(gh, 0, 2, opts); - - // Cost distribution flows should sum to total flow - double cost_sum = 0.0; - for (auto flow : summary.flows) { - cost_sum += flow; +TEST(FlowCore, EqualCostTierNeedsTwoDinicPhases) { + // review-14 flow.equal_cost_multiple_hop_tiers, review-16 solver.multiple_dinic_phases: + // C = {0: 0, 1: 3, 2: 6} in one tier of cost 3 drained by two successful level builds (plus the + // tier's last unsuccessful one). + auto g = make_graph(4, {{0, 3, 1, 3}, {0, 1, 1, 1}, {1, 2, 1, 1}, {2, 3, 1, 1}}); + auto r = solve(g, {0}, {3}, std::nullopt); + EXPECT_EQ(r.placed, 2); + EXPECT_EQ(r.cost_distribution, curve({{3, 2}})); + EXPECT_EQ(r.counters.dijkstra_runs, 2); + EXPECT_EQ(r.counters.dinic_phases, 3); + EXPECT_EQ(counters_of(r), (Counters{2, 8, 8, 3, 2})); + expect_certified(g, {0}, {3}, std::nullopt, r); +} + +TEST(FlowCore, CyclicAdmissibleNeedsUpdateBeforeDrain) { + // review-16 solver.cyclic_admissible: two parallel cost-1 links. Potentials are updated by + // the first Dijkstra before the drain (on the zero potentials no arc has reduced cost 0), and + // the zero-reduced-cost residual cycle 0 -> 1 -> 0 left after the first push is drained in + // the same tier. + auto g = make_graph(2, {{0, 1, 1, 1}, {0, 1, 1, 1}}); + auto r = solve(g, {0}, {1}, 2); + EXPECT_EQ(r.placed, 2); + EXPECT_EQ(r.cost_distribution, curve({{1, 2}})); + EXPECT_EQ(counters_of(r), (Counters{1, 4, 4, 1, 2})); + expect_certified(g, {0}, {1}, 2, r); + // review-17 partial_drain: cap 1 stops inside the tier; the destinations stay connected. + auto p = solve(g, {0}, {1}, 1); + EXPECT_EQ(p.placed, 1); + EXPECT_EQ(p.cost_distribution, curve({{1, 1}})); + EXPECT_FALSE(p.min_cut.has_value()); + EXPECT_EQ(counters_of(p), (Counters{1, 4, 4, 1, 1})); + expect_certified(g, {0}, {1}, 1, p); +} + +TEST(FlowCore, FailedFinalRunRaisesPotentials) { + // review-17 failed_final_run: the side branch 3 is reached at 8 by the first run and at 10 by + // the failed final run; dual feasibility is claimed inside the closure {0, 3} only. + auto g = make_graph(4, {{0, 1, 1, 1}, {1, 2, 1, 1}, {1, 3, 1, 7}, {0, 3, 1, 10}}); + auto r = solve(g, {0}, {2}, std::nullopt); + EXPECT_EQ(r.placed, 1); + EXPECT_EQ(r.cost_distribution, curve({{2, 1}})); + EXPECT_EQ(r.potentials, (std::vector{0, 1, 2, 10})); + ASSERT_TRUE(r.min_cut.has_value()); + EXPECT_EQ(ids_to_input(g, *r.min_cut), (std::vector{0})); + EXPECT_EQ(counters_of(r), (Counters{2, 10, 9, 2, 1})); + expect_certified(g, {0}, {2}, std::nullopt, r); +} + +TEST(FlowCore, FreshSolveUsesReleasedCheapEdge) { + // review-14 flow.release_no_growth, review-15 fresh.owner_release_atomic: on the view W + own + // = [1, 1, 1] the solve at value 1 takes the cheap link (cost 2), not the old cost-4 one. + auto g = make_graph(3, {{0, 1, 1, 1}, {0, 1, 2, 3}, {1, 2, 2, 1}}); + const std::vector view = to_ids(g, std::vector{1, 1, 1}); + auto r = solve(g, {0}, {2}, 1, &view); + EXPECT_EQ(to_input(g, r.edge_flows), (std::vector{1, 0, 1})); + EXPECT_EQ(r.cost_distribution, curve({{2, 1}})); + std::vector residual(view.size()); + for (std::size_t e = 0; e < view.size(); ++e) residual[e] = view[e] - r.edge_flows[e]; + EXPECT_EQ(to_input(g, residual), (std::vector{0, 1, 0})); + expect_certified(g, {0}, {2}, 1, r, &view); + EXPECT_TRUE(same_payload(r, solve(g, {0}, {2}, 1, &view))); +} + +TEST(FlowCore, AttainedValueUnderCapAndInfinity) { + // review-15 C1 and review-16 certificate.internal_cap on one edge of capacity 3 and cost 7. + auto g = make_graph(2, {{0, 1, 3, 7}}); + auto c2 = solve(g, {0}, {1}, 2); + EXPECT_EQ(c2.placed, 2); + EXPECT_FALSE(c2.min_cut.has_value()); + EXPECT_EQ(counters_of(c2), (Counters{1, 4, 4, 1, 1})); + expect_certified(g, {0}, {1}, 2, c2); + auto c3 = solve(g, {0}, {1}, 3); + EXPECT_EQ(c3.placed, 3); + ASSERT_TRUE(c3.min_cut.has_value()); + EXPECT_EQ(*c3.min_cut, (std::vector{0})); + EXPECT_EQ(counters_of(c3), (Counters{1, 4, 4, 1, 1})); // the cap stops before a failed run + auto inf = solve(g, {0}, {1}, std::nullopt); + EXPECT_EQ(inf.placed, 3); + EXPECT_EQ(inf.cost_distribution, curve({{7, 3}})); + EXPECT_EQ(counters_of(inf), (Counters{2, 6, 6, 2, 1})); + expect_certified(g, {0}, {1}, std::nullopt, inf); + // The internal cap 2^63 - 2: the solve runs to disconnection (no clamp, APPROACH 2.2 r25). + auto big = solve(g, {0}, {1}, kMaxFlowCap); + EXPECT_TRUE(same_payload(big, inf)); + expect_certified(g, {0}, {1}, kMaxFlowCap, big); +} + +TEST(FlowCore, ZeroCapIsThePrescribedZeroPayload) { + // review-16 certificate.zero_cap, review-17 C2.zero_cap_branch. + auto g = make_graph(4, {{0, 1, 2, 1}, {1, 2, 2, 2}, {3, 2, 1, 1}}); + auto r = solve(g, {0}, {2}, 0); + EXPECT_EQ(r.placed, 0); + EXPECT_TRUE(r.cost_distribution.empty()); + EXPECT_EQ(r.edge_flows, (std::vector{0, 0, 0})); + EXPECT_FALSE(r.min_cut.has_value()); + EXPECT_EQ(r.potentials, (std::vector(4, kNeverReached))); + EXPECT_EQ(counters_of(r), (Counters{0, 0, 0, 0, 0})); + expect_certified(g, {0}, {2}, 0, r); +} + +TEST(FlowCore, PositiveCapOnZeroView) { + // review-17 C2.positive_cap_no_out_capacity: a positive cap runs one failed Dijkstra even when + // the view gives the sources no out-capacity; roots at 0, min_cut []. + auto g = make_graph(3, {{0, 1, 5, 1}, {1, 2, 5, 1}}); + const std::vector view = to_ids(g, std::vector{0, 5}); + auto r = solve(g, {0}, {2}, 4, &view); + EXPECT_EQ(r.placed, 0); + EXPECT_TRUE(r.cost_distribution.empty()); + ASSERT_TRUE(r.min_cut.has_value()); + EXPECT_TRUE(r.min_cut->empty()); + EXPECT_EQ(r.potentials, (std::vector{0, kNeverReached, kNeverReached})); + EXPECT_EQ(counters_of(r), (Counters{1, 2, 2, 0, 0})); + expect_certified(g, {0}, {2}, 4, r, &view); + // An all-zero view behaves the same way. + const std::vector zeros(2, 0); + auto z = solve(g, {0}, {2}, 1, &zeros); + EXPECT_EQ(z.placed, 0); + ASSERT_TRUE(z.min_cut.has_value()); + EXPECT_TRUE(z.min_cut->empty()); + EXPECT_EQ(z.potentials[0], 0); +} + +TEST(FlowCore, LegalMaximumUnits) { + // review-17 terminal_saturation: one edge at 2^62 - 1 units; the closure is seeded at the real + // sources, so the cut holds the real edge (virtual arcs never saturate). + auto g = make_graph(2, {{0, 1, kL62m1, 1}}); + auto r = solve(g, {0}, {1}, std::nullopt); + EXPECT_EQ(r.placed, kL62m1); + EXPECT_EQ(r.cost_distribution, curve({{1, kL62m1}})); + ASSERT_TRUE(r.min_cut.has_value()); + EXPECT_EQ(*r.min_cut, (std::vector{0})); + EXPECT_EQ(r.potentials, (std::vector{0, 1})); + expect_certified(g, {0}, {1}, std::nullopt, r, nullptr, nullptr, /*tier_a=*/false); + // A sum of capacities at 2^62 - 1 split over parallel paths with large costs. + auto h = make_graph(3, {{0, 1, kL62m1 - 10, (Amount{1} << 60)}, {0, 2, 5, 3}, {2, 1, 5, (Amount{1} << 60)}}); + auto rh = solve(h, {0}, {1}, std::nullopt); + EXPECT_EQ(rh.placed, kL62m1 - 5); + EXPECT_EQ(rh.cost_distribution, curve({{(Amount{1} << 60), kL62m1 - 10}, {(Amount{1} << 60) + 3, 5}})); + expect_certified(h, {0}, {1}, std::nullopt, rh, nullptr, nullptr, /*tier_a=*/false); + auto capped = solve(h, {0}, {1}, kL62m1 - 6); + EXPECT_EQ(capped.placed, kL62m1 - 6); + EXPECT_FALSE(capped.min_cut.has_value()); + expect_certified(h, {0}, {1}, kL62m1 - 6, capped, nullptr, nullptr, /*tier_a=*/false); +} + +TEST(FlowCore, EndpointSetsAndVirtualTerminals) { + // review-17 C2.reverse_virtual and review-16 global.all_destinations: both destinations have + // inflow, so both carry pi(t*) = 2. + auto g = make_graph(3, {{0, 1, 1, 1}, {0, 2, 1, 2}}); + auto r = solve(g, {0}, {1, 2}, std::nullopt); + EXPECT_EQ(r.placed, 2); + EXPECT_EQ(r.cost_distribution, curve({{1, 1}, {2, 1}})); + EXPECT_EQ(r.potentials, (std::vector{0, 2, 2})); + expect_certified(g, {0}, {1, 2}, std::nullopt, r); + // Source transit is dominated: source 0 supplies nothing. + auto t = make_graph(3, {{0, 1, 1, 1}, {1, 2, 2, 1}}); + auto rt = solve(t, {0, 1}, {2}, std::nullopt); + EXPECT_EQ(rt.placed, 2); + EXPECT_EQ(to_input(t, rt.edge_flows), (std::vector{0, 2})); + EXPECT_EQ(rt.potentials, (std::vector{0, 0, 1})); + expect_certified(t, {0, 1}, {2}, std::nullopt, rt); + // Duplicates are removed. + EXPECT_TRUE(same_payload(solve(t, {1, 0, 1}, {2, 2}, std::nullopt), rt)); +} + +TEST(FlowCore, ClosureAndSentinels) { + // review-14 flow.cut: the closure follows reverse arcs, so the forward-only boundary is wrong. + auto g = make_graph(5, {{0, 1, 1, 1}, {0, 2, 1, 1}, {1, 3, 1, 1}, {2, 3, 1, 1}, {3, 4, 1, 1}}); + auto r = solve(g, {0}, {4}, std::nullopt); + EXPECT_EQ(r.placed, 1); + ASSERT_TRUE(r.min_cut.has_value()); + EXPECT_EQ(ids_to_input(g, *r.min_cut), (std::vector{4})); + expect_certified(g, {0}, {4}, std::nullopt, r); + // Node 3 is never settled when the cap stops the solve with the destination connected. + auto h = make_graph(4, {{0, 1, 2, 1}, {1, 2, 2, 2}, {3, 2, 1, 1}}); + auto rh = solve(h, {0}, {2}, 1); + EXPECT_FALSE(rh.min_cut.has_value()); + EXPECT_EQ(rh.potentials, (std::vector{0, 1, 3, kNeverReached})); + expect_certified(h, {0}, {2}, 1, rh); +} + +TEST(FlowCore, ExcludedEndpointsAreSkipped) { + auto g = make_graph(3, {{0, 1, 2, 1}, {1, 2, 2, 1}}); + const std::vector x0 = {0}; + auto no_source = g.eligibility(x0, {}); + auto r = solve(g, {0}, {2}, std::nullopt, nullptr, &no_source); + EXPECT_EQ(r.placed, 0); + ASSERT_TRUE(r.min_cut.has_value()); + EXPECT_TRUE(r.min_cut->empty()); + EXPECT_EQ(r.potentials, (std::vector(3, kNeverReached))); + expect_certified(g, {0}, {2}, std::nullopt, r, nullptr, &no_source); + const std::vector x2 = {2}; + auto no_dest = g.eligibility(x2, {}); + auto rd = solve(g, {0}, {2}, std::nullopt, nullptr, &no_dest); + EXPECT_EQ(rd.placed, 0); + EXPECT_EQ(rd.potentials, (std::vector{0, 1, kNeverReached})); + expect_certified(g, {0}, {2}, std::nullopt, rd, nullptr, &no_dest); + // One allowed source of two. + auto rs = solve(g, {0, 1}, {2}, std::nullopt, nullptr, &no_source); + EXPECT_EQ(rs.placed, 2); + expect_certified(g, {0, 1}, {2}, std::nullopt, rs, nullptr, &no_source); +} + +TEST(FlowCore, Review13MasksEqualTheirCuts) { + // Sources {0, 1}, destinations {3, 4} under three Eligibilities. + auto g = make_graph(5, {{0, 2, 2, 2}, {1, 2, 1, 1}, {2, 3, 2, 3}, {2, 4, 1, 5}}); + const auto inv = id_of_input(g); + for (auto [mask, expect] : std::vector, Amount>>{{{}, 3}, {{1}, 2}, {{2}, 1}}) { + std::vector excluded; + for (auto i : mask) excluded.push_back(inv[static_cast(i)]); + auto el = g.eligibility({}, excluded); + auto r = solve(g, {0, 1}, {3, 4}, std::nullopt, nullptr, &el); + EXPECT_EQ(r.placed, expect); + expect_certified(g, {0, 1}, {3, 4}, std::nullopt, r, nullptr, &el); } - - EXPECT_NEAR(cost_sum, total, 1e-9) - << "Cost distribution must account for all flow"; -} - -//============================================================================= -// SECTION 7: ADVANCED FEATURES -//============================================================================= - -TEST(MaxFlow, OptionalOutputs_ResidualAndReachable) { - // Validates optional residual capacity and reachable nodes outputs - auto g = make_square_graph(1); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.with_edge_flows = true; - opts.with_reachable = true; - opts.with_residuals = true; - - auto [total, summary] = algs.max_flow(gh, 0, 2, opts); - - // Verify output dimensions - EXPECT_EQ(summary.residual_capacity.size(), - static_cast(g.num_edges())) - << "Residual capacity vector size must match edge count"; - EXPECT_EQ(summary.reachable_nodes.size(), - static_cast(g.num_nodes())) - << "Reachable nodes vector size must match node count"; -} - -TEST(MaxFlow, NodeMask_BlocksFlowThroughNode) { - // Validates that node masking prevents flow through masked nodes - auto g = make_line_graph(3); - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - // Mask out node 1 (middle node) - auto node_mask_vec = make_bool_mask(g.num_nodes()); - node_mask_vec[1] = false; - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.node_mask = std::span(node_mask_vec.get(), - static_cast(g.num_nodes())); - - auto [total, summary] = algs.max_flow(gh, 0, 2, opts); - - EXPECT_NEAR(total, 0.0, 1e-12) - << "Masking middle node should prevent all flow"; -} - -TEST(MaxFlow, EdgeMask_SelectiveEdgeFiltering) { - // Validates edge mask functionality - auto g = make_n_disjoint_paths(2, 10.0); - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - // Create mask with all edges enabled - auto edge_mask_vec = make_bool_mask(g.num_edges(), true); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.with_edge_flows = true; - opts.edge_mask = std::span(edge_mask_vec.get(), - static_cast(g.num_edges())); - - auto [total, summary] = algs.max_flow(gh, 0, 3, opts); - - // With all edges enabled, should use both paths - EXPECT_NEAR(total, 20.0, 1e-9) - << "With all edges enabled, both paths should be used"; - - validate_capacity_constraints(g, summary); - validate_flow_conservation(g, summary, 0, 3); -} - -TEST(MaxFlow, BatchMaxFlow_IndependentMaskApplication) { - // Validates batch max-flow with different masks per pair - auto g = make_line_graph(3); - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - std::vector> pairs = {{0, 2}, {0, 2}}; - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - - // First pair: no mask (should find flow) - // Second pair: mask node 1 (should find no flow) - std::vector> node_masks; - auto nm0 = make_bool_mask(g.num_nodes()); // all true - auto nm1 = make_bool_mask(g.num_nodes()); - nm1[1] = false; // block middle node for second pair - node_masks.push_back(std::span(nm0.get(), - static_cast(g.num_nodes()))); - node_masks.push_back(std::span(nm1.get(), - static_cast(g.num_nodes()))); - - auto summaries = algs.batch_max_flow(gh, pairs, opts, node_masks, {}); - - ASSERT_EQ(summaries.size(), 2u); - EXPECT_GT(summaries[0].total_flow, 0.0) - << "First pair (unmasked) should find flow"; - EXPECT_NEAR(summaries[1].total_flow, 0.0, 1e-12) - << "Second pair (masked) should find no flow"; -} - -//============================================================================= -// SECTION 8: COMPLEX TOPOLOGIES -// -// Validates algorithm correctness on realistic network topologies beyond -// simple test graphs. -//============================================================================= - -TEST(MaxFlow, GridGraph_3x3_MultipleEqualCostPaths) { - // Grid graphs have many equal-cost paths between corners - // Validates ECMP behavior in complex topology - auto g = make_grid_graph(3, 3); - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = true; - opts.with_edge_flows = true; - - // Flow from top-left (0) to bottom-right (8) - auto [total, summary] = algs.max_flow(gh, 0, 8, opts); - - // Should find flow through multiple equal-cost paths - EXPECT_GT(total, 0.0) << "Should find flow in grid topology"; - - validate_capacity_constraints(g, summary); - validate_flow_conservation(g, summary, 0, 8); -} - -TEST(MaxFlow, SharedBottleneck_MultiplePathsMerge) { - // Two paths that merge at an intermediate node before reaching sink - // Validates correct handling of shared edges - auto g = make_shared_bottleneck_graph(10.0, 5.0); - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.with_edge_flows = true; - - auto [total, summary] = algs.max_flow(gh, 0, 3, opts); - - // Shared bottleneck should limit total flow - // Path 1: 0→1 (10), 1→3 (5) - bottleneck at 1→3 - // Path 2: 0→2 (10), 2→3 (10) - // Total limited by bottleneck + other path = 5 + 10 = 15 - EXPECT_NEAR(total, 15.0, 1e-9) - << "Flow limited by shared bottleneck plus alternate path"; - - validate_capacity_constraints(g, summary); - validate_flow_conservation(g, summary, 0, 3); -} - -//============================================================================= -// SECTION 10: REAL-WORLD TOPOLOGY VALIDATION (NetGraph Integration Tests) -//============================================================================= -// -// These tests validate that NetGraph-Core produces the same maxflow results -// as the original NetGraph repository for important real-world topologies. -// Reference: NetGraph tests/integration/test_scenario_3.py and test_maxflow_api.py - -TEST(MaxFlow, Clos_3Tier_Topology_Proportional_Shortest) { - /** - * Validates 3-tier Clos fabric maxflow matches NetGraph scenario_3 expectations. - * - * Topology: 2 interconnected 3-tier Clos fabrics - * Source: Clos1 T1 nodes (0-3, 8-11) = 8 nodes - * Sink: Clos2 T1 nodes (32-35, 40-43) = 8 nodes - * - * Expected: 3200.0 Gbps (from NetGraph integration test expectations.py) - * Bottleneck: 8 active inter-clos spine links × 400 Gbps = 3200 Gbps - */ - auto g = make_3tier_clos_graph(); - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - // Test with PROPORTIONAL placement (matches NetGraph scenario_3 "capacity_analysis_forward") - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = true; // shortest_path: true in scenario_3.yaml - opts.with_edge_flows = true; - - // Compute maxflow from all Clos1 T1 nodes to all Clos2 T1 nodes - // We'll compute from representative nodes and verify total capacity - // For simplicity, test one representative path: node 0 (clos1/b1/t1-1) to node 32 (clos2/b1/t1-1) - auto [total, summary] = algs.max_flow(gh, 0, 32, opts); - - // Expected: Each T1 node can push 400 Gbps through the network - // (4 T2 paths × 100 Gbps = 400 Gbps from T1 to T2 layer) - // But bottleneck at inter-clos spines limits per-T1 flow - EXPECT_NEAR(total, 400.0, 1.0) - << "Single T1-to-T1 maxflow should be ~400 Gbps"; - - validate_capacity_constraints(g, summary); - validate_flow_conservation(g, summary, 0, 32); -} - -TEST(MaxFlow, Clos_3Tier_Topology_EqualBalanced_Shortest) { - /** - * Validates 3-tier Clos with EQUAL_BALANCED placement. - * Reference: NetGraph scenario_3 "capacity_analysis_forward_balanced" - */ - auto g = make_3tier_clos_graph(); - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::EqualBalanced; - opts.shortest_path = true; - opts.with_edge_flows = true; - - auto [total, summary] = algs.max_flow(gh, 0, 32, opts); - - // With EQUAL_BALANCED, flow is split equally across paths - // Expected similar capacity as PROPORTIONAL for this balanced topology - EXPECT_GT(total, 0.0) << "Should find positive flow"; - EXPECT_LE(total, 400.0 + 1.0) << "Should not exceed topology capacity"; - - validate_capacity_constraints(g, summary); - validate_flow_conservation(g, summary, 0, 32); -} - -TEST(MaxFlow, Triangle_Topology_Combine_Mode) { - /** - * Triangle topology from NetGraph solver tests. - * Reference: test_maxflow_api.py::test_max_flow_combine_basic() - * - * Expected maxflow A->C: 2.0 (1 direct + 1 via B) - */ - auto g = make_triangle_topology(); - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = false; // Use all paths - opts.with_edge_flows = true; - - auto [total, summary] = algs.max_flow(gh, 0, 2, opts); - - EXPECT_NEAR(total, 2.0, 1e-9) - << "Triangle A->C maxflow should be 2.0 (1 direct + 1 via B)"; - - validate_capacity_constraints(g, summary); - validate_flow_conservation(g, summary, 0, 2); -} - -TEST(MaxFlow, SimpleParallelPaths_Topology) { - /** - * Two disjoint parallel paths topology. - * Reference: test_maxflow_api.py::test_max_flow_combine_basic() using _simple_network() - * - * Expected maxflow S->T: 2.0 (both paths saturated) - */ - auto g = make_simple_parallel_paths_topology(); - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - // Test with shortest_path=True (should use both equal-cost paths) - MaxFlowOptions opts_sp; - opts_sp.placement = FlowPlacement::Proportional; - opts_sp.shortest_path = true; - opts_sp.with_edge_flows = true; - - auto [total_sp, summary_sp] = algs.max_flow(gh, 0, 3, opts_sp); - - EXPECT_NEAR(total_sp, 2.0, 1e-9) - << "Should saturate both equal-cost parallel paths with shortest_path=True"; - - validate_capacity_constraints(g, summary_sp); - validate_flow_conservation(g, summary_sp, 0, 3); - - // Test with shortest_path=False (should also be 2.0 since all paths are equal cost) - MaxFlowOptions opts_full; - opts_full.placement = FlowPlacement::Proportional; - opts_full.shortest_path = false; - opts_full.with_edge_flows = true; - - auto [total_full, summary_full] = algs.max_flow(gh, 0, 3, opts_full); - - EXPECT_NEAR(total_full, 2.0, 1e-9) - << "Should saturate both paths with shortest_path=False"; - - validate_capacity_constraints(g, summary_full); - validate_flow_conservation(g, summary_full, 0, 3); } -TEST(MaxFlow, ShortestPath_MustSaturateAllEqualCostPaths) { - /** - * Validates that shortest_path=True correctly saturates ALL equal-cost paths, - * not just a single path. - * Reference: test_maxflow_api.py::test_shortest_path_vs_full_max_flow() - * - * When multiple parallel paths have equal cost, the algorithm must utilize - * all of them to maximize throughput, even with shortest_path=True. - */ - auto g = make_simple_parallel_paths_topology(); - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = true; - - auto [total, _] = algs.max_flow(gh, 0, 3, opts); - - // Two equal-cost paths with capacity 1.0 each should yield total flow of 2.0 - EXPECT_NEAR(total, 2.0, 1e-9) - << "shortest_path=True must saturate all equal-cost paths, not just one"; -} - -//============================================================================= -// SECTION 11: SENSITIVITY ANALYSIS -//============================================================================= - -TEST(MaxFlow, Sensitivity_SinglePath) { - auto g = make_line_graph(3); - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - - auto results = algs.sensitivity_analysis(gh, 0, 2, opts); - - // Path: 0->1->2. Both edges saturated and critical. - // Capacity is 1.0. Flow is 1.0. - // Removing any edge drops flow to 0. Delta = 1.0. - EXPECT_EQ(results.size(), 2u); - for (const auto& p : results) { - EXPECT_NEAR(p.second, 1.0, 1e-9); +TEST(FlowCore, InputValidationBeforeAnyWork) { + auto g = make_graph(3, {{0, 1, 2, 1}, {1, 2, 2, 1}}); + auto h = make_graph(3, {{0, 1, 2, 1}, {1, 2, 2, 1}}); + const std::vector view = {2, 2}; + const std::vector s = {0}, d = {2}; + auto call = [&](std::vector v, std::vector src, std::vector dst, std::optional cap, + const Eligibility* el = nullptr) { + return error_of([&] { (void)place_global_units(g, v, src, dst, el, cap); }); + }; + EXPECT_NE(call({2}, s, d, 1).find("view has length 1, expected 2"), std::string::npos); + EXPECT_NE(call({2, 3}, s, d, 1).find("capacity_units"), std::string::npos); + EXPECT_NE(call({-1, 2}, s, d, 1).find("outside"), std::string::npos); + EXPECT_NE(call(view, {}, d, 1).find("sources: the endpoint set is empty"), std::string::npos); + EXPECT_NE(call(view, s, {}, 1).find("destinations: the endpoint set is empty"), std::string::npos); + EXPECT_NE(call(view, {3}, d, 1).find("out of node range"), std::string::npos); + EXPECT_NE(call(view, s, {-1}, 1).find("out of node range"), std::string::npos); + EXPECT_NE(call(view, {0, 2}, {2}, 1).find("both endpoint sets"), std::string::npos); + EXPECT_NE(call(view, s, d, -1).find("cap"), std::string::npos); + EXPECT_NE(call(view, s, d, kMaxFlowCap + 1).find("cap"), std::string::npos); + auto foreign = h.eligibility(); + EXPECT_NE(call(view, s, d, 1, &foreign).find("foreign Eligibility"), std::string::npos); + // The overlap is checked on the full sets, also when an Eligibility excludes the vertex. + const std::vector x0 = {0}; + auto el = g.eligibility(x0, {}); + EXPECT_NE(call(view, {0}, {0}, 1, &el).find("both endpoint sets"), std::string::npos); + // Order: the view before the endpoints, the endpoints before the cap. + EXPECT_NE(call({2}, {}, d, -1).find("view"), std::string::npos); + EXPECT_NE(call(view, {}, d, -1).find("sources"), std::string::npos); +} + +TEST(FlowCore, IdenticalCallsIdenticalPayloads) { + std::mt19937 rng(7); + for (int trial = 0; trial < 20; ++trial) { + std::vector edges; + for (int e = 0; e < 40; ++e) { + edges.push_back({static_cast(rng() % 12), static_cast(rng() % 12), + static_cast(rng() % 9), static_cast(1 + rng() % 6)}); + } + auto g = make_graph(12, edges); + auto a = solve(g, {0, 1}, {10, 11}, std::nullopt); + auto b = solve(g, {0, 1}, {10, 11}, std::nullopt); + EXPECT_TRUE(same_payload(a, b)); } } -TEST(MaxFlow, Sensitivity_TwoParallelPaths) { - // Two paths, capacity 10 each. Total 20. - auto g = make_n_disjoint_paths(2, 10.0); - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; +namespace { - auto results = algs.sensitivity_analysis(gh, 0, 3, opts); +struct RandomCase { + StrictMultiDiGraph g; + std::vector s, d; + std::vector view; + std::optional cap; + std::vector xn; + std::vector xe; +}; - // All 4 edges are saturated. - // Removing any edge kills one path (flow 20 -> 10). Delta = 10. - EXPECT_EQ(results.size(), 4u); - for (const auto& p : results) { - EXPECT_NEAR(p.second, 10.0, 1e-9); +RandomCase random_case(std::mt19937& rng, int n, int m, Amount max_cap, Cost max_cost, bool masks) { + std::vector edges; + for (int e = 0; e < m; ++e) { + edges.push_back({static_cast(rng() % n), static_cast(rng() % n), + static_cast(rng() % (max_cap + 1)), static_cast(1 + rng() % max_cost)}); } -} - -TEST(MaxFlow, Sensitivity_PartialSaturation) { - // Topology: S->A (cap 10), S->B (cap 5) - // A->T (cap 5), B->T (cap 10) - // Flow: S->A->T limited by A->T (5). S->B->T limited by S->B (5). Total 10. - // Saturated: A->T (edge 2), S->B (edge 1). - // Non-Saturated: S->A (edge 0), B->T (edge 3). - - std::int32_t src[4] = {0, 0, 1, 2}; - std::int32_t dst[4] = {1, 2, 3, 3}; - double cap[4] = {10.0, 5.0, 5.0, 10.0}; - std::int64_t cost[4] = {1, 1, 1, 1}; - - auto g = StrictMultiDiGraph::from_arrays(4, - std::span(src, 4), std::span(dst, 4), - std::span(cap, 4), std::span(cost, 4)); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = false; - - auto results = algs.sensitivity_analysis(gh, 0, 3, opts); - - // Should only report saturated edges: S->B and A->T. - // (We assume edge indices follow insertion order: 0, 1, 2, 3) - // S->B is index 1. A->T is index 2. - - // Check results - int count = 0; - for (const auto& p : results) { - if (p.first == 1 || p.first == 2) { - EXPECT_NEAR(p.second, 5.0, 1e-9); - count++; - } else { - // If non-saturated edges are reported (which they shouldn't be based on logic), fail. - FAIL() << "Reported sensitivity for non-saturated edge " << p.first; - } + auto g = make_graph(n, edges); + std::vector nodes(static_cast(n)); + std::iota(nodes.begin(), nodes.end(), 0); + std::shuffle(nodes.begin(), nodes.end(), rng); + const int ns = 1 + static_cast(rng() % static_cast(std::min(2, n - 1))); + const int nd = 1 + static_cast(rng() % static_cast(std::min(2, n - ns))); + std::vector s(nodes.begin(), nodes.begin() + ns); + std::vector d(nodes.begin() + ns, nodes.begin() + std::min(n, ns + nd)); + std::vector view; + for (Amount c : g.capacity_units()) view.push_back(rng() % 4 == 0 ? static_cast(rng() % (c + 1)) : c); + std::optional cap; + switch (rng() % 5) { + case 0: cap = std::nullopt; break; + case 1: cap = 0; break; + case 2: cap = 1; break; + case 3: cap = static_cast(rng() % (2 * max_cap + 2)); break; + default: cap = kMaxFlowCap; break; } - EXPECT_EQ(count, 2); -} - -TEST(MaxFlow, Sensitivity_ShortestPathVsMaxFlow) { - // Tests that shortest_path mode produces different sensitivity results - // than full max-flow mode when there are paths of different costs. - // - // Topology: S(0) -> A(1) -> T(2) [cost 1+1=2, cap 10 each] - // S(0) -> B(3) -> T(2) [cost 2+2=4, cap 5 each] - // - // With shortest_path=false (full max-flow): - // - Uses both paths: S->A->T (10) + S->B->T (5) = 15 total - // - All 4 edges are saturated and critical - // - // With shortest_path=true (single-pass, IP/IGP mode): - // - Only uses cheapest path: S->A->T (10) - // - Edges 2,3 (S->B->T path) are NOT used, so NOT critical - - std::int32_t src[4] = {0, 1, 0, 3}; - std::int32_t dst[4] = {1, 2, 3, 2}; - double cap[4] = {10.0, 10.0, 5.0, 5.0}; - std::int64_t cost[4] = {1, 1, 2, 2}; // S->A->T costs 2, S->B->T costs 4 - - auto g = StrictMultiDiGraph::from_arrays(4, - std::span(src, 4), std::span(dst, 4), - std::span(cap, 4), std::span(cost, 4)); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - // Step 1: Verify baseline flow values with max_flow - // This validates the routing semantics before testing sensitivity - { - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = false; - auto [flow_full, _] = algs.max_flow(gh, 0, 2, opts); - EXPECT_NEAR(flow_full, 15.0, 1e-9) << "Full max-flow should be 15"; + std::vector xn; + std::vector xe; + if (masks && rng() % 2 == 0) { + if (rng() % 3 == 0) xn.push_back(static_cast(rng() % n)); + for (int k = 0; k < 2; ++k) if (m > 0 && rng() % 2 == 0) xe.push_back(static_cast(rng() % m)); } - { - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = true; - auto [flow_sp, _] = algs.max_flow(gh, 0, 2, opts); - EXPECT_NEAR(flow_sp, 10.0, 1e-9) << "Shortest-path flow should be 10"; + return {std::move(g), s, d, view, cap, xn, xe}; +} + +} // namespace + +TEST(FlowCore, RandomGraphsTierAExhaustive) { + // Nonuniform costs, parallel edges, self-loops, partial views, endpoint sets, masks and caps; + // every payload certified, the curve against the minimum cost of every integer value. + std::mt19937 rng(20261004); + int positive = 0; + for (int trial = 0; trial < 2000; ++trial) { + const int n = 2 + static_cast(rng() % 4); + const int m = 1 + static_cast(rng() % 6); + auto c = random_case(rng, n, m, 2, 5, true); + std::optional el; + if (!c.xn.empty() || !c.xe.empty()) el = c.g.eligibility(c.xn, c.xe); + const Eligibility* elp = el ? &*el : nullptr; + auto r = place_global_units(c.g, c.view, c.s, c.d, elp, c.cap); + const auto fails = certify(c.g, c.view, c.s, c.d, c.cap, elp, r, true); + ASSERT_TRUE(fails.empty()) << "trial " << trial << ": " << fails.front(); + positive += r.placed > 0; } - - // Step 2: Test sensitivity with shortest_path=false (full max-flow) - { - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = false; - - auto results = algs.sensitivity_analysis(gh, 0, 2, opts); - - // All 4 edges should be saturated and critical - EXPECT_EQ(results.size(), 4u) << "Full max-flow should report all 4 edges as critical"; - - // Edges 0,1 (S->A->T path) have delta 10 when removed - // Edges 2,3 (S->B->T path) have delta 5 when removed - for (const auto& p : results) { - if (p.first == 0 || p.first == 1) { - EXPECT_NEAR(p.second, 10.0, 1e-9); - } else { - EXPECT_NEAR(p.second, 5.0, 1e-9); - } - } + EXPECT_GT(positive, 250); // the population is not vacuous +} + +TEST(FlowCore, RandomGraphsTierBIndependent) { + std::mt19937 rng(1816); + int multi_tier = 0; + for (int trial = 0; trial < 150; ++trial) { + const int n = 6 + static_cast(rng() % 20); + const int m = n + static_cast(rng() % (4 * n)); + auto c = random_case(rng, n, m, 1000, 40, true); + std::optional el; + if (!c.xn.empty() || !c.xe.empty()) el = c.g.eligibility(c.xn, c.xe); + const Eligibility* elp = el ? &*el : nullptr; + auto r = place_global_units(c.g, c.view, c.s, c.d, elp, c.cap); + const auto fails = certify(c.g, c.view, c.s, c.d, c.cap, elp, r, false); + ASSERT_TRUE(fails.empty()) << "trial " << trial << ": " << fails.front(); + multi_tier += r.cost_distribution.size() > 1; } - - // Step 3: Test sensitivity with shortest_path=true (IP/IGP mode) - { - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = true; - - auto results = algs.sensitivity_analysis(gh, 0, 2, opts); - - // Only edges 0,1 (S->A->T path) should be critical - // Edges 2,3 (S->B->T path) are not used under shortest-path routing - EXPECT_EQ(results.size(), 2u) << "Shortest-path mode should only report 2 edges as critical"; - - for (const auto& p : results) { - EXPECT_TRUE(p.first == 0 || p.first == 1) - << "Edge " << p.first << " should not be critical in shortest-path mode"; - // Baseline=10, removing either S->A or A->T forces traffic to S->B->T (cap 5) - // Delta = 10 - 5 = 5 - EXPECT_NEAR(p.second, 5.0, 1e-9); + EXPECT_GT(multi_tier, 30); +} + +TEST(FlowCore, RandomLargeUnitsTierB) { + // Capacities near the domain bound (sum below 2^62) and costs near 2^40. + std::mt19937_64 rng(62); + for (int trial = 0; trial < 40; ++trial) { + const int n = 4 + static_cast(rng() % 6); + const int m = 3 + static_cast(rng() % 12); + std::vector edges; + const Amount per = ((Amount{1} << 62) - 1) / m; + for (int e = 0; e < m; ++e) { + edges.push_back({static_cast(rng() % n), static_cast(rng() % n), + static_cast(rng() % static_cast(per)), + static_cast(1 + rng() % (std::uint64_t{1} << 40))}); } + auto g = make_graph(n, edges); + std::vector view(g.capacity_units().begin(), g.capacity_units().end()); + auto r = place_global_units(g, view, std::vector{0}, std::vector{static_cast(n - 1)}, + nullptr, std::nullopt); + const auto fails = certify(g, view, {0}, {n - 1}, std::nullopt, nullptr, r, false); + ASSERT_TRUE(fails.empty()) << "trial " << trial << ": " << fails.front(); } } -// ============================================================================ -// Regression: residual completion phase (flow cancellation) -// ============================================================================ - -// Successive shortest paths over forward-only SPF DAGs can strand capacity that -// only reverse-arc cancellation can recover. On this full-duplex topology the -// first tier routes 0->1->2->3 (cost 3), blocking both the direct 1->3 exit and -// the 2->3 exit; the true maximum needs to reroute it. Expected max flow is 3: -// 0->1->3 (1), 0->2->1->3 (1), 0->2->3 (1); the cut {0,2}|{1,3} has capacity 3. -TEST(MaxFlow, CompletionPhase_RecoversCancellation) { - std::vector src, dst; - std::vector cap; - std::vector cost; - auto add_duplex = [&](std::int32_t u, std::int32_t v, double c, std::int64_t k) { - src.push_back(u); dst.push_back(v); cap.push_back(c); cost.push_back(k); - src.push_back(v); dst.push_back(u); cap.push_back(c); cost.push_back(k); - }; - add_duplex(1, 3, 3.0, 17); - add_duplex(0, 2, 8.0, 17); - add_duplex(1, 2, 1.0, 1); - add_duplex(1, 0, 1.0, 1); - add_duplex(3, 2, 1.0, 1); - auto g = StrictMultiDiGraph::from_arrays(4, src, dst, cap, cost); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = false; - - auto [total, summary] = algs.max_flow(gh, 0, 3, opts); - EXPECT_NEAR(total, 3.0, 1e-9) << "true max flow requires cancelling the first tier"; - - // Max-flow/min-cut duality: the reported cut capacity must equal the flow. - double cut_cap = 0.0; - auto capv = g.capacity_view(); - for (auto eid : summary.min_cut.edges) cut_cap += capv[static_cast(eid)]; - EXPECT_NEAR(cut_cap, total, 1e-9) << "min-cut capacity must equal total flow"; - - // Cost distribution must account for exactly the total flow. - double dist_sum = 0.0; - for (auto f : summary.flows) dist_sum += f; - EXPECT_NEAR(dist_sum, total, 1e-9); -} - -// The completion phase must respect masks: masking node 2 on the topology above -// limits the flow to the direct 0->1->3 route. -TEST(MaxFlow, CompletionPhase_RespectsNodeMask) { - std::vector src, dst; - std::vector cap; - std::vector cost; - auto add_duplex = [&](std::int32_t u, std::int32_t v, double c, std::int64_t k) { - src.push_back(u); dst.push_back(v); cap.push_back(c); cost.push_back(k); - src.push_back(v); dst.push_back(u); cap.push_back(c); cost.push_back(k); - }; - add_duplex(1, 3, 3.0, 17); - add_duplex(0, 2, 8.0, 17); - add_duplex(1, 2, 1.0, 1); - add_duplex(1, 0, 1.0, 1); - add_duplex(3, 2, 1.0, 1); - auto g = StrictMultiDiGraph::from_arrays(4, src, dst, cap, cost); - - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - - auto mask = make_bool_mask(4); - mask[2] = false; - - MaxFlowOptions opts; - opts.placement = FlowPlacement::Proportional; - opts.shortest_path = false; - opts.node_mask = std::span(mask.get(), 4); - - auto [total, summary] = algs.max_flow(gh, 0, 3, opts); - EXPECT_NEAR(total, 1.0, 1e-9) << "without node 2 only 0->1->3 remains (bottleneck 1)"; +TEST(FlowCore, CertificateRejectsMutatedPayloads) { + // The test oracle itself is not vacuous: the review-17 negative controls are rejected. + auto one = make_graph(2, {{0, 1, 3, 7}}); + std::vector v1 = {3}; + auto under = solve(one, {0}, {1}, 1); + EXPECT_FALSE(certify(one, v1, {0}, {1}, 2, nullptr, under, true).empty()); // underfill under cap 2 + auto slope = solve(one, {0}, {1}, 2); + slope.cost_distribution = curve({{6, 2}}); + EXPECT_FALSE(certify(one, v1, {0}, {1}, 2, nullptr, slope, true).empty()); + auto cut = solve(one, {0}, {1}, std::nullopt); + cut.min_cut = std::vector{}; + EXPECT_FALSE(certify(one, v1, {0}, {1}, std::nullopt, nullptr, cut, true).empty()); + auto par = make_graph(2, {{0, 1, 1, 1}, {0, 1, 1, 3}}); + std::vector vp = {1, 1}; + auto omitted = solve(par, {0}, {1}, std::nullopt); + omitted.cost_distribution = curve({{2, 2}}); // same moment, omitted breakpoint at 1 + EXPECT_FALSE(certify(par, vp, {0}, {1}, std::nullopt, nullptr, omitted, true).empty()); + EXPECT_FALSE(certify(par, vp, {0}, {1}, std::nullopt, nullptr, omitted, false).empty()); + auto any = make_graph(3, {{0, 1, 1, 1}, {0, 2, 1, 2}}); + std::vector va = {1, 1}; + auto pot = solve(any, {0}, {1, 2}, std::nullopt); + pot.potentials[1] = 1; // an inflow destination below pi(t*) + EXPECT_FALSE(certify(any, va, {0}, {1, 2}, std::nullopt, nullptr, pot, true).empty()); + auto sentinel = solve(one, {0}, {1}, 1); + sentinel.potentials = {kNeverReached, kNeverReached}; + EXPECT_FALSE(certify(one, v1, {0}, {1}, 1, nullptr, sentinel, true).empty()); + auto zero = solve(one, {0}, {1}, 0); + zero.counters.dijkstra_runs = 1; + EXPECT_FALSE(certify(one, v1, {0}, {1}, 0, nullptr, zero, true).empty()); } diff --git a/tests/cpp/shortest_paths_tests.cpp b/tests/cpp/shortest_paths_tests.cpp deleted file mode 100644 index 2ba2255..0000000 --- a/tests/cpp/shortest_paths_tests.cpp +++ /dev/null @@ -1,837 +0,0 @@ -#include -#include -#include -#include -#include -#include "netgraph/core/constants.hpp" -#include "netgraph/core/shortest_paths.hpp" -#include "netgraph/core/strict_multidigraph.hpp" -#include "netgraph/core/backend.hpp" -#include "netgraph/core/algorithms.hpp" -#include "netgraph/core/options.hpp" -#include "test_utils.hpp" - -using namespace netgraph::core; -using namespace netgraph::core::test; - -TEST(ShortestPaths, SingleSourceAllNodes) { - auto g = make_line_graph(5); - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - sel.tie_break = EdgeTieBreak::Deterministic; - - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, {}, {}, {}); - - // Distances should be 0, 1, 2, 3, 4 - EXPECT_DOUBLE_EQ(dist[0], 0.0); - EXPECT_DOUBLE_EQ(dist[1], 1.0); - EXPECT_DOUBLE_EQ(dist[2], 2.0); - EXPECT_DOUBLE_EQ(dist[3], 3.0); - EXPECT_DOUBLE_EQ(dist[4], 4.0); - - expect_pred_dag_valid(dag, g.num_nodes()); -} - -TEST(ShortestPaths, DisconnectedComponents) { - // Create two disconnected components: 0-1 and 2-3 - std::int32_t src[2] = {0, 2}; - std::int32_t dst[2] = {1, 3}; - double cap[2] = {1.0, 1.0}; - std::int64_t cost[2] = {1, 1}; - - auto g = StrictMultiDiGraph::from_arrays(4, - std::span(src, 2), std::span(dst, 2), - std::span(cap, 2), std::span(cost, 2)); - - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - sel.tie_break = EdgeTieBreak::Deterministic; - - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, {}, {}, {}); - - // Nodes 0, 1 should be reachable - EXPECT_DOUBLE_EQ(dist[0], 0.0); - EXPECT_DOUBLE_EQ(dist[1], 1.0); - - // Nodes 2, 3 should be unreachable - EXPECT_EQ(dist[2], std::numeric_limits::max()); - EXPECT_EQ(dist[3], std::numeric_limits::max()); -} - -TEST(ShortestPaths, MultipleEqualCostPaths) { - auto g = make_square_graph(2); // Two equal-cost paths - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - sel.tie_break = EdgeTieBreak::Deterministic; - - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, {}, {}, {}); - - // Both paths should have cost 2 - EXPECT_DOUBLE_EQ(dist[2], 2.0); - - // Node 2 should have two parents in the DAG - auto start = static_cast(dag.parent_offsets[2]); - auto end = static_cast(dag.parent_offsets[3]); - EXPECT_GT(end - start, 0); // At least one predecessor -} - -TEST(ShortestPaths, ResidualAwareFiltering) { - auto g = make_line_graph(3); - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = true; // Only use edges with capacity - sel.tie_break = EdgeTieBreak::Deterministic; - - // Create residual with first edge having zero capacity - std::vector residual(g.num_edges(), 1.0); - residual[0] = 0.0; // Block first edge - - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, residual, {}, {}); - - // Node 1 should be unreachable - EXPECT_EQ(dist[1], std::numeric_limits::max()); - EXPECT_EQ(dist[2], std::numeric_limits::max()); -} - -TEST(ShortestPaths, NodeMaskIsolation) { - // Verify that masking out the middle node in a line graph blocks reachability - auto g = make_line_graph(3); - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - sel.tie_break = EdgeTieBreak::Deterministic; - - // Mask out node 1 (middle node) - blocks path from 0 to 2 - auto node_mask = make_bool_mask(g.num_nodes()); - node_mask[1] = false; - - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, {}, std::span(node_mask.get(), g.num_nodes()), {}); - - // Nodes 1 and 2 should be unreachable - EXPECT_DOUBLE_EQ(dist[0], 0.0); - EXPECT_EQ(dist[1], std::numeric_limits::max()); - EXPECT_EQ(dist[2], std::numeric_limits::max()); -} - -TEST(ShortestPaths, EdgeMaskFiltering) { - // Verify that masking out an edge removes it from consideration - auto g = make_line_graph(3); - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - sel.tie_break = EdgeTieBreak::Deterministic; - - // Mask out first edge (0->1) - blocks the entire path - auto edge_mask = make_bool_mask(g.num_edges()); - edge_mask[0] = false; - - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, {}, {}, std::span(edge_mask.get(), g.num_edges())); - - // Node 1 should be unreachable - EXPECT_DOUBLE_EQ(dist[0], 0.0); - EXPECT_EQ(dist[1], std::numeric_limits::max()); -} - -TEST(ShortestPaths, EarlyExitOptimization) { - auto g = make_line_graph(10); - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - sel.tie_break = EdgeTieBreak::Deterministic; - - // With destination specified, should exit early - auto [dist, dag] = shortest_paths(g, 0, NodeId(2), true, sel, {}, {}, {}); - - EXPECT_DOUBLE_EQ(dist[2], 2.0); - expect_pred_dag_valid(dag, g.num_nodes()); -} - -TEST(ShortestPaths, PredDAGIntegrity) { - auto g = make_grid_graph(3, 3); - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - sel.tie_break = EdgeTieBreak::Deterministic; - - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, {}, {}, {}); - - expect_pred_dag_valid(dag, g.num_nodes()); - - // Source node should have no predecessors - EXPECT_EQ(dag.parent_offsets[0], 0); - EXPECT_EQ(dag.parent_offsets[1], 0); -} - -TEST(ShortestPaths, TieBreakingDeterminism) { - // Two parallel edges with same cost - std::int32_t src[2] = {0, 0}; - std::int32_t dst[2] = {1, 1}; - double cap[2] = {1.0, 2.0}; - std::int64_t cost[2] = {1, 1}; - - auto g = StrictMultiDiGraph::from_arrays(2, - std::span(src, 2), std::span(dst, 2), - std::span(cap, 2), std::span(cost, 2)); - - EdgeSelection sel; - sel.multi_edge = false; // Single edge selection - sel.require_capacity = false; - sel.tie_break = EdgeTieBreak::Deterministic; - - auto [dist1, dag1] = shortest_paths(g, 0, std::nullopt, false, sel, {}, {}, {}); - auto [dist2, dag2] = shortest_paths(g, 0, std::nullopt, false, sel, {}, {}, {}); - - // Results should be deterministic - EXPECT_EQ(dist1[1], dist2[1]); - - // Should select the same edge both times - EXPECT_EQ(dag1.via_edges[0], dag2.via_edges[0]); -} - -TEST(ShortestPaths, PreferHigherResidualTieBreak) { - // Same two parallel edges but enforce HigherResidual tie-break - std::int32_t src[2] = {0, 0}; - std::int32_t dst[2] = {1, 1}; - double cap[2] = {1.0, 2.0}; - std::int64_t cost[2] = {1, 1}; - auto g = StrictMultiDiGraph::from_arrays(2, - std::span(src, 2), std::span(dst, 2), - std::span(cap, 2), std::span(cost, 2)); - - // Residuals equal to capacity; higher residual should pick edge with cap=2 - EdgeSelection sel; - sel.multi_edge = false; - sel.require_capacity = true; - sel.tie_break = EdgeTieBreak::PreferHigherResidual; - - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, false, sel, g.capacity_view(), {}, {}); - ASSERT_FALSE(dag.via_edges.empty()); - // Edge ids are compacted; find which corresponds to cap=2 by checking capacity - auto chosen = dag.via_edges[0]; - auto capv = g.capacity_view(); - EXPECT_EQ(capv[chosen], 2.0); -} - -TEST(ShortestPaths, ResolveToPathsSplittingParallelEdges) { - // Test resolve_to_paths both with and without splitting parallel edges - // Graph 0->1 has two parallel edges, 1->2 single edge - std::int32_t src[3] = {0, 0, 1}; - std::int32_t dst[3] = {1, 1, 2}; - double cap[3] = {1.0, 1.0, 1.0}; - std::int64_t cost[3] = {1, 1, 1}; - auto g = StrictMultiDiGraph::from_arrays(3, - std::span(src, 3), std::span(dst, 3), - std::span(cap, 3), std::span(cost, 3)); - - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = false; sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, 0, 2, true, sel, {}, {}, {}); - - // Resolve without splitting (grouped parallel edges) - auto grouped = resolve_to_paths(dag, 0, 2, /*split_parallel_edges=*/false); - ASSERT_FALSE(grouped.empty()); - ASSERT_GE(grouped[0].size(), 2u); - // First tuple corresponds to node 0 with two parallel edges grouped - EXPECT_EQ(grouped[0][0].first, 0); - EXPECT_EQ(grouped[0][0].second.size(), 2u); - - // Resolve with splitting should produce 2 concrete paths (one per parallel edge) - auto concrete = resolve_to_paths(dag, 0, 2, /*split_parallel_edges=*/true); - EXPECT_EQ(concrete.size(), 2u); -} - -TEST(ShortestPaths, ResolveToPathsWithMaxPathsLimit) { - // Verify that max_paths limit caps enumeration in resolve_to_paths - // Create graph with multiple equal-cost paths via 2x2 grid - std::int32_t src[4] = {0, 0, 1, 2}; - std::int32_t dst[4] = {1, 2, 3, 3}; - double cap[4] = {1.0, 1.0, 1.0, 1.0}; - std::int64_t cost[4] = {1, 1, 1, 1}; - auto g = StrictMultiDiGraph::from_arrays(4, - std::span(src, 4), std::span(dst, 4), - std::span(cap, 4), std::span(cost, 4)); - - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = false; sel.tie_break = EdgeTieBreak::Deterministic; - auto [dist, dag] = shortest_paths(g, 0, 3, true, sel, {}, {}, {}); - - // Without limit, should find both paths - auto all_paths = resolve_to_paths(dag, 0, 3, false, std::nullopt); - EXPECT_GE(all_paths.size(), 1u); - - // With max_paths=1, enumeration should stop at first path - auto limited = resolve_to_paths(dag, 0, 3, false, std::int64_t(1)); - EXPECT_EQ(limited.size(), 1u); -} - -TEST(ShortestPaths, LargeGraphStressTest) { - // Create a larger graph to test performance - auto g = make_grid_graph(20, 20); // 400 nodes - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = false; - sel.tie_break = EdgeTieBreak::Deterministic; - - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, {}, {}, {}); - - // Bottom-right corner should be reachable with cost 38 (19 right + 19 down) - EXPECT_DOUBLE_EQ(dist[399], 38.0); - expect_pred_dag_valid(dag, g.num_nodes()); -} - -TEST(ShortestPaths, RejectsMaskLengthMismatch) { - // Simple line graph 0->1->2 - auto g = make_line_graph(3); - auto be = make_cpu_backend(); - Algorithms algs(be); - auto gh = algs.build_graph(g); - SpfOptions opts; - opts.multipath = true; - opts.dst = 2; - // Create node_mask with wrong length (N-1) - auto node_mask = make_bool_mask(static_cast(g.num_nodes() - 1), true); - opts.node_mask = std::span(node_mask.get(), static_cast(g.num_nodes() - 1)); - EXPECT_THROW({ (void)algs.spf(gh, 0, opts); }, std::invalid_argument); - - // Edge mask wrong length (M-1) - SpfOptions opts2; - opts2.multipath = true; - opts2.dst = 2; - auto edge_mask = make_bool_mask(static_cast(g.num_edges() - 1), true); - opts2.edge_mask = std::span(edge_mask.get(), static_cast(g.num_edges() - 1)); - EXPECT_THROW({ (void)algs.spf(gh, 0, opts2); }, std::invalid_argument); -} - -// ============================================================================ -// Regression: zero-cost edges must not create cycles in the PredDAG -// ============================================================================ - -// A zero-cost pair 1<->2 previously recorded each node as the other's parent -// (both relaxations see an "equal cost" path), producing a cyclic PredDAG. -// EqualBalanced placement then stalled (returned 0 flow) and path enumeration -// walked the cycle forever. Equal-cost predecessors are now only accepted while -// the child is unsettled, which keeps the DAG acyclic by settle order. -TEST(ShortestPaths, ZeroCostEdges_PredDAGIsAcyclic) { - std::int32_t src_arr[4] = {0, 1, 2, 2}; - std::int32_t dst_arr[4] = {1, 2, 1, 3}; - double cap_arr[4] = {10.0, 10.0, 10.0, 10.0}; - std::int64_t cost_arr[4] = {1, 0, 0, 1}; - auto g = StrictMultiDiGraph::from_arrays(4, - std::span(src_arr, 4), std::span(dst_arr, 4), - std::span(cap_arr, 4), std::span(cost_arr, 4)); - - EdgeSelection sel; - sel.multi_edge = true; - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, /*multipath=*/true, sel); - - EXPECT_EQ(dist[3], 2); - - // No 2-cycles: v's parent u must not also have v as a parent. - for (std::int32_t v = 0; v < 4; ++v) { - for (auto i = dag.parent_offsets[static_cast(v)]; - i < dag.parent_offsets[static_cast(v) + 1]; ++i) { - auto u = dag.parents[static_cast(i)]; - for (auto j = dag.parent_offsets[static_cast(u)]; - j < dag.parent_offsets[static_cast(u) + 1]; ++j) { - EXPECT_NE(dag.parents[static_cast(j)], v) - << "cycle " << u << "<->" << v << " in PredDAG"; - } - } - } - - // Path enumeration must terminate and yield the single simple path. - auto paths = resolve_to_paths(dag, 0, 3); - ASSERT_EQ(paths.size(), 1u); - EXPECT_EQ(paths[0].size(), 4u); // 0 -> 1 -> 2 -> 3 -} - -// --------------------------------------------------------------------------- -// shortest_paths_to: reverse SPF toward one destination, with forced fan-out. -// --------------------------------------------------------------------------- -#include "netgraph/core/flow_state.hpp" - -namespace { -// Nodes: 0=P (pseudo source), 1=S1, 2=S2, 3=M, 4=T. -// Edges: 0: P->S1 cost 0 cap 1e6; 1: P->S2 cost 0 cap 1e6; -// 2: S1->T cost 1 cap 100; 3: S2->M cost 1 cap 20; 4: M->T cost 1 cap 60. -// S1 is one hop from T, S2 two hops, so an SPF from P keeps only S1. -StrictMultiDiGraph make_fanout_graph() { - std::int32_t src[5] = {0, 0, 1, 2, 3}; - std::int32_t dst[5] = {1, 2, 4, 3, 4}; - double cap[5] = {1e6, 1e6, 100.0, 20.0, 60.0}; - std::int64_t cost[5] = {0, 0, 1, 1, 1}; - return StrictMultiDiGraph::from_arrays(5, - std::span(src, 5), std::span(dst, 5), std::span(cap, 5), std::span(cost, 5)); -} -EdgeSelection cost_only_sel() { - EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = false; sel.tie_break = EdgeTieBreak::Deterministic; - return sel; -} -} // namespace - -TEST(ShortestPathsTo, DistancesMatchForwardDistancesToDst) { - auto g = make_grid_graph(3, 4); - const NodeId t = g.num_nodes() - 1; - auto [dist_to, dag] = shortest_paths_to(g, t, /*multipath=*/true, cost_only_sel()); - expect_pred_dag_valid(dag, g.num_nodes()); - for (NodeId s = 0; s < g.num_nodes(); ++s) { - auto [dist_from_s, fwd] = shortest_paths(g, s, t, /*multipath=*/true, cost_only_sel()); - EXPECT_EQ(dist_to[static_cast(s)], dist_from_s[static_cast(t)]) << "node " << s; - } - // Every DAG entry u -> v via e lies on a shortest u -> t walk. - const auto esrc = g.edge_src_view(); const auto edst = g.edge_dst_view(); const auto cost = g.cost_view(); - for (NodeId v = 0; v < g.num_nodes(); ++v) { - for (auto i = dag.parent_offsets[static_cast(v)]; i < dag.parent_offsets[static_cast(v)+1]; ++i) { - const auto e = static_cast(dag.via_edges[static_cast(i)]); - const NodeId u = dag.parents[static_cast(i)]; - EXPECT_EQ(esrc[e], u); EXPECT_EQ(edst[e], v); - EXPECT_EQ(dist_to[static_cast(u)], cost[e] + dist_to[static_cast(v)]); - } - } -} - -TEST(ShortestPathsTo, ForwardSpfFromPseudoSourceKeepsOnlyNearestSource) { - auto g = make_fanout_graph(); - auto [dist, dag] = shortest_paths(g, 0, 4, /*multipath=*/true, cost_only_sel()); - // T (node 4) is reached only via S1 -> T (edge 2): S2's branch costs 2 and is - // not on a shortest P -> T path, so a placement from P never uses S2. - ASSERT_EQ(dag.parent_offsets[5] - dag.parent_offsets[4], 1); - EXPECT_EQ(dag.via_edges[static_cast(dag.parent_offsets[4])], 2); -} - -TEST(ShortestPathsTo, FanoutEdgesForceEverySourceIntoTheDag) { - auto g = make_fanout_graph(); - EdgeId fan[2] = {0, 1}; - auto [dist, dag] = shortest_paths_to(g, 4, /*multipath=*/true, cost_only_sel(), {}, {}, {}, std::span(fan, 2)); - expect_pred_dag_valid(dag, g.num_nodes()); - EXPECT_EQ(dist[1], 1); EXPECT_EQ(dist[2], 2); EXPECT_EQ(dist[0], 1) << "pseudo source keeps the SPF distance via S1"; - // Both S1 and S2 now have P as parent via their attachment edge. - ASSERT_EQ(dag.parent_offsets[2] - dag.parent_offsets[1], 1); EXPECT_EQ(dag.via_edges[static_cast(dag.parent_offsets[1])], 0); - ASSERT_EQ(dag.parent_offsets[3] - dag.parent_offsets[2], 1); EXPECT_EQ(dag.via_edges[static_cast(dag.parent_offsets[2])], 1); - // Entry the SPF already recorded (P->S1) is not duplicated. - int p_entries = 0; - for (auto v : dag.parents) if (v == 0) ++p_entries; - EXPECT_EQ(p_entries, 2); - - // Lossless equal-balanced admission over the fan-out: shares 50/50 of 100; - // S2's branch admits 20 of 50 (link 3) so the whole demand scales to 0.4. - FlowState fs(g); - EXPECT_NEAR(fs.place_on_dag(0, 4, dag, 100.0, FlowPlacement::EqualBalanced), 40.0, 1e-9); - EXPECT_NEAR(fs.edge_flow_view()[2], 20.0, 1e-9); - EXPECT_NEAR(fs.edge_flow_view()[3], 20.0, 1e-9); -} - -TEST(ShortestPathsTo, FanoutSkipsUnreachableAndMaskedHeads) { - auto g = make_fanout_graph(); - EdgeId fan[2] = {0, 1}; - auto edge_mask = make_bool_mask(static_cast(g.num_edges()), true); - edge_mask[3] = false; // S2 -> M down: S2 cannot reach T - auto [dist, dag] = shortest_paths_to(g, 4, true, cost_only_sel(), {}, {}, - std::span(edge_mask.get(), static_cast(g.num_edges())), - std::span(fan, 2)); - EXPECT_EQ(dist[2], std::numeric_limits::max()); - EXPECT_EQ(dag.parent_offsets[3] - dag.parent_offsets[2], 0) << "no fan-out entry to an unreachable source"; - EXPECT_EQ(dag.parent_offsets[2] - dag.parent_offsets[1], 1); -} - -TEST(ShortestPathsTo, FanoutFromInteriorNodeIsRejected) { - auto g = make_fanout_graph(); - EdgeId fan[1] = {4}; // M -> T, but M already has the incoming entry S2 -> M - EXPECT_THROW((void)shortest_paths_to(g, 4, true, cost_only_sel(), {}, {}, {}, std::span(fan, 1)), - std::invalid_argument); - EdgeId bad[1] = {99}; - EXPECT_THROW((void)shortest_paths_to(g, 4, true, cost_only_sel(), {}, {}, {}, std::span(bad, 1)), - std::invalid_argument); -} - -TEST(ShortestPathsTo, SinglePathModeKeepsOneSuccessorPerNode) { - auto g = make_n_disjoint_paths(3, 10.0); - const NodeId t = g.num_nodes() - 1; - auto [dist, dag] = shortest_paths_to(g, t, /*multipath=*/false, cost_only_sel()); - expect_pred_dag_valid(dag, g.num_nodes()); - // Count DAG entries leaving node 0: exactly one successor. - int leaving_src = 0; - for (auto p : dag.parents) if (p == 0) ++leaving_src; - EXPECT_EQ(leaving_src, 1); -} - -// --------------------------------------------------------------------------- -// Queue differential: the bucket queue must reproduce the reference heap's -// results bit for bit (distances, parent_offsets, parents, via_edges) across -// the whole option matrix, including zero-cost edges, masks, residuals, -// single-path capacity ties, destination early exit and workspace reuse. -// --------------------------------------------------------------------------- -namespace { - -using SpfResult = std::pair, PredDAG>; - -struct DiffGraph { - StrictMultiDiGraph g; - std::unique_ptr node_mask, edge_mask; - std::vector residual; - std::span nm() const { return {node_mask.get(), static_cast(g.num_nodes())}; } - std::span em() const { return {edge_mask.get(), static_cast(g.num_edges())}; } -}; - -struct DiffSpec { - unsigned seed; int n; int degree; bool masked; Cost max_cost; int zero_pct; bool int64_offset; -}; - -DiffGraph make_diff_graph(const DiffSpec& s) { - std::mt19937 rng(s.seed); - std::vector src, dst; - std::vector cap; - std::vector cost; - auto draw_cost = [&]() -> Cost { - Cost c = (static_cast(rng() % 100) < s.zero_pct) ? 0 : 1 + static_cast(rng() % static_cast(s.max_cost)); - return c + (s.int64_offset ? (Cost(1) << 34) : 0); - }; - for (int u = 0; u < s.n; ++u) { - src.push_back(u); dst.push_back((u + 1) % s.n); cost.push_back(draw_cost()); cap.push_back(1.0 + double(rng() % 100) / 7.0); - for (int k = 1; k < s.degree; ++k) { - src.push_back(u); dst.push_back(static_cast(rng() % s.n)); // self-loops and parallel edges allowed - cost.push_back(draw_cost()); cap.push_back(1.0 + double(rng() % 100) / 7.0); - } - } - // A root node with out-edges only (never has an incoming DAG entry): its - // out-edges serve as legal fanout edges for the reverse search. - const int root = s.n; - for (int k = 0; k < 3; ++k) { src.push_back(root); dst.push_back(static_cast(rng() % s.n)); cost.push_back(draw_cost()); cap.push_back(2.0); } - DiffGraph d{StrictMultiDiGraph::from_arrays(s.n + 1, src, dst, cap, cost), nullptr, nullptr, {}}; - const int N = d.g.num_nodes(); const int E = d.g.num_edges(); - d.node_mask = std::make_unique(static_cast(N)); - d.edge_mask = std::make_unique(static_cast(E)); - d.residual.assign(d.g.capacity_view().begin(), d.g.capacity_view().end()); - for (int i = 0; i < N; ++i) d.node_mask[static_cast(i)] = !s.masked || rng() % 23 != 0; - for (int i = 0; i < E; ++i) { - d.edge_mask[static_cast(i)] = !s.masked || rng() % 11 != 0; - if (s.masked && rng() % 13 == 0) d.residual[static_cast(i)] = 0; - if (s.masked && rng() % 17 == 0) d.residual[static_cast(i)] = kMinCap * static_cast(rng() % 3); - } - return d; -} - -bool same_result(const SpfResult& a, const SpfResult& b) { - return a.first == b.first && a.second.parent_offsets == b.second.parent_offsets && - a.second.parents == b.second.parents && a.second.via_edges == b.second.via_edges; -} - -const std::vector kDiffSpecs = { - {1, 40, 5, true, 31, 0, false}, {2, 43, 6, true, 31, 0, false}, {3, 46, 6, true, 31, 25, false}, - {4, 60, 4, false, 1, 0, false}, {5, 64, 5, true, 1, 30, false}, {6, 80, 6, true, 1000, 10, false}, - {7, 50, 6, true, 31, 0, true}, {8, 120, 8, false, 31, 0, false}, {9, 90, 3, true, 5, 40, false}, - {10, 30, 12, true, 3, 20, false}, - // Ring graphs (degree 1): a frontier of one node exercises the bucket - // queue's inline item, and zero-cost edges its promotion into the buckets. - {11, 300, 1, false, 31, 0, false}, {12, 300, 1, true, 5, 30, false}, -}; - -template -void for_each_option(const DiffGraph& d, Fn&& fn) { - for (int mp = 0; mp < 2; ++mp) for (int me = 0; me < 2; ++me) for (int tb = 0; tb < 2; ++tb) - for (int rc = 0; rc < 2; ++rc) for (int ur = 0; ur < 2; ++ur) for (int um = 0; um < 2; ++um) - for (EdgePriority pri : {EdgePriority::Residual, EdgePriority::Capacity, EdgePriority::Headroom}) { - EdgeSelection sel; - sel.multi_edge = me != 0; - sel.require_capacity = rc != 0; - sel.tie_break = tb ? EdgeTieBreak::PreferHigherResidual : EdgeTieBreak::Deterministic; - sel.priority = pri; - auto res = ur ? std::span(d.residual) : std::span{}; - auto nm = um ? d.nm() : std::span{}; - auto em = um ? d.em() : std::span{}; - fn(mp != 0, sel, res, nm, em); - } -} - -} // namespace - -TEST(ShortestPaths, QueueDifferential_Forward) { - std::size_t checked = 0; - for (const auto& spec : kDiffSpecs) { - auto d = make_diff_graph(spec); - const int n = d.g.num_nodes(); - for (NodeId s : {NodeId(0), NodeId(n / 3), NodeId(n - 2), NodeId(n - 1)}) { - for_each_option(d, [&](bool mp, const EdgeSelection& sel, auto res, auto nm, auto em) { - auto full = shortest_paths(d.g, s, std::nullopt, mp, sel, res, nm, em, SpfQueue::Heap); - NodeId far = -1; Cost fd = -1; - for (int v = 0; v < n; ++v) if (full.first[static_cast(v)] != std::numeric_limits::max() && full.first[static_cast(v)] > fd) { fd = full.first[static_cast(v)]; far = v; } - const auto row = d.g.row_offsets_view(); const auto col = d.g.col_indices_view(); - std::vector> dsts = {std::nullopt, s, NodeId((s + n / 2) % n), far}; - if (row[static_cast(s) + 1] > row[static_cast(s)]) dsts.push_back(col[static_cast(row[static_cast(s)])]); - for (auto dst : dsts) { - auto expected = shortest_paths(d.g, s, dst, mp, sel, res, nm, em, SpfQueue::Heap); - for (int rep = 0; rep < 2; ++rep) { - auto actual = shortest_paths(d.g, s, dst, mp, sel, res, nm, em, SpfQueue::Bucket); - ++checked; - ASSERT_TRUE(same_result(expected, actual)) - << "seed=" << spec.seed << " src=" << s << " dst=" << (dst ? *dst : -1) - << " mp=" << mp << " me=" << sel.multi_edge << " rc=" << sel.require_capacity; - } - // Auto must equal one of the two explicit routes, and both equal each other. - auto automatic = shortest_paths(d.g, s, dst, mp, sel, res, nm, em); - ASSERT_TRUE(same_result(expected, automatic)); - // The DAG-only variant is the same search minus the distance vector. - if (dst) { // *dst may be -1 (no reachable node): then the cost is INT64_MAX - const Cost want = (*dst >= 0 && *dst < n) ? expected.first[static_cast(*dst)] - : std::numeric_limits::max(); - for (SpfQueue q : {SpfQueue::Heap, SpfQueue::Bucket}) { - auto [cost, dag] = shortest_paths_dag(d.g, s, *dst, mp, sel, res, nm, em, q); - ASSERT_EQ(cost, want); - ASSERT_TRUE(same_result(expected, SpfResult{expected.first, dag})); - } - } - } - }); - } - } - EXPECT_GT(checked, 60000u); -} - -TEST(ShortestPaths, QueueDifferential_Reverse) { - std::size_t checked = 0; - for (const auto& spec : kDiffSpecs) { - auto d = make_diff_graph(spec); - const int n = d.g.num_nodes(); - const NodeId root = n - 1; - const auto row = d.g.row_offsets_view(); const auto aei = d.g.adj_edge_index_view(); - std::vector fanout(aei.begin() + row[static_cast(root)], aei.begin() + row[static_cast(root) + 1]); - for (NodeId t : {NodeId(0), NodeId(n / 3), NodeId(n - 2)}) { - for_each_option(d, [&](bool mp, const EdgeSelection& sel, auto res, auto nm, auto em) { - for (int with_fanout = 0; with_fanout < 2; ++with_fanout) { - auto fo = with_fanout ? std::span(fanout) : std::span{}; - auto expected = shortest_paths_to(d.g, t, mp, sel, res, nm, em, fo, SpfQueue::Heap); - for (int rep = 0; rep < 2; ++rep) { - auto actual = shortest_paths_to(d.g, t, mp, sel, res, nm, em, fo, SpfQueue::Bucket); - ++checked; - ASSERT_TRUE(same_result(expected, actual)) - << "seed=" << spec.seed << " dst=" << t << " mp=" << mp << " fanout=" << with_fanout; - } - } - }); - } - } - EXPECT_GT(checked, 15000u); -} - -TEST(ShortestPaths, QueueDifferential_ComparisonHasTeeth) { - // Guard: the comparison must reject a result whose predecessor order differs. - auto d = make_diff_graph(kDiffSpecs[7]); - EdgeSelection sel; - auto a = shortest_paths(d.g, 0, std::nullopt, true, sel, {}, {}, {}, SpfQueue::Heap); - auto b = a; - bool permuted = false; - for (std::size_t v = 0; v + 1 < b.second.parent_offsets.size() && !permuted; ++v) { - auto lo = static_cast(b.second.parent_offsets[v]); - auto hi = static_cast(b.second.parent_offsets[v + 1]); - if (hi - lo >= 2 && b.second.parents[lo] != b.second.parents[hi - 1]) { - std::swap(b.second.parents[lo], b.second.parents[hi - 1]); - std::swap(b.second.via_edges[lo], b.second.via_edges[hi - 1]); - permuted = true; - } - } - ASSERT_TRUE(permuted) << "fixture has no multi-parent node"; - EXPECT_FALSE(same_result(a, b)); - EXPECT_TRUE(same_result(a, a)); -} - -TEST(ShortestPaths, BucketQueueKeepsCapacityOrderedPredecessors) { - // 0 -> {1,2,3} at cost 1 with bottlenecks 1, 3, 2; each -> 4 at cost 1. - // Equal-cost nodes settle in descending bottleneck order, so 4's parents are - // recorded as 2, 3, 1 (not in node-id order). Both queues must agree. - std::vector src = {0, 0, 0, 1, 2, 3}; - std::vector dst = {1, 2, 3, 4, 4, 4}; - std::vector cap = {1.0, 3.0, 2.0, 5.0, 5.0, 5.0}; - std::vector cost = {1, 1, 1, 1, 1, 1}; - auto g = StrictMultiDiGraph::from_arrays(5, src, dst, cap, cost); - EdgeSelection sel; - for (SpfQueue q : {SpfQueue::Heap, SpfQueue::Bucket, SpfQueue::Auto}) { - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, {}, {}, {}, q); - std::vector parents(dag.parents.begin() + dag.parent_offsets[4], dag.parents.begin() + dag.parent_offsets[5]); - EXPECT_EQ(parents, (std::vector{2, 3, 1})); - EXPECT_EQ(dist[4], 2); - } -} - -TEST(ShortestPaths, WorkspaceIsResetAfterFanoutThrow) { - auto d = make_diff_graph(kDiffSpecs[1]); - const int n = d.g.num_nodes(); - EdgeSelection sel; - auto before = shortest_paths_to(d.g, 0, true, sel, d.residual, d.nm(), d.em(), {}, SpfQueue::Bucket); - auto before_fwd = shortest_paths(d.g, 3, std::nullopt, true, sel, d.residual, d.nm(), d.em(), SpfQueue::Bucket); - // A fanout edge leaving a node that already has an incoming DAG entry throws - // after the search has filled the workspace. - EdgeId offending = -1; - const auto esrc = d.g.edge_src_view(); - for (std::size_t v = 0; v + 1 < before.second.parent_offsets.size() && offending < 0; ++v) { - if (before.second.parent_offsets[v + 1] > before.second.parent_offsets[v]) { - const auto row = d.g.row_offsets_view(); const auto aei = d.g.adj_edge_index_view(); - if (row[v + 1] > row[v]) offending = aei[static_cast(row[v])]; - } - } - ASSERT_GE(offending, 0); - ASSERT_TRUE(before.second.parent_offsets[static_cast(esrc[static_cast(offending)]) + 1] > - before.second.parent_offsets[static_cast(esrc[static_cast(offending)])]); - std::vector bad = {offending}; - for (SpfQueue q : {SpfQueue::Bucket, SpfQueue::Heap}) { - EXPECT_THROW((void)shortest_paths_to(d.g, 0, true, sel, d.residual, d.nm(), d.em(), bad, q), std::invalid_argument); - EXPECT_THROW((void)shortest_paths_to(d.g, 0, true, sel, d.residual, d.nm(), d.em(), std::vector{n * 10}, q), std::invalid_argument); - auto after = shortest_paths_to(d.g, 0, true, sel, d.residual, d.nm(), d.em(), {}, q); - EXPECT_TRUE(same_result(before, after)); - auto after_fwd = shortest_paths(d.g, 3, std::nullopt, true, sel, d.residual, d.nm(), d.em(), q); - EXPECT_TRUE(same_result(before_fwd, after_fwd)); - } -} - -TEST(ShortestPaths, WorkspaceReuseAcrossGraphSizesAndEmptyGraphs) { - auto small = make_diff_graph(kDiffSpecs[0]); - auto large = make_diff_graph(kDiffSpecs[7]); - auto empty = StrictMultiDiGraph::from_arrays(0, {}, {}, {}, {}); - EdgeSelection sel; - for (SpfQueue q : {SpfQueue::Bucket, SpfQueue::Heap}) { - auto s1 = shortest_paths(small.g, 1, std::nullopt, true, sel, small.residual, small.nm(), small.em(), q); - auto l1 = shortest_paths(large.g, 5, NodeId(7), false, sel, {}, {}, {}, q); - auto r1 = shortest_paths_to(large.g, 9, true, sel, large.residual, {}, {}, {}, q); - auto e1 = shortest_paths(empty, 0, std::nullopt, true, sel, {}, {}, {}, q); - EXPECT_TRUE(e1.first.empty()); - EXPECT_EQ(e1.second.parent_offsets.size(), 1u); - auto e2 = shortest_paths_to(empty, 0, true, sel, {}, {}, {}, {}, q); - EXPECT_TRUE(e2.first.empty()); - auto s2 = shortest_paths(small.g, 1, std::nullopt, true, sel, small.residual, small.nm(), small.em(), q); - auto l2 = shortest_paths(large.g, 5, NodeId(7), false, sel, {}, {}, {}, q); - auto r2 = shortest_paths_to(large.g, 9, true, sel, large.residual, {}, {}, {}, q); - EXPECT_TRUE(same_result(s1, s2)); - EXPECT_TRUE(same_result(l1, l2)); - EXPECT_TRUE(same_result(r1, r2)); - // A masked-out source still returns an all-INF result without touching the workspace. - auto masked = make_bool_mask(static_cast(small.g.num_nodes()), true); - masked[1] = false; - auto m = shortest_paths(small.g, 1, std::nullopt, true, sel, {}, std::span(masked.get(), static_cast(small.g.num_nodes())), {}, q); - for (auto dv : m.first) EXPECT_EQ(dv, std::numeric_limits::max()); - auto s3 = shortest_paths(small.g, 1, std::nullopt, true, sel, small.residual, small.nm(), small.em(), q); - EXPECT_TRUE(same_result(s1, s3)); - } -} - -// --------------------------------------------------------------------------- -// EdgeSelection::priority: which per-edge value ranks equal-cost alternatives. -// --------------------------------------------------------------------------- -TEST(ShortestPaths, EdgePriorityRanksParallelEdges) { - // Two parallel equal-cost edges 0 -> 1: capacities 10 and 5, residuals 2 and 4. - std::vector src = {0, 0}, dst = {1, 1}; - std::vector cap = {10.0, 5.0}; - std::vector cost = {1, 1}; - auto g = StrictMultiDiGraph::from_arrays(2, src, dst, cap, cost); - std::vector residual = {2.0, 4.0}; - // Edge ids after construction: both cost 1, same (src,dst): input order kept, so id 0 has cap 10. - ASSERT_DOUBLE_EQ(g.capacity_view()[0], 10.0); - auto pick = [&](EdgePriority pri, bool with_residual) { - EdgeSelection sel; sel.multi_edge = false; sel.tie_break = EdgeTieBreak::PreferHigherResidual; sel.priority = pri; - auto [d, dag] = shortest_paths(g, 0, std::nullopt, true, sel, - with_residual ? std::span(residual) : std::span{}, {}, {}); - EXPECT_EQ(dag.parent_offsets[2] - dag.parent_offsets[1], 1); - return dag.via_edges[static_cast(dag.parent_offsets[1])]; - }; - // With a residual: Residual prefers residual 4 (edge 1); Capacity prefers cap 10 (edge 0); - // Headroom prefers 4/5 = 0.8 over 2/10 = 0.2 (edge 1). - EXPECT_EQ(pick(EdgePriority::Residual, true), 1); - EXPECT_EQ(pick(EdgePriority::Capacity, true), 0); - EXPECT_EQ(pick(EdgePriority::Headroom, true), 1); - // Without a residual every priority falls back to the capacity (headroom is 1 - // for both, so the deterministic smaller id wins). - EXPECT_EQ(pick(EdgePriority::Residual, false), 0); - EXPECT_EQ(pick(EdgePriority::Capacity, false), 0); - EXPECT_EQ(pick(EdgePriority::Headroom, false), 0); -} - -TEST(ShortestPaths, EdgePriorityRanksEqualCostPaths) { - // 0 -> 1 -> 3 and 0 -> 2 -> 3, both cost 2. Path via 1: capacities 100/100 with - // residuals 10/10 (headroom 0.1). Path via 2: capacities 20/20 with residuals - // 8/8 (headroom 0.4). Single-path mode keeps the path with the better bottleneck. - std::vector src = {0, 1, 0, 2}, dst = {1, 3, 2, 3}; - std::vector cap = {100.0, 100.0, 20.0, 20.0}; - std::vector cost = {1, 1, 1, 1}; - auto g = StrictMultiDiGraph::from_arrays(4, src, dst, cap, cost); - std::vector residual(4); - const auto esrc = g.edge_src_view(), edst = g.edge_dst_view(); - for (std::size_t e = 0; e < 4; ++e) residual[e] = (esrc[e] == 1 || edst[e] == 1) ? 10.0 : 8.0; - auto parent_of_dst = [&](EdgePriority pri) { - EdgeSelection sel; sel.multi_edge = true; sel.priority = pri; - auto [d, dag] = shortest_paths(g, 0, std::nullopt, /*multipath=*/false, sel, residual, {}, {}); - EXPECT_EQ(d[3], 2); - EXPECT_EQ(dag.parent_offsets[4] - dag.parent_offsets[3], 1); - return dag.parents[static_cast(dag.parent_offsets[3])]; - }; - EXPECT_EQ(parent_of_dst(EdgePriority::Residual), 1); // bottleneck residual 10 > 8 - EXPECT_EQ(parent_of_dst(EdgePriority::Capacity), 1); // bottleneck capacity 100 > 20 - EXPECT_EQ(parent_of_dst(EdgePriority::Headroom), 2); // headroom 0.4 > 0.1 - // Both queue routes agree for every priority. - for (EdgePriority pri : {EdgePriority::Residual, EdgePriority::Capacity, EdgePriority::Headroom}) { - EdgeSelection sel; sel.multi_edge = false; sel.tie_break = EdgeTieBreak::PreferHigherResidual; sel.priority = pri; - auto a = shortest_paths(g, 0, std::nullopt, false, sel, residual, {}, {}, SpfQueue::Heap); - auto b = shortest_paths(g, 0, std::nullopt, false, sel, residual, {}, {}, SpfQueue::Bucket); - EXPECT_TRUE(same_result(a, b)); - auto ra = shortest_paths_to(g, 3, false, sel, residual, {}, {}, {}, SpfQueue::Heap); - auto rb = shortest_paths_to(g, 3, false, sel, residual, {}, {}, {}, SpfQueue::Bucket); - EXPECT_TRUE(same_result(ra, rb)); - } -} - -TEST(ShortestPaths, DagVariantHandlesInvalidEndpoints) { - auto d = make_diff_graph(kDiffSpecs[0]); - const int n = d.g.num_nodes(); - EdgeSelection sel; - // Unreachable or out-of-range destinations report INT64_MAX; the DAG is the - // full-search DAG (nothing stops the search early). - auto [c_out, dag_out] = shortest_paths_dag(d.g, 0, NodeId(n + 5), true, sel, {}, {}, {}); - EXPECT_EQ(c_out, std::numeric_limits::max()); - auto full = shortest_paths(d.g, 0, std::nullopt, true, sel, {}, {}, {}); - EXPECT_TRUE(same_result(full, SpfResult{full.first, dag_out})); - // A masked-out source yields an empty DAG and no cost. - auto masked = make_bool_mask(static_cast(n), true); - masked[0] = false; - auto [c_m, dag_m] = shortest_paths_dag(d.g, 0, 1, true, sel, {}, std::span(masked.get(), static_cast(n)), {}); - EXPECT_EQ(c_m, std::numeric_limits::max()); - EXPECT_EQ(dag_m.parents.size(), 0u); - EXPECT_EQ(dag_m.parent_offsets.size(), static_cast(n) + 1); -} - -// --------------------------------------------------------------------------- -// make_hops_dag: explicit routes with parallel edges per hop. -// --------------------------------------------------------------------------- -TEST(ShortestPaths, MakeHopsDagBuildsBundledHops) { - // 0 -> 1 via four parallel edges, 1 -> 2 via one edge, 1 -> 3 unrelated. - std::vector src = {0, 0, 0, 0, 1, 1}, dst = {1, 1, 1, 1, 2, 3}; - std::vector cap = {10, 10, 10, 10, 50, 5}; - std::vector cost = {1, 1, 1, 1, 1, 1}; - auto g = StrictMultiDiGraph::from_arrays(4, src, dst, cap, cost); - std::vector> hops = {{0, 1, 2, 3}, {4}}; - auto dag = make_hops_dag(g, hops); - ASSERT_EQ(dag.parent_offsets, (std::vector{0, 0, 4, 5, 5})); - EXPECT_EQ(dag.parents, (std::vector{0, 0, 0, 0, 1})); - EXPECT_EQ(dag.via_edges, (std::vector{0, 1, 2, 3, 4})); - // Hop order is kept as given. - auto reordered = make_hops_dag(g, std::vector>{{3, 1}, {4}}); - EXPECT_EQ(reordered.via_edges, (std::vector{3, 1, 4})); - // from_edges is the one-edge-per-hop case. - auto single = make_path_dag(g, std::array{2, 4}); - EXPECT_EQ(single.via_edges, (std::vector{2, 4})); - EXPECT_EQ(single.parent_offsets, (std::vector{0, 0, 1, 2, 2})); -} - -TEST(ShortestPaths, MakeHopsDagRejectsMalformedRoutes) { - std::vector src = {0, 0, 1, 1, 2}, dst = {1, 1, 2, 3, 0}; - std::vector cap = {1, 1, 1, 1, 1}; - std::vector cost = {1, 1, 1, 1, 1}; - auto g = StrictMultiDiGraph::from_arrays(4, src, dst, cap, cost); - using H = std::vector>; - EXPECT_THROW((void)make_hops_dag(g, H{}), std::invalid_argument); // empty route - EXPECT_THROW((void)make_hops_dag(g, H{{0}, {}}), std::invalid_argument); // empty hop - EXPECT_THROW((void)make_hops_dag(g, H{{0, 7}}), std::invalid_argument); // id out of range - EXPECT_THROW((void)make_hops_dag(g, H{{0, 0}}), std::invalid_argument); // repeated edge in a hop - EXPECT_THROW((void)make_hops_dag(g, H{{2, 3}}), std::invalid_argument); // not parallel (1->2 and 1->3) - EXPECT_NO_THROW((void)make_hops_dag(g, H{{0, 1}, {3}})); // 0->1 bundle, then 1->3 - EXPECT_THROW((void)make_hops_dag(g, H{{0}, {4}}), std::invalid_argument); // 0->1 then 2->0: not contiguous - EXPECT_THROW((void)make_hops_dag(g, H{{0}, {2}, {4}}), std::invalid_argument); // 0->1->2->0 revisits 0 - EXPECT_THROW((void)make_path_dag(g, std::array{0, 4}), std::invalid_argument); -} diff --git a/tests/cpp/smoke_test.cpp b/tests/cpp/smoke_test.cpp index eb29f21..bfe6563 100644 --- a/tests/cpp/smoke_test.cpp +++ b/tests/cpp/smoke_test.cpp @@ -1,36 +1,30 @@ #include + +#include #include +#include + +#include "netgraph/core/max_flow.hpp" #include "netgraph/core/strict_multidigraph.hpp" -#include "netgraph/core/shortest_paths.hpp" #include "netgraph/core/types.hpp" using namespace netgraph::core; -TEST(GraphSmoke, ConstructAndRunSPF) { - const std::int32_t N = 3; - // 0 -> 1 (cost 10) - // 1 -> 2 (cost 20) - // 0 -> 2 (cost 100) - std::int32_t src[3] = {0, 1, 0}; - std::int32_t dst[3] = {1, 2, 2}; - double cap[3] = {10.0, 10.0, 10.0}; - std::int64_t cost[3] = {10, 20, 100}; - - auto g = StrictMultiDiGraph::from_arrays(N, - std::span(src, 3), - std::span(dst, 3), - std::span(cap, 3), - std::span(cost, 3)); - - EXPECT_EQ(g.num_nodes(), N); +TEST(GraphSmoke, ConstructAndSolve) { + // 0 -> 1 (cost 10), 1 -> 2 (cost 20), 0 -> 2 (cost 100); capacities 10 units each. + const std::vector src = {0, 1, 0}; + const std::vector dst = {1, 2, 2}; + const std::vector cap = {10, 10, 10}; + const std::vector cost = {10, 20, 100}; + StrictMultiDiGraph g(3, src, dst, cap, cost, 1.0); + EXPECT_EQ(g.num_nodes(), 3); EXPECT_EQ(g.num_edges(), 3); - // Run SPF from 0 - EdgeSelection sel; - auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel); - - // Check distances - EXPECT_EQ(dist[0], 0); - EXPECT_EQ(dist[1], 10); - EXPECT_EQ(dist[2], 30); // Path 0->1->2 is cost 10+20=30, better than 0->2 (100) + const std::vector s = {0}; + const std::vector t = {2}; + auto r = place_global_units(g, g.capacity_units(), s, t, nullptr, std::nullopt); + EXPECT_EQ(r.placed, 20); + ASSERT_EQ(r.cost_distribution.size(), 2u); + EXPECT_EQ(r.cost_distribution[0], (std::pair{30, 10})); // 0 -> 1 -> 2 first + EXPECT_EQ(r.cost_distribution[1], (std::pair{100, 10})); // then the direct edge } diff --git a/tests/cpp/strict_multidigraph_tests.cpp b/tests/cpp/strict_multidigraph_tests.cpp index c475009..b68c164 100644 --- a/tests/cpp/strict_multidigraph_tests.cpp +++ b/tests/cpp/strict_multidigraph_tests.cpp @@ -1,354 +1,255 @@ +// The graph on integer units (APPROACH 2.1, 3): construction, validation order, layout B' +// ids, the reverse CSR, input_index, to_units and as_float. #include + +#include #include +#include +#include +#include +#include +#include + #include "netgraph/core/strict_multidigraph.hpp" #include "test_utils.hpp" using namespace netgraph::core; using namespace netgraph::core::test; -TEST(StrictMultiDiGraph, EmptyGraph) { - auto g = StrictMultiDiGraph::from_arrays(10, {}, {}, {}, {}); - EXPECT_EQ(g.num_nodes(), 10); - EXPECT_EQ(g.num_edges(), 0); - expect_csr_valid(g); - - // All row offsets should be 0 - auto row = g.row_offsets_view(); - for (auto offset : row) { - EXPECT_EQ(offset, 0); - } -} - -TEST(StrictMultiDiGraph, SingleNode) { - auto g = StrictMultiDiGraph::from_arrays(1, {}, {}, {}, {}); - EXPECT_EQ(g.num_nodes(), 1); - EXPECT_EQ(g.num_edges(), 0); - expect_csr_valid(g); -} - -TEST(StrictMultiDiGraph, SingleEdge) { - std::int32_t src[1] = {0}; - std::int32_t dst[1] = {1}; - double cap[1] = {5.0}; - std::int64_t cost[1] = {10}; - - auto g = StrictMultiDiGraph::from_arrays(2, - std::span(src, 1), std::span(dst, 1), - std::span(cap, 1), std::span(cost, 1)); - - EXPECT_EQ(g.num_nodes(), 2); - EXPECT_EQ(g.num_edges(), 1); - expect_csr_valid(g); - - // Check edge properties - auto cap_view = g.capacity_view(); - auto cost_view = g.cost_view(); - auto src_view = g.edge_src_view(); - auto dst_view = g.edge_dst_view(); - - EXPECT_EQ(cap_view[0], 5.0); - EXPECT_EQ(cost_view[0], 10); - EXPECT_EQ(src_view[0], 0); - EXPECT_EQ(dst_view[0], 1); -} - -TEST(StrictMultiDiGraph, ParallelEdges) { - // Three parallel edges from 0 to 1 - std::int32_t src[3] = {0, 0, 0}; - std::int32_t dst[3] = {1, 1, 1}; - double cap[3] = {1.0, 2.0, 3.0}; - std::int64_t cost[3] = {1, 1, 2}; - - auto g = StrictMultiDiGraph::from_arrays(2, - std::span(src, 3), std::span(dst, 3), - std::span(cap, 3), std::span(cost, 3)); - - EXPECT_EQ(g.num_nodes(), 2); - EXPECT_EQ(g.num_edges(), 3); - expect_csr_valid(g); - - // All three edges should be in node 0's adjacency list - auto row = g.row_offsets_view(); - EXPECT_EQ(row[1] - row[0], 3); -} - -TEST(StrictMultiDiGraph, EdgeSortingByCost) { - // Edges with different costs should be sorted - std::int32_t src[3] = {0, 0, 0}; - std::int32_t dst[3] = {1, 1, 1}; - double cap[3] = {1.0, 2.0, 3.0}; - std::int64_t cost[3] = {3, 1, 2}; // Unsorted +namespace { - auto g = StrictMultiDiGraph::from_arrays(2, - std::span(src, 3), std::span(dst, 3), - std::span(cap, 3), std::span(cost, 3)); +constexpr Amount L62 = Amount{1} << 62; - // Edges should be reordered by cost - auto cost_view = g.cost_view(); - EXPECT_EQ(cost_view[0], 1); // Lowest cost first - EXPECT_EQ(cost_view[1], 2); - EXPECT_EQ(cost_view[2], 3); // Highest cost last -} - -TEST(StrictMultiDiGraph, ExternalEdgeIdsPreserved) { - std::int32_t src[3] = {0, 0, 0}; - std::int32_t dst[3] = {1, 1, 1}; - double cap[3] = {1.0, 2.0, 3.0}; - std::int64_t cost[3] = {3, 1, 2}; - std::int64_t ext[3] = {100, 200, 300}; - - auto g = StrictMultiDiGraph::from_arrays(2, - std::span(src, 3), std::span(dst, 3), - std::span(cap, 3), std::span(cost, 3), - std::span(ext, 3)); - - // External IDs should be reordered alongside edges - auto ext_view = g.ext_edge_ids_view(); - EXPECT_EQ(ext_view.size(), 3); - - // Edge with cost 1 (input index 1) should have ext_id 200 - auto cost_view = g.cost_view(); - for (std::size_t i = 0; i < 3; ++i) { - if (cost_view[i] == 1) { - EXPECT_EQ(ext_view[i], 200); - } else if (cost_view[i] == 2) { - EXPECT_EQ(ext_view[i], 300); - } else if (cost_view[i] == 3) { - EXPECT_EQ(ext_view[i], 100); +// The message of the std::invalid_argument thrown by `fn`, or "" when nothing is thrown. +template +std::string error_of(F&& fn) { + try { + fn(); + } catch (const std::invalid_argument& e) { + return e.what(); + } + return ""; +} + +StrictMultiDiGraph build(std::int32_t n, std::vector src, std::vector dst, std::vector cap, + std::vector cost, double q = 1.0) { + return StrictMultiDiGraph(n, src, dst, cap, cost, q); +} + +bool contains(const std::string& s, const std::string& part) { return s.find(part) != std::string::npos; } + +} // namespace + +TEST(Graph, BPrimeOrderInputIndexAndReverseCsr) { + // (u, v, cap, cost) in input order; prototype fixture graph_bprime_order. + const std::vector edges = {{2, 0, 5, 3}, {0, 2, 4, 2}, {0, 1, 3, 2}, {1, 2, 2, 1}, + {0, 1, 1, 2}, {0, 2, 6, 1}, {2, 1, 7, 1}}; + auto g = make_graph(3, edges); + std::vector expect(edges.size()); + std::iota(expect.begin(), expect.end(), 0); + std::stable_sort(expect.begin(), expect.end(), [&](int a, int b) { + const auto& x = edges[static_cast(a)]; + const auto& y = edges[static_cast(b)]; + return std::tie(x.u, x.cost, x.v) < std::tie(y.u, y.cost, y.v); + }); + EXPECT_EQ(std::vector(g.input_index().begin(), g.input_index().end()), expect); + EXPECT_EQ(expect, (std::vector{5, 2, 4, 1, 3, 6, 0})); + EXPECT_EQ(std::vector(g.row_offsets().begin(), g.row_offsets().end()), + (std::vector{0, 4, 5, 7})); + // Every per-edge array is the input array permuted by input_index. + for (std::size_t e = 0; e < edges.size(); ++e) { + const auto& in = edges[static_cast(g.input_index()[e])]; + EXPECT_EQ(g.edge_src()[e], in.u); + EXPECT_EQ(g.edge_dst()[e], in.v); + EXPECT_EQ(g.capacity_units()[e], in.cap); + EXPECT_EQ(g.cost()[e], in.cost); + } + // The out-edges of u are the id range of its row, in (cost, dst, input) order. + for (NodeId u = 0; u < 3; ++u) { + for (auto e = g.row_offsets()[u]; e < g.row_offsets()[u + 1]; ++e) { + EXPECT_EQ(g.edge_src()[static_cast(e)], u); } } -} - -TEST(StrictMultiDiGraph, CSROffsetsMonotonicity) { - auto g = make_grid_graph(3, 3); - expect_csr_valid(g); - - auto row = g.row_offsets_view(); - for (std::size_t i = 0; i < row.size() - 1; ++i) { - EXPECT_LE(row[i], row[i + 1]) << "CSR offsets not monotonic"; + // input_index round trip through the caller's inverse. + const auto inv = id_of_input(g); + for (std::size_t i = 0; i < edges.size(); ++i) { + EXPECT_EQ(g.input_index()[static_cast(inv[i])], static_cast(i)); + } + std::vector caps_in; + for (const auto& e : edges) caps_in.push_back(e.cap); + EXPECT_EQ(to_input(g, g.capacity_units()), caps_in); + // Reverse CSR in (cost, src, input) order per destination, as input positions. + const std::vector> expect_rev = {{0}, {6, 2, 4}, {5, 3, 1}}; + for (NodeId v = 0; v < 3; ++v) { + std::vector got; + for (auto s = g.in_row_offsets()[v]; s < g.in_row_offsets()[v + 1]; ++s) { + const EdgeId e = g.in_adj_edge_index()[static_cast(s)]; + EXPECT_EQ(g.edge_dst()[static_cast(e)], v); + got.push_back(g.input_index()[static_cast(e)]); + } + EXPECT_EQ(got, expect_rev[static_cast(v)]) << "node " << v; } } -TEST(StrictMultiDiGraph, CSRReverseAdjacency) { - // Create a simple graph: 0->1, 1->2, 0->2 - std::int32_t src[3] = {0, 1, 0}; - std::int32_t dst[3] = {1, 2, 2}; - double cap[3] = {1.0, 1.0, 1.0}; - std::int64_t cost[3] = {1, 1, 1}; - - auto g = StrictMultiDiGraph::from_arrays(3, - std::span(src, 3), std::span(dst, 3), - std::span(cap, 3), std::span(cost, 3)); - - // Check reverse adjacency (incoming edges) - auto in_row = g.in_row_offsets_view(); - auto in_col = g.in_col_indices_view(); - - // Node 0 has no incoming edges - EXPECT_EQ(in_row[1] - in_row[0], 0); - - // Node 1 has one incoming edge (from 0) - EXPECT_EQ(in_row[2] - in_row[1], 1); - EXPECT_EQ(in_col[in_row[1]], 0); - - // Node 2 has two incoming edges (from 0 and 1) - EXPECT_EQ(in_row[3] - in_row[2], 2); -} - -TEST(StrictMultiDiGraph, MaxCapacityEdges) { - std::int32_t src[1] = {0}; - std::int32_t dst[1] = {1}; - double cap[1] = {std::numeric_limits::max()}; - std::int64_t cost[1] = {1}; - - auto g = StrictMultiDiGraph::from_arrays(2, - std::span(src, 1), std::span(dst, 1), - std::span(cap, 1), std::span(cost, 1)); - - auto cap_view = g.capacity_view(); - EXPECT_EQ(cap_view[0], std::numeric_limits::max()); -} - -TEST(StrictMultiDiGraph, ZeroCostEdges) { - std::int32_t src[2] = {0, 0}; - std::int32_t dst[2] = {1, 1}; - double cap[2] = {1.0, 2.0}; - std::int64_t cost[2] = {0, 1}; - - auto g = StrictMultiDiGraph::from_arrays(2, - std::span(src, 2), std::span(dst, 2), - std::span(cap, 2), std::span(cost, 2)); - - // Zero cost edge should be first in sorted order - auto cost_view = g.cost_view(); - EXPECT_EQ(cost_view[0], 0); - EXPECT_EQ(cost_view[1], 1); -} - -TEST(StrictMultiDiGraph, InvalidNumNodesThrows) { - // Negative node count - EXPECT_THROW({ - auto g = StrictMultiDiGraph::from_arrays(-1, {}, {}, {}, {}); - (void)g; - }, std::invalid_argument); -} - -TEST(StrictMultiDiGraph, MismatchedArrayLengthsThrow) { - std::int32_t src[1] = {0}; - std::int32_t dst[1] = {0}; - double cap[1] = {1.0}; - std::int64_t cost[2] = {1, 2}; - EXPECT_THROW({ - auto g = StrictMultiDiGraph::from_arrays(1, - std::span(src, 1), std::span(dst, 1), - std::span(cap, 1), std::span(cost, 2)); - (void)g; - }, std::invalid_argument); -} - -TEST(StrictMultiDiGraph, ExtEdgeIdsWrongLengthThrows) { - std::int32_t src[1] = {0}; - std::int32_t dst[1] = {0}; - double cap[1] = {1.0}; - std::int64_t cost[1] = {1}; - std::int64_t ext[2] = {10, 20}; - EXPECT_THROW({ - auto g = StrictMultiDiGraph::from_arrays(1, - std::span(src, 1), std::span(dst, 1), - std::span(cap, 1), std::span(cost, 1), - std::span(ext, 2)); - (void)g; - }, std::invalid_argument); -} - -TEST(StrictMultiDiGraph, OutOfRangeNodeThrows) { - std::int32_t src[1] = {0}; - std::int32_t dst[1] = {2}; // out of range for num_nodes=2 (0..1) - double cap[1] = {1.0}; - std::int64_t cost[1] = {1}; - EXPECT_THROW({ - auto g = StrictMultiDiGraph::from_arrays(2, - std::span(src, 1), std::span(dst, 1), - std::span(cap, 1), std::span(cost, 1)); - (void)g; - }, std::out_of_range); -} - -TEST(StrictMultiDiGraph, NegativeCapacityThrows) { - std::int32_t src[1] = {0}; - std::int32_t dst[1] = {1}; - double cap[1] = {-1.0}; - std::int64_t cost[1] = {1}; - EXPECT_THROW({ - auto g = StrictMultiDiGraph::from_arrays(2, - std::span(src, 1), std::span(dst, 1), - std::span(cap, 1), std::span(cost, 1)); - (void)g; - }, std::invalid_argument); -} - -TEST(StrictMultiDiGraph, NegativeCostThrows) { - std::int32_t src[1] = {0}; - std::int32_t dst[1] = {1}; - double cap[1] = {1.0}; - std::int64_t cost[1] = {-1}; - EXPECT_THROW({ - auto g = StrictMultiDiGraph::from_arrays(2, - std::span(src, 1), std::span(dst, 1), - std::span(cap, 1), std::span(cost, 1)); - (void)g; - }, std::invalid_argument); -} - -TEST(StrictMultiDiGraph, SelfLoopAllowed) { - // Self-loops (edges from node to itself) should be valid - std::int32_t src[1] = {0}; - std::int32_t dst[1] = {0}; // Self-loop - double cap[1] = {5.0}; - std::int64_t cost[1] = {1}; - - auto g = StrictMultiDiGraph::from_arrays(1, - std::span(src, 1), std::span(dst, 1), - std::span(cap, 1), std::span(cost, 1)); - - EXPECT_EQ(g.num_nodes(), 1); - EXPECT_EQ(g.num_edges(), 1); - - // Verify self-loop appears in both forward and reverse adjacency - auto row = g.row_offsets_view(); - EXPECT_EQ(row[1] - row[0], 1); // One outgoing edge - - auto in_row = g.in_row_offsets_view(); - EXPECT_EQ(in_row[1] - in_row[0], 1); // One incoming edge -} - -TEST(StrictMultiDiGraph, NegativeNodeIdThrows) { - // Negative node IDs should be rejected - std::int32_t src[1] = {-1}; // Invalid negative ID - std::int32_t dst[1] = {0}; - double cap[1] = {1.0}; - std::int64_t cost[1] = {1}; - EXPECT_THROW({ - auto g = StrictMultiDiGraph::from_arrays(1, - std::span(src, 1), std::span(dst, 1), - std::span(cap, 1), std::span(cost, 1)); - (void)g; - }, std::out_of_range); -} - -TEST(StrictMultiDiGraph, MultipleCostTiers) { - // Verify edges are sorted by cost into distinct tiers - std::int32_t src[6] = {0, 0, 0, 0, 0, 0}; - std::int32_t dst[6] = {1, 1, 1, 1, 1, 1}; - double cap[6] = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0}; - std::int64_t cost[6] = {10, 5, 10, 1, 5, 1}; // Three tiers: 1, 5, 10 - - auto g = StrictMultiDiGraph::from_arrays(2, - std::span(src, 6), std::span(dst, 6), - std::span(cap, 6), std::span(cost, 6)); - - auto cost_view = g.cost_view(); - // Sorted costs should be [1, 1, 5, 5, 10, 10] - EXPECT_EQ(cost_view[0], 1); - EXPECT_EQ(cost_view[1], 1); - EXPECT_EQ(cost_view[2], 5); - EXPECT_EQ(cost_view[3], 5); - EXPECT_EQ(cost_view[4], 10); - EXPECT_EQ(cost_view[5], 10); -} - -TEST(StrictMultiDiGraph, CostSummaryMaxAndGcd) { - auto empty = StrictMultiDiGraph::from_arrays(0, {}, {}, {}, {}); +TEST(Graph, EqualKeysKeepInputOrder) { + // Parallel edges with equal (src, cost, dst) are ranked by input position, in both CSRs. + auto g = make_graph(3, {{0, 1, 4, 2}, {1, 2, 1, 1}, {0, 1, 7, 2}, {0, 1, 5, 1}, {2, 1, 3, 2}, {0, 1, 9, 2}}); + EXPECT_EQ(std::vector(g.input_index().begin(), g.input_index().end()), + (std::vector{3, 0, 2, 5, 1, 4})); + std::vector rev; + for (auto s = g.in_row_offsets()[1]; s < g.in_row_offsets()[2]; ++s) { + rev.push_back(g.input_index()[static_cast(g.in_adj_edge_index()[static_cast(s)])]); + } + // (cost, src, input): cost 1 from 0 (input 3), then cost 2 from 0 (inputs 0, 2, 5), then from 2. + EXPECT_EQ(rev, (std::vector{3, 0, 2, 5, 4})); +} + +TEST(Graph, ReverseOrderIsCostThenSource) { + // A scan in id order would give (src, cost, input); the contract is (cost, src, input). + auto g = make_graph(3, {{0, 2, 1, 5}, {1, 2, 1, 1}}); + ASSERT_EQ(g.in_row_offsets()[2], 0); + const EdgeId first = g.in_adj_edge_index()[0]; + EXPECT_EQ(g.input_index()[static_cast(first)], 1); +} + +TEST(Graph, ShapesOfEmptyAndIsolated) { + auto empty = build(0, {}, {}, {}, {}); + EXPECT_EQ(empty.num_nodes(), 0); + EXPECT_EQ(empty.num_edges(), 0); + EXPECT_EQ(std::vector(empty.row_offsets().begin(), empty.row_offsets().end()), + (std::vector{0})); + EXPECT_EQ(std::vector(empty.in_row_offsets().begin(), empty.in_row_offsets().end()), + (std::vector{0})); EXPECT_EQ(empty.max_cost(), 0); EXPECT_EQ(empty.cost_gcd(), 0); - { - std::vector src = {0, 1, 2}, dst = {1, 2, 0}; - std::vector cap = {1.0, 1.0, 1.0}; - std::vector cost = {6, 9, 0}; - auto g = StrictMultiDiGraph::from_arrays(3, src, dst, cap, cost); - EXPECT_EQ(g.max_cost(), 9); - EXPECT_EQ(g.cost_gcd(), 3); - StrictMultiDiGraph copy = g; - EXPECT_EQ(copy.max_cost(), 9); - EXPECT_EQ(copy.cost_gcd(), 3); - StrictMultiDiGraph moved = std::move(copy); - EXPECT_EQ(moved.max_cost(), 9); - EXPECT_EQ(moved.cost_gcd(), 3); + auto isolated = build(4, {}, {}, {}, {}); + EXPECT_EQ(std::vector(isolated.row_offsets().begin(), isolated.row_offsets().end()), + (std::vector{0, 0, 0, 0, 0})); + auto g = make_graph(5, {{3, 1, 1, 6}, {1, 3, 1, 4}, {3, 3, 2, 10}}); + EXPECT_EQ(std::vector(g.row_offsets().begin(), g.row_offsets().end()), + (std::vector{0, 0, 1, 1, 3, 3})); + EXPECT_EQ(std::vector(g.in_row_offsets().begin(), g.in_row_offsets().end()), + (std::vector{0, 0, 1, 1, 3, 3})); + EXPECT_EQ(g.max_cost(), 10); + EXPECT_EQ(g.cost_gcd(), 2); +} + +TEST(Graph, ExactUnitsAboveTwo53) { + const Amount big = (Amount{1} << 53) + 1; + auto g = make_graph(2, {{0, 1, big, 1}}); + EXPECT_EQ(g.capacity_units()[0], big); + EXPECT_EQ(as_float(big, 1.0), 9007199254740992.0); // the documented export limit +} + +TEST(Graph, CopyIsANewGraph) { + auto g = make_graph(3, {{0, 2, 4, 2}, {0, 1, 3, 1}}); + StrictMultiDiGraph c(g); + EXPECT_NE(c.uid(), g.uid()); + EXPECT_EQ(std::vector(c.input_index().begin(), c.input_index().end()), + std::vector(g.input_index().begin(), g.input_index().end())); + EXPECT_EQ(std::vector(c.capacity_units().begin(), c.capacity_units().end()), + std::vector(g.capacity_units().begin(), g.capacity_units().end())); + auto h = make_graph(3, {{0, 2, 4, 2}, {0, 1, 3, 1}}); + EXPECT_NE(h.uid(), g.uid()); + EXPECT_GT(g.uid(), 0u); +} + +TEST(Graph, Resolution) { + for (double q : {std::ldexp(1.0, -32), std::ldexp(1.0, -12), 1.0, std::ldexp(1.0, 32)}) { + auto g = build(2, {0}, {1}, {1}, {1}, q); + EXPECT_EQ(g.resolution(), q); } - { - std::vector src = {0, 1}, dst = {1, 0}; - std::vector cap = {1.0, 1.0}; - std::vector cost = {0, 0}; - auto g = StrictMultiDiGraph::from_arrays(2, src, dst, cap, cost); - EXPECT_EQ(g.max_cost(), 0); - EXPECT_EQ(g.cost_gcd(), 0); + for (double q : {3.0, std::ldexp(1.0, -33), std::ldexp(1.0, 33), 0.0, -1.0, -0.5, + std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity(), 1e308}) { + EXPECT_TRUE(contains(error_of([&] { (void)build(2, {0}, {1}, {1}, {1}, q); }), "resolution")) << q; } - { - std::vector src = {0}, dst = {1}; - std::vector cap = {1.0}; - std::vector cost = {(std::int64_t{1} << 40) + 7}; - auto g = StrictMultiDiGraph::from_arrays(2, src, dst, cap, cost); - EXPECT_EQ(g.max_cost(), (std::int64_t{1} << 40) + 7); - EXPECT_EQ(g.cost_gcd(), (std::int64_t{1} << 40) + 7); +} + +TEST(Graph, EveryValidationError) { + // The unit values. + EXPECT_TRUE(contains(error_of([] { (void)build(2, {0}, {1}, {-1}, {1}); }), "capacity_units")); + EXPECT_TRUE(contains(error_of([] { (void)build(2, {0}, {1}, {L62}, {1}); }), "units domain")); + // The shapes. + EXPECT_TRUE(contains(error_of([] { (void)build(-1, {}, {}, {}, {}); }), "num_nodes")); + EXPECT_TRUE(contains(error_of([] { (void)build(2, {0, 1}, {1}, {1}, {1}); }), "same length")); + EXPECT_TRUE(contains(error_of([] { (void)build(2, {0}, {1}, {1, 2}, {1}); }), "same length")); + EXPECT_TRUE(contains(error_of([] { (void)build(2, {0}, {1}, {1}, {}); }), "same length")); + // Zero and negative costs (rule 28). + EXPECT_TRUE(contains(error_of([] { (void)build(2, {0}, {1}, {1}, {0}); }), "cost")); + EXPECT_TRUE(contains(error_of([] { (void)build(2, {0}, {1}, {1}, {-5}); }), "costs must be positive")); + // The remaining bounds. + EXPECT_TRUE(contains(error_of([] { (void)build(2, {2}, {1}, {1}, {1}); }), "out of node range")); + EXPECT_TRUE(contains(error_of([] { (void)build(2, {0}, {-1}, {1}, {1}); }), "out of node range")); + EXPECT_TRUE(contains(error_of([] { (void)build(2, {0, 0}, {1, 1}, {L62 / 2, L62 / 2}, {1, 1}); }), + "sum of all capacities")); + EXPECT_TRUE(contains(error_of([] { (void)build(2, {0, 0}, {1, 1}, {1, 1}, {L62 / 2, L62 / 2}); }), + "sum of all edge costs")); + // Just below the bounds is accepted. + auto caps = build(2, {0, 0}, {1, 1}, {L62 / 2, L62 / 2 - 1}, {1, 1}); + EXPECT_EQ(caps.capacity_units()[0] + caps.capacity_units()[1], L62 - 1); + auto costs = build(2, {0, 0}, {1, 1}, {1, 1}, {L62 / 2, L62 / 2 - 1}); + EXPECT_EQ(costs.max_cost(), L62 / 2); + auto huge_cost = build(2, {0}, {1}, {1}, {L62 - 1}); + EXPECT_EQ(huge_cost.max_cost(), L62 - 1); + EXPECT_TRUE(contains(error_of([] { (void)build(2, {0}, {1}, {1}, {std::numeric_limits::max()}); }), + "sum of all edge costs")); +} + +TEST(Graph, ValidationOrder) { + // Resolution before the unit values. + EXPECT_TRUE(contains(error_of([] { (void)build(2, {0}, {1}, {-1}, {1}, 3.0); }), "resolution")); + // The unit values before the shapes. + EXPECT_TRUE(contains(error_of([] { (void)build(2, {0, 1}, {1}, {-1}, {1}); }), "capacity_units")); + // The shapes before the costs. + EXPECT_TRUE(contains(error_of([] { (void)build(2, {0, 1}, {1}, {1}, {0}); }), "same length")); + EXPECT_TRUE(contains(error_of([] { (void)build(-1, {0}, {1}, {1}, {0}); }), "num_nodes")); + // The costs before the remaining bounds (endpoints, sums). + EXPECT_TRUE(contains(error_of([] { (void)build(2, {0}, {5}, {1}, {0}); }), "costs must be positive")); + EXPECT_TRUE(contains(error_of([] { (void)build(2, {0, 0}, {1, 1}, {L62 / 2, L62 / 2}, {1, 0}); }), + "costs must be positive")); + // Endpoints before the sums. + EXPECT_TRUE(contains(error_of([] { (void)build(2, {0, 7}, {1, 1}, {L62 / 2, L62 / 2}, {1, 1}); }), + "out of node range")); +} + +TEST(Units, HalfUpBoundaries) { + for (int k : {-12, 0, -32, 32}) { + const double q = std::ldexp(1.0, k); + EXPECT_EQ(to_units(2.5 * q, q), 3) << k; + EXPECT_EQ(to_units(0.5 * q, q), 1) << k; + EXPECT_EQ(to_units(0.49 * q, q), 0) << k; + EXPECT_EQ(to_units(7.5 * q, q), 8) << k; + EXPECT_EQ(to_units(std::nextafter(7.5 * q, 0.0), q), 7) << k; + EXPECT_EQ(to_units(std::nextafter(0.5 * q, 0.0), q), 0) << k; + EXPECT_EQ(to_units(0.0, q), 0) << k; + EXPECT_EQ(to_units(-0.0, q), 0) << k; + EXPECT_EQ(as_float(12345, q), 12345 * q) << k; } + // Physical 1e15 at q = 2^-12 is 4.096e18 units: below 2^62 and exact. + EXPECT_EQ(to_units(1e15, std::ldexp(1.0, -12)), Amount{4096000000000000000}); + // Large integral values: the double is the unit count. + EXPECT_EQ(to_units(9007199254740993.0, 1.0), Amount{9007199254740992}); + EXPECT_EQ(to_units(4503599627370497.5, 1.0), Amount{4503599627370498}); // 2^52 + 1.5 is not a double + EXPECT_EQ(to_units(4503599627370495.5, 1.0), Amount{4503599627370496}); // 2^52 - 0.5 rounds up + EXPECT_EQ(to_units(4611686018427387392.0, 1.0), Amount{4611686018427387392}); // 2^62 - 512 +} + +TEST(Units, RejectedValues) { + const double q = std::ldexp(1.0, -12); + EXPECT_TRUE(contains(error_of([&] { (void)to_units(std::numeric_limits::quiet_NaN(), q); }), "NaN")); + EXPECT_TRUE(contains(error_of([&] { (void)to_units(std::numeric_limits::infinity(), q); }), "finite")); + EXPECT_TRUE(contains(error_of([&] { (void)to_units(-std::numeric_limits::infinity(), q); }), "finite")); + EXPECT_TRUE(contains(error_of([&] { (void)to_units(-1e-300, q); }), "negative")); + EXPECT_TRUE(contains(error_of([&] { (void)to_units(4611686018427387904.0, 1.0); }), "bound")); // 2^62 + EXPECT_TRUE(contains(error_of([&] { (void)to_units(std::numeric_limits::max(), std::ldexp(1.0, 32)); }), + "bound")); + EXPECT_TRUE(contains(error_of([&] { (void)to_units(1e300, std::ldexp(1.0, -32)); }), "bound")); + EXPECT_TRUE(contains(error_of([&] { (void)to_units(1.0, 3.0); }), "resolution")); + EXPECT_TRUE(contains(error_of([&] { (void)as_float(1, 0.0); }), "resolution")); + // Admission at q = 2^-12: [1e15, 1.25e14] fits, [1e15, 1.26e14] exceeds the unit sum. + const std::vector ok = {to_units(1e15, q), to_units(1.25e14, q)}; + auto g = build(2, {0, 0}, {1, 1}, ok, {1, 1}, q); + EXPECT_EQ(g.capacity_units()[0] + g.capacity_units()[1], ok[0] + ok[1]); + const std::vector over = {to_units(1e15, q), to_units(1.26e14, q)}; + EXPECT_TRUE(contains(error_of([&] { (void)build(2, {0, 0}, {1, 1}, over, {1, 1}, q); }), "sum of all capacities")); } diff --git a/tests/cpp/test_utils.hpp b/tests/cpp/test_utils.hpp index f041799..f17a174 100644 --- a/tests/cpp/test_utils.hpp +++ b/tests/cpp/test_utils.hpp @@ -1,479 +1,416 @@ #pragma once #include + +#include +#include +#include +#include +#include +#include #include +#include #include -#include "netgraph/core/strict_multidigraph.hpp" -#include "netgraph/core/shortest_paths.hpp" + #include "netgraph/core/max_flow.hpp" +#include "netgraph/core/strict_multidigraph.hpp" namespace netgraph::core::test { -// Helper to create bool arrays for masks (avoids std::vector issues with .data()) -inline std::unique_ptr make_bool_mask(std::size_t n, bool default_val = true) { - auto mask = std::make_unique(n); - std::fill_n(mask.get(), n, default_val); - return mask; -} - -// Graph builders matching Python fixtures -inline StrictMultiDiGraph make_line_graph(int n) { - // Create a simple line graph 0->1->2->...->n-1 - if (n <= 1) { - return StrictMultiDiGraph::from_arrays(n, {}, {}, {}, {}); +// An edge in input coordinates. +struct InEdge { + NodeId u; + NodeId v; + Amount cap; + Cost cost; +}; + +inline StrictMultiDiGraph make_graph(std::int32_t n, const std::vector& edges, double q = 1.0) { + std::vector src, dst; + std::vector cap; + std::vector cost; + for (const auto& e : edges) { + src.push_back(e.u); + dst.push_back(e.v); + cap.push_back(e.cap); + cost.push_back(e.cost); } - std::vector src, dst; - std::vector cap; - std::vector cost; - for (int i = 0; i < n - 1; ++i) { - src.push_back(i); - dst.push_back(i + 1); - cap.push_back(1.0); - cost.push_back(1); - } - return StrictMultiDiGraph::from_arrays(n, src, dst, cap, cost); + return StrictMultiDiGraph(n, src, dst, cap, cost, q); } -inline StrictMultiDiGraph make_square_graph(int type = 1) { - // Type 1: one shortest route and one longer alternative - // 0->1->2 (cost 2) vs 0->3->2 (cost 4) - if (type == 1) { - std::int32_t src_arr[4] = {0, 1, 0, 3}; - std::int32_t dst_arr[4] = {1, 2, 3, 2}; - double cap_arr[4] = {1.0, 1.0, 2.0, 2.0}; - std::int64_t cost_arr[4] = {1, 1, 2, 2}; - return StrictMultiDiGraph::from_arrays(4, - std::span(src_arr, 4), std::span(dst_arr, 4), - std::span(cap_arr, 4), std::span(cost_arr, 4)); - } - // Type 2: two equal-cost shortest routes - std::int32_t src_arr[4] = {0, 1, 0, 3}; - std::int32_t dst_arr[4] = {1, 2, 3, 2}; - double cap_arr[4] = {1.0, 1.0, 2.0, 2.0}; - std::int64_t cost_arr[4] = {1, 1, 1, 1}; - return StrictMultiDiGraph::from_arrays(4, - std::span(src_arr, 4), std::span(dst_arr, 4), - std::span(cap_arr, 4), std::span(cost_arr, 4)); +// The caller's inverse of input_index(): input position -> EdgeId. +inline std::vector id_of_input(const StrictMultiDiGraph& g) { + auto ii = g.input_index(); + std::vector inv(ii.size()); + for (std::size_t e = 0; e < ii.size(); ++e) inv[static_cast(ii[e])] = static_cast(e); + return inv; } -inline StrictMultiDiGraph make_grid_graph(int rows, int cols) { - // Create a grid graph with edges going right and down - int n = rows * cols; - std::vector src, dst; - std::vector cap; - std::vector cost; - - for (int r = 0; r < rows; ++r) { - for (int c = 0; c < cols; ++c) { - int node = r * cols + c; - // Right edge - if (c < cols - 1) { - src.push_back(node); - dst.push_back(node + 1); - cap.push_back(1.0); - cost.push_back(1); - } - // Down edge - if (r < rows - 1) { - src.push_back(node); - dst.push_back(node + cols); - cap.push_back(1.0); - cost.push_back(1); - } - } - } - return StrictMultiDiGraph::from_arrays(n, src, dst, cap, cost); +// A per-edge array in input order, permuted to id order. +template +std::vector to_ids(const StrictMultiDiGraph& g, const std::vector& by_input) { + auto ii = g.input_index(); + std::vector out(ii.size()); + for (std::size_t e = 0; e < ii.size(); ++e) out[e] = by_input[static_cast(ii[e])]; + return out; } -inline StrictMultiDiGraph make_n_disjoint_paths(int n, double capacity, std::int64_t cost = 1) { - // Create N disjoint equal-cost paths from source to sink - // Topology: 0 → [1..n] → (n+1) - // Each path has 2 hops with specified capacity and cost - int num_nodes = n + 2; // source, n intermediates, sink - std::vector src, dst; - std::vector cap; - std::vector cost_vec; - - for (int i = 0; i < n; ++i) { - // First hop: 0 → (i+1) - src.push_back(0); - dst.push_back(i + 1); - cap.push_back(capacity); - cost_vec.push_back(cost); - - // Second hop: (i+1) → (n+1) - src.push_back(i + 1); - dst.push_back(n + 1); - cap.push_back(capacity); - cost_vec.push_back(cost); - } - - return StrictMultiDiGraph::from_arrays(num_nodes, src, dst, cap, cost_vec); +// A per-edge array in id order, permuted to input order. +template +std::vector to_input(const StrictMultiDiGraph& g, std::span by_id) { + auto ii = g.input_index(); + std::vector out(ii.size()); + for (std::size_t e = 0; e < ii.size(); ++e) out[static_cast(ii[e])] = by_id[e]; + return out; } -inline StrictMultiDiGraph make_tiered_graph(double cap_tier1, double cap_tier2, - std::int64_t cost_tier1 = 1, - std::int64_t cost_tier2 = 2) { - // Create topology with two different-cost paths - // Path 1: 0→1→3 (tier1 cost and capacity) - // Path 2: 0→2→3 (tier2 cost and capacity) - std::int32_t src_arr[4] = {0, 1, 0, 2}; - std::int32_t dst_arr[4] = {1, 3, 2, 3}; - double cap_arr[4] = {cap_tier1, cap_tier1, cap_tier2, cap_tier2}; - std::int64_t cost_arr[4] = {cost_tier1, cost_tier1, cost_tier2, cost_tier2}; +template +std::vector to_input(const StrictMultiDiGraph& g, const std::vector& by_id) { + return to_input(g, std::span(by_id)); +} - return StrictMultiDiGraph::from_arrays(4, - std::span(src_arr, 4), std::span(dst_arr, 4), - std::span(cap_arr, 4), std::span(cost_arr, 4)); +// EdgeIds translated to sorted input positions. +inline std::vector ids_to_input(const StrictMultiDiGraph& g, const std::vector& ids) { + std::vector out; + for (EdgeId e : ids) out.push_back(g.input_index()[static_cast(e)]); + std::sort(out.begin(), out.end()); + return out; } -inline StrictMultiDiGraph make_shared_bottleneck_graph(double path_cap, double bottleneck_cap) { - // Create topology with shared bottleneck - // Two paths: 0→1→3 and 0→2→3, where edge 1→3 is shared - // Path 1: 0→1 (path_cap), 1→3 (bottleneck_cap) - // Path 2: 0→2 (path_cap), 2→3 (path_cap) - // Bottleneck: 1→3 limits total flow - std::int32_t src_arr[4] = {0, 1, 0, 2}; - std::int32_t dst_arr[4] = {1, 3, 2, 3}; - double cap_arr[4] = {path_cap, bottleneck_cap, path_cap, path_cap}; - std::int64_t cost_arr[4] = {1, 1, 1, 1}; +inline std::vector> curve(std::initializer_list> c) { return c; } - return StrictMultiDiGraph::from_arrays(4, - std::span(src_arr, 4), std::span(dst_arr, 4), - std::span(cap_arr, 4), std::span(cost_arr, 4)); +inline FlowCoreResult solve(const StrictMultiDiGraph& g, std::vector s, std::vector d, + std::optional cap, const std::vector* view = nullptr, + const Eligibility* elig = nullptr) { + std::vector full(g.capacity_units().begin(), g.capacity_units().end()); + return place_global_units(g, view ? std::span(*view) : std::span(full), s, d, elig, cap); } -inline StrictMultiDiGraph make_3tier_clos_graph() { - /** - * Build a 3-tier Clos topology matching NetGraph scenario_3.yaml structure. - * - * Structure (replicates scenario_3 from NetGraph integration tests): - * - 2 Clos instances (clos1, clos2) - * - Each Clos has: - * - 2 bricks (b1, b2) - * - Each brick has: - * - 4 T1 nodes (tier1/leaf layer) - * - 4 T2 nodes (tier2/aggregation layer) - * - Mesh: all T1->T2 edges within brick (100 Gbps, cost 1) - * - 16 spine nodes (T3) - * - Connections: Each brick's 4 T2 nodes connect one-to-one to 4 spines (400 Gbps, cost 1) - * - Inter-Clos: 16 spine-to-spine links (400 Gbps, cost 1) - * - * Node numbering: - * - Clos1: - * - b1/t1: nodes 0-3 - * - b1/t2: nodes 4-7 - * - b2/t1: nodes 8-11 - * - b2/t2: nodes 12-15 - * - spine: nodes 16-31 - * - Clos2: - * - b1/t1: nodes 32-35 - * - b1/t2: nodes 36-39 - * - b2/t1: nodes 40-43 - * - b2/t2: nodes 44-47 - * - spine: nodes 48-63 - * - * Total nodes: 64 - * - * Expected max flow from clos1 T1 nodes to clos2 T1 nodes: 3200.0 Gbps - * (8 inter-clos spine links × 400 Gbps = 3200 Gbps bottleneck) - */ +inline bool same_payload(const FlowCoreResult& a, const FlowCoreResult& b) { + const auto& x = a.counters; + const auto& y = b.counters; + return a.placed == b.placed && a.cost_distribution == b.cost_distribution && a.edge_flows == b.edge_flows && + a.min_cut == b.min_cut && a.potentials == b.potentials && x.dijkstra_runs == y.dijkstra_runs && + x.dijkstra_pops == y.dijkstra_pops && x.settled == y.settled && x.arc_scans == y.arc_scans && + x.dinic_phases == y.dinic_phases && x.augmentations == y.augmentations; +} - constexpr int num_nodes = 64; - std::vector src, dst; - std::vector cap; - std::vector cost; +// Value -> minimum cost of every integer flow value, by enumerating every integer flow on the +// eligible edges (small graphs only). Independent of the solver. +inline std::map exhaustive_costs(const StrictMultiDiGraph& g, std::span view, + const std::vector& srcs, const std::vector& dsts, + const Eligibility* elig) { + const auto m = static_cast(g.num_edges()); + const auto n = static_cast(g.num_nodes()); + std::vector free; + for (std::size_t e = 0; e < m; ++e) { + if (elig == nullptr || elig->edge_ok(static_cast(e))) free.push_back(e); + } + std::set S, D; + for (NodeId s : srcs) if (elig == nullptr || elig->node_ok(s)) S.insert(s); + for (NodeId d : dsts) if (elig == nullptr || elig->node_ok(d)) D.insert(d); + std::map best; + std::vector f(free.size(), 0); + while (true) { + std::vector bal(n, 0); + __int128 cost = 0; + for (std::size_t k = 0; k < free.size(); ++k) { + const auto e = free[k]; + bal[static_cast(g.edge_src()[e])] -= f[k]; + bal[static_cast(g.edge_dst()[e])] += f[k]; + cost += static_cast<__int128>(g.cost()[e]) * f[k]; + } + bool ok = true; + Amount value = 0; + for (std::size_t v = 0; v < n && ok; ++v) { + const auto node = static_cast(v); + if (S.count(node)) { ok = bal[v] <= 0; value -= bal[v]; } + else if (D.count(node)) ok = bal[v] >= 0; + else ok = bal[v] == 0; + } + if (ok) { + auto it = best.find(value); + if (it == best.end() || cost < it->second) best[value] = cost; + } + std::size_t k = 0; + while (k < free.size() && f[k] == view[free[k]]) f[k++] = 0; + if (k == free.size()) break; + ++f[k]; + } + return best; +} - // Helper to add mesh connections between two groups - auto add_mesh = [&](int group1_start, int group1_count, int group2_start, int group2_count, double capacity) { - for (int i = 0; i < group1_count; ++i) { - for (int j = 0; j < group2_count; ++j) { - src.push_back(group1_start + i); - dst.push_back(group2_start + j); - cap.push_back(capacity); - cost.push_back(1); +// Breakpoints [(value, cost)] of the min-cost curve by successive shortest paths with +// Bellman-Ford on the residual (no potentials, no Dinic, its own arc order); run to +// disconnection. Independent of the solver. +inline std::vector> independent_curve(const StrictMultiDiGraph& g, + std::span view, + const std::vector& srcs, + const std::vector& dsts, + const Eligibility* elig) { + struct Arc { int u, v; Amount cap; Cost w; Amount flow; }; + const int n = g.num_nodes(); + const int S = n, T = n + 1; + std::vector arcs; + for (std::size_t e = 0; e < static_cast(g.num_edges()); ++e) { + if (elig != nullptr && !elig->edge_ok(static_cast(e))) continue; + arcs.push_back({g.edge_src()[e], g.edge_dst()[e], view[e], g.cost()[e], 0}); + } + for (NodeId s : srcs) if (elig == nullptr || elig->node_ok(s)) arcs.push_back({S, s, kMaxFlowCap, 0, 0}); + for (NodeId d : dsts) if (elig == nullptr || elig->node_ok(d)) arcs.push_back({d, T, kMaxFlowCap, 0, 0}); + std::vector> pts{{0, 0}}; + while (true) { + const __int128 inf = std::numeric_limits<__int128>::max(); + std::vector<__int128> dist(static_cast(n + 2), inf); + std::vector> parent(static_cast(n + 2), {-1, 0}); + dist[static_cast(S)] = 0; + for (int it = 0; it < n + 2; ++it) { + bool changed = false; + for (int i = 0; i < static_cast(arcs.size()); ++i) { + const auto& a = arcs[static_cast(i)]; + const auto du = dist[static_cast(a.u)]; + const auto dv = dist[static_cast(a.v)]; + if (a.flow < a.cap && du != inf && du + a.w < dv) { + dist[static_cast(a.v)] = du + a.w; + parent[static_cast(a.v)] = {i, 1}; + changed = true; + } + if (a.flow > 0 && dist[static_cast(a.v)] != inf && + dist[static_cast(a.v)] - a.w < dist[static_cast(a.u)]) { + dist[static_cast(a.u)] = dist[static_cast(a.v)] - a.w; + parent[static_cast(a.u)] = {i, -1}; + changed = true; + } } + if (!changed) break; } - }; - - // Helper to add one-to-one connections - auto add_one_to_one = [&](int group1_start, int group1_count, int group2_start, double capacity) { - for (int i = 0; i < group1_count; ++i) { - src.push_back(group1_start + i); - dst.push_back(group2_start + i); - cap.push_back(capacity); - cost.push_back(1); + if (dist[static_cast(T)] == inf) return pts; + std::vector> path; + for (int v = T; v != S;) { + auto [i, sign] = parent[static_cast(v)]; + path.emplace_back(i, sign); + const auto& a = arcs[static_cast(i)]; + v = sign == 1 ? a.u : a.v; } - }; - - // Clos1 brick1: T1 (0-3) mesh to T2 (4-7) - add_mesh(0, 4, 4, 4, 100.0); - - // Clos1 brick2: T1 (8-11) mesh to T2 (12-15) - add_mesh(8, 4, 12, 4, 100.0); - - // Clos1 b1/T2 (4-7) one-to-one to spine (16-19) - add_one_to_one(4, 4, 16, 400.0); - - // Clos1 b2/T2 (12-15) one-to-one to spine (20-23) - add_one_to_one(12, 4, 20, 400.0); - - // Inter-Clos: Clos1 spine (16-31) one-to-one to Clos2 spine (48-63) - add_one_to_one(16, 16, 48, 400.0); - - // Clos2 spine (48-63) to b1/T2 (36-39) - only first 4 spines connect - add_one_to_one(48, 4, 36, 400.0); - - // Clos2 spine (52-55) to b2/T2 (44-47) - add_one_to_one(52, 4, 44, 400.0); - - // Clos2 brick1: T2 (36-39) mesh to T1 (32-35) - add_mesh(36, 4, 32, 4, 100.0); - - // Clos2 brick2: T2 (44-47) mesh to T1 (40-43) - add_mesh(44, 4, 40, 4, 100.0); - - return StrictMultiDiGraph::from_arrays(num_nodes, src, dst, cap, cost); + Amount amount = std::numeric_limits::max(); + for (auto [i, sign] : path) { + const auto& a = arcs[static_cast(i)]; + amount = std::min(amount, sign == 1 ? a.cap - a.flow : a.flow); + } + for (auto [i, sign] : path) arcs[static_cast(i)].flow += sign * amount; + pts.emplace_back(pts.back().first + amount, pts.back().second + dist[static_cast(T)] * amount); + } } -inline StrictMultiDiGraph make_triangle_topology() { - /** - * Triangle topology from NetGraph solver tests. - * Used in test_maxflow_api.py::_triangle_network() - * - * Topology: - * A(0) -> B(1) (cap 2) - * B(1) -> C(2) (cap 1) - * A(0) -> C(2) (cap 1) - * - * Expected max flow A->C: 2.0 (1 direct + 1 via B) - */ - std::int32_t src_arr[3] = {0, 1, 0}; - std::int32_t dst_arr[3] = {1, 2, 2}; - double cap_arr[3] = {2.0, 1.0, 1.0}; - std::int64_t cost_arr[3] = {1, 1, 1}; - - return StrictMultiDiGraph::from_arrays(3, - std::span(src_arr, 3), std::span(dst_arr, 3), - std::span(cap_arr, 3), std::span(cost_arr, 3)); +// Cost of a piecewise-linear curve at an integer value inside its domain. Every segment of both +// curves has an integer slope (the cost per unit of one tier or one augmenting path), so the +// value is exact in 128 bits: costs stay below 2^62 per unit and amounts below 2^62. +inline __int128 curve_at(const std::vector>& pts, Amount x) { + for (std::size_t i = 1; i < pts.size(); ++i) { + const auto [a, ca] = pts[i - 1]; + const auto [b, cb] = pts[i]; + if (a <= x && x <= b) { + const __int128 slope = (cb - ca) / (b - a); + return ca + slope * (x - a); + } + } + return pts.back().second; } -inline StrictMultiDiGraph make_simple_parallel_paths_topology() { - /** - * Simple network with two disjoint parallel paths. - * From NetGraph solver tests: test_maxflow_api.py::_simple_network() - * - * Topology: - * S(0) -> A(1) (cap 1) -> T(3) (cap 1) - * S(0) -> B(2) (cap 1) -> T(3) (cap 1) - * - * Expected max flow S->T: 2.0 - */ - std::int32_t src_arr[4] = {0, 1, 0, 2}; - std::int32_t dst_arr[4] = {1, 3, 2, 3}; - double cap_arr[4] = {1.0, 1.0, 1.0, 1.0}; - std::int64_t cost_arr[4] = {1, 1, 1, 1}; - - return StrictMultiDiGraph::from_arrays(4, - std::span(src_arr, 4), std::span(dst_arr, 4), - std::span(cap_arr, 4), std::span(cost_arr, 4)); +inline std::vector> payload_points(const FlowCoreResult& r) { + std::vector> pts{{0, 0}}; + for (const auto& [c, u] : r.cost_distribution) { + pts.emplace_back(pts.back().first + u, pts.back().second + static_cast<__int128>(c) * u); + } + return pts; } -// Assertion helpers -inline void expect_csr_valid(const StrictMultiDiGraph& g) { - auto row = g.row_offsets_view(); - auto col = g.col_indices_view(); - auto aei = g.adj_edge_index_view(); - auto edge_src = g.edge_src_view(); - auto edge_dst = g.edge_dst_view(); - const auto M = static_cast(g.num_edges()); - const auto N = static_cast(g.num_nodes()); - - // --- Forward CSR structural checks --- - - // Offsets should be monotonic - EXPECT_EQ(row.size(), N + 1); - for (std::size_t i = 0; i < row.size() - 1; ++i) { - EXPECT_LE(row[i], row[i + 1]) << "Row offsets not monotonic at " << i; +// The certificate of APPROACH 2.2 in two ordered branches. Returns the failed clauses. With +// `tier_a`, the complete curve is compared with the exhaustive optimum of every value; else with +// the independent Bellman-Ford curve at the union of both breakpoint sets. +inline std::vector certify(const StrictMultiDiGraph& g, std::span view, + std::vector srcs, std::vector dsts, + std::optional cap, const Eligibility* elig, + const FlowCoreResult& r, bool tier_a) { + std::vector fails; + const auto m = static_cast(g.num_edges()); + const auto n = static_cast(g.num_nodes()); + std::sort(srcs.begin(), srcs.end()); + srcs.erase(std::unique(srcs.begin(), srcs.end()), srcs.end()); + std::sort(dsts.begin(), dsts.end()); + dsts.erase(std::unique(dsts.begin(), dsts.end()), dsts.end()); + auto edge_ok = [&](std::size_t e) { return elig == nullptr || elig->edge_ok(static_cast(e)); }; + auto node_ok = [&](NodeId v) { return elig == nullptr || elig->node_ok(v); }; + if (r.edge_flows.size() != m || r.potentials.size() != n) { + fails.push_back("shape"); + return fails; } - - // Total edges match - EXPECT_EQ(static_cast(row[row.size() - 1]), M); - EXPECT_EQ(col.size(), M); - EXPECT_EQ(aei.size(), M); - - // All column indices and edge indices in range - for (std::size_t i = 0; i < col.size(); ++i) { - EXPECT_GE(col[i], 0) << "Invalid column index at " << i; - EXPECT_LT(col[i], g.num_nodes()) << "Column index out of range at " << i; - EXPECT_GE(aei[i], 0) << "Invalid edge index at " << i; - EXPECT_LT(aei[i], g.num_edges()) << "Edge index out of range at " << i; + if (cap.has_value() && *cap == 0) { + const auto& c = r.counters; + if (r.placed != 0 || !r.cost_distribution.empty() || r.min_cut.has_value() || + std::any_of(r.edge_flows.begin(), r.edge_flows.end(), [](Amount x) { return x != 0; }) || + std::any_of(r.potentials.begin(), r.potentials.end(), [](Cost p) { return p != kNeverReached; }) || + c.dijkstra_runs || c.dijkstra_pops || c.settled || c.arc_scans || c.dinic_phases || c.augmentations) { + fails.push_back("zero cap: the prescribed zero payload"); + } + return fails; } - - // --- Forward CSR semantic checks --- - - // Bijection: every edge appears exactly once in the CSR - std::vector edge_count(M, 0); - for (std::size_t u = 0; u < N; ++u) { - auto s = static_cast(row[u]); - auto e = static_cast(row[u + 1]); - for (std::size_t j = s; j < e; ++j) { - auto eid = static_cast(aei[j]); - ASSERT_LT(eid, M) << "Edge index out of range at CSR pos " << j; - edge_count[eid]++; - - // Source consistency: edge must originate from node u - EXPECT_EQ(static_cast(edge_src[eid]), u) - << "Forward CSR: edge " << eid << " in node " << u - << "'s adjacency has src=" << edge_src[eid]; - - // Destination consistency: col_indices must match edge destination - EXPECT_EQ(col[j], edge_dst[eid]) - << "Forward CSR: col_indices[" << j << "]=" << col[j] - << " doesn't match edge_dst[" << eid << "]=" << edge_dst[eid]; + std::vector<__int128> bal(n, 0); + __int128 moment = 0; + for (std::size_t e = 0; e < m; ++e) { + const Amount f = r.edge_flows[e]; + if (f < 0 || f > view[e]) fails.push_back("feasibility at edge " + std::to_string(e)); + if (!edge_ok(e) && f != 0) fails.push_back("flow on an ineligible edge " + std::to_string(e)); + bal[static_cast(g.edge_src()[e])] -= f; + bal[static_cast(g.edge_dst()[e])] += f; + moment += static_cast<__int128>(g.cost()[e]) * f; + } + std::set S(srcs.begin(), srcs.end()), D(dsts.begin(), dsts.end()); + __int128 supply = 0, inflow = 0; + std::vector<__int128> sup(n, 0), inf_d(n, 0); + for (std::size_t v = 0; v < n; ++v) { + const auto node = static_cast(v); + if (S.count(node)) { + if (bal[v] > 0) fails.push_back("source absorbs flow"); + if (!node_ok(node) && bal[v] != 0) fails.push_back("excluded source supplies"); + sup[v] = -bal[v]; + supply += -bal[v]; + } else if (D.count(node)) { + if (bal[v] < 0) fails.push_back("destination emits flow"); + if (!node_ok(node) && bal[v] != 0) fails.push_back("excluded destination receives"); + inf_d[v] = bal[v]; + inflow += bal[v]; + } else if (bal[v] != 0) { + fails.push_back("conservation at node " + std::to_string(v)); } } - for (std::size_t eid = 0; eid < M; ++eid) { - EXPECT_EQ(edge_count[eid], 1) - << "Edge " << eid << " appears " << edge_count[eid] - << " times in forward CSR (expected exactly 1)"; + if (supply != r.placed || inflow != r.placed) fails.push_back("value differs from supply or inflow"); + if (r.placed < 0 || (cap.has_value() && r.placed > *cap)) fails.push_back("placed outside [0, cap]"); + // Full residual closure seeded at the allowed real sources. + const std::size_t s_star = n, t_star = n + 1; + std::vector>> out(n + 2); + for (std::size_t e = 0; e < m; ++e) { + if (!edge_ok(e)) continue; + const auto u = static_cast(g.edge_src()[e]); + const auto v = static_cast(g.edge_dst()[e]); + if (r.edge_flows[e] < view[e]) out[u].emplace_back(v, g.cost()[e]); + if (r.edge_flows[e] > 0) out[v].emplace_back(u, -g.cost()[e]); } - - // --- Reverse CSR checks --- - auto in_row = g.in_row_offsets_view(); - auto in_col = g.in_col_indices_view(); - auto in_aei = g.in_adj_edge_index_view(); - - EXPECT_EQ(in_row.size(), N + 1); - for (std::size_t i = 0; i < in_row.size() - 1; ++i) { - EXPECT_LE(in_row[i], in_row[i + 1]) << "Reverse row offsets not monotonic at " << i; + for (NodeId s : srcs) { + if (!node_ok(s)) continue; + out[s_star].emplace_back(static_cast(s), 0); + if (sup[static_cast(s)] > 0) out[static_cast(s)].emplace_back(s_star, 0); } - EXPECT_EQ(static_cast(in_row[in_row.size() - 1]), M); - EXPECT_EQ(in_col.size(), M); - EXPECT_EQ(in_aei.size(), M); - - // Bijection and consistency for reverse CSR - std::vector rev_edge_count(M, 0); - for (std::size_t v = 0; v < N; ++v) { - auto s = static_cast(in_row[v]); - auto e = static_cast(in_row[v + 1]); - for (std::size_t j = s; j < e; ++j) { - auto eid = static_cast(in_aei[j]); - ASSERT_LT(eid, M) << "Reverse edge index out of range at CSR pos " << j; - rev_edge_count[eid]++; - - // Destination consistency: edge must point to node v - EXPECT_EQ(static_cast(edge_dst[eid]), v) - << "Reverse CSR: edge " << eid << " in node " << v - << "'s incoming list has dst=" << edge_dst[eid]; - - // Source consistency: in_col_indices must match edge source - EXPECT_EQ(in_col[j], edge_src[eid]) - << "Reverse CSR: in_col_indices[" << j << "]=" << in_col[j] - << " doesn't match edge_src[" << eid << "]=" << edge_src[eid]; + for (NodeId d : dsts) { + if (!node_ok(d)) continue; + out[static_cast(d)].emplace_back(t_star, 0); + if (inf_d[static_cast(d)] > 0) out[t_star].emplace_back(static_cast(d), 0); + } + std::vector seen(n + 2, 0); + std::vector queue; + for (NodeId s : srcs) { + if (node_ok(s)) { seen[static_cast(s)] = 1; queue.push_back(static_cast(s)); } + } + for (std::size_t qi = 0; qi < queue.size(); ++qi) { + for (auto [v, w] : out[queue[qi]]) { + (void)w; + if (!seen[v]) { seen[v] = 1; queue.push_back(v); } } } - for (std::size_t eid = 0; eid < M; ++eid) { - EXPECT_EQ(rev_edge_count[eid], 1) - << "Edge " << eid << " appears " << rev_edge_count[eid] - << " times in reverse CSR (expected exactly 1)"; + const bool connected = seen[t_star] != 0; + if (!cap.has_value() && connected) fails.push_back("uncapped solve left the destinations connected"); + if (cap.has_value() && r.placed != *cap && connected) fails.push_back("attained value: early stop"); + if (connected) { + if (r.min_cut.has_value()) fails.push_back("min_cut present on a connected solve"); + } else { + std::vector expect; + __int128 cut_value = 0; + for (std::size_t e = 0; e < m; ++e) { + if (!edge_ok(e)) continue; + if (seen[static_cast(g.edge_src()[e])] && !seen[static_cast(g.edge_dst()[e])] && + view[e] > 0) { + expect.push_back(static_cast(e)); + cut_value += view[e]; + } + } + if (!r.min_cut.has_value()) fails.push_back("min_cut missing on a maximal solve"); + else if (*r.min_cut != expect) fails.push_back("min_cut differs from the closure cut"); + if (cut_value != r.placed) fails.push_back("cut value differs from placed"); } -} - -inline void expect_pred_dag_valid(const PredDAG& dag, int num_nodes) { - EXPECT_EQ(dag.parent_offsets.size(), static_cast(num_nodes + 1)); - - // Offsets monotonic - for (std::size_t i = 0; i < dag.parent_offsets.size() - 1; ++i) { - EXPECT_LE(dag.parent_offsets[i], dag.parent_offsets[i + 1]); + for (std::size_t v = 0; v < n; ++v) { + if (r.potentials[v] < kNeverReached) fails.push_back("potential below the sentinel"); + if (seen[v] && r.potentials[v] == kNeverReached) fails.push_back("closure vertex holds NEVER_REACHED"); } - - auto total = static_cast(dag.parent_offsets.back()); - EXPECT_EQ(dag.parents.size(), total); - EXPECT_EQ(dag.via_edges.size(), total); - - // Node IDs in range - for (auto p : dag.parents) { - EXPECT_GE(p, 0); - EXPECT_LT(p, num_nodes); + for (NodeId s : srcs) { + const auto p = r.potentials[static_cast(s)]; + if (node_ok(s) && p != 0) fails.push_back("allowed root with potential other than 0"); } -} - -// Extended PredDAG validation that also checks semantic correctness against a graph. -// Verifies that each entry refers to an actual edge from parent to node, and that -// distances are consistent (dist[v] == dist[parent] + edge_cost). -inline void expect_pred_dag_semantically_valid(const StrictMultiDiGraph& g, - const PredDAG& dag, - const std::vector& dist) { - const auto N = g.num_nodes(); - const auto edge_src = g.edge_src_view(); - const auto edge_dst = g.edge_dst_view(); - const auto edge_cost = g.cost_view(); - - expect_pred_dag_valid(dag, N); - - for (std::int32_t v = 0; v < N; ++v) { - auto s = static_cast(dag.parent_offsets[static_cast(v)]); - auto e = static_cast(dag.parent_offsets[static_cast(v) + 1]); - for (std::size_t i = s; i < e; ++i) { - auto parent = dag.parents[i]; - auto eid = static_cast(dag.via_edges[i]); - ASSERT_LT(eid, static_cast(g.num_edges())); - // Edge must go from parent -> v - EXPECT_EQ(edge_src[eid], parent) - << "Node " << v << ": via_edge " << eid << " src mismatch"; - EXPECT_EQ(edge_dst[eid], v) - << "Node " << v << ": via_edge " << eid << " dst mismatch"; - // Distance consistency - auto d_v = dist[static_cast(v)]; - auto d_p = dist[static_cast(parent)]; - if (d_v < std::numeric_limits::max() && d_p < std::numeric_limits::max()) { - EXPECT_EQ(d_v, d_p + edge_cost[eid]) - << "Distance inconsistency at node " << v; + for (std::size_t u = 0; u < n; ++u) { + if (!seen[u] || r.potentials[u] == kNeverReached) continue; + for (auto [v, w] : out[u]) { + if (v >= n || !seen[v] || r.potentials[v] == kNeverReached) continue; + if (static_cast<__int128>(w) + r.potentials[u] - r.potentials[v] < 0) { + fails.push_back("negative reduced cost inside the closure"); } } } -} - -// FlowSummary validation helpers -inline void validate_capacity_constraints(const StrictMultiDiGraph& g, const FlowSummary& summary) { - ASSERT_EQ(summary.edge_flows.size(), static_cast(g.num_edges())) - << "Edge flow vector size mismatch"; - - auto caps = g.capacity_view(); - for (std::size_t i = 0; i < summary.edge_flows.size(); ++i) { - EXPECT_GE(summary.edge_flows[i], 0.0) - << "Edge " << i << " has negative flow: " << summary.edge_flows[i]; - EXPECT_LE(summary.edge_flows[i], caps[i] + 1e-9) - << "Edge " << i << " exceeds capacity: flow=" << summary.edge_flows[i] - << " cap=" << caps[i]; + std::optional pt; + for (NodeId d : dsts) { + const auto p = r.potentials[static_cast(d)]; + if (p != kNeverReached) pt = pt ? std::min(*pt, p) : p; } -} - -inline void validate_flow_conservation(const StrictMultiDiGraph& g, const FlowSummary& summary, - NodeId src, NodeId dst) { - auto row = g.row_offsets_view(); - auto aei = g.adj_edge_index_view(); - auto in_row = g.in_row_offsets_view(); - auto in_aei = g.in_adj_edge_index_view(); - - for (std::int32_t u = 0; u < g.num_nodes(); ++u) { - if (u == src || u == dst) continue; // Skip source and sink - - double outflow = 0.0; - for (std::size_t j = row[u]; j < row[u + 1]; ++j) { - outflow += summary.edge_flows[aei[j]]; + if (pt) { + for (NodeId d : dsts) { + const auto p = r.potentials[static_cast(d)]; + if (p == kNeverReached) continue; + if (inf_d[static_cast(d)] > 0 && p != *pt) fails.push_back("inflow destination potential differs from pi(t*)"); } - - double inflow = 0.0; - for (std::size_t j = in_row[u]; j < in_row[u + 1]; ++j) { - inflow += summary.edge_flows[in_aei[j]]; + if (!r.cost_distribution.empty() && r.cost_distribution.back().first != *pt) { + fails.push_back("pi(t*) differs from the last curve cost"); + } + } else if (r.placed != 0) { + fails.push_back("flow delivered but no destination reached"); + } + __int128 total = 0, curve_moment = 0; + for (std::size_t i = 0; i < r.cost_distribution.size(); ++i) { + const auto [c, u] = r.cost_distribution[i]; + if (u <= 0) fails.push_back("curve volume not positive"); + if (i > 0 && c <= r.cost_distribution[i - 1].first) fails.push_back("curve costs not strictly increasing"); + total += u; + curve_moment += static_cast<__int128>(c) * u; + } + if (total != r.placed) fails.push_back("curve total differs from placed"); + if (curve_moment != moment) fails.push_back("curve moment differs from the flow cost"); + const auto pts = payload_points(r); + if (tier_a) { + const auto best = exhaustive_costs(g, view, srcs, dsts, elig); + const Amount fmax = best.rbegin()->first; + const Amount top = cap.has_value() ? std::min(*cap, fmax) : fmax; + if (r.placed != top) fails.push_back("placed differs from min(cap, F_max)"); + for (Amount x = 0; x <= std::min(top, r.placed); ++x) { + auto it = best.find(x); + if (it == best.end() || curve_at(pts, x) != it->second) { + fails.push_back("curve differs from the exhaustive optimum at " + std::to_string(x)); + break; + } + } + } else { + const auto ref = independent_curve(g, view, srcs, dsts, elig); + const Amount fmax = ref.back().first; + const Amount top = cap.has_value() ? std::min(*cap, fmax) : fmax; + if (r.placed != top) fails.push_back("placed differs from min(cap, F_max)"); + std::set xs; + for (auto [x, c] : pts) { (void)c; xs.insert(x); } + for (auto [x, c] : ref) { (void)c; xs.insert(x); } + for (Amount x : xs) { + if (x > std::min(top, r.placed)) continue; + if (curve_at(pts, x) != curve_at(ref, x)) { + fails.push_back("curve differs from the independent solver at " + std::to_string(x)); + break; + } } - - EXPECT_NEAR(inflow, outflow, 1e-9) - << "Flow not conserved at node " << u - << ": inflow=" << inflow << " outflow=" << outflow; } + return fails; } } // namespace netgraph::core::test diff --git a/tests/py/conftest.py b/tests/py/conftest.py deleted file mode 100644 index 17422e5..0000000 --- a/tests/py/conftest.py +++ /dev/null @@ -1,956 +0,0 @@ -"""Shared pytest fixtures for the test suite. - -Provides small, reusable builders to reduce repetition across tests. - -Fixtures (logical order): -- build_graph: generic builder from edge tuples. -- line1_graph: 3-node line with tiered B->C parallels. -- square1_graph: shortest vs longer alternative path. -- square2_graph: two equal-cost shortest paths. -- two_disjoint_shortest_graph: two disjoint equal-cost routes with bottlenecks. -- graph3: mixed 6-node topology with parallels and a longer route. -- square4_graph: 4-node square with mixed tiers. -- fully_connected_graph: factory for K_n directed (no self-loops). -""" - -from __future__ import annotations - -import numpy as np -import pytest - -import netgraph_core as ngc - -# Global Algorithms fixture (CPU backend) and helper to build graph handles - - -@pytest.fixture -def algs(): - be = ngc.Backend.cpu() - return ngc.Algorithms(be) - - -@pytest.fixture -def to_handle(algs): - def _h(g: ngc.StrictMultiDiGraph): - return algs.build_graph(g) - - return _h - - -# Centralized helper fixtures and shared assertions for tests - - -@pytest.fixture -def t_cost(): - """Return a function to extract distance to a target node as float.""" - - def _t_cost(dist: np.ndarray, node: int) -> float: - return float(dist[int(node)]) - - return _t_cost - - -@pytest.fixture -def flows_by_eid(): - """Return a function mapping EdgeId -> edge flow value for a graph.""" - - def _flows_by_eid( - g: ngc.StrictMultiDiGraph, edge_flows: np.ndarray - ) -> dict[int, float]: - arr = np.asarray(edge_flows, dtype=float) - return {int(eid): float(arr[eid]) for eid in range(g.num_edges())} - - return _flows_by_eid - - -@pytest.fixture -def cost_dist_dict(): - """Return a function converting FlowSummary (costs, flows) to a dict.""" - - def _to_dict(summary) -> dict[float, float]: - costs = np.asarray(getattr(summary, "costs", []), dtype=float) - flows = np.asarray(getattr(summary, "flows", []), dtype=float) - return {float(c): float(f) for c, f in zip(costs, flows, strict=False)} - - return _to_dict - - -@pytest.fixture -def assert_pred_dag_integrity(): - """Return an assertion helper to validate PredDAG shapes, id ranges, and semantics. - - Checks structural validity (sizes, ranges, monotonicity) AND semantic - correctness: each parent entry must correspond to an actual edge in the - graph from the claimed parent to the node, and if distances are provided, - the distance must be consistent (dist[v] == dist[parent] + edge_cost). - """ - - def _assert( - g: ngc.StrictMultiDiGraph, - dag: ngc.PredDAG, - dist: np.ndarray | None = None, - ) -> None: - off = np.asarray(dag.parent_offsets) - par = np.asarray(dag.parents) - via = np.asarray(dag.via_edges) - # Offsets length and monotonicity - assert off.shape == (g.num_nodes() + 1,) - assert np.all(off[:-1] <= off[1:]) - total = int(off[-1]) - # Parents/via sizes match total entries - assert par.shape == (total,) - assert via.shape == (total,) - if total > 0: - # Id ranges - assert int(par.min()) >= 0 and int(par.max()) < g.num_nodes() - assert int(via.min()) >= 0 and int(via.max()) < g.num_edges() - - # Semantic validation: each entry must reference an actual graph edge - edge_src = np.asarray(g.edge_src_view()) - edge_dst = np.asarray(g.edge_dst_view()) - edge_cost = np.asarray(g.cost_view()) - for v in range(g.num_nodes()): - s, e = int(off[v]), int(off[v + 1]) - for i in range(s, e): - parent = int(par[i]) - eid = int(via[i]) - # The via_edge must go from parent -> v - assert int(edge_src[eid]) == parent, ( - f"PredDAG entry for node {v}: via_edge {eid} has src=" - f"{int(edge_src[eid])}, expected parent={parent}" - ) - assert int(edge_dst[eid]) == v, ( - f"PredDAG entry for node {v}: via_edge {eid} has dst=" - f"{int(edge_dst[eid])}, expected node={v}" - ) - # Distance consistency (if distances provided) - if dist is not None: - d_v = float(dist[v]) - d_p = float(dist[parent]) - ec = float(edge_cost[eid]) - if d_v < 1e18 and d_p < 1e18: # skip unreachable - assert abs(d_v - (d_p + ec)) < 1e-9, ( - f"PredDAG distance inconsistency at node {v}: " - f"dist[{v}]={d_v} != dist[{parent}]={d_p} + " - f"cost({eid})={ec} = {d_p + ec}" - ) - - return _assert - - -@pytest.fixture -def assert_edge_flows_shape(): - """Return an assertion helper to validate edge_flows presence/shape. - - expected_present=True means summary.edge_flows must exist and be length N. - If False, allow either empty list or omitted; if present, allow 0 or N. - """ - - def _assert( - g: ngc.StrictMultiDiGraph, summary, expected_present: bool = True - ) -> None: - edge_flows = getattr(summary, "edge_flows", None) - if expected_present: - assert edge_flows is not None - arr = np.asarray(edge_flows) - assert arr.shape == (g.num_edges(),) - else: - if edge_flows is not None: - arr = np.asarray(edge_flows) - assert arr.size in (0, g.num_edges()) - - return _assert - - -@pytest.fixture -def assert_valid_min_cut(): - """Return an assertion helper to validate a MinCut. - - Always checks that edge ids are unique and in range. Note that an empty cut - satisfies those two checks vacuously, so pass ``total_flow`` whenever the call - site computed a true maximum flow: that enables the max-flow/min-cut duality - check (``total_flow == sum(capacity[cut])``), which is what actually pins the - result down. - - Duality only holds for a genuine maximum flow. Do NOT pass ``total_flow`` for - ``EQUAL_BALANCED`` placement (an ECMP admission model that deliberately places - less than the maximum) or for ``shortest_path=True`` (a single augmentation). - """ - - def _assert( - g: ngc.StrictMultiDiGraph, min_cut, total_flow: float | None = None - ) -> None: - edges = [int(e) for e in getattr(min_cut, "edges", [])] - assert len(edges) == len(set(edges)), "min-cut has duplicate edge ids" - for e in edges: - assert 0 <= e < g.num_edges() - - if total_flow is None: - return - - cap = np.asarray(g.capacity_view(), dtype=float) - cut_cap = float(cap[edges].sum()) if edges else 0.0 - assert cut_cap == pytest.approx(total_flow), ( - f"max-flow/min-cut duality violated: flow={total_flow} " - f"but cut capacity={cut_cap} (cut={edges})" - ) - if total_flow > 0: - assert edges, "positive flow reported with an empty min-cut" - - return _assert - - -@pytest.fixture -def certify_max_flow(): - """Return a helper that *proves* a max-flow result correct without an oracle. - - Checks four properties. Together they are a certificate of optimality: by weak - duality any feasible flow is bounded by any s-t cut, so a feasible flow whose - value equals the capacity of a genuine cut is necessarily maximum. - - 1. feasibility -- ``0 <= flow(e) <= capacity(e)``, and masked-out edges idle - 2. conservation -- inflow == outflow at interior nodes; net at sink == total - 3. genuine cut -- deleting the reported cut edges disconnects src from dst - 4. tightness -- ``total == sum(capacity[cut])`` - - Pass ``maximal=False`` for configurations that deliberately place less than the - maximum (``EQUAL_BALANCED`` placement, ``shortest_path=True``); only checks 1-2 - apply there, and they still hold. - - Requires ``with_edge_flows=True`` on the ``max_flow`` call. - """ - - def _certify( - g: ngc.StrictMultiDiGraph, - summary, - total: float, - src: int, - dst: int, - *, - maximal: bool = True, - node_mask=None, - edge_mask=None, - tol: float = 1e-6, - ) -> None: - flow = np.asarray(summary.edge_flows, dtype=float) - assert flow.size == g.num_edges(), ( - "certify_max_flow needs edge flows; pass with_edge_flows=True" - ) - cap = np.asarray(g.capacity_view(), dtype=float) - e_src = np.asarray(g.edge_src_view()) - e_dst = np.asarray(g.edge_dst_view()) - n = g.num_nodes() - - # 1. feasibility - assert np.all(flow >= -tol), "negative flow on some edge" - assert np.all(flow <= cap + tol), "flow exceeds capacity on some edge" - - usable = np.ones(g.num_edges(), dtype=bool) - if edge_mask is not None: - usable &= np.asarray(edge_mask, dtype=bool) - if node_mask is not None: - nm = np.asarray(node_mask, dtype=bool) - usable &= nm[e_src] & nm[e_dst] - assert np.all(np.abs(flow[~usable]) <= tol), "flow placed on a masked-out edge" - - # 2. conservation - net = np.zeros(n) - np.add.at(net, e_src, -flow) - np.add.at(net, e_dst, flow) - interior = [v for v in range(n) if v != src and v != dst] - if interior: - worst = float(np.max(np.abs(net[interior]))) - assert worst <= tol, f"flow not conserved at an interior node (net={worst})" - assert net[dst] == pytest.approx(total, abs=tol), ( - f"net inflow at sink ({net[dst]}) != reported total ({total})" - ) - - if not maximal: - return - - # 3. the reported cut must actually separate src from dst - cut = {int(e) for e in np.asarray(summary.min_cut.edges)} - adj: list[list[int]] = [[] for _ in range(n)] - for e in range(g.num_edges()): - if e in cut or not usable[e] or cap[e] <= 0: - continue - adj[int(e_src[e])].append(int(e_dst[e])) - seen = {int(src)} - stack = [int(src)] - while stack: - u = stack.pop() - for v in adj[u]: - if v not in seen: - seen.add(v) - stack.append(v) - assert int(dst) not in seen, ( - f"reported min-cut {sorted(cut)} does not separate {src} from {dst}" - ) - - # 4. tightness -- with (1)-(3) this proves the flow is maximum - cut_cap = float(cap[sorted(cut)].sum()) if cut else 0.0 - assert cut_cap == pytest.approx(total, abs=tol), ( - f"flow ({total}) != min-cut capacity ({cut_cap}); flow is not maximal" - ) - - return _certify - - -@pytest.fixture -def build_graph(): - """Build a StrictMultiDiGraph from edge tuples. - - Args: - num_nodes: Number of nodes in the graph - edges: List of tuples (src, dst, cost, cap[, ext_id]) - - Returns: - StrictMultiDiGraph instance - """ - - def _builder(num_nodes: int, edges: list[tuple]) -> ngc.StrictMultiDiGraph: - if not edges: - return ngc.StrictMultiDiGraph.from_arrays( - num_nodes, - np.empty((0,), dtype=np.int32), - np.empty((0,), dtype=np.int32), - np.empty((0,), dtype=np.float64), - np.empty((0,), dtype=np.int64), - np.empty((0,), dtype=np.int64), - ) - src = np.array([e[0] for e in edges], dtype=np.int32) - dst = np.array([e[1] for e in edges], dtype=np.int32) - cost = np.array([int(e[2]) for e in edges], dtype=np.int64) - cap = np.array([float(e[3]) for e in edges], dtype=np.float64) - ext_edge_ids = np.arange(len(edges), dtype=np.int64) - return ngc.StrictMultiDiGraph.from_arrays( - num_nodes, src, dst, cap, cost, ext_edge_ids - ) - - return _builder - - -@pytest.fixture -def line1_graph(build_graph): - """Line graph A->B->C with unit costs; caps as in typical tests. - - Nodes: 0->1 (cap 5, cost 1), 1->2 has parallel edges with costs {1,1,2} and - capacities {1,3,7} to test tiering. - """ - - edges = [ - (0, 1, 1, 5, 0), # A->B - (1, 2, 1, 1, 2), # B->C (min-cost) - (1, 2, 1, 3, 4), # B->C (min-cost) - (1, 2, 2, 7, 6), # B->C (higher cost) - ] - return build_graph(3, edges) - - -@pytest.fixture -def square1_graph(build_graph): - """Square1: one shortest route and one longer alternative. - - 0->1->2 with total cost 2; 0->3->2 with total cost 4. - """ - - edges = [ - (0, 1, 1, 1, 0), - (1, 2, 1, 1, 1), - (0, 3, 2, 2, 2), - (3, 2, 2, 2, 3), - ] - return build_graph(4, edges) - - -@pytest.fixture -def square2_graph(build_graph): - """Square2: two equal-cost shortest routes from 0->2 via 1 and 3. - - All edge costs are 1; both paths have total cost 2. - """ - - edges = [ - (0, 1, 1, 1, 0), - (1, 2, 1, 1, 1), - (0, 3, 1, 2, 2), - (3, 2, 1, 2, 3), - ] - return build_graph(4, edges) - - -@pytest.fixture -def graph3(build_graph): - """Six-node mixed topology with parallel edges and multiple paths.""" - - edges = [ - (0, 1, 1, 2, 0), # A->B parallels - (0, 1, 1, 4, 1), - (0, 1, 1, 6, 2), - (1, 2, 1, 1, 3), # B->C parallels - (1, 2, 1, 2, 4), - (1, 2, 1, 3, 5), - (2, 3, 2, 3, 6), # C->D - (0, 4, 1, 5, 7), # A->E - (4, 2, 1, 4, 8), # E->C - (0, 3, 4, 2, 9), # A->D - (2, 5, 1, 1, 10), # C->F - (5, 3, 1, 2, 11), # F->D - ] - return build_graph(6, edges) - - -@pytest.fixture -def square4_graph(build_graph): - """A 4-node square with parallel/higher-cost tiers used in max-flow tests. - - Nodes: A=0, B=1, C=2, D=3 - """ - - edges = [ - (0, 1, 1, 100, 0), # A->B - (1, 2, 1, 125, 1), # B->C - (0, 3, 1, 75, 2), # A->D - (3, 2, 1, 50, 3), # D->C - (1, 3, 1, 50, 4), # B->D - (3, 1, 1, 50, 5), # D->B - (0, 1, 2, 200, 6), # A->B (higher cost) - (1, 3, 2, 200, 7), # B->D (higher cost) - (3, 2, 2, 200, 8), # D->C (higher cost) - ] - return build_graph(4, edges) - - -@pytest.fixture -def triangle1_graph(build_graph): - """Triangle graph with higher capacities on A<->B and B<->C; lower on A<->C. - - Nodes: 0(A), 1(B), 2(C) - Edges directed both ways to match the fixture used across tests. - """ - - edges = [ - (0, 1, 1, 15, 0), - (1, 0, 1, 15, 1), - (1, 2, 1, 15, 2), - (2, 1, 1, 15, 3), - (0, 2, 1, 5, 4), - (2, 0, 1, 5, 5), - ] - return build_graph(3, edges) - - -@pytest.fixture -def square3_graph(build_graph): - """Square with cross-links B<->D and mixed capacities. - - Nodes: 0(A),1(B),2(C),3(D) - """ - - edges = [ - (0, 1, 1, 100, 0), - (1, 2, 1, 125, 1), - (0, 3, 1, 75, 2), - (3, 2, 1, 50, 3), - (1, 3, 1, 50, 4), - (3, 1, 1, 50, 5), - ] - return build_graph(4, edges) - - -@pytest.fixture -def graph1_graph(build_graph): - """Five-node graph with bidirectional edges between B and C.""" - - edges = [ - (0, 1, 1, 1, 0), - (0, 2, 1, 1, 1), - (1, 3, 1, 1, 2), - (2, 3, 1, 1, 3), - (1, 2, 1, 1, 4), - (2, 1, 1, 1, 5), - (3, 4, 1, 1, 6), - ] - return build_graph(5, edges) - - -@pytest.fixture -def graph2_graph(build_graph): - """Five-node branched graph with bidirectional C-D edges.""" - - edges = [ - (0, 1, 1, 1, 0), - (1, 2, 1, 1, 1), - (1, 3, 1, 1, 2), - (2, 3, 1, 1, 3), - (3, 2, 1, 1, 4), - (2, 4, 1, 1, 5), - (3, 4, 1, 1, 6), - ] - return build_graph(5, edges) - - -@pytest.fixture -def graph4_graph(build_graph): - """Graph with three parallel branches from A to C of increasing cost/capacity. - - Nodes: 0(A),1(B),2(B1),3(B2),4(C) - """ - - edges = [ - (0, 1, 1, 1, 0), - (1, 4, 1, 1, 1), - (0, 2, 2, 2, 2), - (2, 4, 2, 2, 3), - (0, 3, 3, 3, 4), - (3, 4, 3, 3, 5), - ] - return build_graph(5, edges) - - -@pytest.fixture -def make_fully_connected_graph(build_graph): - """Return a builder for fully-connected directed graphs (no self-loops). - - Usage: make_fully_connected_graph(n, cost=1.0, cap=1.0) - """ - - def _build( - n: int, *, cost: float = 1.0, cap: float = 1.0 - ) -> ngc.StrictMultiDiGraph: - edges = [] - lid = 0 - for s in range(n): - for d in range(n): - if s == d: - continue - edges.append((s, d, float(cost), float(cap), lid)) - lid += 1 - return build_graph(n, edges) - - return _build - - -@pytest.fixture -def dag_to_pred_map(): - """Return a converter from PredDAG to {node: {parent: [EdgeId...]}} mapping.""" - - def _convert(g: ngc.StrictMultiDiGraph, dag: ngc.PredDAG): - pred: dict[int, dict[int, list[int]]] = {} - offsets = np.asarray(dag.parent_offsets) - parents = np.asarray(dag.parents) - via = np.asarray(dag.via_edges) - n = g.num_nodes() - for v in range(n): - start = int(offsets[v]) - end = int(offsets[v + 1]) - if start == end: - continue - group: dict[int, list[int]] = {} - for i in range(start, end): - p = int(parents[i]) - e = int(via[i]) - group.setdefault(p, []).append(e) - pred[v] = group - if 0 not in pred and n > 0: - pred[0] = {} - return pred - - return _convert - - -@pytest.fixture -def assert_paths_concrete(): - """Validate path tuples returned by resolve_to_paths. - - Ensures structure is ((node, (edge_ids...)), ..., (dst, ())). - If expect_split=True, each hop (except src/dst) must have exactly one edge id. - If False, hops must have >=1 edge id. - """ - - def _assert(paths, src: int, dst: int, expect_split: bool) -> None: - for path in paths: - # path should be a tuple/list of elements - assert isinstance(path, (tuple, list)) and len(path) >= 2 - # First element is (src, ()) - first = path[0] - assert isinstance(first, (tuple, list)) and len(first) == 2 - assert int(first[0]) == int(src) - assert isinstance(first[1], (tuple, list)) and len(first[1]) >= 1 - if expect_split: - assert len(first[1]) == 1 - # Last element is (dst, ()) - last = path[-1] - assert isinstance(last, (tuple, list)) and len(last) == 2 - assert int(last[0]) == int(dst) - assert isinstance(last[1], (tuple, list)) and len(last[1]) == 0 - # Intermediate hops - for elem in path[1:-1]: - assert isinstance(elem, (tuple, list)) and len(elem) == 2 - node, edges = elem - # node id is int - assert isinstance(int(node), int) - # edges is tuple/list of ints - assert isinstance(edges, (tuple, list)) and len(edges) >= 1 - if expect_split: - assert len(edges) == 1 - for e in edges: - assert isinstance(int(e), int) - - return _assert - - -@pytest.fixture -def two_disjoint_shortest_graph(build_graph): - """Two disjoint shortest routes 0->1->3 and 0->2->3 with equal cost. - - Capacities configured so that total is limited by per-path bottlenecks. - """ - - edges = [ - (0, 1, 1, 3, 0), # S->A cap 3 - (1, 3, 1, 2, 1), # A->T cap 2 - (0, 2, 1, 4, 2), # S->B cap 4 - (2, 3, 1, 1, 3), # B->T cap 1 - ] - return build_graph(4, edges) - - -@pytest.fixture -def graph5(build_graph): - """Fully connected 5-node directed graph with unit costs/caps.""" - - edges = [] - lid = 0 - for s in range(5): - for d in range(5): - if s == d: - continue - edges.append((s, d, 1.0, 1.0, lid)) - lid += 1 - return build_graph(5, edges) - - -@pytest.fixture -def square5_graph(build_graph): - """Five-node 'square5' topology used in KSP tests. - - Nodes: 0(A),1(B),2(C),3(D),4(E) - Edges: A->B, A->C, B->D, C->D, B->C, C->B; E is isolated for negative tests. - """ - - edges = [ - (0, 1, 1, 1, 0), # A->B - (0, 2, 1, 1, 1), # A->C - (1, 3, 1, 1, 2), # B->D - (2, 3, 1, 1, 3), # C->D - (1, 2, 1, 1, 4), # B->C - (2, 1, 1, 1, 5), # C->B - ] - return build_graph(5, edges) - - -# FlowPolicy canonical configurations -# These represent common use cases for flow routing and traffic engineering - - -@pytest.fixture -def make_flow_policy_config(): - """Factory for canonical FlowPolicy configurations. - - Canonical configurations: - - SHORTEST_PATHS_ECMP: IP/IGP-style ECMP (hash-based multipath, shortest paths only) - - SHORTEST_PATHS_WCMP: IP/IGP-style WCMP (proportional multipath, shortest paths only) - - TE_WCMP_UNLIM: Traffic engineering with unlimited proportional flows - - TE_ECMP_UP_TO_256_LSP: TE with up to 256 ECMP LSPs (tunnel-based) - - TE_ECMP_16_LSP: TE with exactly 16 ECMP LSPs (fixed allocation) - - Usage: - config = make_flow_policy_config("SHORTEST_PATHS_ECMP") - policy = ngc.FlowPolicy(algs, graph, config) - """ - - def _make(config_name: str) -> ngc.FlowPolicyConfig: - if config_name == "SHORTEST_PATHS_ECMP": - # IP/IGP ECMP: Hash-based equal splitting across shortest paths - # - multipath=True: Each flow splits across all equal-cost next-hops - # - require_capacity=False: Routes based on costs only (IGP behavior) - # - shortest_path=True: Only use lowest-cost paths - # - max_flow_count=1: Single flow (models single demand stream) - config = ngc.FlowPolicyConfig() - config.path_alg = ngc.PathAlg.SPF - config.flow_placement = ngc.FlowPlacement.EQUAL_BALANCED - config.selection = ngc.EdgeSelection( - multi_edge=True, - require_capacity=False, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - config.require_capacity = False - config.multipath = True - config.shortest_path = True - config.min_flow_count = 1 - config.max_flow_count = 1 - return config - - elif config_name == "SHORTEST_PATHS_WCMP": - # IP/IGP WCMP: Proportional splitting across shortest paths - # - Similar to ECMP but uses PROPORTIONAL placement - # - Flow splits proportionally to available capacity - config = ngc.FlowPolicyConfig() - config.path_alg = ngc.PathAlg.SPF - config.flow_placement = ngc.FlowPlacement.PROPORTIONAL - config.selection = ngc.EdgeSelection( - multi_edge=True, - require_capacity=False, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - config.require_capacity = False - config.multipath = True - config.shortest_path = True - config.min_flow_count = 1 - config.max_flow_count = 1 - return config - - elif config_name == "TE_WCMP_UNLIM": - # Traffic Engineering: Unlimited proportional flows - # - require_capacity=True: Respects capacity, progressive fill - # - No flow count limits: Creates flows as needed - # - Uses all available paths (not just shortest) - config = ngc.FlowPolicyConfig() - config.path_alg = ngc.PathAlg.SPF - config.flow_placement = ngc.FlowPlacement.PROPORTIONAL - config.selection = ngc.EdgeSelection( - multi_edge=True, - require_capacity=True, - tie_break=ngc.EdgeTieBreak.PREFER_HIGHER_RESIDUAL, - ) - config.require_capacity = True - config.multipath = True - config.min_flow_count = 1 - # max_flow_count not set: unlimited - return config - - elif config_name == "TE_ECMP_UP_TO_256_LSP": - # Traffic Engineering: Up to 256 ECMP LSPs (tunnels) - # - multipath=False: Each flow is a single-path LSP/tunnel - # - max_flow_count=256: Up to 256 parallel LSPs - # - ECMP across LSPs (not within each LSP) - config = ngc.FlowPolicyConfig() - config.path_alg = ngc.PathAlg.SPF - config.flow_placement = ngc.FlowPlacement.EQUAL_BALANCED - config.selection = ngc.EdgeSelection( - multi_edge=False, - require_capacity=True, - tie_break=ngc.EdgeTieBreak.PREFER_HIGHER_RESIDUAL, - ) - config.require_capacity = True - config.multipath = False - config.min_flow_count = 1 - config.max_flow_count = 256 - config.reoptimize_flows_on_each_placement = True - return config - - elif config_name == "TE_ECMP_16_LSP": - # Traffic Engineering: Exactly 16 ECMP LSPs (fixed allocation) - # - Similar to TE_ECMP_UP_TO_256_LSP but fixed at 16 flows - # - Models hardware with fixed LSP resources - config = ngc.FlowPolicyConfig() - config.path_alg = ngc.PathAlg.SPF - config.flow_placement = ngc.FlowPlacement.EQUAL_BALANCED - config.selection = ngc.EdgeSelection( - multi_edge=False, - require_capacity=True, - tie_break=ngc.EdgeTieBreak.PREFER_HIGHER_RESIDUAL, - ) - config.require_capacity = True - config.multipath = False - config.min_flow_count = 16 - config.max_flow_count = 16 - config.reoptimize_flows_on_each_placement = True - return config - - else: - raise ValueError(f"Unknown configuration: {config_name}") - - return _make - - -@pytest.fixture -def make_flow_policy(make_flow_policy_config): - """Factory for creating FlowPolicy with canonical configurations and optional masks. - - Usage: - policy = make_flow_policy( - "SHORTEST_PATHS_ECMP", - algs, - graph_handle, - node_mask=mask_array, - edge_mask=mask_array - ) - """ - - def _make( - config_name: str, - algs: ngc.Algorithms, - graph_handle, - node_mask=None, - edge_mask=None, - ) -> ngc.FlowPolicy: - # Reuse the single-source preset factory to avoid drift. - base_cfg = make_flow_policy_config(config_name) - # Pass masks directly to FlowPolicy constructor (not stored on config). - return ngc.FlowPolicy( - algs, graph_handle, base_cfg, node_mask=node_mask, edge_mask=edge_mask - ) - - return _make - - -# ============================================================================ -# Validation helpers for path distribution and flow placement -# ============================================================================ - - -def analyze_path_usage( - fg: ngc.FlowGraph, graph: ngc.StrictMultiDiGraph, source: int, target: int -) -> dict[int, float]: - """Analyze which parallel paths are used and their volumes. - - For topologies with parallel paths between source and target, - identifies which middle nodes are used and how much flow goes through each. - - Args: - fg: FlowGraph with placed flows - graph: Topology graph - source: Source node - target: Target node - - Returns: - Dict mapping middle_node_id -> total_flow_volume through that path - For 2-hop parallel paths: {middle_node: flow_volume} - """ - edge_flows = fg.edge_flow_view() - edge_src = graph.edge_src_view() - edge_dst = graph.edge_dst_view() - - path_usage = {} - - # Find all edges from source and sum their flows - for edge_id in range(graph.num_edges()): - if edge_src[edge_id] == source: - middle_node = edge_dst[edge_id] - flow = edge_flows[edge_id] - if flow > 1e-9: # Only count non-zero flows - path_usage[middle_node] = flow - - return path_usage - - -def count_lsps_per_path( - policy: ngc.FlowPolicy, - fg: ngc.FlowGraph, - graph: ngc.StrictMultiDiGraph, - source: int, - target: int, -) -> dict[int, list[int]]: - """Count how many LSPs use each parallel path. - - For topologies with parallel paths, determines which LSPs (flows) - use which paths. - - Args: - policy: FlowPolicy with placed flows - fg: FlowGraph with placed flows - graph: Topology graph - source: Source node - target: Target node - - Returns: - Dict mapping middle_node_id -> list of flow_ids using that path - """ - edge_src = graph.edge_src_view() - edge_dst = graph.edge_dst_view() - - path_lsps: dict[int, list[int]] = {} - - # For each flow, determine which path it uses - for flow_id in range(policy.flow_count()): - try: - flow_index = ngc.FlowIndex(source, target, 0, flow_id) - flow_edges = fg.get_flow_edges(flow_index) - - # Find the middle node this flow uses (for 2-hop paths) - # flow_edges is list of (edge_id, volume) tuples - for edge_id, _ in flow_edges: - src = edge_src[edge_id] - dst = edge_dst[edge_id] - if src == source: - middle_node = dst - if middle_node not in path_lsps: - path_lsps[middle_node] = [] - path_lsps[middle_node].append(flow_id) - break - except (KeyError, RuntimeError): - pass # Flow might not exist or have no edges - - return path_lsps - - -def check_distribution_balanced( - lsps_per_path: dict[int, list[int]], tolerance: int = 1 -) -> tuple[bool, dict]: - """Check if LSPs are evenly distributed across paths. - - Args: - lsps_per_path: Dict mapping path_id -> list of LSP ids - tolerance: Maximum allowed difference in LSP count between paths - - Returns: - (is_balanced, stats) - is_balanced: True if max_count - min_count <= tolerance - stats: Dict with min, max, counts for debugging - """ - if not lsps_per_path: - return (True, {"counts": [], "min": 0, "max": 0, "variance": 0}) - - counts = [len(lsps) for lsps in lsps_per_path.values()] - min_count = min(counts) - max_count = max(counts) - variance = max_count - min_count - - stats = { - "counts": counts, - "min": min_count, - "max": max_count, - "variance": variance, - "per_path": {path_id: len(lsps) for path_id, lsps in lsps_per_path.items()}, - } - - is_balanced = variance <= tolerance - - return (is_balanced, stats) - - -def calculate_theoretical_capacity_parallel_paths( - capacity_per_path: float, num_paths: int, num_lsps: int -) -> float: - """Calculate theoretical maximum capacity with optimal LSP distribution. - - For parallel equal-capacity paths with single-path LSPs. - - Args: - capacity_per_path: Capacity of each path - num_paths: Number of available paths - num_lsps: Number of LSPs to distribute - - Returns: - Theoretical maximum total capacity with optimal distribution - """ - if num_lsps <= num_paths: - # Each LSP gets its own path - return num_lsps * capacity_per_path - else: - # LSPs must share paths - lsps_per_path = num_lsps / num_paths - capacity_per_lsp = capacity_per_path / lsps_per_path - return num_lsps * capacity_per_lsp diff --git a/tests/py/flow_oracle.py b/tests/py/flow_oracle.py new file mode 100644 index 0000000..333b406 --- /dev/null +++ b/tests/py/flow_oracle.py @@ -0,0 +1,198 @@ +"""Independent checks of the flow-core payload (APPROACH 2.2) for the Python tests. + +Nothing here calls the extension's solver: an exhaustive minimum cost of every integer +value on small graphs, and the certificate in its two ordered branches (feasibility, +conservation, attained value, the full residual closure of the real sources and its cut, +potentials with the NEVER_REACHED sentinel tested first, the virtual-terminal convention, +the curve shape, total and moment, and the complete curve against the exhaustive optimum). +""" + +from __future__ import annotations + +from collections import deque +from fractions import Fraction +from itertools import product + +NEVER_REACHED = -1 + + +def exhaustive(num_nodes, src, dst, cost, view, sources, dests, allowed=None): + """Minimum cost of every integer flow value over the allowed edges (id coordinates).""" + edges = [e for e in range(len(src)) if allowed is None or allowed[e]] + s_set, d_set = set(sources), set(dests) + best: dict[int, int] = {} + for flow in product(*(range(view[e] + 1) for e in edges)): + bal = [0] * num_nodes + for e, x in zip(edges, flow, strict=True): + bal[src[e]] -= x + bal[dst[e]] += x + ok = all( + (bal[v] <= 0) + if v in s_set + else (bal[v] >= 0) + if v in d_set + else bal[v] == 0 + for v in range(num_nodes) + ) + if not ok: + continue + value = -sum(bal[s] for s in s_set) + c = sum(cost[e] * x for e, x in zip(edges, flow, strict=True)) + if value not in best or c < best[value]: + best[value] = c + return best + + +def curve_value(curve, x): + """Cost of the convex function given by its marginal curve at the integer value x.""" + total = Fraction(0) + left = x + for c, u in curve: + take = min(left, u) + total += c * take + left -= take + if left == 0: + break + return total + + +def certify( + g, view, sources, dests, cap, payload, node_allowed=None, edge_allowed=None +): + """Failed clauses of the certificate (empty when the payload is certified).""" + n, m = g.num_nodes(), g.num_edges() + src = [int(x) for x in g.edge_src()] + dst = [int(x) for x in g.edge_dst()] + cost = [int(x) for x in g.cost()] + node_ok = [True] * n if node_allowed is None else [bool(x) for x in node_allowed] + edge_ok = [True] * m if edge_allowed is None else [bool(x) for x in edge_allowed] + s_all, d_all = sorted(set(sources)), sorted(set(dests)) + f = payload["edge_flows"] + pi = payload["potentials"] + placed = payload["placed"] + curve = [tuple(t) for t in payload["cost_distribution"]] + fails: list[str] = [] + if cap == 0: + zero = { + "placed": 0, + "cost_distribution": [], + "edge_flows": [0] * m, + "min_cut": None, + "potentials": [NEVER_REACHED] * n, + } + if any(payload[k] != v for k, v in zero.items()) or any( + payload["counters"].values() + ): + fails.append("zero cap: the prescribed zero payload") + return fails + bal = [0] * n + for e in range(m): + if not 0 <= f[e] <= view[e]: + fails.append(f"feasibility at {e}") + if not edge_ok[e] and f[e]: + fails.append(f"flow on ineligible edge {e}") + bal[src[e]] -= f[e] + bal[dst[e]] += f[e] + supply = {s: -bal[s] for s in s_all} + inflow = {d: bal[d] for d in d_all} + for v in range(n): + if v in supply: + if supply[v] < 0 or (not node_ok[v] and supply[v]): + fails.append(f"source {v}") + elif v in inflow: + if inflow[v] < 0 or (not node_ok[v] and inflow[v]): + fails.append(f"destination {v}") + elif bal[v]: + fails.append(f"conservation at {v}") + if sum(supply.values()) != placed or sum(inflow.values()) != placed: + fails.append("value differs from supply or inflow") + if cap is not None and not 0 <= placed <= cap: + fails.append("placed outside [0, cap]") + s_star, t_star = n, n + 1 + out: list[list[tuple[int, int]]] = [[] for _ in range(n + 2)] + for e in range(m): + if not edge_ok[e]: + continue + if f[e] < view[e]: + out[src[e]].append((dst[e], cost[e])) + if f[e] > 0: + out[dst[e]].append((src[e], -cost[e])) + roots = [s for s in s_all if node_ok[s]] + for s in roots: + out[s_star].append((s, 0)) + if supply[s] > 0: + out[s].append((s_star, 0)) + for d in d_all: + if node_ok[d]: + out[d].append((t_star, 0)) + if inflow[d] > 0: + out[t_star].append((d, 0)) + seen = set(roots) + queue = deque(roots) + while queue: + u = queue.popleft() + for v, _w in out[u]: + if v not in seen: + seen.add(v) + queue.append(v) + connected = t_star in seen + if cap is None and connected: + fails.append("uncapped solve left the destinations connected") + if cap is not None and placed != cap and connected: + fails.append("attained value: early stop") + if connected: + if payload["min_cut"] is not None: + fails.append("min_cut on a connected solve") + else: + expect = [ + e + for e in range(m) + if edge_ok[e] and src[e] in seen and dst[e] not in seen and view[e] > 0 + ] + if payload["min_cut"] != expect: + fails.append(f"min_cut {payload['min_cut']} != closure cut {expect}") + if sum(view[e] for e in expect) != placed: + fails.append("cut value differs from placed") + for v in range(n): + if pi[v] < NEVER_REACHED: + fails.append("potential below the sentinel") + if v in seen and pi[v] == NEVER_REACHED: + fails.append(f"closure vertex {v} holds NEVER_REACHED") + for s in roots: + if pi[s] != 0: + fails.append(f"root {s} potential {pi[s]}") + for u in range(n): + if u not in seen or pi[u] == NEVER_REACHED: + continue + for v, w in out[u]: + if v < n and v in seen and pi[v] != NEVER_REACHED and w + pi[u] - pi[v] < 0: + fails.append(f"negative reduced cost {u}->{v}") + reached = [d for d in d_all if pi[d] != NEVER_REACHED] + if reached: + pt = min(pi[d] for d in reached) + for d in reached: + if inflow[d] > 0 and pi[d] != pt: + fails.append(f"inflow destination {d} potential differs from pi(t*)") + elif placed: + fails.append("flow delivered but no destination reached") + if any(u <= 0 for _c, u in curve): + fails.append("curve volume not positive") + if any(b[0] <= a[0] for a, b in zip(curve, curve[1:], strict=False)): + fails.append("curve costs not strictly increasing") + if sum(u for _c, u in curve) != placed: + fails.append("curve total differs from placed") + if sum(c * u for c, u in curve) != sum(cost[e] * f[e] for e in range(m)): + fails.append("curve moment differs from the flow cost") + if m <= 7 and max(view, default=0) <= 3: + best = exhaustive( + n, src, dst, cost, view, roots, [d for d in d_all if node_ok[d]], edge_ok + ) + fmax = max(best) + top = fmax if cap is None else min(cap, fmax) + if placed != top: + fails.append(f"placed {placed} != min(cap, F_max) {top}") + for x in range(min(top, placed) + 1): + if curve_value(curve, x) != best[x]: + fails.append(f"curve differs from the exhaustive optimum at {x}") + break + return fails diff --git a/tests/py/test_api_validation.py b/tests/py/test_api_validation.py deleted file mode 100644 index fad6b01..0000000 --- a/tests/py/test_api_validation.py +++ /dev/null @@ -1,147 +0,0 @@ -"""API validation: argument shapes, bounds, and error handling for frontdoor APIs.""" - -from __future__ import annotations - -import numpy as np -import pytest - -import netgraph_core as ngc - - -def _make_graph(num_nodes, src, dst, cap, cost): - """Helper to build graph with auto-generated ext_edge_ids.""" - ext_edge_ids = np.arange(len(src), dtype=np.int64) - return ngc.StrictMultiDiGraph.from_arrays( - num_nodes, src, dst, cap, cost, ext_edge_ids - ) - - -def _small_graph(build_graph): - # 0 -> 1 with cost=1, cap=1 - return build_graph(2, [(0, 1, 1.0, 1.0, 0)]) - - -def test_spf_invalid_node_mask_shape_raises(build_graph, algs, to_handle): - g = _small_graph(build_graph) - sel = ngc.EdgeSelection( - multi_edge=True, - require_capacity=False, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - bad_mask = np.array([True], dtype=bool) # wrong length - with pytest.raises(TypeError): - algs.spf(to_handle(g), 0, 1, selection=sel, node_mask=bad_mask) - - -def test_spf_invalid_edge_mask_shape_raises(build_graph, algs, to_handle): - g = _small_graph(build_graph) - sel = ngc.EdgeSelection( - multi_edge=True, - require_capacity=False, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - bad_edge_mask = np.array([True, False, True], dtype=bool) # wrong length - with pytest.raises(TypeError): - algs.spf(to_handle(g), 0, 1, selection=sel, edge_mask=bad_edge_mask) - - -def test_spf_invalid_source_node_raises(build_graph, algs, to_handle): - g = _small_graph(build_graph) - with pytest.raises(ValueError): - algs.spf(to_handle(g), 10) # out of range src - - -def test_max_flow_invalid_masks_shape_raises(build_graph, algs, to_handle): - g = _small_graph(build_graph) - bad_edge_mask = np.array([True, False, True], dtype=bool) - with pytest.raises(TypeError): - algs.max_flow(to_handle(g), 0, 1, edge_mask=bad_edge_mask) - - bad_node_mask = np.array([True], dtype=bool) - with pytest.raises(TypeError): - algs.max_flow(to_handle(g), 0, 1, node_mask=bad_node_mask) - - -def test_ksp_invalid_k_and_dst_raises(build_graph, algs, to_handle): - g = _small_graph(build_graph) - with pytest.raises(ValueError): - algs.ksp(to_handle(g), 0, 1, k=0, max_cost_factor=None, unique=True) - with pytest.raises(ValueError): - algs.ksp(to_handle(g), 0, 100, k=1, max_cost_factor=None, unique=True) - - -def test_batch_max_flow_invalid_pairs_shape_raises(build_graph, algs, to_handle): - g = _small_graph(build_graph) - pairs = np.array([[0, 1, 2]], dtype=np.int32) # wrong shape - with pytest.raises(TypeError): - algs.batch_max_flow(to_handle(g), pairs) - - -def test_batch_max_flow_masks_length_mismatch_raises(build_graph, algs, to_handle): - g = _small_graph(build_graph) - pairs = np.array([[0, 1], [0, 1]], dtype=np.int32) - node_masks = [np.array([True, True], dtype=bool)] # len 1, need 2 - with pytest.raises(TypeError): - algs.batch_max_flow(to_handle(g), pairs, node_masks=node_masks) - - -def test_flowstate_views_are_readonly(): - """Verify that FlowState views return read-only arrays.""" - g = _make_graph( - 3, - np.array([0, 1], dtype=np.int32), - np.array([1, 2], dtype=np.int32), - np.array([10.0, 10.0]), - np.ones(2, dtype=np.int64), - ) - - fs = ngc.FlowState(g) - - # Test capacity_view is read-only - capacity_view = fs.capacity_view() - assert not capacity_view.flags.writeable, "capacity_view should be read-only" - with pytest.raises((ValueError, RuntimeError)): - capacity_view[0] = 5.0 - - # Test residual_view is read-only - residual_view = fs.residual_view() - assert not residual_view.flags.writeable, "residual_view should be read-only" - with pytest.raises((ValueError, RuntimeError)): - residual_view[0] = 5.0 - - # Test edge_flow_view is read-only - edge_flow_view = fs.edge_flow_view() - assert not edge_flow_view.flags.writeable, "edge_flow_view should be read-only" - with pytest.raises((ValueError, RuntimeError)): - edge_flow_view[0] = 5.0 - - -def test_flowgraph_views_are_readonly(): - """Verify that FlowGraph views return read-only arrays.""" - g = _make_graph( - 3, - np.array([0, 1], dtype=np.int32), - np.array([1, 2], dtype=np.int32), - np.array([10.0, 10.0]), - np.ones(2, dtype=np.int64), - ) - - fg = ngc.FlowGraph(g) - - # Test capacity_view is read-only - capacity_view = fg.capacity_view() - assert not capacity_view.flags.writeable, "capacity_view should be read-only" - with pytest.raises((ValueError, RuntimeError)): - capacity_view[0] = 999.0 - - # Test residual_view is read-only - residual_view = fg.residual_view() - assert not residual_view.flags.writeable, "residual_view should be read-only" - with pytest.raises((ValueError, RuntimeError)): - residual_view[0] = 999.0 - - # Test edge_flow_view is read-only - edge_flow_view = fg.edge_flow_view() - assert not edge_flow_view.flags.writeable, "edge_flow_view should be read-only" - with pytest.raises((ValueError, RuntimeError)): - edge_flow_view[0] = 999.0 diff --git a/tests/py/test_batch_max_flow_consistency.py b/tests/py/test_batch_max_flow_consistency.py deleted file mode 100644 index 7ad2521..0000000 --- a/tests/py/test_batch_max_flow_consistency.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Batch max-flow consistency with per-pair max_flow results.""" - -import numpy as np - -import netgraph_core as ngc - - -def test_batch_max_flow_matches_individuals_proportional( - square1_graph, algs, to_handle -): - g = square1_graph - pairs = np.array([[0, 2], [0, 3]], dtype=np.int32) - out = algs.batch_max_flow( - to_handle(g), - pairs, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - with_edge_flows=False, - ) - # Individual calculations - v1, _ = algs.max_flow( - to_handle(g), - 0, - 2, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - with_edge_flows=False, - ) - v2, _ = algs.max_flow( - to_handle(g), - 0, - 3, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - with_edge_flows=False, - ) - assert len(out) == 2 - assert np.isclose(out[0].total_flow, v1) - assert np.isclose(out[1].total_flow, v2) - - -def test_batch_max_flow_matches_individuals_equal_balanced( - line1_graph, algs, to_handle -): - g = line1_graph - pairs = np.array([[0, 2], [1, 2]], dtype=np.int32) - out = algs.batch_max_flow( - to_handle(g), - pairs, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - shortest_path=False, - with_edge_flows=False, - ) - v1, _ = algs.max_flow( - to_handle(g), - 0, - 2, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - shortest_path=False, - with_edge_flows=False, - ) - v2, _ = algs.max_flow( - to_handle(g), - 1, - 2, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - shortest_path=False, - with_edge_flows=False, - ) - assert len(out) == 2 - assert np.isclose(out[0].total_flow, v1) - assert np.isclose(out[1].total_flow, v2) diff --git a/tests/py/test_batch_max_flow_masks.py b/tests/py/test_batch_max_flow_masks.py deleted file mode 100644 index 83c363f..0000000 --- a/tests/py/test_batch_max_flow_masks.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Batch max-flow with node/edge masks and per-edge flow outputs.""" - -from __future__ import annotations - -import numpy as np - -import netgraph_core as ngc - - -def test_batch_max_flow_with_masks_and_edge_flows(square1_graph, algs, to_handle): - g = square1_graph - pairs = np.array([[0, 2], [0, 3]], dtype=np.int32) - node_masks = [ - np.array([True, True, True, True], dtype=bool), - np.array([True, True, True, False], dtype=bool), # block node 3 for second pair - ] - # Supply a proper mask for each pair; the first pair allows all edges - edge_masks = [ - np.ones(g.num_edges(), dtype=bool), - np.array([True, True, False, False], dtype=bool), # block the alt path edges - ] - out = algs.batch_max_flow( - to_handle(g), - pairs, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - with_edge_flows=True, - node_masks=node_masks, - edge_masks=edge_masks, # type: ignore[arg-type] - ) - assert len(out) == 2 - # Second pair should be zero due to masks blocking all routes - assert out[1].total_flow == 0.0 - # Edge flows length matches num_edges for with_edge_flows=True - assert np.asarray(out[0].edge_flows).shape == (g.num_edges(),) diff --git a/tests/py/test_cross_validation.py b/tests/py/test_cross_validation.py deleted file mode 100644 index 3765e01..0000000 --- a/tests/py/test_cross_validation.py +++ /dev/null @@ -1,356 +0,0 @@ -"""Cross-validation tests comparing NetGraph-Core to original NetGraph implementation. - -This module validates that NetGraph-Core produces the same maxflow results -as the original NetGraph repository for important real-world topologies. - -References: -- NetGraph tests/integration/test_scenario_3.py (Clos topology) -- NetGraph tests/solver/test_maxflow_api.py (Triangle, parallel paths) -- NetGraph tests/model/test_flow.py (Basic topologies) -""" - -from __future__ import annotations - -import numpy as np -import pytest - -import netgraph_core as ngc - - -def _make_graph(num_nodes, src, dst, cap, cost): - """Helper to build graph with auto-generated ext_edge_ids.""" - ext_edge_ids = np.arange(len(src), dtype=np.int64) - return ngc.StrictMultiDiGraph.from_arrays( - num_nodes, src, dst, cap, cost, ext_edge_ids - ) - - -def _build_3tier_clos_graph() -> ngc.StrictMultiDiGraph: - """Build a 3-tier Clos topology matching NetGraph scenario_3.yaml structure. - - Structure (replicates scenario_3 from NetGraph integration tests): - - 2 Clos instances (clos1, clos2) - - Each Clos has: - - 2 bricks (b1, b2) - - Each brick has: - - 4 T1 nodes (tier1/leaf layer) - - 4 T2 nodes (tier2/aggregation layer) - - Mesh: all T1->T2 edges within brick (100 Gbps, cost 1) - - 16 spine nodes (T3) - - Connections: Each brick's 4 T2 nodes connect one-to-one to 4 spines (400 Gbps, cost 1) - - Inter-Clos: 16 spine-to-spine links (400 Gbps, cost 1) - - Node numbering: - - Clos1: - - b1/t1: nodes 0-3 - - b1/t2: nodes 4-7 - - b2/t1: nodes 8-11 - - b2/t2: nodes 12-15 - - spine: nodes 16-31 - - Clos2: - - b1/t1: nodes 32-35 - - b1/t2: nodes 36-39 - - b2/t1: nodes 40-43 - - b2/t2: nodes 44-47 - - spine: nodes 48-63 - - Total nodes: 64 - - Expected max flow from clos1 T1 nodes to clos2 T1 nodes: 3200.0 Gbps - (8 inter-clos spine links × 400 Gbps = 3200 Gbps bottleneck) - """ - num_nodes = 64 - src_list = [] - dst_list = [] - cap_list = [] - cost_list = [] - - def add_mesh(group1_start, group1_count, group2_start, group2_count, capacity): - """Add mesh connections between two groups.""" - for i in range(group1_count): - for j in range(group2_count): - src_list.append(group1_start + i) - dst_list.append(group2_start + j) - cap_list.append(capacity) - cost_list.append(1) - - def add_one_to_one(group1_start, group1_count, group2_start, capacity): - """Add one-to-one connections.""" - for i in range(group1_count): - src_list.append(group1_start + i) - dst_list.append(group2_start + i) - cap_list.append(capacity) - cost_list.append(1) - - # Clos1 brick1: T1 (0-3) mesh to T2 (4-7) - add_mesh(0, 4, 4, 4, 100.0) - - # Clos1 brick2: T1 (8-11) mesh to T2 (12-15) - add_mesh(8, 4, 12, 4, 100.0) - - # Clos1 b1/T2 (4-7) one-to-one to spine (16-19) - add_one_to_one(4, 4, 16, 400.0) - - # Clos1 b2/T2 (12-15) one-to-one to spine (20-23) - add_one_to_one(12, 4, 20, 400.0) - - # Inter-Clos: Clos1 spine (16-31) one-to-one to Clos2 spine (48-63) - add_one_to_one(16, 16, 48, 400.0) - - # Clos2 spine (48-63) to b1/T2 (36-39) - only first 4 spines connect - add_one_to_one(48, 4, 36, 400.0) - - # Clos2 spine (52-55) to b2/T2 (44-47) - add_one_to_one(52, 4, 44, 400.0) - - # Clos2 brick1: T2 (36-39) mesh to T1 (32-35) - add_mesh(36, 4, 32, 4, 100.0) - - # Clos2 brick2: T2 (44-47) mesh to T1 (40-43) - add_mesh(44, 4, 40, 4, 100.0) - - return _make_graph( - num_nodes, - np.array(src_list, dtype=np.int32), - np.array(dst_list, dtype=np.int32), - np.array(cap_list, dtype=np.float64), - np.array(cost_list, dtype=np.int64), - ) - - -def _build_triangle_graph() -> ngc.StrictMultiDiGraph: - """Triangle topology from NetGraph solver tests. - - Used in test_maxflow_api.py::_triangle_network() - - Topology: - A(0) -> B(1) (cap 2) - B(1) -> C(2) (cap 1) - A(0) -> C(2) (cap 1) - - Expected max flow A->C: 2.0 (1 direct + 1 via B) - """ - src = np.array([0, 1, 0], dtype=np.int32) - dst = np.array([1, 2, 2], dtype=np.int32) - cap = np.array([2.0, 1.0, 1.0], dtype=np.float64) - cost = np.array([1, 1, 1], dtype=np.int64) - - return _make_graph(3, src, dst, cap, cost) - - -def _build_simple_parallel_paths_graph() -> ngc.StrictMultiDiGraph: - """Simple network with two disjoint parallel paths. - - From NetGraph solver tests: test_maxflow_api.py::_simple_network() - - Topology: - S(0) -> A(1) (cap 1) -> T(3) (cap 1) - S(0) -> B(2) (cap 1) -> T(3) (cap 1) - - Expected max flow S->T: 2.0 - """ - src = np.array([0, 1, 0, 2], dtype=np.int32) - dst = np.array([1, 3, 2, 3], dtype=np.int32) - cap = np.array([1.0, 1.0, 1.0, 1.0], dtype=np.float64) - cost = np.array([1, 1, 1, 1], dtype=np.int64) - - return _make_graph(4, src, dst, cap, cost) - - -class TestNetGraphCrossValidation: - """Validate maxflow results match original NetGraph for important topologies.""" - - def test_3tier_clos_proportional_shortest(self, algs, to_handle): - """Validates 3-tier Clos fabric maxflow matches NetGraph scenario_3 expectations. - - Reference: NetGraph tests/integration/test_scenario_3.py - Expected: 3200.0 Gbps from clos1 T1 nodes to clos2 T1 nodes - """ - g = _build_3tier_clos_graph() - gh = to_handle(g) - - # Test single T1-to-T1 path (representative of full fabric capacity) - # node 0 (clos1/b1/t1-1) to node 32 (clos2/b1/t1-1) - total, _summary = algs.max_flow( - gh, - 0, - 32, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=True, - with_edge_flows=False, - ) - - # Each T1 node can push 400 Gbps through the network - # (4 T2 paths × 100 Gbps = 400 Gbps from T1 to T2 layer) - assert pytest.approx(total, rel=0, abs=1.0) == 400.0 - - def test_3tier_clos_equal_balanced_shortest(self, algs, to_handle): - """Validates 3-tier Clos with EQUAL_BALANCED placement. - - Reference: NetGraph scenario_3 "capacity_analysis_forward_balanced" - """ - g = _build_3tier_clos_graph() - gh = to_handle(g) - - total, _summary = algs.max_flow( - gh, - 0, - 32, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - shortest_path=True, - with_edge_flows=False, - ) - - # With EQUAL_BALANCED, flow is split equally across paths - # Expected similar capacity as PROPORTIONAL for this balanced topology - assert total > 0.0, "Should find positive flow" - assert total <= 400.0 + 1.0, "Should not exceed topology capacity" - - def test_triangle_topology_combine_mode(self, algs, to_handle): - """Triangle topology from NetGraph solver tests. - - Reference: test_maxflow_api.py::test_max_flow_combine_basic() - Expected maxflow A->C: 2.0 (1 direct + 1 via B) - """ - g = _build_triangle_graph() - gh = to_handle(g) - - total, _summary = algs.max_flow( - gh, - 0, - 2, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, # Use all paths - with_edge_flows=False, - ) - - assert pytest.approx(total, rel=0, abs=1e-9) == 2.0 - - def test_simple_parallel_paths_topology_shortest_path(self, algs, to_handle): - """Two disjoint parallel paths topology with shortest_path=True. - - Reference: test_maxflow_api.py::test_shortest_path_vs_full_max_flow() - Expected maxflow S->T: 2.0 (both equal-cost paths saturated) - """ - g = _build_simple_parallel_paths_graph() - gh = to_handle(g) - - # Test with shortest_path=True (should use both equal-cost paths) - total_sp, _summary = algs.max_flow( - gh, - 0, - 3, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=True, - with_edge_flows=False, - ) - - assert pytest.approx(total_sp, rel=0, abs=1e-9) == 2.0 - - def test_simple_parallel_paths_topology_full_maxflow(self, algs, to_handle): - """Two disjoint parallel paths topology with shortest_path=False. - - Reference: test_maxflow_api.py::test_shortest_path_vs_full_max_flow() - Expected maxflow S->T: 2.0 - """ - g = _build_simple_parallel_paths_graph() - gh = to_handle(g) - - # Test with shortest_path=False - total_full, _summary = algs.max_flow( - gh, - 0, - 3, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - with_edge_flows=False, - ) - - assert pytest.approx(total_full, rel=0, abs=1e-9) == 2.0 - - -class TestCommonFixtureTopologies: - """Validate that common test fixtures produce expected maxflow results.""" - - def test_square1_graph_proportional(self, square1_graph, algs, to_handle): - """Validate square1_graph (one shortest, one longer alternative path).""" - gh = to_handle(square1_graph) - - # With shortest_path=True, should use shortest path only - total_sp, _ = algs.max_flow( - gh, - 0, - 2, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=True, - with_edge_flows=False, - ) - - # Shortest path: 0->1->2, capacity limited to 1.0 - assert pytest.approx(total_sp, rel=0, abs=1e-9) == 1.0 - - # With shortest_path=False, should use both paths - total_full, _ = algs.max_flow( - gh, - 0, - 2, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - with_edge_flows=False, - ) - - # Both paths: shortest (cap 1) + longer (cap 2) = 3.0 - assert pytest.approx(total_full, rel=0, abs=1e-9) == 3.0 - - def test_line1_graph_wcmp_vs_ecmp(self, line1_graph, algs, to_handle): - """Validate line1_graph behavior with WCMP vs ECMP placement.""" - gh = to_handle(line1_graph) - - # WCMP (PROPORTIONAL) with shortest_path=True - total_wcmp, _ = algs.max_flow( - gh, - 0, - 2, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=True, - with_edge_flows=False, - ) - - # ECMP (EQUAL_BALANCED) with shortest_path=True - total_ecmp, _ = algs.max_flow( - gh, - 0, - 2, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - shortest_path=True, - with_edge_flows=False, - ) - - # WCMP should utilize parallel paths proportionally to capacity - # ECMP should split equally, potentially leaving capacity unused - assert total_wcmp > 0.0 - assert total_ecmp > 0.0 - # WCMP typically utilizes more capacity when paths have different capacities - assert total_wcmp >= total_ecmp - 1e-9 - - def test_graph3_multipath_capacity(self, graph3, algs, to_handle): - """Validate graph3 (complex 6-node topology with parallels and longer routes).""" - gh = to_handle(graph3) - - total, _ = algs.max_flow( - gh, - 0, - 2, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=True, - with_edge_flows=False, - ) - - # graph3 has multiple parallel paths from 0->2 - # Total capacity should be sum of parallel edge capacities - # 0->1: caps [2, 4, 6] = 12 total - # 1->2: caps [1, 2, 3] = 6 total - # Bottleneck at 1->2 limits to 6.0 - # But also 0->4->2 path exists with cap 4 - # Total expected: 10.0 - assert pytest.approx(total, rel=0, abs=1e-9) == 10.0 diff --git a/tests/py/test_edge_cases.py b/tests/py/test_edge_cases.py deleted file mode 100644 index a8d5f5f..0000000 --- a/tests/py/test_edge_cases.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Edge case and special behavior tests for NetGraph-Core. - -This module tests: -- Unsafe API usage patterns that lack runtime guards -- Edge cases in EqualBalanced min-flow gating with parallel edges -- Exact integer distance calculations (int64) for large cost values - -Note: Object lifetime tests are in test_lifetime_safety.py -""" - -from __future__ import annotations - -import numpy as np -import pytest - -import netgraph_core as ngc - - -def _make_graph(num_nodes, src, dst, cap, cost): - """Helper to build graph with auto-generated ext_edge_ids.""" - ext_edge_ids = np.arange(len(src), dtype=np.int64) - return ngc.StrictMultiDiGraph.from_arrays( - num_nodes, src, dst, cap, cost, ext_edge_ids - ) - - -def test_eb_min_flow_gating_does_not_prune_valid_parallel_edges(algs): - """EB path gating: do not prune paths where per-edge residual < min_flow but group supports it.""" - # Graph: 0->1 cap 1.2; 1->2 has two parallel edges cap 0.6 each; all costs=1 - src = np.array([0, 1, 1], dtype=np.int32) - dst = np.array([1, 2, 2], dtype=np.int32) - cap = np.array([1.2, 0.6, 0.6], dtype=np.float64) - cost = np.array([1, 1, 1], dtype=np.int64) - g = _make_graph(3, src, dst, cap, cost) - fg = ngc.FlowGraph(g) - sel = ngc.EdgeSelection( - multi_edge=True, require_capacity=True, tie_break=ngc.EdgeTieBreak.DETERMINISTIC - ) - # Use EqualBalanced with max_flow_count=1 so per_target == requested volume - cfg = ngc.FlowPolicyConfig( - path_alg=ngc.PathAlg.SPF, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - selection=sel, - min_flow_count=1, - max_flow_count=1, - shortest_path=True, - ) - policy = ngc.FlowPolicy(algs, algs.build_graph(g), cfg) - placed, leftover = policy.place_demand(fg, 0, 2, 0, 1.0) - # Should place 1.0 (group capacity min(0.6)*2 = 1.2 supports it) - assert placed >= 1.0 - 1e-9 - - -def test_spf_distance_dtype_int64_exact(algs): - """Distances should support dtype='int64' to avoid float rounding for large sums.""" - # Build a chain 0->1->2 with very large costs that are not exactly representable in float64. - big = np.int64(2**53 + 1) # not exactly representable in float64 - src = np.array([0, 1], dtype=np.int32) - dst = np.array([1, 2], dtype=np.int32) - cap = np.array([1.0, 1.0], dtype=np.float64) - cost = np.array([big, big], dtype=np.int64) - g = _make_graph(3, src, dst, cap, cost) - try: - dist_i64, _ = algs.spf(algs.build_graph(g), 0, 2, dtype="int64") - except TypeError: - pytest.skip("Algorithms.spf does not support dtype= argument yet") - else: - dist_i64 = np.asarray(dist_i64) - assert dist_i64.dtype == np.int64 - assert int(dist_i64[2]) == int(big + big) - - -def test_ksp_distance_dtype_int64_exact(algs): - """KSP distances should support dtype='int64' to avoid float rounding.""" - big = np.int64(2**53 + 1) - src = np.array([0, 1], dtype=np.int32) - dst = np.array([1, 2], dtype=np.int32) - cap = np.array([1.0, 1.0], dtype=np.float64) - cost = np.array([big, big], dtype=np.int64) - g = _make_graph(3, src, dst, cap, cost) - try: - items = algs.ksp(algs.build_graph(g), 0, 2, k=1, dtype="int64") - except TypeError: - pytest.skip("Algorithms.ksp does not support dtype= argument yet") - else: - assert len(items) >= 1 - dist_i64, _ = items[0] - dist_i64 = np.asarray(dist_i64) - assert dist_i64.dtype == np.int64 - assert int(dist_i64[2]) == int(big + big) - - -def test_max_flow_zero_capacity_on_shortest_path_equal_balanced(algs, to_handle): - """EqualBalanced with require_capacity=False returns 0 when shortest path has no capacity. - - With require_capacity=False (true IP/IGP semantics), routing is cost-only. - If the shortest path has a zero-capacity edge, no flow can be placed. - - Topology: - Shortest path: 0->1->2 (cost 2, but 0->1 has cap=0) - Longer path: 0->3->2 (cost 4, cap=100) - """ - src = np.array([0, 1, 0, 3], dtype=np.int32) - dst = np.array([1, 2, 3, 2], dtype=np.int32) - cap = np.array( - [0.0, 10.0, 100.0, 100.0], dtype=np.float64 - ) # 0->1 has zero capacity - cost = np.array([1, 1, 2, 2], dtype=np.int64) - g = _make_graph(4, src, dst, cap, cost) - - # Test with EqualBalanced + require_capacity=False + shortest_path=True - total, summary = algs.max_flow( - to_handle(g), - 0, - 2, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - shortest_path=True, - require_capacity=False, - with_edge_flows=True, - ) - - # Should return 0 because the shortest path (cost 2) has no capacity - assert np.isclose(total, 0.0), ( - f"Expected 0 flow when shortest path has zero capacity, got {total}" - ) - - # Verify no edge flows were placed - edge_flows = np.asarray(summary.edge_flows) - assert np.allclose(edge_flows, 0.0), f"Expected no edge flows, got {edge_flows}" - - -def test_max_flow_zero_capacity_on_shortest_path_proportional(algs, to_handle): - """Proportional with require_capacity=False returns 0 when shortest path has no capacity. - - With require_capacity=False, if the shortest path has a zero-capacity edge, - no flow can be placed. Both placements behave consistently. - """ - src = np.array([0, 1, 0, 3], dtype=np.int32) - dst = np.array([1, 2, 3, 2], dtype=np.int32) - cap = np.array([0.0, 10.0, 100.0, 100.0], dtype=np.float64) - cost = np.array([1, 1, 2, 2], dtype=np.int64) - g = _make_graph(4, src, dst, cap, cost) - - total, summary = algs.max_flow( - to_handle(g), - 0, - 2, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=True, - require_capacity=False, - with_edge_flows=True, - ) - - assert np.isclose(total, 0.0), ( - f"Expected 0 flow when shortest path has zero capacity, got {total}" - ) - - -def test_max_flow_require_capacity_true_finds_alternative(algs, to_handle): - """With require_capacity=True, max_flow should find an alternative path with capacity. - - This is the contrast test: when require_capacity=True, the algorithm should - skip the zero-capacity edge and find the longer path that has capacity. - """ - src = np.array([0, 1, 0, 3], dtype=np.int32) - dst = np.array([1, 2, 3, 2], dtype=np.int32) - cap = np.array([0.0, 10.0, 100.0, 100.0], dtype=np.float64) - cost = np.array([1, 1, 2, 2], dtype=np.int64) - g = _make_graph(4, src, dst, cap, cost) - - total, summary = algs.max_flow( - to_handle(g), - 0, - 2, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - shortest_path=True, - require_capacity=True, # This should skip zero-capacity edges - with_edge_flows=True, - ) - - # Should find the longer path 0->3->2 and place 100 flow - assert np.isclose(total, 100.0), ( - f"Expected 100 flow via alternative path, got {total}" - ) diff --git a/tests/py/test_equal_balanced_modes.py b/tests/py/test_equal_balanced_modes.py deleted file mode 100644 index c186271..0000000 --- a/tests/py/test_equal_balanced_modes.py +++ /dev/null @@ -1,218 +0,0 @@ -"""EQUAL_BALANCED and EQUAL_BALANCED_LOSSY placement semantics. - -Both model a hop-by-hop forwarding table that does not react to load: the -split set is every shortest-path edge with capacity. EQUAL_BALANCED admits -losslessly (a full member blocks admission on that DAG), EQUAL_BALANCED_LOSSY -forwards best-effort (each member carries what it can and the rest is dropped). -""" - -from __future__ import annotations - -import numpy as np -import pytest - -import netgraph_core as ngc - - -def _graph(src, dst, cap, cost, n): - return ngc.StrictMultiDiGraph.from_arrays( - num_nodes=n, - src=np.array(src, dtype=np.int32), - dst=np.array(dst, dtype=np.int32), - capacity=np.array(cap, dtype=np.float64), - cost=np.array(cost, dtype=np.int64), - ext_edge_ids=np.arange(len(src), dtype=np.int64), - ) - - -SEL = ngc.EdgeSelection( - multi_edge=True, require_capacity=False, tie_break=ngc.EdgeTieBreak.DETERMINISTIC -) - - -@pytest.fixture -def pair(): - """0 -> 1 over two equal-cost parallel edges: edge 0 cap 10, edge 1 cap 100.""" - g = _graph([0, 0], [1, 1], [10.0, 100.0], [1, 1], 2) - algs = ngc.Algorithms(ngc.Backend.cpu()) - handle = algs.build_graph(g) - _, dag = algs.spf( - handle, src=0, dst=None, selection=SEL, multipath=True, dtype="float64" - ) - return g, algs, handle, dag - - -def test_enum_members(): - assert set(ngc.FlowPlacement.__members__) == { - "PROPORTIONAL", - "EQUAL_BALANCED", - "EQUAL_BALANCED_LOSSY", - } - - -def test_full_member_blocks_admission_on_the_same_dag(pair): - g, _, _, dag = pair - fg = ngc.FlowGraph(g) - assert fg.place( - ngc.FlowIndex(0, 1, 0, 0), 0, 1, dag, 20.0, ngc.FlowPlacement.EQUAL_BALANCED - ) == pytest.approx(20.0) - assert fg.place( - ngc.FlowIndex(0, 1, 0, 1), 0, 1, dag, 10.0, ngc.FlowPlacement.EQUAL_BALANCED - ) == pytest.approx(0.0) - assert fg.edge_flow_view()[1] == pytest.approx(10.0) - - -def test_progress_comes_from_a_residual_aware_dag(pair): - g, algs, handle, dag = pair - fg = ngc.FlowGraph(g) - assert fg.place( - ngc.FlowIndex(0, 1, 0, 0), 0, 1, dag, 20.0, ngc.FlowPlacement.EQUAL_BALANCED - ) == pytest.approx(20.0) - residual = np.ascontiguousarray(fg.residual_view(), dtype=np.float64) - _, fresh = algs.spf( - handle, - src=0, - dst=None, - selection=SEL, - residual=residual, - multipath=True, - dtype="float64", - ) - assert fg.place( - ngc.FlowIndex(0, 1, 0, 1), 0, 1, fresh, 10.0, ngc.FlowPlacement.EQUAL_BALANCED - ) == pytest.approx(10.0) - assert fg.edge_flow_view()[1] == pytest.approx(20.0) - - -def test_cost_only_max_flow_is_single_pass(pair): - """place_max_flow with require_capacity=False never re-places on the stale DAG.""" - g, _, _, _ = pair - fs = ngc.FlowState(g) - total = fs.place_max_flow( - 0, - 1, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - shortest_path=False, - require_capacity=False, - ) - assert total == pytest.approx(20.0) - - -def test_lossy_delivers_and_reports_drops(pair): - g, _, _, dag = pair - fg = ngc.FlowGraph(g) - placed, drops = fg.place_with_drops( - ngc.FlowIndex(0, 1, 0, 0), - 0, - 1, - dag, - 100.0, - ngc.FlowPlacement.EQUAL_BALANCED_LOSSY, - ) - assert placed == pytest.approx(60.0) - assert drops == [(0, pytest.approx(40.0))] - assert fg.edge_flow_view().tolist() == pytest.approx([10.0, 50.0]) - placed2, drops2 = fg.place_with_drops( - ngc.FlowIndex(0, 1, 0, 1), - 0, - 1, - dag, - 10.0, - ngc.FlowPlacement.EQUAL_BALANCED_LOSSY, - ) - assert placed2 == pytest.approx(5.0) - assert drops2 == [(0, pytest.approx(5.0))] - - -def test_place_with_drops_is_empty_for_other_modes(pair): - g, _, _, dag = pair - fg = ngc.FlowGraph(g) - placed, drops = fg.place_with_drops( - ngc.FlowIndex(0, 1, 0, 0), 0, 1, dag, 100.0, ngc.FlowPlacement.EQUAL_BALANCED - ) - assert placed == pytest.approx(20.0) - assert drops == [] - - -def test_lossy_ledger_holds_carried_volume_only(pair): - g, _, _, dag = pair - fg = ngc.FlowGraph(g) - idx = ngc.FlowIndex(0, 1, 0, 3) - placed, _ = fg.place_with_drops( - idx, 0, 1, dag, 100.0, ngc.FlowPlacement.EQUAL_BALANCED_LOSSY - ) - assert sum(a for _, a in fg.get_flow_edges(idx)) == pytest.approx(placed) - fg.remove(idx) - assert fg.residual_view().tolist() == pytest.approx([10.0, 100.0]) - - -def test_cost_only_flow_policy_does_not_reroute_around_saturation(): - """require_capacity=False must route on cost alone, even with an EB per-flow target. - - A -> B direct (cap 10, cost 1); A -> C -> B (cap 100, cost 5 + 5). - """ - g = _graph([0, 0, 2], [1, 2, 1], [10.0, 100.0, 100.0], [1, 5, 5], 3) - algs = ngc.Algorithms(ngc.Backend.cpu()) - handle = algs.build_graph(g) - - cfg = ngc.FlowPolicyConfig() - cfg.path_alg = ngc.PathAlg.SPF - cfg.flow_placement = ngc.FlowPlacement.EQUAL_BALANCED - cfg.selection = SEL - cfg.require_capacity = False - cfg.shortest_path = True - cfg.min_flow_count = 1 - cfg.max_flow_count = 1 - - fg = ngc.FlowGraph(g) - _, dag = algs.spf( - handle, src=0, dst=None, selection=SEL, multipath=True, dtype="float64" - ) - assert fg.place( - ngc.FlowIndex(0, 1, 9, 0), 0, 1, dag, 10.0, ngc.FlowPlacement.EQUAL_BALANCED - ) == pytest.approx(10.0) - - policy = ngc.FlowPolicy(algs, handle, cfg) - placed, remaining = policy.place_demand(fg, 0, 1, 0, 50.0) - assert placed == pytest.approx(0.0), ( - "cost-only routing must not discover the A->C->B detour" - ) - assert remaining == pytest.approx(50.0) - assert all(float(v[2]) == 1.0 for v in policy.flows.values()) - - -def test_lossy_static_paths_carry_what_fits_without_equalizing(): - """Pinned routes under EQUAL_BALANCED_LOSSY: each LSP is offered its share - and delivers what fits. A -> B direct cap 10 (edge 0); A -> C -> B cap 100 - (edges 1, 2). Demand 50 over both routes: 25 offered each, 10 + 25 = 35. - """ - g = _graph([0, 0, 2], [1, 2, 1], [10.0, 100.0, 100.0], [1, 1, 1], 3) - algs = ngc.Algorithms(ngc.Backend.cpu()) - handle = algs.build_graph(g) - bundles = [ngc.PredDAG.from_edges(g, [0]), ngc.PredDAG.from_edges(g, [1, 2])] - - def policy(placement): - cfg = ngc.FlowPolicyConfig() - cfg.path_alg = ngc.PathAlg.SPF - cfg.flow_placement = placement - cfg.selection = SEL - cfg.require_capacity = False - cfg.min_flow_count = 1 - cfg.max_flow_count = 2 - p = ngc.FlowPolicy(algs, handle, cfg) - p.set_static_paths(0, 1, bundles) - return p - - fg = ngc.FlowGraph(g) - placed, remaining = policy(ngc.FlowPlacement.EQUAL_BALANCED_LOSSY).place_demand( - fg, 0, 1, 0, 50.0 - ) - assert placed == pytest.approx(35.0) - assert remaining == pytest.approx(15.0) - assert fg.edge_flow_view().tolist() == pytest.approx([10.0, 25.0, 25.0]) - - fg = ngc.FlowGraph(g) - placed, _ = policy(ngc.FlowPlacement.EQUAL_BALANCED).place_demand(fg, 0, 1, 0, 50.0) - assert placed == pytest.approx(20.0, abs=1e-3), ( - "lossless: equal carried share, bottleneck 10" - ) diff --git a/tests/py/test_ext_edge_ids.py b/tests/py/test_ext_edge_ids.py deleted file mode 100644 index 83bbe88..0000000 --- a/tests/py/test_ext_edge_ids.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Tests for external edge ID preservation in StrictMultiDiGraph.""" - -import numpy as np - -import netgraph_core - - -def test_ext_edge_ids_without_reverse(): - """Test that external edge IDs are preserved through reordering.""" - # Create edges in non-sorted order to trigger reordering - num_nodes = 4 - src = np.array([2, 0, 1, 0], dtype=np.int32) - dst = np.array([3, 1, 2, 2], dtype=np.int32) - capacity = np.array([10.0, 20.0, 15.0, 25.0], dtype=np.float64) - cost = np.array([5, 1, 3, 1], dtype=np.int64) - ext_edge_ids = np.array([1000, 2000, 3000, 4000], dtype=np.int64) - - g = netgraph_core.StrictMultiDiGraph.from_arrays( - num_nodes=num_nodes, - src=src, - dst=dst, - capacity=capacity, - cost=cost, - ext_edge_ids=ext_edge_ids, - ) - - assert g.num_nodes() == 4 - assert g.num_edges() == 4 - - # Get reordered data - src_view = g.edge_src_view() - dst_view = g.edge_dst_view() - cost_view = g.cost_view() - ext_ids_view = g.ext_edge_ids_view() - - # Core sorts by (cost, src, dst), so expected order: - # (0→1, cost=1, ext_id=2000) - # (0→2, cost=1, ext_id=4000) - # (1→2, cost=3, ext_id=3000) - # (2→3, cost=5, ext_id=1000) - expected_order = [ - (0, 1, 1, 2000), - (0, 2, 1, 4000), - (1, 2, 3, 3000), - (2, 3, 5, 1000), - ] - - for i, (exp_src, exp_dst, exp_cost, exp_ext_id) in enumerate(expected_order): - assert src_view[i] == exp_src - assert dst_view[i] == exp_dst - assert cost_view[i] == exp_cost - assert ext_ids_view[i] == exp_ext_id - - -def test_ext_edge_ids_with_manual_reverse(): - """Test ext_edge_ids when reverse edges are manually added with distinct IDs.""" - num_nodes = 3 - - # Manually add forward and reverse edges with distinct ext_edge_ids - src = np.array([0, 1, 1, 2], dtype=np.int32) # fwd0, rev0, fwd1, rev1 - dst = np.array([1, 0, 2, 1], dtype=np.int32) - capacity = np.array([10.0, 10.0, 20.0, 20.0], dtype=np.float64) - cost = np.array([1, 1, 2, 2], dtype=np.int64) - ext_edge_ids = np.array([0, 1, 2, 3], dtype=np.int64) # Distinct for fwd/rev - - g = netgraph_core.StrictMultiDiGraph.from_arrays( - num_nodes=num_nodes, - src=src, - dst=dst, - capacity=capacity, - cost=cost, - ext_edge_ids=ext_edge_ids, - ) - - assert g.num_edges() == 4 - - ext_ids_view = g.ext_edge_ids_view() - src_view = g.edge_src_view() - dst_view = g.edge_dst_view() - - # Verify all ext_edge_ids are distinct - assert len(set(ext_ids_view)) == 4, "ext_edge_ids should all be distinct" - - # Verify we can distinguish forward from reverse - # In sorted order by cost, src, dst: - # (0→1, cost=1, ext_id=0), (1→0, cost=1, ext_id=1), (1→2, cost=2, ext_id=2), (2→1, cost=2, ext_id=3) - expected = [(0, 1, 0), (1, 0, 1), (1, 2, 2), (2, 1, 3)] - for i, (exp_src, exp_dst, exp_ext_id) in enumerate(expected): - assert src_view[i] == exp_src - assert dst_view[i] == exp_dst - assert ext_ids_view[i] == exp_ext_id - - -def test_ext_edge_ids_encoding_scheme(): - """Test a typical encoding scheme: (linkIndex << 1) | directionBit.""" - num_nodes = 3 - - # Simulate two links with forward/reverse encoding - # Link 0: fwd=(0<<1)|0=0, rev=(0<<1)|1=1 - # Link 1: fwd=(1<<1)|0=2, rev=(1<<1)|1=3 - src = np.array([0, 1, 1, 2], dtype=np.int32) - dst = np.array([1, 0, 2, 1], dtype=np.int32) - capacity = np.array([10.0, 10.0, 20.0, 20.0], dtype=np.float64) - cost = np.array([1, 1, 1, 1], dtype=np.int64) - ext_edge_ids = np.array([0, 1, 2, 3], dtype=np.int64) - - g = netgraph_core.StrictMultiDiGraph.from_arrays( - num_nodes=num_nodes, - src=src, - dst=dst, - capacity=capacity, - cost=cost, - ext_edge_ids=ext_edge_ids, - ) - - ext_ids_view = g.ext_edge_ids_view() - src_view = g.edge_src_view() - dst_view = g.edge_dst_view() - - # Decode ext_edge_ids back to (linkIndex, directionBit) - for i in range(len(ext_ids_view)): - ext_id = int(ext_ids_view[i]) - link_idx = ext_id >> 1 - dir_bit = ext_id & 1 - direction = "rev" if dir_bit else "fwd" - - # Verify encoding is consistent - assert link_idx in (0, 1), f"Invalid link_idx {link_idx}" - assert direction in ("fwd", "rev"), f"Invalid direction {direction}" - - # Verify forward edges have dir_bit=0, reverse have dir_bit=1 - if src_view[i] < dst_view[i]: # Assuming forward means src < dst - assert dir_bit == 0, f"Forward edge {i} should have dir_bit=0" - else: - assert dir_bit == 1, f"Reverse edge {i} should have dir_bit=1" - - -if __name__ == "__main__": - test_ext_edge_ids_without_reverse() - test_ext_edge_ids_with_manual_reverse() - test_ext_edge_ids_encoding_scheme() - print("All ext_edge_ids tests passed!") diff --git a/tests/py/test_flow_policy.py b/tests/py/test_flow_policy.py deleted file mode 100644 index 774e903..0000000 --- a/tests/py/test_flow_policy.py +++ /dev/null @@ -1,282 +0,0 @@ -"""FlowPolicy behavior across canonical graphs. - -Systematic checks for PROPORTIONAL vs EQUAL_BALANCED and multipath True/False -on the canonical graphs used elsewhere. Assertions compare total placed and -remaining volumes to expected capacities. -""" - -from __future__ import annotations - -from typing import Dict, Tuple - -import numpy as np -import pytest - -import netgraph_core as ngc - -# Policy configs covered (subset): -# - SHORTEST_PATHS_WCMP -> Proportional split over equal-cost SPF (multipath) -# - SHORTEST_PATHS_ECMP -> Equal-balanced over equal-cost SPF (multipath) -# - TE_ECMP_16_LSP -> Equal-balanced, capacity-aware single-path selection, 16 flows -POLICY_CONFIGS = ( - "SHORTEST_PATHS_WCMP", - "SHORTEST_PATHS_ECMP", - "TE_ECMP_16_LSP", -) - - -def make_policy(config: str, algs: ngc.Algorithms, gh) -> ngc.FlowPolicy: - if config == "SHORTEST_PATHS_WCMP": - sel = ngc.EdgeSelection( - multi_edge=True, - require_capacity=True, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - cfg = ngc.FlowPolicyConfig( - path_alg=ngc.PathAlg.SPF, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - selection=sel, - shortest_path=True, - ) - return ngc.FlowPolicy(algs, gh, cfg) - if config == "SHORTEST_PATHS_ECMP": - sel = ngc.EdgeSelection( - multi_edge=True, - require_capacity=True, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - cfg = ngc.FlowPolicyConfig( - path_alg=ngc.PathAlg.SPF, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - selection=sel, - shortest_path=True, - max_flow_count=1, - ) - return ngc.FlowPolicy(algs, gh, cfg) - if config == "TE_ECMP_16_LSP": - sel = ngc.EdgeSelection( - multi_edge=False, - require_capacity=True, - tie_break=ngc.EdgeTieBreak.PREFER_HIGHER_RESIDUAL, - ) - cfg = ngc.FlowPolicyConfig( - path_alg=ngc.PathAlg.SPF, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - selection=sel, - min_flow_count=16, - max_flow_count=16, - reoptimize_flows_on_each_placement=True, - # TE placement should not stop early due to tiny increments - # to ensure full utilization - ) - return ngc.FlowPolicy(algs, gh, cfg) - raise AssertionError(f"Unknown config {config}") - - -# Demands per graph -EXPECTED: Dict[str, Tuple[Tuple[int, int], float]] = { - # key: graph fixture name; value: ((src,dst), demand) - "square1_graph": ((0, 2), 3.0), # total cap 3 - "line1_graph": ((0, 2), 7.0), # total cap limited by 0->1 = 5 - "graph3": ((0, 2), 10.0), # total cap 10 - "two_disjoint_shortest_graph": ((0, 3), 3.0), # total cap 3 - "square4_graph": ((0, 1), 350.0), # total cap 350 - "triangle1_graph": ((0, 2), 5.0), # A->C direct cap 5, cheaper detours huge - "square3_graph": ((0, 2), 320.0), # large demand to exercise tiers - "graph1_graph": ((0, 4), 1.0), - "graph2_graph": ((0, 4), 1.0), - "graph4_graph": ((0, 4), 6.0), -} - - -def _run_case( - g: ngc.StrictMultiDiGraph, - label: str, - src: int, - dst: int, - demand: float, - algs: ngc.Algorithms, - to_handle, -): - for key in POLICY_CONFIGS: - fg = ngc.FlowGraph(g) - policy = make_policy(key, algs, to_handle(g)) - placed, remaining = policy.place_demand( - fg, src, dst, flowClass=0, volume=demand - ) - - if key == "SHORTEST_PATHS_WCMP": - exp_total, _ = algs.max_flow( - to_handle(g), - src, - dst, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=True, - ) - expected = float(exp_total) - assert placed == pytest.approx(expected, rel=0, abs=1e-3), ( - f"placed mismatch for {label}:{key}: got {placed}, expected ~{expected}" - ) - assert remaining == pytest.approx(demand - expected, rel=0, abs=1e-3), ( - f"remaining mismatch for {label}:{key}: got {remaining}, expected ~{demand - expected}" - ) - - # Check optimality - if placed > 0 and demand >= expected: - assert placed >= expected * 0.99, ( - f"placed {placed} is less than 99% of max_flow {expected} " - f"for {label}:{key}" - ) - - elif key == "SHORTEST_PATHS_ECMP": - exp_total, _ = algs.max_flow( - to_handle(g), - src, - dst, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - shortest_path=True, - ) - expected = float(exp_total) - assert placed == pytest.approx(expected, rel=0, abs=1e-3), ( - f"placed mismatch for {label}:{key}: got {placed}, expected ~{expected}" - ) - assert remaining == pytest.approx(demand - expected, rel=0, abs=1e-3), ( - f"remaining mismatch for {label}:{key}: got {remaining}, expected ~{demand - expected}" - ) - - # Verify ECMP equal balancing - if placed > 0: - flows = policy.flows - volumes = [v[3] for v in flows.values()] - if len(volumes) > 1: - for vol in volumes: - assert vol == pytest.approx(volumes[0], abs=1e-3), ( - f"ECMP violation for {label}:{key}: " - f"volumes not equal: {volumes}" - ) - - elif key == "TE_ECMP_16_LSP": - # TE-like full mode: placement should not exceed max_flow, and accounting must hold - exp_total, _ = algs.max_flow( - to_handle(g), - src, - dst, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - shortest_path=False, - ) - expected = float(exp_total) - assert placed <= expected + 1e-3, ( - f"placed exceeds max_flow for {label}:{key}: got {placed}, max {expected}" - ) - assert (placed + remaining) == pytest.approx(demand, rel=0, abs=1e-6), ( - f"accounting mismatch for {label}:{key}: placed+remaining={placed + remaining}, demand={demand}" - ) - - # Verify flow count - flow_count = policy.flow_count() - assert flow_count == 16 or flow_count == 0, ( - f"TE_ECMP_16_LSP should create exactly 16 flows (or 0 if no path), " - f"got {flow_count} for {label}:{key}" - ) - - # Verify ECMP balancing - if placed > 0 and flow_count > 0: - flows = policy.flows - volumes = [v[3] for v in flows.values()] - if len(volumes) > 1: - for vol in volumes: - assert vol == pytest.approx(volumes[0], abs=1e-3), ( - f"TE ECMP violation for {label}:{key}: " - f"LSPs not equal: {volumes}" - ) - else: - raise AssertionError(f"Unknown policy key {key}") - - -@pytest.mark.parametrize( - "fixture_name", - [ - "square1_graph", - "line1_graph", - "graph3", - "two_disjoint_shortest_graph", - "square4_graph", - "triangle1_graph", - "square3_graph", - "graph1_graph", - "graph2_graph", - "graph4_graph", - ], -) -def test_policy_placement_modes_match_maxflow_reference( - fixture_name, algs, to_handle, request -): - """Validates that FlowPolicy placement matches max_flow for various configurations. - - Tests PROPORTIONAL vs EQUAL_BALANCED vs TE_ECMP across different topologies, - ensuring placed+remaining accounting is correct and results match max_flow reference. - """ - g = request.getfixturevalue(fixture_name) - (src, dst), dem = EXPECTED[fixture_name] - _run_case(g, fixture_name, src, dst, dem, algs, to_handle) - - -def test_flow_costs_match_expected(algs, to_handle): - """Verify reported costs match known path costs on simple topologies.""" - # Simple 3-node chain: 0 -> 1 -> 2 - # Edge 0->1: cost 10 - # Edge 1->2: cost 20 - # Expected path cost: 30 - g = ngc.StrictMultiDiGraph.from_arrays( - 3, - np.array([0, 1], dtype=np.int32), - np.array([1, 2], dtype=np.int32), - np.array([100.0, 100.0], dtype=np.float64), - np.array([10, 20], dtype=np.int64), - np.array([0, 1], dtype=np.int64), - ) - - fg = ngc.FlowGraph(g) - config = ngc.FlowPolicyConfig() - config.multipath = False - policy = ngc.FlowPolicy(algs, to_handle(g), config) - - placed, _ = policy.place_demand(fg, 0, 2, flowClass=0, volume=50.0) - - assert placed > 0, "Should place flow on simple chain" - assert len(policy.flows) == 1, "Should create exactly one flow" - - # Check reported cost - flow_info = list(policy.flows.values())[0] - src, dst, cost, volume = flow_info - - assert cost == 30, f"Cost should be 30 (10+20), got {cost}" - - # Test with parallel edges of different costs - # 0 -> 1 (cost 5) - # 1 -> 2 via edge A (cost 10) or edge B (cost 20) - # Shortest path: 0->1->2A, cost = 15 - g2 = ngc.StrictMultiDiGraph.from_arrays( - 3, - np.array([0, 1, 1], dtype=np.int32), - np.array([1, 2, 2], dtype=np.int32), - np.array([100.0, 100.0, 100.0], dtype=np.float64), - np.array([5, 10, 20], dtype=np.int64), - np.array([0, 1, 2], dtype=np.int64), - ) - - fg2 = ngc.FlowGraph(g2) - config2 = ngc.FlowPolicyConfig() - config2.multipath = False - config2.shortest_path = True - policy2 = ngc.FlowPolicy(algs, to_handle(g2), config2) - - placed2, _ = policy2.place_demand(fg2, 0, 2, flowClass=0, volume=50.0) - - assert placed2 > 0, "Should place flow" - - # Check cost is shortest path cost - flow_info2 = list(policy2.flows.values())[0] - cost2 = flow_info2[2] - - assert cost2 == 15, f"Cost should be 15 (5+10), got {cost2}" diff --git a/tests/py/test_flow_policy_configurations.py b/tests/py/test_flow_policy_configurations.py deleted file mode 100644 index 2b61855..0000000 --- a/tests/py/test_flow_policy_configurations.py +++ /dev/null @@ -1,513 +0,0 @@ -"""FlowPolicy tests across canonical configurations. - -Tests 5 canonical configurations: -- SHORTEST_PATHS_ECMP: IP/IGP ECMP (hash-based multipath, shortest paths only) -- SHORTEST_PATHS_WCMP: IP/IGP WCMP (proportional multipath, shortest paths only) -- TE_WCMP_UNLIM: Traffic engineering with unlimited proportional flows -- TE_ECMP_UP_TO_256_LSP: TE with up to 256 ECMP LSPs (tunnel-based) -- TE_ECMP_16_LSP: TE with exactly 16 ECMP LSPs (fixed allocation) - -Validates: -- Accounting: placed + remaining = demand -- Capacity constraints: TE configurations respect max_flow -- Flow count enforcement: min/max flow counts -- Balancing: ECMP equal, WCMP proportional -- Cost distribution: volumes sum correctly -- Progressive TE: uses multiple cost tiers when needed -- Lifecycle: remove_demand, rebalance -""" - -from __future__ import annotations - -from typing import Dict, List, Tuple - -import pytest - -import netgraph_core as ngc - -# All canonical configurations to test -ALL_CONFIGS = [ - "SHORTEST_PATHS_ECMP", - "SHORTEST_PATHS_WCMP", - "TE_WCMP_UNLIM", - "TE_ECMP_UP_TO_256_LSP", - "TE_ECMP_16_LSP", -] - -ECMP_CONFIGS = ["SHORTEST_PATHS_ECMP", "TE_ECMP_UP_TO_256_LSP", "TE_ECMP_16_LSP"] -WCMP_CONFIGS = ["SHORTEST_PATHS_WCMP", "TE_WCMP_UNLIM"] -CAPACITY_AWARE_CONFIGS = ["TE_WCMP_UNLIM", "TE_ECMP_UP_TO_256_LSP", "TE_ECMP_16_LSP"] -TE_CONFIGS = ["TE_WCMP_UNLIM", "TE_ECMP_UP_TO_256_LSP", "TE_ECMP_16_LSP"] - - -def get_expected_flow_count(config_name: str) -> Tuple[int, int | None]: - """Return (min_expected, max_expected) flow count for a configuration.""" - if config_name in ["SHORTEST_PATHS_ECMP", "SHORTEST_PATHS_WCMP"]: - return (1, 1) - elif config_name == "TE_WCMP_UNLIM": - return (1, None) # Unlimited - elif config_name == "TE_ECMP_UP_TO_256_LSP": - return (1, 256) - elif config_name == "TE_ECMP_16_LSP": - return (16, 16) - raise ValueError(f"Unknown configuration: {config_name}") - - -def extract_cost_distribution(policy: ngc.FlowPolicy) -> Dict[float, float]: - """Extract cost distribution from policy.flows dictionary.""" - cost_dist = {} - for _key, value in policy.flows.items(): - # value is tuple: (src, dst, cost, volume) - cost = float(value[2]) - volume = float(value[3]) - cost_dist[cost] = cost_dist.get(cost, 0.0) + volume - return cost_dist - - -def check_equal_balancing(volumes: List[float], tolerance: float = 1e-3) -> bool: - """Check if all volumes are approximately equal.""" - if not volumes or len(volumes) == 1: - return True - avg = sum(volumes) / len(volumes) - # Use both absolute and relative tolerance for robustness - return all( - abs(v - avg) < tolerance or (abs(v - avg) / avg < 1e-6 if avg > 0 else False) - for v in volumes - ) - - -# ============================================================================ -# FIXTURES -# ============================================================================ - - -@pytest.fixture -def multi_path_graph(build_graph): - """4 parallel paths with equal cost but different capacities.""" - # S=0, T=5 - # Path 1: 0->1->5, cost=10, cap=100 - # Path 2: 0->2->5, cost=10, cap=80 - # Path 3: 0->3->5, cost=10, cap=60 - # Path 4: 0->4->5, cost=10, cap=40 - edges = [ - (0, 1, 5, 100, 0), - (1, 5, 5, 100, 1), - (0, 2, 5, 80, 2), - (2, 5, 5, 80, 3), - (0, 3, 5, 60, 4), - (3, 5, 5, 60, 5), - (0, 4, 5, 40, 6), - (4, 5, 5, 40, 7), - ] - return build_graph(6, edges) - - -@pytest.fixture -def multi_tier_graph(build_graph): - """3 cost tiers with different capacities.""" - # S=0, T=4 - # Tier 1: cost=10, cap=50 (0->1->4) - # Tier 2: cost=15, cap=75 (0->2->4) - # Tier 3: cost=20, cap=100 (0->3->4) - edges = [ - (0, 1, 5, 50, 0), - (1, 4, 5, 50, 1), - (0, 2, 7, 75, 2), - (2, 4, 8, 75, 3), - (0, 3, 10, 100, 4), - (3, 4, 10, 100, 5), - ] - return build_graph(5, edges) - - -# ============================================================================ -# TEST 1: ACCOUNTING VALIDATION -# ============================================================================ - - -@pytest.mark.parametrize("config_name", ALL_CONFIGS) -def test_accounting_correctness( - config_name, multi_path_graph, algs, to_handle, make_flow_policy -): - """Verify placed + remaining = demand for all configurations.""" - g = multi_path_graph - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g)) - - demand = 200.0 - placed, remaining = policy.place_demand(fg, 0, 5, flowClass=0, volume=demand) - - assert placed + remaining == pytest.approx(demand, rel=0, abs=1e-6), ( - f"{config_name}: placed({placed}) + remaining({remaining}) != demand({demand})" - ) - - -# ============================================================================ -# TEST 2: CAPACITY CONSTRAINTS -# ============================================================================ - - -@pytest.mark.parametrize("config_name", CAPACITY_AWARE_CONFIGS) -def test_capacity_aware_respects_max_flow( - config_name, multi_path_graph, algs, to_handle, make_flow_policy -): - """Verify capacity-aware configurations don't exceed max_flow.""" - g = multi_path_graph - fg = ngc.FlowGraph(g) - - # Calculate max flow - max_flow, _ = algs.max_flow( - to_handle(g), - 0, - 5, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - with_edge_flows=False, - ) - - policy = make_flow_policy(config_name, algs, to_handle(g)) - placed, _ = policy.place_demand(fg, 0, 5, flowClass=0, volume=1000.0) - - assert placed <= float(max_flow) + 1e-3, ( - f"{config_name}: placed({placed}) exceeds max_flow({max_flow})" - ) - - -# ============================================================================ -# TEST 3: FLOW COUNT VALIDATION -# ============================================================================ - - -@pytest.mark.parametrize("config_name", ALL_CONFIGS) -def test_flow_count_enforcement( - config_name, multi_path_graph, algs, to_handle, make_flow_policy -): - """Verify each configuration creates the expected number of flows.""" - g = multi_path_graph - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g)) - - placed, _ = policy.place_demand(fg, 0, 5, flowClass=0, volume=200.0) - flow_count = policy.flow_count() - - min_exp, max_exp = get_expected_flow_count(config_name) - - assert flow_count >= min_exp, ( - f"{config_name}: flow_count({flow_count}) < min_expected({min_exp})" - ) - - if max_exp is not None: - assert flow_count <= max_exp, ( - f"{config_name}: flow_count({flow_count}) > max_expected({max_exp})" - ) - - # Specific checks for fixed-count configurations - if config_name == "TE_ECMP_16_LSP": - assert flow_count == 16, ( - f"{config_name}: expected exactly 16 flows, got {flow_count}" - ) - elif config_name in ["SHORTEST_PATHS_ECMP", "SHORTEST_PATHS_WCMP"]: - assert flow_count == 1, ( - f"{config_name}: expected exactly 1 flow, got {flow_count}" - ) - - -# ============================================================================ -# TEST 4: EQUAL BALANCING (ECMP) -# ============================================================================ - - -@pytest.mark.parametrize("config_name", ECMP_CONFIGS) -def test_ecmp_equal_balancing( - config_name, multi_path_graph, algs, to_handle, make_flow_policy -): - """Verify ECMP configurations split flows equally.""" - g = multi_path_graph - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g)) - - placed, _ = policy.place_demand(fg, 0, 5, flowClass=0, volume=200.0) - - if placed == 0: - pytest.skip("No flow placed, cannot check balancing") - - flows = policy.flows - volumes = [v[3] for v in flows.values()] - - assert check_equal_balancing(volumes, tolerance=1e-3), ( - f"{config_name}: flows not equally balanced. Volumes: {volumes}" - ) - - -# ============================================================================ -# TEST 5: PROPORTIONAL BALANCING (WCMP) -# ============================================================================ - - -@pytest.mark.parametrize("config_name", WCMP_CONFIGS) -def test_wcmp_proportional_balancing( - config_name, multi_path_graph, algs, to_handle, make_flow_policy -): - """Verify WCMP configurations split flows proportionally.""" - g = multi_path_graph - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g)) - - placed, _ = policy.place_demand(fg, 0, 5, flowClass=0, volume=200.0) - - if placed == 0: - pytest.skip("No flow placed, cannot check balancing") - - flows = policy.flows - volumes = [v[3] for v in flows.values()] - - # Verify total is correct - assert abs(sum(volumes) - placed) < 1e-6, ( - f"{config_name}: sum of flow volumes doesn't match placed" - ) - - -# ============================================================================ -# TEST 6: COST DISTRIBUTION -# ============================================================================ - - -@pytest.mark.parametrize("config_name", ALL_CONFIGS) -def test_cost_distribution_sums_correctly( - config_name, multi_path_graph, algs, to_handle, make_flow_policy -): - """Verify cost distribution volumes sum to total placed volume.""" - g = multi_path_graph - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g)) - - placed, _ = policy.place_demand(fg, 0, 5, flowClass=0, volume=200.0) - - if placed == 0: - pytest.skip("No flow placed, cannot check cost distribution") - - cost_dist = extract_cost_distribution(policy) - total_from_costs = sum(cost_dist.values()) - - assert abs(total_from_costs - placed) < 1e-6, ( - f"{config_name}: sum of cost_distribution ({total_from_costs}) != placed ({placed})" - ) - - -# ============================================================================ -# TEST 7: PROGRESSIVE TE USES MULTIPLE TIERS -# ============================================================================ - - -@pytest.mark.parametrize("config_name", TE_CONFIGS) -def test_te_uses_multiple_cost_tiers( - config_name, multi_tier_graph, algs, to_handle, make_flow_policy -): - """Verify TE configurations use progressively more expensive paths when needed.""" - g = multi_tier_graph - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g)) - - # Demand exceeds cheapest tier (50), should use tier 2 and 3 - demand = 150.0 - placed, _ = policy.place_demand(fg, 0, 4, flowClass=0, volume=demand) - - cost_dist = extract_cost_distribution(policy) - unique_costs = sorted(cost_dist.keys()) - - # Should have used multiple cost tiers - assert len(unique_costs) >= 2, ( - f"{config_name}: expected multiple cost tiers, got {unique_costs}" - ) - - -# ============================================================================ -# TEST 8: PLACED DEMAND TRACKING -# ============================================================================ - - -@pytest.mark.parametrize("config_name", ALL_CONFIGS) -def test_placed_demand_tracking( - config_name, multi_path_graph, algs, to_handle, make_flow_policy -): - """Verify policy.placed_demand() matches returned placed value.""" - g = multi_path_graph - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g)) - - placed, _ = policy.place_demand(fg, 0, 5, flowClass=0, volume=200.0) - tracked_placed = policy.placed_demand() - - assert abs(placed - tracked_placed) < 1e-9, ( - f"{config_name}: placed({placed}) != policy.placed_demand()({tracked_placed})" - ) - - -# ============================================================================ -# TEST 9: FLOW DICTIONARY STRUCTURE -# ============================================================================ - - -@pytest.mark.parametrize("config_name", ALL_CONFIGS) -def test_flow_dictionary_structure( - config_name, multi_path_graph, algs, to_handle, make_flow_policy -): - """Verify policy.flows has correct structure.""" - g = multi_path_graph - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g)) - - placed, _ = policy.place_demand(fg, 0, 5, flowClass=0, volume=200.0) - - if placed == 0: - pytest.skip("No flow placed") - - flows = policy.flows - - # Check structure - assert isinstance(flows, dict), f"{config_name}: flows is not a dict" - - for key, value in flows.items(): - # Key should be tuple of 4 elements - assert len(key) == 4, f"{config_name}: flow key length != 4" - - # Value should be tuple of 4 elements: (src, dst, cost, volume) - assert len(value) == 4, f"{config_name}: flow value length != 4" - - # src, dst should be ints - assert isinstance(value[0], int), f"{config_name}: src not int" - assert isinstance(value[1], int), f"{config_name}: dst not int" - - # cost, volume should be numeric - assert isinstance(value[2], (int, float)), f"{config_name}: cost not numeric" - assert isinstance(value[3], (int, float)), f"{config_name}: volume not numeric" - - # volume should be positive - assert value[3] >= 0, f"{config_name}: negative volume" - - -# ============================================================================ -# TEST 10: REMOVE DEMAND -# ============================================================================ - - -@pytest.mark.parametrize("config_name", ALL_CONFIGS) -def test_remove_demand_clears_flows( - config_name, multi_path_graph, algs, to_handle, make_flow_policy -): - """Verify removing demand clears all flows.""" - g = multi_path_graph - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g)) - - # Place demand - placed, _ = policy.place_demand(fg, 0, 5, flowClass=0, volume=200.0) - - if placed == 0: - pytest.skip("No flow placed") - - # Remove demand - policy.remove_demand(fg) - - # Verify cleared - assert policy.flow_count() == 0, f"{config_name}: flow_count != 0 after remove" - assert policy.placed_demand() == pytest.approx(0.0, abs=1e-12), ( - f"{config_name}: placed_demand != 0 after remove" - ) - - -# ============================================================================ -# TEST 11: COMPARISON WITH MAX_FLOW REFERENCE -# ============================================================================ - - -# Demands per graph for reference comparison -EXPECTED: Dict[str, Tuple[Tuple[int, int], float]] = { - "square1_graph": ((0, 2), 3.0), - "line1_graph": ((0, 2), 7.0), - "graph3": ((0, 2), 10.0), - "two_disjoint_shortest_graph": ((0, 3), 3.0), - "square4_graph": ((0, 1), 350.0), - "triangle1_graph": ((0, 2), 5.0), - "square3_graph": ((0, 2), 320.0), - "graph1_graph": ((0, 4), 1.0), - "graph2_graph": ((0, 4), 1.0), - "graph4_graph": ((0, 4), 6.0), -} - - -@pytest.mark.parametrize( - "fixture_name", - [ - "square1_graph", - "line1_graph", - "graph3", - "two_disjoint_shortest_graph", - "square4_graph", - "triangle1_graph", - "square3_graph", - "graph1_graph", - "graph2_graph", - "graph4_graph", - ], -) -@pytest.mark.parametrize( - "config_name", ["SHORTEST_PATHS_WCMP", "SHORTEST_PATHS_ECMP", "TE_ECMP_16_LSP"] -) -def test_matches_maxflow_reference( - fixture_name, config_name, algs, to_handle, make_flow_policy, request -): - """Validate that FlowPolicy placement matches max_flow for various configurations.""" - g = request.getfixturevalue(fixture_name) - (src, dst), demand = EXPECTED[fixture_name] - - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g)) - placed, remaining = policy.place_demand(fg, src, dst, flowClass=0, volume=demand) - - # Compute expected using Algorithms.max_flow for corresponding mode - if config_name == "SHORTEST_PATHS_WCMP": - exp_total, _ = algs.max_flow( - to_handle(g), - src, - dst, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=True, - ) - expected = float(exp_total) - assert placed == pytest.approx(expected, rel=0, abs=1e-3), ( - f"placed mismatch for {fixture_name}:{config_name}: got {placed}, expected ~{expected}" - ) - assert remaining == pytest.approx(demand - expected, rel=0, abs=1e-3), ( - f"remaining mismatch for {fixture_name}:{config_name}: got {remaining}, expected ~{demand - expected}" - ) - elif config_name == "SHORTEST_PATHS_ECMP": - exp_total, _ = algs.max_flow( - to_handle(g), - src, - dst, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - shortest_path=True, - ) - expected = float(exp_total) - assert placed == pytest.approx(expected, rel=0, abs=1e-3), ( - f"placed mismatch for {fixture_name}:{config_name}: got {placed}, expected ~{expected}" - ) - assert remaining == pytest.approx(demand - expected, rel=0, abs=1e-3), ( - f"remaining mismatch for {fixture_name}:{config_name}: got {remaining}, expected ~{demand - expected}" - ) - elif config_name == "TE_ECMP_16_LSP": - exp_total, _ = algs.max_flow( - to_handle(g), - src, - dst, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - shortest_path=False, - ) - expected = float(exp_total) - assert placed <= expected + 1e-3, ( - f"placed exceeds max_flow for {fixture_name}:{config_name}: got {placed}, max {expected}" - ) - assert (placed + remaining) == pytest.approx(demand, rel=0, abs=1e-6), ( - f"accounting mismatch for {fixture_name}:{config_name}: placed+remaining={placed + remaining}, demand={demand}" - ) diff --git a/tests/py/test_flow_policy_edge_cases.py b/tests/py/test_flow_policy_edge_cases.py deleted file mode 100644 index ea7262b..0000000 --- a/tests/py/test_flow_policy_edge_cases.py +++ /dev/null @@ -1,408 +0,0 @@ -"""FlowPolicy edge case tests. - -Tests boundary conditions and error handling across canonical configurations. -""" - -from __future__ import annotations - -import pytest - -import netgraph_core as ngc - -# All canonical configurations to test -ALL_CONFIGS = [ - "SHORTEST_PATHS_ECMP", - "SHORTEST_PATHS_WCMP", - "TE_WCMP_UNLIM", - "TE_ECMP_UP_TO_256_LSP", - "TE_ECMP_16_LSP", -] - -CAPACITY_AWARE_CONFIGS = ["TE_WCMP_UNLIM", "TE_ECMP_UP_TO_256_LSP", "TE_ECMP_16_LSP"] -TE_CONFIGS = ["TE_WCMP_UNLIM", "TE_ECMP_UP_TO_256_LSP", "TE_ECMP_16_LSP"] - -# ============================================================================ -# FIXTURES -# ============================================================================ - - -@pytest.fixture -def single_path_graph(build_graph): - """Single linear path: 0 -> 1 -> 2.""" - edges = [ - (0, 1, 5, 100, 0), - (1, 2, 5, 100, 1), - ] - return build_graph(3, edges) - - -@pytest.fixture -def bottleneck_graph(build_graph): - """Linear path with bottleneck at second edge.""" - edges = [ - (0, 1, 5, 100, 0), - (1, 2, 5, 10, 1), - ] - return build_graph(3, edges) - - -@pytest.fixture -def limited_paths_graph(build_graph): - """Four equal-cost parallel 2-hop paths.""" - edges = [ - (0, 1, 5, 50, 0), - (1, 5, 5, 50, 1), - (0, 2, 5, 50, 2), - (2, 5, 5, 50, 3), - (0, 3, 5, 50, 4), - (3, 5, 5, 50, 5), - (0, 4, 5, 50, 6), - (4, 5, 5, 50, 7), - ] - return build_graph(6, edges) - - -@pytest.fixture -def disconnected_graph(build_graph): - """Graph with isolated nodes.""" - edges = [ - (0, 1, 5, 100, 0), - ] - return build_graph(3, edges) - - -# ============================================================================ -# TEST 1: INSUFFICIENT CAPACITY -# ============================================================================ - - -@pytest.mark.parametrize("config_name", ALL_CONFIGS) -def test_insufficient_capacity( - config_name, single_path_graph, algs, to_handle, make_flow_policy -): - """Verify graceful handling when demand > capacity.""" - g = single_path_graph - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g)) - - # Path capacity is 100, demand is 500 - demand = 500.0 - placed, remaining = policy.place_demand(fg, 0, 2, flowClass=0, volume=demand) - - # Should place up to capacity - assert placed <= 100.0 + 1e-3, ( - f"{config_name}: placed ({placed}) exceeds available capacity (100)" - ) - - # Accounting must be correct - assert placed + remaining == pytest.approx(demand, abs=1e-6), ( - f"{config_name}: accounting broken with insufficient capacity" - ) - - # For capacity-aware configurations, should place exactly max capacity - if config_name in CAPACITY_AWARE_CONFIGS: - assert placed == pytest.approx(100.0, abs=1e-3), ( - f"{config_name}: didn't fully utilize capacity when demand exceeds capacity" - ) - - -# ============================================================================ -# TEST 2: SINGLE PATH NETWORK -# ============================================================================ - - -@pytest.mark.parametrize("config_name", ALL_CONFIGS) -def test_single_path_network( - config_name, single_path_graph, algs, to_handle, make_flow_policy -): - """Verify behavior on networks with only one path.""" - g = single_path_graph - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g)) - - demand = 50.0 - placed, remaining = policy.place_demand(fg, 0, 2, flowClass=0, volume=demand) - - # Should successfully place on the single path - assert placed > 0, f"{config_name}: failed to place flow on single-path network" - - # Accounting - assert placed + remaining == pytest.approx(demand, abs=1e-6), ( - f"{config_name}: accounting broken on single-path network" - ) - - -# ============================================================================ -# TEST 3: FEWER PATHS THAN REQUESTED FLOWS -# ============================================================================ - - -@pytest.mark.parametrize("config_name", ["TE_ECMP_16_LSP", "TE_ECMP_UP_TO_256_LSP"]) -def test_fewer_paths_than_requested( - config_name, limited_paths_graph, algs, to_handle, make_flow_policy -): - """Verify behavior when network has fewer paths than requested flows. - - With 4 paths and 16 LSPs, all 4 paths must be used. - """ - from conftest import ( - analyze_path_usage, - check_distribution_balanced, - count_lsps_per_path, - ) - - g = limited_paths_graph # Only 4 paths available - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g)) - - demand = 150.0 - placed, remaining = policy.place_demand(fg, 0, 5, flowClass=0, volume=demand) - - assert placed > 0, ( - f"{config_name}: failed to place when fewer paths than requested flows" - ) - - flow_count = policy.flow_count() - assert flow_count >= 1, f"{config_name}: no flows created on limited paths" - - # Verify path usage - path_usage = analyze_path_usage(fg, g, 0, 5) - available_paths = 4 - - if config_name == "TE_ECMP_16_LSP": - # All 4 paths must be used - assert len(path_usage) == available_paths, ( - f"{config_name}: With 16 LSPs and 4 available paths, " - f"all 4 paths must be used, but only {len(path_usage)} were used. " - f"Paths with flow: {list(path_usage.keys())}" - ) - - # Verify LSP distribution - lsps_per_path = count_lsps_per_path(policy, fg, g, 0, 5) - expected_lsps_per_path = 16 // 4 - - for path_id in [1, 2, 3, 4]: - if path_id in lsps_per_path: - lsp_count = len(lsps_per_path[path_id]) - assert lsp_count == expected_lsps_per_path, ( - f"{config_name}: Path via node {path_id} should have " - f"{expected_lsps_per_path} LSPs, but has {lsp_count}. " - f"Distribution: {lsps_per_path}" - ) - else: - raise AssertionError( - f"{config_name}: Path via node {path_id} has no LSPs! " - f"Distribution: {lsps_per_path}" - ) - - # Check balance - is_balanced, stats = check_distribution_balanced(lsps_per_path, tolerance=0) - assert is_balanced, ( - f"{config_name}: LSPs not evenly distributed. Stats: {stats}" - ) - - # Check capacity - assert placed >= 140.0, ( - f"{config_name}: With 16 LSPs balanced across 4 paths, " - f"should place ~150 units (demand), but only placed {placed}" - ) - - -# ============================================================================ -# TEST 4: ZERO CAPACITY (SATURATED) NETWORK -# ============================================================================ - - -@pytest.mark.parametrize("config_name", CAPACITY_AWARE_CONFIGS) -def test_zero_remaining_capacity( - config_name, single_path_graph, algs, to_handle, make_flow_policy -): - """Verify behavior when all links are saturated.""" - g = single_path_graph - fg = ngc.FlowGraph(g) - policy1 = make_flow_policy(config_name, algs, to_handle(g)) - - # First placement: saturate the network - demand1 = 100.0 - placed1, remaining1 = policy1.place_demand(fg, 0, 2, flowClass=0, volume=demand1) - - # Try to place more on saturated network (different flowClass) - placed_b, remaining_b = policy1.place_demand(fg, 0, 2, flowClass=1, volume=50.0) - - # Should not be able to place additional flow - assert placed_b < 1e-6, ( - f"{config_name}: placed flow on saturated network. placed_b={placed_b}" - ) - - -# ============================================================================ -# TEST 5: DISCONNECTED NETWORK -# ============================================================================ - - -@pytest.mark.parametrize("config_name", ALL_CONFIGS) -def test_no_path_exists( - config_name, disconnected_graph, algs, to_handle, make_flow_policy -): - """Verify behavior when no path exists.""" - g = disconnected_graph - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g)) - - # Try to route from 0 to 2 (no path exists) - demand = 100.0 - placed, remaining = policy.place_demand(fg, 0, 2, flowClass=0, volume=demand) - - # Should place nothing - assert placed == pytest.approx(0.0, abs=1e-9), ( - f"{config_name}: placed volume when no path exists" - ) - - # All demand should remain - assert remaining == pytest.approx(demand, abs=1e-9), ( - f"{config_name}: remaining doesn't match demand when no path exists" - ) - - # Should have no flows - assert policy.flow_count() == 0, f"{config_name}: created flows when no path exists" - - -# ============================================================================ -# TEST 6: BOTTLENECK NETWORK -# ============================================================================ - - -@pytest.mark.parametrize("config_name", CAPACITY_AWARE_CONFIGS) -def test_bottleneck_network( - config_name, bottleneck_graph, algs, to_handle, make_flow_policy -): - """Verify behavior with severe bottleneck.""" - g = bottleneck_graph - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g)) - - demand = 100.0 - placed, remaining = policy.place_demand(fg, 0, 2, flowClass=0, volume=demand) - - # Should place up to bottleneck capacity (10) - # Allow some tolerance for TE policies that may find alternate routes - assert placed <= 100.0 + 1e-3, ( - f"{config_name}: exceeded total capacity through bottleneck. Got {placed}" - ) - - -# ============================================================================ -# TEST 7: VERY LARGE DEMAND -# ============================================================================ - - -@pytest.mark.parametrize("config_name", ALL_CONFIGS) -def test_very_large_demand( - config_name, single_path_graph, algs, to_handle, make_flow_policy -): - """Verify behavior with extremely large demand.""" - g = single_path_graph - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g)) - - # Demand 1000x capacity - demand = 100000.0 - placed, remaining = policy.place_demand(fg, 0, 2, flowClass=0, volume=demand) - - # Accounting must still be correct - assert placed + remaining == pytest.approx(demand, abs=1e-3), ( - f"{config_name}: accounting broken with very large demand" - ) - - # Should not exceed capacity - assert placed <= 100.0 + 1e-3, ( - f"{config_name}: exceeded capacity with very large demand" - ) - - -# ============================================================================ -# TEST 8: ZERO DEMAND -# ============================================================================ - - -@pytest.mark.parametrize("config_name", ALL_CONFIGS) -def test_zero_demand(config_name, single_path_graph, algs, to_handle, make_flow_policy): - """Verify behavior with zero demand.""" - g = single_path_graph - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g)) - - demand = 0.0 - placed, remaining = policy.place_demand(fg, 0, 2, flowClass=0, volume=demand) - - # Should place nothing - assert placed == pytest.approx(0.0, abs=1e-9), ( - f"{config_name}: placed volume with zero demand" - ) - - # Remaining should be zero - assert remaining == pytest.approx(0.0, abs=1e-9), ( - f"{config_name}: remaining non-zero with zero demand" - ) - - -# ============================================================================ -# TEST 9: NEGATIVE DEMAND (ERROR CASE) -# ============================================================================ - - -@pytest.mark.parametrize("config_name", ALL_CONFIGS) -def test_negative_demand( - config_name, single_path_graph, algs, to_handle, make_flow_policy -): - """Verify behavior with negative demand (should be rejected or clamped).""" - g = single_path_graph - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g)) - - # This might raise an exception or clamp to zero - either is acceptable - try: - demand = -100.0 - placed, remaining = policy.place_demand(fg, 0, 2, flowClass=0, volume=demand) - - # If it doesn't raise, should treat as zero or clamp - assert placed == pytest.approx(0.0, abs=1e-9), ( - f"{config_name}: placed volume with negative demand" - ) - except (ValueError, RuntimeError): - # Acceptable to raise error for negative demand - pass - - -# ============================================================================ -# TEST 10: MULTIPLE DEMANDS SAME FLOW CLASS -# ============================================================================ - - -@pytest.mark.parametrize("config_name", TE_CONFIGS) -def test_multiple_demands_additive( - config_name, single_path_graph, algs, to_handle, make_flow_policy -): - """Verify behavior when trying to place multiple demands for same (src,dst,class).""" - g = single_path_graph - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g)) - - # Place first demand - demand1 = 50.0 - placed1, _ = policy.place_demand(fg, 0, 2, flowClass=0, volume=demand1) - - # Try to place second demand for same (src, dst, flowClass) - # Current behavior: likely adds to existing demand - try: - demand2 = 30.0 - placed2, _ = policy.place_demand(fg, 0, 2, flowClass=0, volume=demand2) - - # If it succeeds, verify total placed doesn't exceed capacity - total_placed = policy.placed_demand() - assert total_placed <= 100.0 + 1e-3, ( - f"{config_name}: total placed exceeds capacity after multiple placements" - ) - except RuntimeError: - # Acceptable to reject duplicate placement - pass diff --git a/tests/py/test_flow_policy_lifecycle.py b/tests/py/test_flow_policy_lifecycle.py deleted file mode 100644 index 2f13fb5..0000000 --- a/tests/py/test_flow_policy_lifecycle.py +++ /dev/null @@ -1,76 +0,0 @@ -"""FlowPolicy lifecycle: place, rebalance, remove, and flow count bounds.""" - -from __future__ import annotations - -import pytest - -import netgraph_core as ngc - - -def _make_sel(): - return ngc.EdgeSelection( - multi_edge=True, - require_capacity=True, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - - -def _small_graph(build_graph): - # 0 -> 1 (cap 3), 0 -> 2 -> 1 (cap 2) - edges = [ - (0, 1, 1.0, 3.0, 0), - (0, 2, 1.0, 2.0, 1), - (2, 1, 1.0, 2.0, 2), - ] - return build_graph(3, edges) - - -def test_flow_policy_place_rebalance_remove(build_graph, algs, to_handle): - g = _small_graph(build_graph) - fg = ngc.FlowGraph(g) - cfg = ngc.FlowPolicyConfig( - path_alg=ngc.PathAlg.SPF, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - selection=_make_sel(), - min_flow_count=2, - max_flow_count=4, - ) - policy = ngc.FlowPolicy(algs, to_handle(g), cfg) - - placed, remaining = policy.place_demand(fg, 0, 1, flowClass=0, volume=4.0) - assert placed + remaining == pytest.approx(4.0, rel=0, abs=1e-9) - assert policy.flow_count() >= 1 - assert policy.placed_demand() == pytest.approx(placed, rel=0, abs=1e-9) - assert isinstance(policy.flows, dict) - assert all(len(k) == 4 for k in policy.flows.keys()) - - # Rebalance to a higher target - placed2, remaining2 = policy.rebalance_demand(fg, 0, 1, flowClass=0, target=5.0) - # Rebalance keeps total demand constant; target is per-flow guidance - assert placed2 + remaining2 == pytest.approx(placed, rel=0, abs=1e-9) - # Flows should be roughly equalized after rebalance - vals = [v[3] for v in policy.flows.values()] - if len(vals) >= 2: - assert max(vals) - min(vals) <= 1e-6 - - # Remove and ensure counters reset - policy.remove_demand(fg) - assert policy.flow_count() == 0 - assert policy.placed_demand() == pytest.approx(0.0, rel=0, abs=1e-12) - - -def test_flow_policy_flow_count_bounds(build_graph, algs, to_handle): - g = _small_graph(build_graph) - fg = ngc.FlowGraph(g) - # Constrain to exactly 3 flows - cfg = ngc.FlowPolicyConfig( - path_alg=ngc.PathAlg.SPF, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - selection=_make_sel(), - min_flow_count=3, - max_flow_count=3, - ) - policy = ngc.FlowPolicy(algs, to_handle(g), cfg) - placed, _ = policy.place_demand(fg, 0, 1, flowClass=0, volume=3.0) - assert placed >= 0.0 - assert policy.flow_count() == 3 diff --git a/tests/py/test_flow_policy_masks.py b/tests/py/test_flow_policy_masks.py deleted file mode 100644 index 2e14f49..0000000 --- a/tests/py/test_flow_policy_masks.py +++ /dev/null @@ -1,383 +0,0 @@ -"""FlowPolicy mask tests. - -Tests node and edge mask functionality for failure simulation across -canonical configurations. -""" - -from __future__ import annotations - -import numpy as np -import pytest - -import netgraph_core as ngc - -# All canonical configurations to test -ALL_CONFIGS = [ - "SHORTEST_PATHS_ECMP", - "SHORTEST_PATHS_WCMP", - "TE_WCMP_UNLIM", - "TE_ECMP_UP_TO_256_LSP", - "TE_ECMP_16_LSP", -] - -CAPACITY_AWARE_CONFIGS = ["TE_WCMP_UNLIM", "TE_ECMP_UP_TO_256_LSP", "TE_ECMP_16_LSP"] - -# ============================================================================ -# FIXTURES -# ============================================================================ - - -@pytest.fixture -def redundant_paths_graph(build_graph): - """Graph with redundant paths for testing masking.""" - # S=0, T=4 - # Path 1: 0->1->4, cost=10, cap=100 (primary) - # Path 2: 0->2->4, cost=10, cap=100 (backup) - # Path 3: 0->3->4, cost=15, cap=100 (expensive backup) - edges = [ - (0, 1, 5, 100, 0), # Primary path - (1, 4, 5, 100, 1), - (0, 2, 5, 100, 2), # Backup path (same cost) - (2, 4, 5, 100, 3), - (0, 3, 7, 100, 4), # Expensive path - (3, 4, 8, 100, 5), - ] - return build_graph(5, edges) - - -# ============================================================================ -# TEST 1: NODE MASK EXCLUSION -# ============================================================================ - - -@pytest.mark.parametrize("config_name", ALL_CONFIGS) -def test_node_mask_excludes_node( - config_name, redundant_paths_graph, algs, to_handle, make_flow_policy -): - """Verify configurations respect node exclusions.""" - g = redundant_paths_graph - - # Without mask - should place successfully using all paths - fg1 = ngc.FlowGraph(g) - policy1 = make_flow_policy(config_name, algs, to_handle(g), node_mask=None) - placed1, _ = policy1.place_demand(fg1, 0, 4, flowClass=0, volume=100.0) - - # Create mask excluding node 1 (part of primary path) - node_mask = np.ones(5, dtype=bool) - node_mask[1] = False # Exclude node 1 - - # With mask - should place using alternate paths - fg2 = ngc.FlowGraph(g) - policy2 = make_flow_policy(config_name, algs, to_handle(g), node_mask=node_mask) - placed2, _ = policy2.place_demand(fg2, 0, 4, flowClass=0, volume=100.0) - - # Should still place flow (via nodes 2 or 3) - assert placed2 > 0, f"{config_name}: failed to place when node 1 masked" - - # Placement with mask may differ from without mask (configuration-dependent) - # Verifies alternate path discovery when primary path is masked - - -# ============================================================================ -# TEST 2: EDGE MASK EXCLUSION -# ============================================================================ - - -@pytest.mark.parametrize("config_name", ALL_CONFIGS) -def test_edge_mask_excludes_edge( - config_name, redundant_paths_graph, algs, to_handle, make_flow_policy -): - """Verify configurations respect edge exclusions.""" - g = redundant_paths_graph - - # Without mask - fg1 = ngc.FlowGraph(g) - policy1 = make_flow_policy(config_name, algs, to_handle(g), edge_mask=None) - placed1, _ = policy1.place_demand(fg1, 0, 4, flowClass=0, volume=100.0) - - # Create mask excluding edge 0 (0->1, first hop of primary path) - edge_mask = np.ones(6, dtype=bool) - edge_mask[0] = False # Exclude edge 0 - - # With mask - should use alternate paths - fg2 = ngc.FlowGraph(g) - policy2 = make_flow_policy(config_name, algs, to_handle(g), edge_mask=edge_mask) - placed2, _ = policy2.place_demand(fg2, 0, 4, flowClass=0, volume=100.0) - - # Should still place flow (via other edges) - assert placed2 > 0, f"{config_name}: failed to place when edge 0 masked" - - -# ============================================================================ -# TEST 3: MASK REDUCES CAPACITY -# ============================================================================ - - -@pytest.mark.parametrize("config_name", CAPACITY_AWARE_CONFIGS) -def test_mask_reduces_available_capacity( - config_name, redundant_paths_graph, algs, to_handle, make_flow_policy -): - """Verify masking reduces available capacity correctly.""" - g = redundant_paths_graph - - # Without mask - get baseline capacity - fg1 = ngc.FlowGraph(g) - policy1 = make_flow_policy(config_name, algs, to_handle(g)) - placed1, _ = policy1.place_demand(fg1, 0, 4, flowClass=0, volume=1000.0) - - # With mask excluding primary path nodes - node_mask = np.ones(5, dtype=bool) - node_mask[1] = False # Exclude primary path - - fg2 = ngc.FlowGraph(g) - policy2 = make_flow_policy(config_name, algs, to_handle(g), node_mask=node_mask) - placed2, _ = policy2.place_demand(fg2, 0, 4, flowClass=0, volume=1000.0) - - # Masked version should place less or equal - assert placed2 <= placed1 + 1e-6, ( - f"{config_name}: masking didn't reduce or maintain capacity. " - f"Without mask: {placed1}, with mask: {placed2}" - ) - - -# ============================================================================ -# TEST 4: MASK CHANGES COSTS -# ============================================================================ - - -@pytest.mark.parametrize("config_name", ALL_CONFIGS) -def test_mask_forces_higher_cost_paths( - config_name, redundant_paths_graph, algs, to_handle, make_flow_policy -): - """Verify masking forces use of alternate (more expensive) paths.""" - g = redundant_paths_graph - - # Helper to extract minimum cost - def get_min_cost(policy): - flows = policy.flows - if not flows: - return float("inf") - costs = [v[2] for v in flows.values()] - return min(costs) - - # Without mask - should use cheap paths (cost 10) - fg1 = ngc.FlowGraph(g) - policy1 = make_flow_policy(config_name, algs, to_handle(g)) - placed1, _ = policy1.place_demand(fg1, 0, 4, flowClass=0, volume=50.0) - - if placed1 > 0: - min_cost1 = get_min_cost(policy1) - - # With mask excluding cheap paths - should use expensive path (cost 15) - node_mask = np.ones(5, dtype=bool) - node_mask[1] = False # Exclude node 1 - node_mask[2] = False # Exclude node 2 - - fg2 = ngc.FlowGraph(g) - policy2 = make_flow_policy(config_name, algs, to_handle(g), node_mask=node_mask) - placed2, _ = policy2.place_demand(fg2, 0, 4, flowClass=0, volume=50.0) - - if placed2 > 0: - min_cost2 = get_min_cost(policy2) - - # Masked version should use more expensive paths - assert min_cost2 >= min_cost1 - 1e-6, ( - f"{config_name}: masking didn't increase or maintain path cost. " - f"Without mask: {min_cost1}, with mask: {min_cost2}" - ) - - -# ============================================================================ -# TEST 5: COMPLETE NETWORK MASKING -# ============================================================================ - - -@pytest.mark.parametrize("config_name", ALL_CONFIGS) -def test_complete_mask_blocks_all_paths( - config_name, redundant_paths_graph, algs, to_handle, make_flow_policy -): - """Verify behavior when all paths are masked.""" - g = redundant_paths_graph - - # Mask all intermediate nodes - node_mask = np.ones(5, dtype=bool) - node_mask[1] = False # Exclude all - node_mask[2] = False # intermediate - node_mask[3] = False # nodes - - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g), node_mask=node_mask) - placed, remaining = policy.place_demand(fg, 0, 4, flowClass=0, volume=100.0) - - # Should place nothing - assert placed == pytest.approx(0.0, abs=1e-9), ( - f"{config_name}: placed volume when all paths masked" - ) - assert remaining == pytest.approx(100.0, abs=1e-9), ( - f"{config_name}: remaining doesn't match demand when all paths masked" - ) - - -# ============================================================================ -# TEST 6: SOURCE NODE MASKED -# ============================================================================ - - -@pytest.mark.parametrize("config_name", ALL_CONFIGS) -def test_source_node_masked( - config_name, redundant_paths_graph, algs, to_handle, make_flow_policy -): - """Verify behavior when source node is masked.""" - g = redundant_paths_graph - - # Mask source node - node_mask = np.ones(5, dtype=bool) - node_mask[0] = False # Exclude source - - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g), node_mask=node_mask) - placed, remaining = policy.place_demand(fg, 0, 4, flowClass=0, volume=100.0) - - # Should place nothing (no traversal can start from excluded source) - assert placed == pytest.approx(0.0, abs=1e-9), ( - f"{config_name}: placed volume when source masked" - ) - assert remaining == pytest.approx(100.0, abs=1e-9), ( - f"{config_name}: remaining doesn't match demand when source masked" - ) - - -# ============================================================================ -# TEST 7: DESTINATION NODE MASKED -# ============================================================================ - - -@pytest.mark.parametrize("config_name", ALL_CONFIGS) -def test_destination_node_masked( - config_name, redundant_paths_graph, algs, to_handle, make_flow_policy -): - """Verify behavior when destination node is masked.""" - g = redundant_paths_graph - - # Mask destination node - node_mask = np.ones(5, dtype=bool) - node_mask[4] = False # Exclude destination - - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g), node_mask=node_mask) - placed, remaining = policy.place_demand(fg, 0, 4, flowClass=0, volume=100.0) - - # Should place nothing (cannot reach excluded destination) - assert placed == pytest.approx(0.0, abs=1e-9), ( - f"{config_name}: placed volume when destination masked" - ) - assert remaining == pytest.approx(100.0, abs=1e-9), ( - f"{config_name}: remaining doesn't match demand when destination masked" - ) - - -# ============================================================================ -# TEST 8: COMBINED NODE AND EDGE MASKS -# ============================================================================ - - -@pytest.mark.parametrize("config_name", ALL_CONFIGS) -def test_combined_node_and_edge_masks( - config_name, redundant_paths_graph, algs, to_handle, make_flow_policy -): - """Verify behavior with both node and edge masks.""" - g = redundant_paths_graph - - # Exclude node 1 and edge 2 (0->2) - node_mask = np.ones(5, dtype=bool) - node_mask[1] = False - - edge_mask = np.ones(6, dtype=bool) - edge_mask[2] = False - - fg = ngc.FlowGraph(g) - policy = make_flow_policy( - config_name, algs, to_handle(g), node_mask=node_mask, edge_mask=edge_mask - ) - placed, remaining = policy.place_demand(fg, 0, 4, flowClass=0, volume=100.0) - - # Should still place via expensive path (0->3->4) - assert placed > 0, f"{config_name}: failed to place with combined masks" - - -# ============================================================================ -# TEST 9: MASK VALIDATION -# ============================================================================ - - -def test_invalid_node_mask_length( - redundant_paths_graph, algs, to_handle, make_flow_policy -): - """Verify that invalid mask lengths are rejected.""" - g = redundant_paths_graph - - # Create mask with wrong length - node_mask = np.ones(10, dtype=bool) # Graph has 5 nodes, not 10 - - # Should raise TypeError for length mismatch - with pytest.raises(TypeError, match="node_mask"): - make_flow_policy("SHORTEST_PATHS_ECMP", algs, to_handle(g), node_mask=node_mask) - - -def test_invalid_edge_mask_length( - redundant_paths_graph, algs, to_handle, make_flow_policy -): - """Verify that invalid mask lengths are rejected.""" - g = redundant_paths_graph - - # Create mask with wrong length - edge_mask = np.ones(10, dtype=bool) # Graph has 6 edges, not 10 - - # Should raise TypeError for length mismatch - with pytest.raises(TypeError, match="edge_mask"): - make_flow_policy("SHORTEST_PATHS_ECMP", algs, to_handle(g), edge_mask=edge_mask) - - -def test_invalid_mask_dtype(redundant_paths_graph, algs, to_handle, make_flow_policy): - """Verify that non-bool masks are rejected.""" - g = redundant_paths_graph - - # Create mask with wrong dtype - node_mask = np.ones(5, dtype=np.float64) # Should be bool - - # Should raise TypeError for wrong dtype - with pytest.raises(TypeError, match="bool"): - make_flow_policy("SHORTEST_PATHS_ECMP", algs, to_handle(g), node_mask=node_mask) - - -# ============================================================================ -# TEST 10: MASK IMMUTABILITY -# ============================================================================ - - -@pytest.mark.parametrize("config_name", ["SHORTEST_PATHS_ECMP", "TE_WCMP_UNLIM"]) -def test_mask_is_copied( - config_name, redundant_paths_graph, algs, to_handle, make_flow_policy -): - """Verify that masks are copied (not referenced) by FlowPolicy.""" - g = redundant_paths_graph - - # Create mask - node_mask = np.ones(5, dtype=bool) - node_mask[1] = False - - fg = ngc.FlowGraph(g) - policy = make_flow_policy(config_name, algs, to_handle(g), node_mask=node_mask) - placed1, _ = policy.place_demand(fg, 0, 4, flowClass=0, volume=50.0) - - # Modify the mask after policy creation - node_mask[2] = False - - # Policy should not be affected by external mask changes - # (behavior should be consistent with first placement) - policy2 = make_flow_policy(config_name, algs, to_handle(g), node_mask=None) - fg2 = ngc.FlowGraph(g) - # This test mainly validates that the policy doesn't crash or behave erratically - # The actual assertion is that this doesn't raise an exception - placed2, _ = policy2.place_demand(fg2, 0, 4, flowClass=0, volume=50.0) - assert placed2 > 0 # Should work normally diff --git a/tests/py/test_flow_policy_path_distribution.py b/tests/py/test_flow_policy_path_distribution.py deleted file mode 100644 index e308d58..0000000 --- a/tests/py/test_flow_policy_path_distribution.py +++ /dev/null @@ -1,532 +0,0 @@ -"""FlowPolicy path distribution tests. - -Verifies that LSPs distribute correctly across available equal-cost paths: -- All available paths are utilized -- Flow distribution is balanced (not clustered) -- Per-path volumes match expected values -- ECMP equal-balancing constraint is satisfied -""" - -from __future__ import annotations - -import pytest - -import netgraph_core as ngc - -# ============================================================================ -# HELPER FUNCTIONS -# ============================================================================ - - -def analyze_path_usage( - fg: ngc.FlowGraph, graph: ngc.StrictMultiDiGraph, source: int, target: int -) -> dict[int, float]: - """Analyze which parallel paths are used and their volumes. - - Returns dict mapping middle_node_id -> total_flow_volume - """ - edge_flows = fg.edge_flow_view() - edge_src = graph.edge_src_view() - edge_dst = graph.edge_dst_view() - - path_usage = {} - - # Find all edges from source - for edge_id in range(graph.num_edges()): - if edge_src[edge_id] == source: - middle_node = edge_dst[edge_id] - flow = edge_flows[edge_id] - if flow > 0.001: - path_usage[middle_node] = flow - - return path_usage - - -def count_lsps_per_path( - policy: ngc.FlowPolicy, - fg: ngc.FlowGraph, - graph: ngc.StrictMultiDiGraph, - source: int, - target: int, -) -> dict[int, list[int]]: - """Count how many LSPs use each path. - - Returns dict mapping middle_node_id -> list of flow_ids - """ - edge_src = graph.edge_src_view() - edge_dst = graph.edge_dst_view() - - path_lsps = {} - - # For each flow, determine which path it uses - for flow_id in range(policy.flow_count()): - try: - flow_index = ngc.FlowIndex(source, target, 0, flow_id) - flow_edges = fg.get_flow_edges(flow_index) - - # Find the middle node this flow uses - # flow_edges is list of (edge_id, volume) tuples - for edge_id, _ in flow_edges: - src = edge_src[edge_id] - dst = edge_dst[edge_id] - if src == source: - middle_node = dst - if middle_node not in path_lsps: - path_lsps[middle_node] = [] - path_lsps[middle_node].append(flow_id) - break - except (KeyError, RuntimeError): - pass # Flow might not exist or have no edges - - return path_lsps - - -# ============================================================================ -# TEST FIXTURES: Parallel Path Topologies -# ============================================================================ - - -@pytest.fixture -def parallel_3_paths(build_graph): - """Three parallel 2-hop paths, each with capacity 100.""" - edges = [ - (0, 1, 1, 100.0), # S -> M1 - (0, 2, 1, 100.0), # S -> M2 - (0, 3, 1, 100.0), # S -> M3 - (1, 4, 1, 100.0), # M1 -> T - (2, 4, 1, 100.0), # M2 -> T - (3, 4, 1, 100.0), # M3 -> T - ] - return build_graph(5, edges) - - -@pytest.fixture -def parallel_5_paths(build_graph): - """Five parallel 2-hop paths.""" - edges = [ - (0, 1, 1, 100.0), - (0, 2, 1, 100.0), - (0, 3, 1, 100.0), - (0, 4, 1, 100.0), - (0, 5, 1, 100.0), - (1, 6, 1, 100.0), - (2, 6, 1, 100.0), - (3, 6, 1, 100.0), - (4, 6, 1, 100.0), - (5, 6, 1, 100.0), - ] - return build_graph(7, edges) - - -@pytest.fixture -def parallel_2_paths(build_graph): - """Two parallel 2-hop paths.""" - edges = [ - (0, 1, 1, 100.0), - (0, 2, 1, 100.0), - (1, 3, 1, 100.0), - (2, 3, 1, 100.0), - ] - return build_graph(4, edges) - - -# ============================================================================ -# PATH UTILIZATION TESTS -# ============================================================================ - - -def test_all_paths_used_with_multipath_false_3_paths_6_lsps( - parallel_3_paths, algs, to_handle -): - """CRITICAL: When multipath=false, ALL available paths must be used. - - This is the test that would have caught the bug! - - With 3 paths and 6 LSPs: - - Expected: 2 LSPs per path (optimal distribution) - - Bug behavior: Only 2 paths used, 3 LSPs each, path-3 idle - """ - g = parallel_3_paths - fg = ngc.FlowGraph(g) - - config = ngc.FlowPolicyConfig() - config.path_alg = ngc.PathAlg.SPF - config.flow_placement = ngc.FlowPlacement.EQUAL_BALANCED - config.selection = ngc.EdgeSelection( - multi_edge=False, - require_capacity=True, - tie_break=ngc.EdgeTieBreak.PREFER_HIGHER_RESIDUAL, - ) - config.multipath = False # Each LSP uses ONE path - config.min_flow_count = 6 - config.max_flow_count = 6 - - policy = ngc.FlowPolicy(algs, to_handle(g), config) - - demand = 250.0 - placed, remaining = policy.place_demand(fg, 0, 4, flowClass=0, volume=demand) - - # Check path usage - path_usage = analyze_path_usage(fg, g, 0, 4) - - # CRITICAL ASSERTION: All 3 paths must be used! - assert len(path_usage) == 3, ( - f"Expected all 3 paths to be used, but only {len(path_usage)} were used. " - f"Paths with flow: {list(path_usage.keys())}" - ) - - # All paths should have equal flow (100 each with optimal distribution) - # With 6 LSPs and 3 paths: 2 LSPs per path = 100 capacity / 2 = 50 per LSP - # Total per path: 2 * 50 = 100 units (if using reoptimization) - # OR if balanced: each LSP gets demand/6, so 250/6 = 41.67 per LSP - # With 2 LSPs per path: 2 * 41.67 = 83.33 per path - - for path_id, flow in path_usage.items(): - assert flow > 0.0, f"Path {path_id} has zero flow" - - # Check LSP distribution - lsps_per_path = count_lsps_per_path(policy, fg, g, 0, 4) - - # With 6 LSPs and 3 paths, each path should have 2 LSPs - for path_id in [1, 2, 3]: # Middle nodes - lsp_count = len(lsps_per_path.get(path_id, [])) - assert lsp_count == 2, ( - f"Path via node {path_id} should have 2 LSPs, but has {lsp_count}. " - f"Distribution: {lsps_per_path}" - ) - - # Expected capacity: 6 LSPs * 50 units = 300 units (if perfectly balanced) - # With demand=250, we should place it all - assert placed >= 240.0, ( # Allow some tolerance - f"Expected to place ~250 units with optimal distribution, got {placed}" - ) - - -def test_all_paths_used_with_multipath_false_5_paths_3_lsps( - parallel_5_paths, algs, to_handle -): - """When paths > LSPs, only use as many paths as needed, but distribute optimally.""" - g = parallel_5_paths - fg = ngc.FlowGraph(g) - - config = ngc.FlowPolicyConfig() - config.path_alg = ngc.PathAlg.SPF - config.flow_placement = ngc.FlowPlacement.EQUAL_BALANCED - config.selection = ngc.EdgeSelection( - multi_edge=False, - require_capacity=True, - tie_break=ngc.EdgeTieBreak.PREFER_HIGHER_RESIDUAL, - ) - config.multipath = False - config.min_flow_count = 3 - config.max_flow_count = 3 - - policy = ngc.FlowPolicy(algs, to_handle(g), config) - - demand = 150.0 - placed, remaining = policy.place_demand(fg, 0, 6, flowClass=0, volume=demand) - - # Check path usage - path_usage = analyze_path_usage(fg, g, 0, 6) - - # With 3 LSPs and 5 paths: should use 3 paths (1 LSP each) - assert len(path_usage) == 3, ( - f"Expected 3 paths to be used (1 LSP each), but {len(path_usage)} were used" - ) - - # Check LSP distribution - lsps_per_path = count_lsps_per_path(policy, fg, g, 0, 6) - - # Each used path should have exactly 1 LSP - for path_id, lsp_list in lsps_per_path.items(): - assert len(lsp_list) == 1, ( - f"Path {path_id} should have 1 LSP, has {len(lsp_list)}" - ) - - # With 1 LSP per path and 100 capacity: 3 * 100 = 300 available - # Demand is 150, so should place it all - assert placed == pytest.approx(150.0, abs=1.0), ( - f"Expected to place all 150 units, got {placed}" - ) - - -def test_all_paths_used_with_multipath_false_2_paths_6_lsps( - parallel_2_paths, algs, to_handle -): - """When LSPs > paths, distribute evenly across available paths.""" - g = parallel_2_paths - fg = ngc.FlowGraph(g) - - config = ngc.FlowPolicyConfig() - config.path_alg = ngc.PathAlg.SPF - config.flow_placement = ngc.FlowPlacement.EQUAL_BALANCED - config.selection = ngc.EdgeSelection( - multi_edge=False, - require_capacity=True, - tie_break=ngc.EdgeTieBreak.PREFER_HIGHER_RESIDUAL, - ) - config.multipath = False - config.min_flow_count = 6 - config.max_flow_count = 6 - - policy = ngc.FlowPolicy(algs, to_handle(g), config) - - demand = 200.0 - placed, remaining = policy.place_demand(fg, 0, 3, flowClass=0, volume=demand) - - # Check path usage - path_usage = analyze_path_usage(fg, g, 0, 3) - - # Both paths must be used - assert len(path_usage) == 2, ( - f"Expected both paths to be used, but only {len(path_usage)} were used" - ) - - # Check LSP distribution - lsps_per_path = count_lsps_per_path(policy, fg, g, 0, 3) - - # With 6 LSPs and 2 paths: each path should have 3 LSPs - for path_id in [1, 2]: # Middle nodes - lsp_count = len(lsps_per_path.get(path_id, [])) - assert lsp_count == 3, ( - f"Path via node {path_id} should have 3 LSPs, but has {lsp_count}" - ) - - # With 3 LSPs per path and 100 capacity: 100/3 = 33.33 per LSP - # Total: 6 * 33.33 = 200 units - assert placed == pytest.approx(200.0, abs=1.0), ( - f"Expected to place all 200 units, got {placed}" - ) - - -# ============================================================================ -# TEST: Path Distribution Balance -# ============================================================================ - - -def test_balanced_path_distribution_multipath_false(parallel_3_paths, algs, to_handle): - """Verify LSPs are evenly distributed across paths, not clustered.""" - g = parallel_3_paths - fg = ngc.FlowGraph(g) - - config = ngc.FlowPolicyConfig() - config.path_alg = ngc.PathAlg.SPF - config.flow_placement = ngc.FlowPlacement.EQUAL_BALANCED - config.selection = ngc.EdgeSelection( - multi_edge=False, - require_capacity=True, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - config.multipath = False - config.min_flow_count = 9 # 9 LSPs across 3 paths = 3 per path - config.max_flow_count = 9 - - policy = ngc.FlowPolicy(algs, to_handle(g), config) - - demand = 300.0 - placed, remaining = policy.place_demand(fg, 0, 4, flowClass=0, volume=demand) - - # Check LSP distribution - lsps_per_path = count_lsps_per_path(policy, fg, g, 0, 4) - - # All 3 paths must be used - assert len(lsps_per_path) == 3, "All 3 paths must be used" - - # Each path should have exactly 3 LSPs (9 / 3 = 3) - for path_id in [1, 2, 3]: - lsp_count = len(lsps_per_path.get(path_id, [])) - assert lsp_count == 3, ( - f"Path {path_id} should have 3 LSPs for balanced distribution, " - f"but has {lsp_count}" - ) - - # With 3 LSPs per path and 100 capacity: 100/3 = 33.33 per LSP - # Total: 9 * 33.33 = 300 units - assert placed == pytest.approx(300.0, abs=1.0) - - -# ============================================================================ -# TEST: ECMP Equal-Balancing Constraint -# ============================================================================ - - -def test_ecmp_all_lsps_equal_volume(parallel_3_paths, algs, to_handle): - """Verify ECMP constraint: all LSPs must carry equal volume.""" - g = parallel_3_paths - fg = ngc.FlowGraph(g) - - config = ngc.FlowPolicyConfig() - config.path_alg = ngc.PathAlg.SPF - config.flow_placement = ngc.FlowPlacement.EQUAL_BALANCED - config.selection = ngc.EdgeSelection( - multi_edge=False, - require_capacity=True, - tie_break=ngc.EdgeTieBreak.PREFER_HIGHER_RESIDUAL, - ) - config.multipath = False - config.min_flow_count = 6 - config.max_flow_count = 6 - - policy = ngc.FlowPolicy(algs, to_handle(g), config) - - demand = 240.0 - placed, remaining = policy.place_demand(fg, 0, 4, flowClass=0, volume=demand) - - # Extract flow volumes - flow_volumes = [] - for flow_id in range(policy.flow_count()): - flow_key = (0, 4, 0, flow_id) - if flow_key in policy.flows: - flow_info = policy.flows[flow_key] - volume = flow_info[3] - flow_volumes.append(volume) - - # All flows should have equal volume (ECMP constraint) - if len(flow_volumes) > 0: - expected_volume = flow_volumes[0] - for vol in flow_volumes: - assert vol == pytest.approx(expected_volume, abs=0.01), ( - f"ECMP violation: LSPs have different volumes: {flow_volumes}" - ) - - -# ============================================================================ -# TEST: Multipath=True Should Use All Paths Differently -# ============================================================================ - - -def test_multipath_true_uses_all_paths(parallel_3_paths, algs, to_handle): - """With multipath=true, each flow should split across all paths.""" - g = parallel_3_paths - fg = ngc.FlowGraph(g) - - config = ngc.FlowPolicyConfig() - config.path_alg = ngc.PathAlg.SPF - config.flow_placement = ngc.FlowPlacement.EQUAL_BALANCED - config.selection = ngc.EdgeSelection( - multi_edge=False, - require_capacity=True, - tie_break=ngc.EdgeTieBreak.PREFER_HIGHER_RESIDUAL, - ) - config.multipath = True # Each flow SPLITS across all paths - config.min_flow_count = 6 - config.max_flow_count = 6 - - policy = ngc.FlowPolicy(algs, to_handle(g), config) - - demand = 250.0 - placed, remaining = policy.place_demand(fg, 0, 4, flowClass=0, volume=demand) - - # Check path usage - path_usage = analyze_path_usage(fg, g, 0, 4) - - # All 3 paths must be used - assert len(path_usage) == 3, ( - f"With multipath=true, all paths should be used, " - f"but only {len(path_usage)} were used" - ) - - # With multipath=true, flows are evenly distributed - # 6 flows, each taking 1/3 of each path = 2 "effective" flows per path - # Each flow: 250/6 = 41.67 units, split 1/3 per path = 13.89 per path per flow - # Total per path: 6 * 13.89 = 83.33 units - - for flow in path_usage.values(): - assert flow == pytest.approx(83.33, abs=1.0), ( - "With multipath=true, paths should be equally loaded" - ) - - # Should place all demand - assert placed == pytest.approx(250.0, abs=1.0) - - -# ============================================================================ -# TEST: Progressive Flow Addition -# ============================================================================ - - -def test_incremental_lsp_placement(parallel_3_paths, algs, to_handle): - """Test placing LSPs incrementally to verify path selection logic.""" - g = parallel_3_paths - fg = ngc.FlowGraph(g) - - config = ngc.FlowPolicyConfig() - config.path_alg = ngc.PathAlg.SPF - config.flow_placement = ngc.FlowPlacement.EQUAL_BALANCED - config.selection = ngc.EdgeSelection( - multi_edge=False, - require_capacity=True, - tie_break=ngc.EdgeTieBreak.PREFER_HIGHER_RESIDUAL, - ) - config.multipath = False - config.min_flow_count = 1 - config.max_flow_count = 1 - config.reoptimize_flows_on_each_placement = False - - # Place LSPs one at a time using DIFFERENT flowClasses (each represents a separate LSP) - lsp_volume = 30.0 - paths_used_over_time = [] - - for i in range(6): - policy = ngc.FlowPolicy(algs, to_handle(g), config) # New policy for each "LSP" - placed, remaining = policy.place_demand( - fg, - 0, - 4, - flowClass=i, - volume=lsp_volume, # Different flowClass = different LSP - ) - - path_usage = analyze_path_usage(fg, g, 0, 4) - paths_used_over_time.append(set(path_usage.keys())) - - # After 3 LSPs, all 3 paths should be in use - if i >= 2: - assert len(path_usage) == 3, ( - f"After placing {i + 1} LSPs, all 3 paths should be used, " - f"but only {len(path_usage)} are used: {list(path_usage.keys())}" - ) - - -# ============================================================================ -# TEST: Capacity Constraint Enforcement -# ============================================================================ - - -def test_capacity_limits_with_multipath_false(parallel_3_paths, algs, to_handle): - """Verify capacity constraints are properly enforced per path.""" - g = parallel_3_paths - fg = ngc.FlowGraph(g) - - config = ngc.FlowPolicyConfig() - config.path_alg = ngc.PathAlg.SPF - config.flow_placement = ngc.FlowPlacement.EQUAL_BALANCED - config.selection = ngc.EdgeSelection( - multi_edge=False, - require_capacity=True, - tie_break=ngc.EdgeTieBreak.PREFER_HIGHER_RESIDUAL, - ) - config.multipath = False - config.min_flow_count = 6 - config.max_flow_count = 6 - - policy = ngc.FlowPolicy(algs, to_handle(g), config) - - # Demand exceeds total capacity (3 * 100 = 300) - demand = 400.0 - placed, remaining = policy.place_demand(fg, 0, 4, flowClass=0, volume=demand) - - # Should place at most 300 (total capacity) - assert placed <= 300.0, f"Placed {placed} exceeds total capacity of 300" - assert remaining >= 100.0, f"Should have at least 100 remaining, got {remaining}" - - # Check no path is over-utilized - edge_flows = fg.edge_flow_view() - edge_cap = g.capacity_view() - - for edge_id in range(g.num_edges()): - flow = edge_flows[edge_id] - cap = edge_cap[edge_id] - assert flow <= cap + 0.1, ( # Small tolerance for floating point - f"Edge {edge_id} flow ({flow}) exceeds capacity ({cap})" - ) diff --git a/tests/py/test_flow_policy_spf_memo.py b/tests/py/test_flow_policy_spf_memo.py deleted file mode 100644 index 172e95c..0000000 --- a/tests/py/test_flow_policy_spf_memo.py +++ /dev/null @@ -1,158 +0,0 @@ -"""FlowPolicy's SPF memo must be invisible: identical results, fewer SPF runs. - -EqualBalanced placement and rebalance rounds re-request path bundles against -residual state that repeats (94% of SPF calls in a measured place/rebalance -cycle were exact input repeats), so get_path_bundle memoizes raw SPF results, -keyed by (src, dst, residual content, residual-awareness). These tests pin the -one way that can go wrong: a stale hit -- serving a cached DAG computed for -residuals that have since changed. Each scenario mutates residual state between -placements and asserts routing reflects the *current* residuals. -""" - -from __future__ import annotations - -import numpy as np -import pytest - -import netgraph_core as ngc - - -def _graph(num_nodes, edges): - """edges: (src, dst, cost, cap).""" - s = np.array([e[0] for e in edges], np.int32) - d = np.array([e[1] for e in edges], np.int32) - k = np.array([int(e[2]) for e in edges], np.int64) - c = np.array([float(e[3]) for e in edges], np.float64) - return ngc.StrictMultiDiGraph.from_arrays( - num_nodes, s, d, c, k, np.arange(len(edges), dtype=np.int64) - ) - - -def _eb_policy(algs, gh, **kw): - sel = ngc.EdgeSelection( - multi_edge=True, - require_capacity=True, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - cfg = ngc.FlowPolicyConfig( - path_alg=ngc.PathAlg.SPF, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - selection=sel, - **kw, - ) - return ngc.FlowPolicy(algs, gh, cfg) - - -def _two_path_graph(): - """0->1->3 (cost 2) and 0->2->3 (cost 4); cheap path capacity 2.""" - return _graph( - 4, - [ - (0, 1, 1, 2.0), - (1, 3, 1, 2.0), - (0, 2, 2, 10.0), - (2, 3, 2, 10.0), - ], - ) - - -def _edges_used(fg, g, idx): - flows = fg.get_flow_edges(ngc.FlowIndex(*idx)) - src = np.asarray(g.edge_src_view()) - dst = np.asarray(g.edge_dst_view()) - return {(int(src[e]), int(dst[e])) for e, amt in flows if amt > 1e-12} - - -class TestMemoInvalidation: - def test_replacement_sees_residuals_changed_by_another_policy(self, algs): - """A cached bundle must not survive residual changes made between calls. - - Policy A places on the cheap path and is removed (restoring residuals, - which reproduces the memo's key). Policy B then saturates the cheap - path through the same FlowGraph. When A places again, its SPF runs - against the *new* residuals and must route via the expensive path; a - stale memo hit would re-serve the cheap-path DAG. - """ - g = _two_path_graph() - gh = algs.build_graph(g) - fg = ngc.FlowGraph(g) - - pa = _eb_policy(algs, gh, max_flow_count=1, min_flow_count=1) - placed, _ = pa.place_demand(fg, 0, 3, 0, 1.0) - assert placed == pytest.approx(1.0) - (idx,) = pa.flows.keys() - assert _edges_used(fg, g, idx) == {(0, 1), (1, 3)} - pa.remove_demand(fg) # residual content is now back to the initial bytes - - pb = _eb_policy(algs, gh, max_flow_count=1, min_flow_count=1) - placed_b, _ = pb.place_demand(fg, 0, 3, 1, 2.0) - assert placed_b == pytest.approx(2.0) # saturates 0->1->3 - - placed2, _ = pa.place_demand(fg, 0, 3, 0, 1.0) - assert placed2 == pytest.approx(1.0) - (idx2,) = pa.flows.keys() - assert _edges_used(fg, g, idx2) == {(0, 2), (2, 3)}, ( - "placement after residual change reused a stale cached path" - ) - - def test_alternating_flow_graphs_stay_isolated(self, algs): - """One policy asked about two FlowGraphs must answer for the right one. - - fg_busy has the cheap path saturated; fg_idle does not. Alternating - get-path queries (via place/remove cycles) between the two must route - differently every time, regardless of which answer the memo last held. - """ - g = _two_path_graph() - gh = algs.build_graph(g) - fg_idle = ngc.FlowGraph(g) - fg_busy = ngc.FlowGraph(g) - - blocker = _eb_policy(algs, gh, max_flow_count=1, min_flow_count=1) - placed, _ = blocker.place_demand(fg_busy, 0, 3, 9, 2.0) - assert placed == pytest.approx(2.0) - - prober = _eb_policy(algs, gh, max_flow_count=1, min_flow_count=1) - for _ in range(3): - placed, _ = prober.place_demand(fg_idle, 0, 3, 0, 1.0) - assert placed == pytest.approx(1.0) - (idx,) = prober.flows.keys() - assert _edges_used(fg_idle, g, idx) == {(0, 1), (1, 3)} - prober.remove_demand(fg_idle) - - placed, _ = prober.place_demand(fg_busy, 0, 3, 0, 1.0) - assert placed == pytest.approx(1.0) - (idx,) = prober.flows.keys() - assert _edges_used(fg_busy, g, idx) == {(0, 2), (2, 3)} - prober.remove_demand(fg_busy) - - def test_eb_rebalance_churn_matches_fresh_policy(self, algs): - """Heavy place/rebalance churn (the memoized path) must land exactly - where a fresh policy with no memo history lands.""" - rng = np.random.default_rng(3) - n = 24 - edges = [] - for _ in range(70): - u, v = rng.integers(0, n, size=2) - if u != v: - edges.append( - ( - int(u), - int(v), - int(rng.integers(1, 21)), - float(rng.integers(1, 6)), - ) - ) - g = _graph(n, edges) - gh = algs.build_graph(g) - - def run(cycles): - p = _eb_policy(algs, gh, max_flow_count=4, min_flow_count=4) - fg = ngc.FlowGraph(g) - for _ in range(cycles): - p.place_demand(fg, 0, n - 1, 0, 8.0) - p.rebalance_demand(fg, 0, n - 1, 0, 2.0) - return np.asarray(fg.edge_flow_view()).copy() - - churned = run(cycles=4) # memo-heavy history - fresh = run(cycles=4) # brand-new policy, identical inputs - np.testing.assert_array_equal(churned, fresh) diff --git a/tests/py/test_flow_policy_validation.py b/tests/py/test_flow_policy_validation.py deleted file mode 100644 index 9f2c62b..0000000 --- a/tests/py/test_flow_policy_validation.py +++ /dev/null @@ -1,385 +0,0 @@ -"""Validation tests for FlowPolicy path distribution and flow placement. - -Tests cover: -- Path usage and distribution -- Optimality vs theoretical maximum -- ECMP constraints and balancing -""" - -import numpy as np -import pytest -from conftest import ( - analyze_path_usage, - calculate_theoretical_capacity_parallel_paths, - check_distribution_balanced, - count_lsps_per_path, -) - -import netgraph_core as ngc - - -def _make_graph(num_nodes, src, dst, cap, cost): - """Helper to build graph with auto-generated ext_edge_ids.""" - ext_edge_ids = np.arange(len(src), dtype=np.int64) - return ngc.StrictMultiDiGraph.from_arrays( - num_nodes, src, dst, cap, cost, ext_edge_ids - ) - - -# ============================================================================ -# FIXTURES: Test Topologies -# ============================================================================ - - -@pytest.fixture -def parallel_paths_varying_capacities(): - """3 parallel paths with different capacities for distribution testing.""" - # S -> [M1, M2, M3] -> T - # Paths: S-M1-T (cap 100), S-M2-T (cap 50), S-M3-T (cap 75) - num_nodes = 5 - src = np.array([0, 0, 0, 1, 2, 3], dtype=np.int32) - dst = np.array([1, 2, 3, 4, 4, 4], dtype=np.int32) - cap = np.array([100.0, 50.0, 75.0, 100.0, 50.0, 75.0], dtype=np.float64) - cost = np.array([10, 10, 10, 10, 10, 10], dtype=np.int64) # Equal costs - - return _make_graph(num_nodes, src, dst, cap, cost) - - -# ============================================================================ -# TEST 1: Path Distribution and Balance -# ============================================================================ - - -def test_path_distribution_with_equal_cost_paths(algs, to_handle): - """Verify LSPs are evenly distributed across equal-cost paths.""" - # Create topology with 4 equal-cost parallel paths - num_nodes = 6 - src = np.array([0, 0, 0, 0, 1, 2, 3, 4], dtype=np.int32) - dst = np.array([1, 2, 3, 4, 5, 5, 5, 5], dtype=np.int32) - cap = np.array([50.0] * 8, dtype=np.float64) - cost = np.array([10] * 8, dtype=np.int64) # All equal cost - g = _make_graph(num_nodes, src, dst, cap, cost) - - fg = ngc.FlowGraph(g) - config = ngc.FlowPolicyConfig() - config.multipath = False # Single-path LSPs - config.min_flow_count = 8 # 8 LSPs for 4 paths = 2 per path - config.max_flow_count = 8 - config.flow_placement = ngc.FlowPlacement.EQUAL_BALANCED - - policy = ngc.FlowPolicy(algs, to_handle(g), config) - placed, remaining = policy.place_demand(fg, 0, 5, flowClass=0, volume=100.0) - - # Verify all paths are used - path_usage = analyze_path_usage(fg, g, 0, 5) - assert len(path_usage) == 4, ( - f"Expected all 4 paths to be used, got {len(path_usage)}: {list(path_usage.keys())}" - ) - - # Verify LSPs are evenly distributed - lsps_per_path = count_lsps_per_path(policy, fg, g, 0, 5) - is_balanced, stats = check_distribution_balanced(lsps_per_path, tolerance=0) - assert is_balanced, f"LSPs not evenly distributed: {stats}" - - # Each path should have exactly 2 LSPs - for path_id in [1, 2, 3, 4]: - assert path_id in lsps_per_path, f"Path {path_id} has no LSPs" - assert len(lsps_per_path[path_id]) == 2, ( - f"Path {path_id} should have 2 LSPs, has {len(lsps_per_path[path_id])}" - ) - - -def test_path_usage_optimality(algs, to_handle): - """Verify that available paths are used optimally to maximize capacity.""" - # 5 parallel paths, 10 LSPs -> should use all 5 paths (2 LSPs each) - num_nodes = 7 - src = np.array([0, 0, 0, 0, 0, 1, 2, 3, 4, 5], dtype=np.int32) - dst = np.array([1, 2, 3, 4, 5, 6, 6, 6, 6, 6], dtype=np.int32) - cap = np.array([100.0] * 10, dtype=np.float64) - cost = np.array([10] * 10, dtype=np.int64) - g = _make_graph(num_nodes, src, dst, cap, cost) - - fg = ngc.FlowGraph(g) - config = ngc.FlowPolicyConfig() - config.multipath = False - config.min_flow_count = 10 - config.max_flow_count = 10 - config.flow_placement = ngc.FlowPlacement.EQUAL_BALANCED - - policy = ngc.FlowPolicy(algs, to_handle(g), config) - demand = 500.0 - placed, remaining = policy.place_demand(fg, 0, 6, flowClass=0, volume=demand) - - # Calculate theoretical maximum - num_paths = 5 - num_lsps = 10 - theoretical_max = calculate_theoretical_capacity_parallel_paths( - 100.0, num_paths, num_lsps - ) - - # Should achieve theoretical maximum (or very close) - assert placed >= theoretical_max * 0.99, ( - f"Placed {placed} is less than 99% of theoretical max {theoretical_max}" - ) - - -# ============================================================================ -# TEST 2: ECMP Constraints -# ============================================================================ - - -def test_ecmp_equal_balancing_strict(algs, to_handle): - """Verify ECMP constraint: all LSPs must carry equal volume.""" - num_nodes = 5 - src = np.array([0, 0, 0, 1, 2, 3], dtype=np.int32) - dst = np.array([1, 2, 3, 4, 4, 4], dtype=np.int32) - cap = np.array([100.0, 100.0, 100.0, 100.0, 100.0, 100.0], dtype=np.float64) - cost = np.array([10] * 6, dtype=np.int64) - g = _make_graph(num_nodes, src, dst, cap, cost) - - fg = ngc.FlowGraph(g) - config = ngc.FlowPolicyConfig() - config.multipath = False - config.min_flow_count = 12 # 12 LSPs - config.max_flow_count = 12 - config.flow_placement = ngc.FlowPlacement.EQUAL_BALANCED - - policy = ngc.FlowPolicy(algs, to_handle(g), config) - placed, remaining = policy.place_demand(fg, 0, 4, flowClass=0, volume=100.0) - - # Verify all LSPs have equal volume - flows = policy.flows - volumes = [v[3] for v in flows.values()] - - if len(volumes) > 1: - ref_volume = volumes[0] - for vol in volumes: - assert vol == pytest.approx(ref_volume, abs=1e-3), ( - f"ECMP violation: volumes not equal. Got {volumes}" - ) - - -def test_ecmp_with_path_capacity_constraints( - algs, to_handle, parallel_paths_varying_capacities -): - """Verify ECMP with varying path capacities limits correctly.""" - g = parallel_paths_varying_capacities - fg = ngc.FlowGraph(g) - - config = ngc.FlowPolicyConfig() - config.multipath = False - config.min_flow_count = 6 # 6 LSPs for 3 paths - config.max_flow_count = 6 - config.flow_placement = ngc.FlowPlacement.EQUAL_BALANCED - - policy = ngc.FlowPolicy(algs, to_handle(g), config) - demand = 300.0 - placed, remaining = policy.place_demand(fg, 0, 4, flowClass=0, volume=demand) - - # Bottleneck: M2 has only 50 capacity - # With 6 LSPs, some must share M2, limiting per-LSP volume to 50/N_M2 - # Theoretical max depends on distribution - - # Verify accounting - assert placed + remaining == pytest.approx(demand, abs=1e-6) - - # Verify ECMP: all LSPs have equal volume - flows = policy.flows - volumes = [v[3] for v in flows.values()] - if len(volumes) > 1: - ref_volume = volumes[0] - for vol in volumes: - assert vol == pytest.approx(ref_volume, abs=1e-3) - - -# ============================================================================ -# TEST 3: Multi-path vs Single-path Behavior -# ============================================================================ - - -def test_multipath_true_vs_false_capacity_difference(algs, to_handle): - """Verify multipath=true achieves higher capacity than multipath=false.""" - # Topology with 3 equal-cost parallel paths - num_nodes = 5 - src = np.array([0, 0, 0, 1, 2, 3], dtype=np.int32) - dst = np.array([1, 2, 3, 4, 4, 4], dtype=np.int32) - cap = np.array([100.0] * 6, dtype=np.float64) - cost = np.array([10] * 6, dtype=np.int64) - g = _make_graph(num_nodes, src, dst, cap, cost) - - # Test 1: multipath=true (hash-based ECMP) - fg1 = ngc.FlowGraph(g) - config1 = ngc.FlowPolicyConfig() - config1.multipath = True # Each flow splits across all paths - config1.min_flow_count = 3 - config1.max_flow_count = 3 - policy1 = ngc.FlowPolicy(algs, to_handle(g), config1) - placed1, _ = policy1.place_demand(fg1, 0, 4, flowClass=0, volume=500.0) - - # Test 2: multipath=false (tunnel-based ECMP) - fg2 = ngc.FlowGraph(g) - config2 = ngc.FlowPolicyConfig() - config2.multipath = False # Each flow uses single path - config2.min_flow_count = 3 - config2.max_flow_count = 3 - policy2 = ngc.FlowPolicy(algs, to_handle(g), config2) - placed2, _ = policy2.place_demand(fg2, 0, 4, flowClass=0, volume=500.0) - - # multipath=true should achieve significantly more capacity - # With 3 flows and 3 paths: - # - multipath=true: each flow uses all 3 paths (hash-based ECMP) - # - multipath=false: each flow uses 1 path (tunnel-based ECMP) - # Both should achieve similar total throughput with balanced distribution. - - # Verify multipath=true placement is at least as good as multipath=false - assert placed1 >= placed2 * 0.99, ( - f"multipath=true ({placed1}) should not be worse than multipath=false ({placed2})" - ) - - -# ============================================================================ -# TEST 4: Flow Count Verification -# ============================================================================ - - -def test_flow_count_matches_configuration(algs, to_handle): - """Verify exact flow count matches configuration when paths available.""" - # Create topology with 10 parallel paths (plenty) - src_nodes = list(range(10)) - dst_nodes = list(range(1, 11)) - src = np.array(src_nodes + dst_nodes, dtype=np.int32) - dst = np.array(dst_nodes + [11] * 10, dtype=np.int32) - cap = np.array([100.0] * 20, dtype=np.float64) - cost = np.array([10] * 20, dtype=np.int64) - g = _make_graph(12, src, dst, cap, cost) - - # Test various flow counts - for target_flows in [1, 4, 8, 16]: - fg = ngc.FlowGraph(g) - config = ngc.FlowPolicyConfig() - config.multipath = False - config.min_flow_count = target_flows - config.max_flow_count = target_flows - - policy = ngc.FlowPolicy(algs, to_handle(g), config) - placed, _ = policy.place_demand(fg, 0, 11, flowClass=0, volume=50.0) - - if placed > 0: - actual_flows = policy.flow_count() - assert actual_flows == target_flows, ( - f"Expected {target_flows} flows, got {actual_flows}" - ) - - -# ============================================================================ -# TEST 5: Distribution Variance -# ============================================================================ - - -def test_distribution_variance_minimal(algs, to_handle): - """Verify LSP distribution has minimal variance across paths.""" - # 4 paths, 9 LSPs -> should be [3, 2, 2, 2] or [3, 3, 2, 1] etc - # Variance should be at most 1 - num_nodes = 6 - src = np.array([0, 0, 0, 0, 1, 2, 3, 4], dtype=np.int32) - dst = np.array([1, 2, 3, 4, 5, 5, 5, 5], dtype=np.int32) - cap = np.array([50.0] * 8, dtype=np.float64) - cost = np.array([10] * 8, dtype=np.int64) - g = _make_graph(num_nodes, src, dst, cap, cost) - - fg = ngc.FlowGraph(g) - config = ngc.FlowPolicyConfig() - config.multipath = False - config.min_flow_count = 9 - config.max_flow_count = 9 - config.flow_placement = ngc.FlowPlacement.EQUAL_BALANCED - - policy = ngc.FlowPolicy(algs, to_handle(g), config) - placed, _ = policy.place_demand(fg, 0, 5, flowClass=0, volume=100.0) - - lsps_per_path = count_lsps_per_path(policy, fg, g, 0, 5) - is_balanced, stats = check_distribution_balanced(lsps_per_path, tolerance=1) - - assert is_balanced, ( - f"Distribution variance too high. Stats: {stats}. " - f"With 9 LSPs and 4 paths, variance should be ≤1" - ) - - -# ============================================================================ -# TEST 6: Optimality with Complex Topology -# ============================================================================ - - -def test_capacity_near_theoretical_max(algs, to_handle): - """Verify placed capacity is near theoretical maximum.""" - # Simple scenario: 3 paths, 6 LSPs, equal capacities - num_nodes = 5 - src = np.array([0, 0, 0, 1, 2, 3], dtype=np.int32) - dst = np.array([1, 2, 3, 4, 4, 4], dtype=np.int32) - cap = np.array([60.0] * 6, dtype=np.float64) - cost = np.array([10] * 6, dtype=np.int64) - g = _make_graph(num_nodes, src, dst, cap, cost) - - fg = ngc.FlowGraph(g) - config = ngc.FlowPolicyConfig() - config.multipath = False - config.min_flow_count = 6 - config.max_flow_count = 6 - config.flow_placement = ngc.FlowPlacement.EQUAL_BALANCED - - policy = ngc.FlowPolicy(algs, to_handle(g), config) - demand = 200.0 - placed, remaining = policy.place_demand(fg, 0, 4, flowClass=0, volume=demand) - - # Theoretical: 3 paths, 6 LSPs -> 2 LSPs per path - # Each path has 60 capacity -> 60/2 = 30 per LSP - # Total: 6 * 30 = 180 - theoretical = calculate_theoretical_capacity_parallel_paths(60.0, 3, 6) - assert theoretical == 180.0 - - # Placed should be at or near theoretical - assert placed >= theoretical * 0.95, ( - f"Placed {placed} is less than 95% of theoretical {theoretical}" - ) - - -# ============================================================================ -# TEST 7: Path Usage Coverage -# ============================================================================ - - -def test_all_equal_cost_paths_utilized(algs, to_handle): - """Verify ALL equal-cost paths are utilized when LSPs exceed path count.""" - # Critical test: 2 paths, 4 LSPs -> both paths must be used - num_nodes = 4 - src = np.array([0, 0, 1, 2], dtype=np.int32) - dst = np.array([1, 2, 3, 3], dtype=np.int32) - cap = np.array([50.0] * 4, dtype=np.float64) - cost = np.array([10] * 4, dtype=np.int64) - g = _make_graph(num_nodes, src, dst, cap, cost) - - fg = ngc.FlowGraph(g) - config = ngc.FlowPolicyConfig() - config.flow_placement = ngc.FlowPlacement.EQUAL_BALANCED - config.multipath = False - config.min_flow_count = 4 - config.max_flow_count = 4 - - policy = ngc.FlowPolicy(algs, to_handle(g), config) - placed, _ = policy.place_demand(fg, 0, 3, flowClass=0, volume=100.0) - - # Both paths MUST be used - path_usage = analyze_path_usage(fg, g, 0, 3) - assert len(path_usage) == 2, ( - f"Both paths must be used with 4 LSPs and 2 paths. " - f"Used: {list(path_usage.keys())}" - ) - - # Each path should have 2 LSPs - lsps_per_path = count_lsps_per_path(policy, fg, g, 0, 3) - for path_id in [1, 2]: - assert path_id in lsps_per_path, f"Path {path_id} not used" - assert len(lsps_per_path[path_id]) == 2, ( - f"Path {path_id} should have 2 LSPs, has {len(lsps_per_path[path_id])}" - ) diff --git a/tests/py/test_graph.py b/tests/py/test_graph.py new file mode 100644 index 0000000..79a5c08 --- /dev/null +++ b/tests/py/test_graph.py @@ -0,0 +1,358 @@ +"""StrictMultiDiGraph on integer units (APPROACH 3): construction, validation order, B' ids, +read-only views, Eligibility.""" + +from __future__ import annotations + +import gc + +import numpy as np +import pytest + +import netgraph_core as ngc + +L62 = 1 << 62 + +# (u, v, capacity_units, cost) in input order. +EDGES = [ + (2, 0, 5, 3), + (0, 2, 4, 2), + (0, 1, 3, 2), + (1, 2, 2, 1), + (0, 1, 1, 2), + (0, 2, 6, 1), + (2, 1, 7, 1), +] + + +def build(n, edges, q=1.0): + return ngc.StrictMultiDiGraph( + n, + [e[0] for e in edges], + [e[1] for e in edges], + [e[2] for e in edges], + [e[3] for e in edges], + q, + ) + + +def test_bprime_ids_and_input_index(): + g = build(3, EDGES) + order = sorted( + range(len(EDGES)), key=lambda i: (EDGES[i][0], EDGES[i][3], EDGES[i][1], i) + ) + assert g.input_index().tolist() == order == [5, 2, 4, 1, 3, 6, 0] + assert g.row_offsets().tolist() == [0, 4, 5, 7] + assert g.edge_src().tolist() == [EDGES[i][0] for i in order] + assert g.edge_dst().tolist() == [EDGES[i][1] for i in order] + assert g.capacity_units().tolist() == [EDGES[i][2] for i in order] + assert g.cost().tolist() == [EDGES[i][3] for i in order] + inv = np.empty(len(EDGES), dtype=np.int64) + inv[g.input_index()] = np.arange(len(EDGES)) + assert g.input_index()[inv].tolist() == list(range(len(EDGES))) + # Reverse CSR in (cost, src, input) order, as input positions. + rev = [] + off, adj = g.in_row_offsets(), g.in_adj_edge_index() + for v in range(3): + rev.append([int(g.input_index()[e]) for e in adj[off[v] : off[v + 1]]]) + assert rev == [[0], [6, 2, 4], [5, 3, 1]] + assert g.max_cost() == 3 + assert g.cost_gcd() == 1 + assert g.num_nodes() == 3 and g.num_edges() == 7 + assert g.resolution() == 1.0 + + +def test_views_are_read_only_and_keep_the_graph_alive(): + g = build(3, EDGES) + views = [ + g.capacity_units(), + g.cost(), + g.edge_src(), + g.edge_dst(), + g.input_index(), + g.row_offsets(), + g.in_row_offsets(), + g.in_adj_edge_index(), + ] + dtypes = [ + np.int64, + np.int64, + np.int32, + np.int32, + np.int32, + np.int32, + np.int32, + np.int32, + ] + for view, dtype in zip(views, dtypes, strict=True): + assert view.dtype == dtype + assert not view.flags.writeable + with pytest.raises(ValueError): + view[0] = 0 + expect = [v.tolist() for v in views] + del g + gc.collect() + assert [v.tolist() for v in views] == expect + + +def test_numpy_integer_inputs_and_exact_large_units(): + big = (1 << 53) + 1 + g = ngc.StrictMultiDiGraph( + np.int64(2), + np.array([0, 0], dtype=np.int32), + np.array([1, 1], dtype=np.uint8), + np.array([big, 7], dtype=np.int64), + np.array([2, 1], dtype=np.uint64), + 2.0**-12, + ) + assert g.capacity_units().tolist() == [7, big] + assert g.resolution() == 2.0**-12 + + +def test_empty_and_isolated(): + g = ngc.StrictMultiDiGraph(0, [], [], [], [], 1) + assert g.row_offsets().tolist() == [0] and g.in_row_offsets().tolist() == [0] + assert g.max_cost() == 0 and g.cost_gcd() == 0 + h = ngc.StrictMultiDiGraph(3, [], [], [], [], 1) + assert h.row_offsets().tolist() == [0, 0, 0, 0] + + +def test_uids_are_distinct(): + a, b = build(3, EDGES), build(3, EDGES) + assert a.uid() != b.uid() and a.uid() > 0 + + +@pytest.mark.parametrize( + "args, pattern", + [ + # The resolution. + ((2, [0], [1], [1], [1], 3.0), "resolution"), + ((2, [0], [1], [1], [1], 2.0**-33), "resolution"), + ((2, [0], [1], [1], [1], 0.0), "resolution"), + ((2, [0], [1], [1], [1], float("nan")), "resolution"), + # The unit values. + ((2, [0], [1], [-1], [1], 1), "capacity_units"), + ((2, [0], [1], [L62], [1], 1), "capacity_units"), + ((2, [0], [1], [1.0], [1], 1), "capacity_units"), + ((2, [0], [1], np.array([1.5]), [1], 1), "capacity_units"), + ((2, [0], [1], [True], [1], 1), "capacity_units"), + # The shapes. + ((-1, [], [], [], [], 1), "num_nodes"), + ((2.0, [0], [1], [1], [1], 1), "num_nodes"), + ((2, [0, 1], [1], [1], [1], 1), "same length"), + ((2, [0], [1], [1, 1], [1], 1), "same length"), + # Zero and negative costs (rule 28). + ((2, [0], [1], [1], [0], 1), "cost"), + ((2, [0], [1], [1], [-3], 1), "cost"), + ((2, [0], [1], [1], [1.5], 1), "cost"), + # The remaining bounds. + ((2, [0], [2], [1], [1], 1), "out of node range"), + ((2, [0, 0], [1, 1], [L62 // 2, L62 // 2], [1, 1], 1), "sum of all capacities"), + ((2, [0, 0], [1, 1], [1, 1], [L62 // 2, L62 // 2], 1), "sum of all edge costs"), + ], +) +def test_validation_errors(args, pattern): + with pytest.raises(ValueError, match=pattern): + ngc.StrictMultiDiGraph(*args) + + +def test_validation_order(): + # The unit values before the shapes, the shapes before the costs, the costs before the + # remaining bounds (endpoints and sums). + with pytest.raises(ValueError, match="capacity_units"): + ngc.StrictMultiDiGraph(2, [0, 1], [1], [-1], [1], 1) + with pytest.raises(ValueError, match="same length"): + ngc.StrictMultiDiGraph(2, [0, 1], [1], [1], [0], 1) + with pytest.raises(ValueError, match="cost"): + ngc.StrictMultiDiGraph(2, [0], [9], [1], [0], 1) + with pytest.raises(ValueError, match="cost"): + ngc.StrictMultiDiGraph(2, [0, 0], [1, 1], [L62 // 2, L62 // 2], [0, 1], 1) + with pytest.raises(ValueError, match="node range"): + ngc.StrictMultiDiGraph(2, [0, 5], [1, 1], [L62 // 2, L62 // 2], [1, 1], 1) + + +def test_bounds_just_below_are_accepted(): + g = ngc.StrictMultiDiGraph( + 2, [0, 0], [1, 1], [L62 // 2, L62 // 2 - 1], [L62 // 2, L62 // 2 - 1], 1 + ) + assert sum(int(x) for x in g.capacity_units()) == L62 - 1 + assert sum(int(x) for x in g.cost()) == L62 - 1 + + +def test_eligibility(): + g = build(4, [(0, 1, 1, 1), (1, 2, 1, 1), (2, 3, 1, 1), (1, 1, 1, 2), (3, 0, 1, 1)]) + every = g.eligibility() + assert every.uid() == 0 and every.graph_uid() == g.uid() + assert every.node_allowed().tolist() == [True] * 4 + el = g.eligibility(excluded_nodes=[1, 1]) + assert el.uid() not in (0, every.uid()) + assert el.node_allowed().tolist() == [True, False, True, True] + allowed_in = [False] * 5 + for e, ok in enumerate(el.edge_allowed().tolist()): + allowed_in[int(g.input_index()[e])] = ok + assert allowed_in == [False, False, True, False, True] + assert not el.edge_allowed().flags.writeable + xe = g.eligibility(excluded_edges=np.array([4, 0, 4])) + assert xe.edge_allowed().tolist() == [False, True, True, True, False] + assert g.eligibility([], [0]).uid() != g.eligibility([], [0]).uid() + with pytest.raises(ValueError, match="range"): + g.eligibility(excluded_nodes=[4]) + with pytest.raises(ValueError, match="range"): + g.eligibility(excluded_edges=[-1]) + with pytest.raises(ValueError): + g.eligibility(excluded_edges=[0.0]) + + +# review-1a D1: the binding validates in the contract's order on unnarrowed integers, so the +# reported defect does not depend on how the same defective input is represented. +@pytest.mark.parametrize( + "args, pattern", + [ + # A zero or negative cost comes before any remaining bound, even one that does not fit + # the C++ type (endpoints beyond int32, a cost beyond int64). + ((2, [2**31], [1], [1], [0], 1.0), r"cost\[0\] = 0: costs must be positive"), + ((2, [-1], [1], [1], [0], 1.0), r"cost\[0\] = 0: costs must be positive"), + ((2, [0], [2**31], [1], [-1], 1.0), r"cost\[0\] = -1: costs must be positive"), + ( + (2, [0], [-(2**70)], [1], [-(2**70)], 1.0), + r"cost\[0\] = -1180591620717411303424", + ), + ( + (2, [0, 0], [1, 1], [1, 1], [2**63, 0], 1.0), + r"cost\[1\] = 0: costs must be positive", + ), + # The unit values come before the costs, the resolution before everything. + ( + (2, [0], [1], [2**62], [0], 1.0), + r"capacity_units\[0\] = 4611686018427387904", + ), + ( + (2, [0], [1], [2**64], [0], 1.0), + r"capacity_units\[0\] = 18446744073709551616", + ), + ( + (2, [0], [1], [-(2**70)], [0], 1.0), + r"capacity_units\[0\] = -1180591620717411303424", + ), + ((2, [0], [1], [-1], [0], 3.0), "resolution"), + # The remaining bounds name the exact value: ids, then the sums. + ( + (2, [2**40], [1], [1], [1], 1.0), + r"edge 0: endpoint \(1099511627776, 1\) out of node range", + ), + ( + (2, [0], [-(2**70)], [1], [1], 1.0), + r"endpoint \(0, -1180591620717411303424\)", + ), + ((2, [0], [1], [1], [2**63], 1.0), "sum of all edge costs"), + ((2, [0, 5], [1, 1], [1, 1], [2**64, 1], 1.0), "out of node range"), + # The shapes. + ((2**40, [], [], [], [], 1.0), "num_nodes"), + ], +) +def test_validation_order_before_narrowing(args, pattern): + with pytest.raises(ValueError, match=pattern): + ngc.StrictMultiDiGraph(*args) + + +def test_validation_independent_of_representation(): + # The same defective graph as Python lists and as NumPy arrays (uint64 beyond int64). + as_lists = (2, [2**63], [1], [1], [0], 1.0) + as_arrays = ( + 2, + np.array([2**63], dtype=np.uint64), + np.array([1], dtype=np.int32), + np.array([1], dtype=np.int64), + np.array([0], dtype=np.int64), + 1.0, + ) + messages = [] + for args in (as_lists, as_arrays): + with pytest.raises(ValueError) as info: + ngc.StrictMultiDiGraph(*args) + messages.append(str(info.value)) + assert ( + messages[0] == messages[1] == "cost[0] = 0: costs must be positive (cost >= 1)" + ) + with pytest.raises(ValueError, match=r"endpoint \(9223372036854775808, 1\)"): + ngc.StrictMultiDiGraph( + 2, np.array([2**63], dtype=np.uint64), [1], [1], [1], 1.0 + ) + + +def test_eligibility_ids_checked_before_narrowing(): + g = build(3, EDGES) + with pytest.raises( + ValueError, match=r"excluded node 1099511627776 out of node range \[0, 3\)" + ): + g.eligibility(excluded_nodes=[2**40]) + with pytest.raises( + ValueError, match=r"excluded edge -1180591620717411303424 out of edge range" + ): + g.eligibility(excluded_edges=[-(2**70)]) + with pytest.raises(ValueError, match="excluded node"): # nodes before edges + g.eligibility(excluded_nodes=[5], excluded_edges=[2**70]) + + +ARG_INDEX = {"src": 1, "dst": 2, "capacity_units": 3, "cost": 4} +VALID = {"src": 0, "dst": 1, "capacity_units": 1, "cost": 0} # the cost stays zero + + +@pytest.mark.parametrize("name", list(ARG_INDEX)) +@pytest.mark.parametrize("ndim", [0, 2]) +@pytest.mark.parametrize("cost", [0, -1]) +def test_array_rank_is_a_shape_defect_before_costs(name, ndim, cost): + # review-1a-fixes D1: the shape step checks rank 1 for every array argument before the + # cost step, so a 2-D or 0-D array is reported before a zero or negative cost. + args = [2, [0], [1], [1], [cost], 1.0] + value = cost if name == "cost" else VALID[name] + bad = np.array(value, dtype=np.int64) + args[ARG_INDEX[name]] = bad.reshape(1, 1) if ndim == 2 else bad + pattern = rf"^{name} must be a 1-D array \(got {ndim} dimensions\)$" + with pytest.raises(ValueError, match=pattern): + ngc.StrictMultiDiGraph(*args) + + +@pytest.mark.parametrize("name", list(ARG_INDEX)) +def test_nested_and_scalar_arguments_are_shape_defects(name): + value = VALID[name] + nested = [2, [0], [1], [1], [0], 1.0] + nested[ARG_INDEX[name]] = [[value]] + with pytest.raises( + ValueError, match=rf"^{name} must be a 1-D sequence of integers \(entry 0" + ): + ngc.StrictMultiDiGraph(*nested) + scalar = [2, [0], [1], [1], [0], 1.0] + scalar[ARG_INDEX[name]] = value + with pytest.raises( + ValueError, match=rf"^{name} must be a 1-D sequence of integers \(got int\)$" + ): + ngc.StrictMultiDiGraph(*scalar) + + +def test_shape_step_keeps_its_place_in_the_order(): + # The resolution and the unit values still come before the shapes, whatever the shape. + with pytest.raises(ValueError, match="resolution"): + ngc.StrictMultiDiGraph(2, np.array([[0]]), [1], [1], [0], 3.0) + with pytest.raises( + ValueError, match=r"capacity_units\[0\] = 4611686018427387904 is outside" + ): + ngc.StrictMultiDiGraph( + 2, [0], [1], np.array([[2**62]], dtype=np.int64), [0], 1.0 + ) + with pytest.raises(ValueError, match=r"capacity_units\[0\] = -1 is outside"): + ngc.StrictMultiDiGraph(2, [0], [1], np.array(-1, dtype=np.int64), [0], 1.0) + with pytest.raises(ValueError, match=r"capacity_units\[1\] = -5 is outside"): + ngc.StrictMultiDiGraph(2, [0], [1], [[1], -5], [0], 1.0) + with pytest.raises(ValueError, match=r"capacity_units = -5 is outside"): + ngc.StrictMultiDiGraph(2, [0], [1], -5, [0], 1.0) + # With valid values the misshapen capacity is a shape defect, before the costs. + with pytest.raises(ValueError, match="capacity_units must be a 1-D array"): + ngc.StrictMultiDiGraph(2, [0], [1], np.array([[1]]), [0], 1.0) + # Rank before lengths. + with pytest.raises(ValueError, match="dst must be a 1-D array"): + ngc.StrictMultiDiGraph(2, [0, 1], np.array([[1, 0]]), [1, 1], [1, 1], 1.0) + # A valid graph from 1-D arrays of every integer kind still builds. + g = ngc.StrictMultiDiGraph( + 2, np.array([0]), np.array([1], dtype=np.uint8), np.array([7]), (3,), 1.0 + ) + assert g.capacity_units().tolist() == [7] diff --git a/tests/py/test_graph_cost_metadata.py b/tests/py/test_graph_cost_metadata.py deleted file mode 100644 index f5e4735..0000000 --- a/tests/py/test_graph_cost_metadata.py +++ /dev/null @@ -1,29 +0,0 @@ -"""StrictMultiDiGraph.max_cost / cost_gcd: the inputs of the SPF queue dispatch rule.""" - -from __future__ import annotations - -import numpy as np - -import netgraph_core as ngc - - -def _graph(costs): - n = len(costs) + 1 - return ngc.StrictMultiDiGraph.from_arrays( - n, - src=np.arange(len(costs), dtype=np.int32), - dst=np.arange(1, n, dtype=np.int32), - capacity=np.ones(len(costs), dtype=np.float64), - cost=np.array(costs, dtype=np.int64), - ext_edge_ids=np.arange(len(costs), dtype=np.int64), - ) - - -def test_cost_metadata(): - assert _graph([6, 9, 0]).max_cost() == 9 - assert _graph([6, 9, 0]).cost_gcd() == 3 - assert _graph([0, 0]).max_cost() == 0 - assert _graph([0, 0]).cost_gcd() == 0 - big = (1 << 40) + 7 - assert _graph([big]).max_cost() == big - assert _graph([big]).cost_gcd() == big diff --git a/tests/py/test_graph_from_arrays.py b/tests/py/test_graph_from_arrays.py deleted file mode 100644 index 19f8595..0000000 --- a/tests/py/test_graph_from_arrays.py +++ /dev/null @@ -1,69 +0,0 @@ -"""StrictMultiDiGraph.from_arrays behaviors and validations.""" - -from __future__ import annotations - -import numpy as np -import pytest - -import netgraph_core as ngc - - -def _make_ext_ids(n): - """Generate ext_edge_ids for n edges.""" - return np.arange(n, dtype=np.int64) - - -def test_from_arrays_basic_bidirectional_manual(): - n = 3 - # Manually include reverse edges - src = np.array([0, 1, 1, 2], dtype=np.int32) - dst = np.array([1, 0, 2, 1], dtype=np.int32) - cap = np.array([1.0, 1.0, 2.0, 2.0], dtype=np.float64) - cost = np.array([1, 1, 1, 1], dtype=np.int64) - g = ngc.StrictMultiDiGraph.from_arrays(n, src, dst, cap, cost, _make_ext_ids(4)) - # Expect reverse edges present; 4 edges total - assert g.num_edges() == 4 - - -def test_from_arrays_mismatched_lengths_raise(): - n = 2 - src = np.array([0], dtype=np.int32) - dst = np.array([1, 1], dtype=np.int32) - cap = np.array([1.0], dtype=np.float64) - cost = np.array([1], dtype=np.int64) - with pytest.raises(TypeError): - ngc.StrictMultiDiGraph.from_arrays(n, src, dst, cap, cost, _make_ext_ids(1)) - - -def test_from_arrays_wrong_dtypes_raise(): - n = 2 - src = np.array([0], dtype=np.int64) # wrong dtype - dst = np.array([1], dtype=np.int32) - cap = np.array([1.0], dtype=np.float32) # wrong dtype - cost = np.array([1], dtype=np.int64) - with pytest.raises((TypeError, ValueError)): - ngc.StrictMultiDiGraph.from_arrays(n, src, dst, cap, cost, _make_ext_ids(1)) - - -def test_from_arrays_negative_capacity_raises(): - n = 2 - src = np.array([0], dtype=np.int32) - dst = np.array([1], dtype=np.int32) - cap = np.array([-1.0], dtype=np.float64) - cost = np.array([1], dtype=np.int64) - with pytest.raises(ValueError): - ngc.StrictMultiDiGraph.from_arrays(n, src, dst, cap, cost, _make_ext_ids(1)) - - -def test_from_arrays_self_loops_behavior(): - n = 2 - src = np.array([0], dtype=np.int32) - dst = np.array([0], dtype=np.int32) # self-loop - cap = np.array([1.0], dtype=np.float64) - cost = np.array([1], dtype=np.int64) - # Self-loops are accepted: StrictMultiDiGraph is a multigraph and from_arrays - # only rejects out-of-range ids and negative capacity/cost. - g = ngc.StrictMultiDiGraph.from_arrays(n, src, dst, cap, cost, _make_ext_ids(1)) - assert g.num_edges() == 1 - assert g.edge_src_view()[0] == 0 - assert g.edge_dst_view()[0] == 0 diff --git a/tests/py/test_graph_structure.py b/tests/py/test_graph_structure.py deleted file mode 100644 index da3926a..0000000 --- a/tests/py/test_graph_structure.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Graph structure tests. - -Validate StrictMultiDiGraph compaction (deterministic ordering and grouping) -and CSR/reverse CSR integrity at a basic level. -""" - -import numpy as np - - -def test_compaction_sorts_and_groups_parallel_edges(build_graph): - # Nodes: 0->1 with mixed order edges; 0->2; expect CSR row for 0 sorted by col and grouped - edges = [ - (0, 2, 2.0, 1.0), - (0, 1, 1.0, 1.0), - (0, 1, 1.5, 2.0), - (1, 2, 1.0, 1.0), - ] - g = build_graph(3, edges) - row = np.asarray(g.row_offsets_view()) - col = np.asarray(g.col_indices_view()) - # Row for node 0 spans [row[0], row[1]) and should be non-decreasing by col - s, e = int(row[0]), int(row[1]) - assert np.all(col[s:e][:-1] <= col[s:e][1:]) - # Parallel edges to the same neighbor should be consecutive (grouped) - # Count transitions; for two neighbors (1 and 2), expect at most two groups - groups = 1 if e - s <= 1 else int(np.sum(col[s:e][1:] != col[s:e][:-1]) + 1) - assert groups <= 2 diff --git a/tests/py/test_imports.py b/tests/py/test_imports.py index 6992915..7f536a1 100644 --- a/tests/py/test_imports.py +++ b/tests/py/test_imports.py @@ -1,8 +1,32 @@ -def test_imports(): - import netgraph_core as ngc - - assert hasattr(ngc, "StrictMultiDiGraph") - assert hasattr(ngc, "Backend") - assert hasattr(ngc, "Algorithms") - assert hasattr(ngc, "EdgeSelection") - assert hasattr(ngc, "EdgeTieBreak") +"""The public surface of the 1a build: the graph on units, Eligibility and the conversions.""" + +import _netgraph_core + +import netgraph_core as ngc + + +def test_public_surface(): + assert set(ngc.__all__) == { + "__version__", + "StrictMultiDiGraph", + "Eligibility", + "to_units", + "as_float", + } + for name in ngc.__all__: + assert hasattr(ngc, name) + assert isinstance(ngc.__version__, str) + + +def test_extension_surface_is_exactly_the_1a_set(): + # The old library is deleted in 1a: the extension exports the graph on units, the + # conversions and the private flow-core entry, nothing else. + public = {n for n in dir(_netgraph_core) if not n.startswith("__")} + assert public == { + "StrictMultiDiGraph", + "Eligibility", + "to_units", + "as_float", + "_place_global_units", + } + assert not hasattr(ngc, "_place_global_units") diff --git a/tests/py/test_ksp.py b/tests/py/test_ksp.py deleted file mode 100644 index 5219b09..0000000 --- a/tests/py/test_ksp.py +++ /dev/null @@ -1,89 +0,0 @@ -"""K-shortest paths (KSP) on canonical graphs; SPF-compatible outputs.""" - -import numpy as np - - -def test_ksp_line1_two_paths( - line1_graph, t_cost, assert_pred_dag_integrity, algs, to_handle -): - g = line1_graph - items = algs.ksp(to_handle(g), 0, 2, k=5, max_cost_factor=None, unique=True) - assert len(items) >= 2 - costs = [t_cost(dist, 2) for dist, _ in items[:2]] - assert all(np.isclose(c, 2.0) for c in costs) - for d, dag in items[:2]: - assert_pred_dag_integrity(g, dag, dist=d) - - -def test_ksp_square1_two_paths( - square1_graph, t_cost, assert_pred_dag_integrity, algs, to_handle -): - g = square1_graph - items = algs.ksp(to_handle(g), 0, 2, k=5, max_cost_factor=None, unique=True) - assert len(items) >= 2 - costs = [t_cost(dist, 2) for dist, _ in items[:2]] - # Expect costs 2 and 4 (A->B->C and A->D->C) - assert set(round(c, 6) for c in costs) == {2.0, 4.0} - for d, dag in items[:2]: - assert_pred_dag_integrity(g, dag, dist=d) - - -def test_ksp_fully_connected_costs( - make_fully_connected_graph, t_cost, assert_pred_dag_integrity, algs, to_handle -): - g = make_fully_connected_graph(5, cost=1.0, cap=1.0) - items = algs.ksp(to_handle(g), 0, 1, k=2, max_cost_factor=None, unique=True) - assert len(items) == 2 - assert np.isclose(t_cost(items[0][0], 1), 1.0) - assert np.isclose(t_cost(items[1][0], 1), 2.0) - for d, dag in items: - assert_pred_dag_integrity(g, dag, dist=d) - - -def test_ksp_max_cost_factor_limit( - make_fully_connected_graph, t_cost, assert_pred_dag_integrity, algs, to_handle -): - g = make_fully_connected_graph(5, cost=1.0, cap=1.0) - # Restrict to paths with cost <= best_cost * 1.0 => only the direct path - items = algs.ksp(to_handle(g), 0, 1, k=5, max_cost_factor=1.0, unique=True) - assert len(items) == 1 - assert np.isclose(t_cost(items[0][0], 1), 1.0) - assert_pred_dag_integrity(g, items[0][1], dist=items[0][0]) - - -def test_ksp_graph5_thresholds( - graph5, t_cost, assert_pred_dag_integrity, algs, to_handle -): - g = graph5 - # Best path cost = 1.0 (direct); with factor 2.0, paths up to cost 2 allowed. - items = algs.ksp(to_handle(g), 0, 1, k=10, max_cost_factor=2.0, unique=True) - # Ensure at least the direct and one via-node path exist - assert len(items) >= 2 - costs = sorted(t_cost(dist, 1) for dist, _ in items[:2]) - assert np.isclose(costs[0], 1.0) - assert np.isclose(costs[1], 2.0) - # With factor 1.0, only direct route - items2 = algs.ksp(to_handle(g), 0, 1, k=10, max_cost_factor=1.0, unique=True) - assert len(items2) == 1 - assert np.isclose(t_cost(items2[0][0], 1), 1.0) - for d, dag in items[:2]: - assert_pred_dag_integrity(g, dag, dist=d) - assert_pred_dag_integrity(g, items2[0][1], dist=items2[0][0]) - - -def test_ksp_square5_routes( - square5_graph, t_cost, assert_pred_dag_integrity, algs, to_handle -): - g = square5_graph - # Multiple routes from A(0) -> D(3): direct two-hop and via B<->C detours - items = algs.ksp(to_handle(g), 0, 3, k=5, max_cost_factor=None, unique=True) - assert len(items) >= 2 - top_costs = sorted(t_cost(dist, 3) for dist, _ in items[:2]) - # Expect two shortest (cost=2) - assert np.isclose(top_costs[0], 2.0) - assert np.isclose(top_costs[1], 2.0) - # No route from A(0) to E(4) - empty = algs.ksp(to_handle(g), 0, 4, k=5, max_cost_factor=None, unique=True) - assert empty == [] - for d, dag in items[:2]: - assert_pred_dag_integrity(g, dag, dist=d) diff --git a/tests/py/test_ksp_variants.py b/tests/py/test_ksp_variants.py deleted file mode 100644 index 0001bfd..0000000 --- a/tests/py/test_ksp_variants.py +++ /dev/null @@ -1,135 +0,0 @@ -"""KSP variants: enumeration with unique=False, k-limit, masks, and out-of-order nodes.""" - -from __future__ import annotations - -import numpy as np - - -def _ksp_graph(build_graph): - # 0->1 direct; 0->2->1; 0->3->2->1 to generate multiple paths - edges = [ - (0, 1, 1, 10, 0), - (0, 2, 1, 10, 1), - (2, 1, 1, 10, 2), - (0, 3, 1, 10, 3), - (3, 2, 1, 10, 4), - ] - return build_graph(4, edges) - - -def test_ksp_unique_false_allows_alternative_enumeration( - build_graph, assert_pred_dag_integrity, algs, to_handle -): - g = _ksp_graph(build_graph) - items = algs.ksp(to_handle(g), 0, 1, k=5, max_cost_factor=None, unique=False) - assert len(items) >= 3 - # Costs should be nondecreasing among top items - costs = [float(dist[1]) for dist, _ in items] - assert all(c >= 1.0 for c in costs) - for d, dag in items[:3]: - assert_pred_dag_integrity(g, dag, dist=d) - - -def test_ksp_respects_k_limit(build_graph, algs, to_handle): - g = _ksp_graph(build_graph) - items = algs.ksp(to_handle(g), 0, 1, k=2, max_cost_factor=None, unique=True) - assert len(items) == 2 - - -def test_ksp_with_masks(build_graph, algs, to_handle): - g = _ksp_graph(build_graph) - node_mask = np.array([True, True, True, False], dtype=bool) # block node 3 - items = algs.ksp( - to_handle(g), 0, 1, k=5, max_cost_factor=None, unique=True, node_mask=node_mask - ) - # Only direct and via-2 remain - assert len(items) >= 2 - - -def test_ksp_out_of_order_node_preddag_semantics( - build_graph, assert_pred_dag_integrity, algs, to_handle -): - """Verify PredDAG correctness when paths visit nodes out of numerical order. - - Topology: 0->1->2 (cost 2) vs 0->3->2 (cost 4). - Path [0,3,2] visits node 3 before node 2, which previously triggered - a CSR fill-order bug where parent entries were swapped between nodes. - """ - edges = [ - (0, 1, 1, 1.0, 0), # 0->1 cost 1 - (1, 2, 1, 1.0, 1), # 1->2 cost 1 - (0, 3, 2, 2.0, 2), # 0->3 cost 2 - (3, 2, 2, 2.0, 3), # 3->2 cost 2 - ] - g = build_graph(4, edges) - items = algs.ksp(to_handle(g), 0, 2, k=5, max_cost_factor=None, unique=True) - assert len(items) >= 2 - - # Verify costs - costs = sorted(float(dist[2]) for dist, _ in items[:2]) - assert np.isclose(costs[0], 2.0), f"Expected shortest cost 2.0, got {costs[0]}" - assert np.isclose(costs[1], 4.0), f"Expected second cost 4.0, got {costs[1]}" - - for d, dag in items: - # Full semantic validation including distance consistency - assert_pred_dag_integrity(g, dag, dist=d) - - # Reconstruct concrete paths via resolve_to_paths - concrete = dag.resolve_to_paths(0, 2, split_parallel_edges=True) - assert len(concrete) >= 1, "Should reconstruct at least one path" - for path in concrete: - assert len(path) >= 2 - assert path[0][0] == 0, "Path should start at source" - assert path[-1][0] == 2, "Path should end at destination" - # Verify edge connectivity - edge_src = np.asarray(g.edge_src_view()) - edge_dst = np.asarray(g.edge_dst_view()) - for i in range(len(path) - 1): - from_node = path[i][0] - to_node = path[i + 1][0] - for eid in path[i][1]: - assert int(edge_src[eid]) == from_node - assert int(edge_dst[eid]) == to_node - - -def test_ksp_larger_out_of_order_topology( - build_graph, assert_pred_dag_integrity, algs, to_handle -): - """Larger topology where k-shortest paths traverse nodes in non-numerical order. - - Path 1: 0->5->3->1 (cost 3, severely out-of-order) - Path 2: 0->2->4->1 (cost 6, also out-of-order) - Path 3: 0->1 (cost 10, direct) - """ - edges = [ - (0, 5, 1, 1.0, 0), # 0->5 cost 1 - (5, 3, 1, 1.0, 1), # 5->3 cost 1 - (3, 1, 1, 1.0, 2), # 3->1 cost 1 - (0, 2, 2, 1.0, 3), # 0->2 cost 2 - (2, 4, 2, 1.0, 4), # 2->4 cost 2 - (4, 1, 2, 1.0, 5), # 4->1 cost 2 - (0, 1, 10, 1.0, 6), # 0->1 cost 10 - ] - g = build_graph(6, edges) - items = algs.ksp(to_handle(g), 0, 1, k=5, max_cost_factor=None, unique=True) - assert len(items) >= 2 - - # Check costs non-decreasing - costs = [float(dist[1]) for dist, _ in items] - for i in range(len(costs) - 1): - assert costs[i] <= costs[i + 1] + 1e-9 - - # First path should be 0->5->3->1 with cost 3 - assert np.isclose(costs[0], 3.0), f"Expected shortest cost 3.0, got {costs[0]}" - - for d, dag in items: - assert_pred_dag_integrity(g, dag, dist=d) - - concrete = dag.resolve_to_paths(0, 1, split_parallel_edges=True) - assert len(concrete) >= 1 - for path in concrete: - # Verify no repeated nodes (simple path) - nodes = [step[0] for step in path] - assert len(nodes) == len(set(nodes)), ( - f"Path contains repeated nodes: {nodes}" - ) diff --git a/tests/py/test_lifetime_safety.py b/tests/py/test_lifetime_safety.py deleted file mode 100644 index 90f4fd2..0000000 --- a/tests/py/test_lifetime_safety.py +++ /dev/null @@ -1,329 +0,0 @@ -""" -Test lifetime management in Python bindings. - -These tests validate that pybind11 keep_alive policies correctly prevent -premature garbage collection of objects that are referenced by raw pointers -in C++ classes. -""" - -import gc -import weakref - -import numpy as np -import pytest - -from netgraph_core import ( - Algorithms, - Backend, - FlowGraph, - FlowPolicy, - FlowPolicyConfig, - FlowState, - StrictMultiDiGraph, -) - - -def _make_graph(num_nodes, src, dst, capacity, cost): - """Helper to build graph with auto-generated ext_edge_ids.""" - ext_edge_ids = np.arange(len(src), dtype=np.int64) - return StrictMultiDiGraph.from_arrays( - num_nodes, src, dst, capacity, cost, ext_edge_ids - ) - - -class TestFlowStateLifetime: - """Test FlowState keeps graph alive (Issue #1).""" - - def test_flowstate_keeps_graph_alive_basic(self): - """FlowState should prevent graph from being garbage collected.""" - # Create graph - g = _make_graph( - 3, - np.array([0, 1], dtype=np.int32), - np.array([1, 2], dtype=np.int32), - np.array([10.0, 10.0]), - np.array([1, 1], dtype=np.int64), - ) - - # Create FlowState (should keep graph alive via keep_alive<0,1>) - fs = FlowState(g) - - # Create weak reference to detect when graph is collected - graph_ref = weakref.ref(g) - - # Delete strong reference to graph - del g - gc.collect() - - # With fix: graph should still be alive (kept by fs) - assert graph_ref() is not None, "Graph was prematurely collected" - - # FlowState should still work - residual = fs.residual_view() - assert len(residual) == 2 - assert residual[0] == 10.0 - - # When both FlowState and all views are deleted, graph can be collected - del residual - del fs - gc.collect() - assert graph_ref() is None, ( - "Graph should be collected after FlowState and views are deleted" - ) - - def test_flowstate_with_residual_keeps_graph_alive(self): - """FlowState(graph, residual) should keep graph alive, not residual.""" - g = _make_graph( - 3, - np.array([0, 1], dtype=np.int32), - np.array([1, 2], dtype=np.int32), - np.array([10.0, 10.0]), - np.array([1, 1], dtype=np.int64), - ) - - residual = np.array([5.0, 7.0]) - fs = FlowState(g, residual) - - graph_ref = weakref.ref(g) - - # Delete references - del g - del residual - gc.collect() - - # Graph should be kept alive (by FlowState), residual is copied - assert graph_ref() is not None, "Graph was prematurely collected" - - # FlowState should work - res = fs.residual_view() - assert len(res) == 2 - assert res[0] == 5.0 - assert res[1] == 7.0 - - -class TestFlowPolicyLifetime: - """Test FlowPolicy keeps Algorithms and Graph alive (Issue #2).""" - - def test_flowpolicy_keeps_algorithms_alive(self): - """FlowPolicy should prevent Algorithms from being garbage collected.""" - # Setup - backend = Backend.cpu() - algs = Algorithms(backend) - - g = _make_graph( - 3, - np.array([0, 1], dtype=np.int32), - np.array([1, 2], dtype=np.int32), - np.array([10.0, 10.0]), - np.array([1, 1], dtype=np.int64), - ) - graph = algs.build_graph(g) - - # Create FlowPolicy - config = FlowPolicyConfig() - policy = FlowPolicy(algs, graph, config) - - # Create weak references - algs_ref = weakref.ref(algs) - graph_ref = weakref.ref(graph) - - # Delete strong references - del algs - del graph - gc.collect() - - # With fix: both should still be alive - assert algs_ref() is not None, "Algorithms was prematurely collected" - assert graph_ref() is not None, "Graph was prematurely collected" - - # Policy should still work - assert policy.flow_count() == 0 - assert policy.placed_demand() == 0.0 - - def test_flowpolicy_keyword_constructor_keeps_alive(self): - """FlowPolicy keyword constructor should also keep dependencies alive.""" - backend = Backend.cpu() - algs = Algorithms(backend) - - g = _make_graph( - 3, - np.array([0, 1], dtype=np.int32), - np.array([1, 2], dtype=np.int32), - np.array([10.0, 10.0]), - np.array([1, 1], dtype=np.int64), - ) - graph = algs.build_graph(g) - - # Use config constructor - cfg = FlowPolicyConfig(min_flow_count=1, max_total_iterations=100) - policy = FlowPolicy(algs, graph, cfg) - - algs_ref = weakref.ref(algs) - - del algs - gc.collect() - - assert algs_ref() is not None, "Algorithms was prematurely collected" - assert policy.flow_count() == 0 - - -class TestFlowGraphLifetime: - """Test FlowGraph lifetime management.""" - - def test_flowgraph_keeps_graph_alive(self): - """FlowGraph should prevent graph from being garbage collected.""" - g = _make_graph( - 3, - np.array([0, 1], dtype=np.int32), - np.array([1, 2], dtype=np.int32), - np.array([10.0, 10.0]), - np.array([1, 1], dtype=np.int64), - ) - - fg = FlowGraph(g) - graph_ref = weakref.ref(g) - - # Delete graph reference - del g - gc.collect() - - # Graph should still be alive (kept by FlowGraph) - assert graph_ref() is not None, "Graph was prematurely collected" - - # FlowGraph should still work - graph_from_fg = fg.graph - assert graph_from_fg.num_nodes() == 3 - assert graph_from_fg.num_edges() == 2 - - # When FlowGraph is deleted, graph can be collected - del fg - del graph_from_fg - gc.collect() - assert graph_ref() is None, ( - "Graph should be collected after FlowGraph is deleted" - ) - - -class TestComplexLifetimeScenarios: - """Test more complex lifetime interactions.""" - - def test_nested_lifetime_chain(self): - """Test chain: Graph -> FlowState -> operations.""" - g = _make_graph( - 3, - np.array([0, 1], dtype=np.int32), - np.array([1, 2], dtype=np.int32), - np.array([10.0, 10.0]), - np.array([1, 1], dtype=np.int64), - ) - - fs = FlowState(g) - - # Create weak references - g_ref = weakref.ref(g) - - # Delete graph - del g - gc.collect() - - # Graph should be kept alive by FlowState - assert g_ref() is not None - - # Should be able to perform operations - fs.reset() - residual = fs.residual_view() - assert len(residual) == 2 - - def test_multiple_flowstates_share_graph(self): - """Multiple FlowStates should all keep the same graph alive.""" - g = _make_graph( - 3, - np.array([0, 1], dtype=np.int32), - np.array([1, 2], dtype=np.int32), - np.array([10.0, 10.0]), - np.array([1, 1], dtype=np.int64), - ) - - fs1 = FlowState(g) - fs2 = FlowState(g, np.array([8.0, 9.0])) - - g_ref = weakref.ref(g) - - del g - gc.collect() - - # Graph kept alive by both FlowStates - assert g_ref() is not None - - # Both should work - assert len(fs1.residual_view()) == 2 - assert len(fs2.residual_view()) == 2 - - # Delete one FlowState - graph should still be alive - del fs1 - gc.collect() - assert g_ref() is not None - - # Delete second FlowState - now graph can be collected - del fs2 - gc.collect() - assert g_ref() is None - - -@pytest.mark.slow -class TestLifetimeUnderStress: - """Stress tests for lifetime management.""" - - def test_rapid_create_delete_cycle(self): - """Rapidly create and delete objects to stress lifetime management.""" - for _ in range(100): - g = _make_graph( - 10, - np.arange(9, dtype=np.int32), - np.arange(1, 10, dtype=np.int32), - np.ones(9), - np.ones(9, dtype=np.int64), - ) - - fs = FlowState(g) - del g # Should be kept alive by fs - - # Do some work - try: - residual = fs.residual_view() - assert len(residual) == 9 - fs.reset() - except Exception: - # If the graph was prematurely collected, this will crash - # That's what we're testing for - pass - - # Delete FlowState - del fs - gc.collect() - - # If we got here without crashing, lifetime management is working - - def test_large_graph_lifetime(self): - """Test lifetime with larger graphs to catch memory issues.""" - n = 1000 - g = _make_graph( - n, - np.arange(n - 1, dtype=np.int32), - np.arange(1, n, dtype=np.int32), - np.random.rand(n - 1) * 100, - np.ones(n - 1, dtype=np.int64), - ) - - fs = FlowState(g) - g_ref = weakref.ref(g) - - del g - gc.collect() - - assert g_ref() is not None - - # Access views multiple times - for _ in range(10): - residual = fs.residual_view() - assert len(residual) == n - 1 diff --git a/tests/py/test_mask_semantics.py b/tests/py/test_mask_semantics.py deleted file mode 100644 index bd5a5b7..0000000 --- a/tests/py/test_mask_semantics.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Quick CI probes for critical masking semantics. - -Validates that masks behave correctly end-to-end across all APIs, -as suggested in the final masking review. -""" - -import numpy as np -import pytest - -import netgraph_core as ngc - - -def _make_graph(num_nodes, src, dst, cap, cost): - """Helper to build graph with auto-generated ext_edge_ids.""" - ext_edge_ids = np.arange(len(src), dtype=np.int64) - return ngc.StrictMultiDiGraph.from_arrays( - num_nodes, src, dst, cap, cost, ext_edge_ids - ) - - -@pytest.fixture -def tiny_graph(): - """Tiny graph: 0 -> 1 -> 2""" - return _make_graph( - 3, - np.array([0, 1], np.int32), - np.array([1, 2], np.int32), - np.array([1.0, 1.0], np.float64), - np.array([1, 1], np.int64), - ) - - -def test_spf_masked_source_returns_empty_dag_inf_distances(tiny_graph): - """SPF: masked source -> empty DAG + inf distances.""" - algs = ngc.Algorithms(ngc.Backend.cpu()) - G = algs.build_graph(tiny_graph) - - dist, dag = algs.spf(G, 0, dst=2, node_mask=np.array([False, True, True], bool)) - - assert np.isinf(dist[2]), "Distance to dst should be infinity" - assert dag.parent_offsets[-1] == 0, "DAG should be empty" - - -def test_spf_edge_mask_excludes_path(tiny_graph): - """SPF: edge mask excludes (1->2) -> unreachable.""" - algs = ngc.Algorithms(ngc.Backend.cpu()) - G = algs.build_graph(tiny_graph) - - # Mask out edge 1 (1->2) - dist, dag = algs.spf(G, 0, dst=2, edge_mask=np.array([True, False], bool)) - - assert np.isinf(dist[2]), "Node 2 should be unreachable" - - -def test_max_flow_destination_masked_returns_zero(tiny_graph): - """MaxFlow: destination masked -> zero flow, empty cut.""" - algs = ngc.Algorithms(ngc.Backend.cpu()) - G = algs.build_graph(tiny_graph) - - # Mask out destination (node 2) - total, summary = algs.max_flow( - G, 0, 2, node_mask=np.array([True, True, False], bool) - ) - - assert total == 0.0, "Flow should be zero" - assert len(summary.min_cut.edges) == 0, "Min-cut should be empty" - - -def test_flow_state_place_max_flow_accepts_masks(tiny_graph): - """FlowState.place_max_flow now accepts masks (strict validation).""" - fs = ngc.FlowState(tiny_graph) - - # Mask out source - placed = fs.place_max_flow( - 0, - 2, - ngc.FlowPlacement.PROPORTIONAL, - node_mask=np.array([False, True, True], bool), - ) - - assert placed == 0.0, "No flow should be placed with masked source" - - -def test_flow_state_place_max_flow_with_valid_masks(tiny_graph): - """FlowState.place_max_flow works correctly with valid masks.""" - fs = ngc.FlowState(tiny_graph) - - # Allow all nodes - placed = fs.place_max_flow( - 0, - 2, - ngc.FlowPlacement.PROPORTIONAL, - node_mask=np.array([True, True, True], bool), - edge_mask=np.array([True, True], bool), - ) - - assert placed > 0, "Flow should be placed with all nodes/edges allowed" - - -def test_batch_max_flow_validates_mask_lengths(tiny_graph): - """Batch: shape mismatches throw (node_masks length != num_nodes).""" - algs = ngc.Algorithms(ngc.Backend.cpu()) - G = algs.build_graph(tiny_graph) - - with pytest.raises(TypeError, match="length"): - # Wrong node mask length (2 instead of 3) - algs.batch_max_flow( - G, - np.array([[0, 2]], np.int32), - node_masks=[np.ones(2, bool)], # Wrong length! - ) - - -def test_flow_policy_with_masks_end_to_end(tiny_graph): - """FlowPolicy with masks: end-to-end place_demand.""" - algs = ngc.Algorithms(ngc.Backend.cpu()) - G = algs.build_graph(tiny_graph) - - # Create policy with destination masked out - cfg = ngc.FlowPolicyConfig() - policy = ngc.FlowPolicy(algs, G, cfg, node_mask=np.array([True, True, False], bool)) - - fg = ngc.FlowGraph(tiny_graph) - placed, leftover = policy.place_demand(fg, 0, 2, flowClass=0, volume=1.0) - - assert placed == 0.0, "No flow placed with masked destination" - assert leftover == pytest.approx(1.0, abs=1e-9), "All volume should remain" - - -def test_ksp_honors_node_mask(tiny_graph): - """KSP: honors node masks (masked intermediate node).""" - algs = ngc.Algorithms(ngc.Backend.cpu()) - G = algs.build_graph(tiny_graph) - - # Mask out intermediate node 1 - paths = algs.ksp(G, 0, 2, k=2, node_mask=np.array([True, False, True], bool)) - - assert len(paths) == 0, "No paths should exist with node 1 masked" - - -def test_min_cut_with_masked_source(tiny_graph): - """FlowState.compute_min_cut with masked source returns empty cut.""" - fs = ngc.FlowState(tiny_graph) - - # Place some flow first - fs.place_max_flow(0, 2, ngc.FlowPlacement.PROPORTIONAL) - - # Compute min-cut with source masked - cut = fs.compute_min_cut(0, node_mask=np.array([False, True, True], bool)) - - assert len(cut.edges) == 0, "Min-cut should be empty with masked source" - - -def test_all_apis_accept_none_masks(tiny_graph): - """All APIs accept None masks (no masking).""" - algs = ngc.Algorithms(ngc.Backend.cpu()) - G = algs.build_graph(tiny_graph) - - # SPF - dist, dag = algs.spf(G, 0, 2, node_mask=None, edge_mask=None) - assert not np.isinf(dist[2]) - - # KSP - paths = algs.ksp(G, 0, 2, k=1, node_mask=None, edge_mask=None) - assert len(paths) == 1 - - # Max flow - total, _ = algs.max_flow(G, 0, 2, node_mask=None, edge_mask=None) - assert total > 0 - - # FlowState.place_max_flow - fs = ngc.FlowState(tiny_graph) - placed = fs.place_max_flow( - 0, 2, ngc.FlowPlacement.PROPORTIONAL, node_mask=None, edge_mask=None - ) - assert placed > 0 - - # FlowState.compute_min_cut - cut = fs.compute_min_cut(0, node_mask=None, edge_mask=None) - assert isinstance(cut, ngc.MinCut) diff --git a/tests/py/test_mask_validation.py b/tests/py/test_mask_validation.py deleted file mode 100644 index eaa726e..0000000 --- a/tests/py/test_mask_validation.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Mask validation tests. - -Validates that API entry points enforce mask length requirements -and reject malformed masks with clear error messages. -""" - -import numpy as np -import pytest - -import netgraph_core as ngc - - -def _make_graph(num_nodes, src, dst, cap, cost): - """Helper to build graph with auto-generated ext_edge_ids.""" - ext_edge_ids = np.arange(len(src), dtype=np.int64) - return ngc.StrictMultiDiGraph.from_arrays( - num_nodes, src, dst, cap, cost, ext_edge_ids - ) - - -@pytest.fixture -def simple_graph(): - """Create a simple 3-node graph: 0->1->2.""" - return _make_graph( - 3, - np.array([0, 1], np.int32), - np.array([1, 2], np.int32), - np.array([10.0, 10.0], np.float64), - np.array([1, 1], np.int64), - ) - - -def test_spf_rejects_wrong_node_mask_length(simple_graph): - """shortest_paths rejects node_mask with wrong length.""" - algs = ngc.Algorithms(ngc.Backend.cpu()) - gh = algs.build_graph(simple_graph) - - with pytest.raises(TypeError, match="node_mask length must equal"): - algs.spf(gh, 0, 2, node_mask=np.array([True, True], dtype=bool)) - - -def test_spf_rejects_wrong_edge_mask_length(simple_graph): - """shortest_paths rejects edge_mask with wrong length.""" - algs = ngc.Algorithms(ngc.Backend.cpu()) - gh = algs.build_graph(simple_graph) - - with pytest.raises(TypeError, match="edge_mask length must equal"): - algs.spf(gh, 0, 2, edge_mask=np.array([True, True, True], dtype=bool)) - - -def test_ksp_rejects_wrong_node_mask_length(simple_graph): - """k_shortest_paths rejects node_mask with wrong length.""" - algs = ngc.Algorithms(ngc.Backend.cpu()) - gh = algs.build_graph(simple_graph) - - with pytest.raises(TypeError, match="node_mask length must equal"): - algs.ksp(gh, 0, 2, k=2, node_mask=np.array([True], dtype=bool)) - - -def test_ksp_rejects_wrong_edge_mask_length(simple_graph): - """k_shortest_paths rejects edge_mask with wrong length.""" - algs = ngc.Algorithms(ngc.Backend.cpu()) - gh = algs.build_graph(simple_graph) - - with pytest.raises(TypeError, match="edge_mask length must equal"): - algs.ksp(gh, 0, 2, k=2, edge_mask=np.array([True], dtype=bool)) - - -def test_max_flow_rejects_wrong_node_mask_length(simple_graph): - """calc_max_flow rejects node_mask with wrong length.""" - algs = ngc.Algorithms(ngc.Backend.cpu()) - gh = algs.build_graph(simple_graph) - - with pytest.raises(TypeError, match="node_mask length must equal"): - algs.max_flow( - gh, 0, 2, node_mask=np.array([True, True, True, True], dtype=bool) - ) - - -def test_max_flow_rejects_wrong_edge_mask_length(simple_graph): - """calc_max_flow rejects edge_mask with wrong length.""" - algs = ngc.Algorithms(ngc.Backend.cpu()) - gh = algs.build_graph(simple_graph) - - with pytest.raises(TypeError, match="edge_mask length must equal"): - algs.max_flow(gh, 0, 2, edge_mask=np.array([True], dtype=bool)) - - -def test_batch_max_flow_rejects_wrong_mask_length(simple_graph): - """batch_max_flow validates all masks upfront.""" - algs = ngc.Algorithms(ngc.Backend.cpu()) - gh = algs.build_graph(simple_graph) - - # Wrong node mask length in batch (batch_max_flow expects numpy array of pairs) - pairs = np.array([[0, 2], [1, 2]], dtype=np.int32) - with pytest.raises(TypeError, match="node_masks length must equal"): - algs.batch_max_flow( - gh, - pairs, - node_masks=[ - np.array([True, True, True], dtype=bool), - np.array([True], dtype=bool), - ], - ) - - # Wrong edge mask length in batch - pairs = np.array([[0, 2]], dtype=np.int32) - with pytest.raises(TypeError, match="edge_masks length must equal"): - algs.batch_max_flow( - gh, pairs, edge_masks=[np.array([True, True, True], dtype=bool)] - ) - - -def test_flow_state_compute_min_cut_rejects_wrong_mask_length(simple_graph): - """FlowState.compute_min_cut rejects wrong mask lengths.""" - fs = ngc.FlowState(simple_graph) - - with pytest.raises(TypeError, match="node_mask length must equal"): - fs.compute_min_cut(0, node_mask=np.array([True], dtype=bool)) - - with pytest.raises(TypeError, match="edge_mask length must equal"): - fs.compute_min_cut(0, edge_mask=np.array([True, True, True], dtype=bool)) - - -def test_flow_state_place_max_flow_rejects_wrong_mask_length(simple_graph): - """FlowState.place_max_flow rejects wrong mask lengths.""" - fs = ngc.FlowState(simple_graph) - - with pytest.raises(TypeError, match="node_mask length must equal"): - fs.place_max_flow( - 0, - 2, - ngc.FlowPlacement.PROPORTIONAL, - node_mask=np.array([True, True], dtype=bool), - ) - - with pytest.raises(TypeError, match="edge_mask length must equal"): - fs.place_max_flow( - 0, - 2, - ngc.FlowPlacement.PROPORTIONAL, - edge_mask=np.array([True, True, True], dtype=bool), - ) - - -def test_flow_policy_rejects_wrong_mask_length_at_construction(simple_graph): - """FlowPolicy validates mask lengths at construction time.""" - algs = ngc.Algorithms(ngc.Backend.cpu()) - gh = algs.build_graph(simple_graph) - cfg = ngc.FlowPolicyConfig() - - with pytest.raises(TypeError, match="node_mask length must equal"): - ngc.FlowPolicy(algs, gh, cfg, node_mask=np.array([True, True], dtype=bool)) - - with pytest.raises(TypeError, match="edge_mask length must equal"): - ngc.FlowPolicy(algs, gh, cfg, edge_mask=np.array([True], dtype=bool)) - - -def test_flow_state_place_max_flow_honors_node_mask(simple_graph): - """FlowState.place_max_flow respects node_mask.""" - fs = ngc.FlowState(simple_graph) - - # Mask out source - node_mask = np.array([False, True, True], dtype=bool) - total = fs.place_max_flow(0, 2, ngc.FlowPlacement.PROPORTIONAL, node_mask=node_mask) - assert total == 0.0 - - -def test_flow_state_place_max_flow_honors_edge_mask(simple_graph): - """FlowState.place_max_flow respects edge_mask.""" - fs = ngc.FlowState(simple_graph) - - # Mask out first edge (0->1) - edge_mask = np.array([False, True], dtype=bool) - total = fs.place_max_flow(0, 2, ngc.FlowPlacement.PROPORTIONAL, edge_mask=edge_mask) - assert total == 0.0 - - -def test_flow_state_place_max_flow_with_valid_masks(simple_graph): - """FlowState.place_max_flow works correctly with valid masks.""" - fs = ngc.FlowState(simple_graph) - - # Allow all nodes and edges - node_mask = np.array([True, True, True], dtype=bool) - edge_mask = np.array([True, True], dtype=bool) - total = fs.place_max_flow( - 0, 2, ngc.FlowPlacement.PROPORTIONAL, node_mask=node_mask, edge_mask=edge_mask - ) - assert total == pytest.approx(10.0, abs=1e-9) - - -def test_all_apis_accept_empty_masks(simple_graph): - """All APIs accept empty masks (no masking).""" - algs = ngc.Algorithms(ngc.Backend.cpu()) - gh = algs.build_graph(simple_graph) - - # SPF with no masks - dist, dag = algs.spf(gh, 0, 2) - assert not np.isinf(dist[2]) - - # KSP with no masks - paths = algs.ksp(gh, 0, 2, k=1) - assert len(paths) == 1 - - # Max flow with no masks - total, _ = algs.max_flow(gh, 0, 2) - assert total > 0 - - # Batch max flow with no masks (expects numpy array) - pairs = np.array([[0, 2]], dtype=np.int32) - summaries = algs.batch_max_flow(gh, pairs) - assert len(summaries) == 1 - - # FlowState with no masks - fs = ngc.FlowState(simple_graph) - cut = fs.compute_min_cut(0) - assert isinstance(cut, ngc.MinCut) - - total = fs.place_max_flow(0, 2, ngc.FlowPlacement.PROPORTIONAL) - assert total > 0 - - -def test_mask_validation_happens_before_expensive_operations(simple_graph): - """Mask validation fails fast before expensive graph operations.""" - algs = ngc.Algorithms(ngc.Backend.cpu()) - gh = algs.build_graph(simple_graph) - - # Invalid mask should be caught before Dijkstra runs - with pytest.raises(TypeError, match="node_mask length must equal"): - algs.spf(gh, 0, 2, node_mask=np.array([True], dtype=bool)) diff --git a/tests/py/test_masking_comprehensive.py b/tests/py/test_masking_comprehensive.py deleted file mode 100644 index 4a4e5fc..0000000 --- a/tests/py/test_masking_comprehensive.py +++ /dev/null @@ -1,653 +0,0 @@ -"""Masking behavior tests. - -Verifies node/edge mask handling across all algorithms: -- Masked source/destination nodes -- Combined node and edge masks -- Interaction with residual capacity -- Masking in max_flow, shortest_paths, and k_shortest_paths -- Mask semantics (true = allowed, false = excluded) -""" - -import numpy as np -import pytest - -import netgraph_core as ngc - -# ============================================================================ -# Test Fixtures -# ============================================================================ - - -@pytest.fixture -def line_graph_3(build_graph): - """Simple line graph: 0 -> 1 -> 2""" - edges = [ - (0, 1, 1.0, 1, 0), - (1, 2, 1.0, 1, 1), - ] - return build_graph(3, edges) - - -@pytest.fixture -def parallel_paths_graph(build_graph): - """Two parallel paths: 0 -> 1 -> 3 and 0 -> 2 -> 3""" - edges = [ - (0, 1, 1.0, 1, 0), - (1, 3, 1.0, 1, 1), - (0, 2, 1.0, 1, 2), - (2, 3, 1.0, 1, 3), - ] - return build_graph(4, edges) - - -@pytest.fixture -def triangle_graph(build_graph): - """Triangle: 0 -> 1 -> 2 and 0 -> 2""" - edges = [ - (0, 1, 2.0, 1, 0), - (1, 2, 1.0, 1, 1), - (0, 2, 1.0, 1, 2), - ] - return build_graph(3, edges) - - -# ============================================================================ -# Masked Source Tests (Critical Bug Fix) -# ============================================================================ - - -def test_masked_source_returns_empty_dag(line_graph_3, algs, to_handle): - """ - CRITICAL BUG FIX TEST: When source is masked out, SPF must return empty DAG - and leave all distances at infinity. - - This tests the fix for the bug where a masked source was still enqueued - in the priority queue, allowing exploration from a forbidden node. - """ - g = line_graph_3 - - # Mask out source node 0 - node_mask = np.array([False, True, True], dtype=bool) - - sel = ngc.EdgeSelection( - multi_edge=True, - require_capacity=False, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - - dist, dag = algs.spf(to_handle(g), 0, selection=sel, node_mask=node_mask) - - # All distances should remain at infinity - assert np.isinf(dist[0]), "Source distance should be infinity when masked" - assert np.isinf(dist[1]), "All nodes should be unreachable when source is masked" - assert np.isinf(dist[2]), "All nodes should be unreachable when source is masked" - - # DAG should be completely empty - offsets = np.asarray(dag.parent_offsets) - parents = np.asarray(dag.parents) - via_edges = np.asarray(dag.via_edges) - - assert len(offsets) == g.num_nodes() + 1 - assert np.all(offsets == 0), "All offsets should be zero for empty DAG" - assert len(parents) == 0, "Parents array should be empty when source is masked" - assert len(via_edges) == 0, "Via edges array should be empty when source is masked" - - -def test_masked_source_with_destination(line_graph_3, algs, to_handle): - """Test masked source with explicit destination.""" - g = line_graph_3 - - # Mask out source - node_mask = np.array([False, True, True], dtype=bool) - - sel = ngc.EdgeSelection( - multi_edge=True, - require_capacity=False, - ) - - dist, dag = algs.spf(to_handle(g), 0, dst=2, selection=sel, node_mask=node_mask) - - # Destination should be unreachable - assert np.isinf(dist[2]), "Destination unreachable when source is masked" - - # DAG should be empty - offsets = np.asarray(dag.parent_offsets) - assert np.all(offsets == 0) - - -def test_masked_source_in_max_flow(line_graph_3, algs, to_handle): - """Test that max_flow correctly handles masked source.""" - g = line_graph_3 - - # Mask out source - node_mask = np.array([False, True, True], dtype=bool) - - flow_val, summary = algs.max_flow( - to_handle(g), - 0, - 2, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - require_capacity=True, - with_edge_flows=True, - node_mask=node_mask, - ) - - # No flow should be possible - assert np.isclose(flow_val, 0.0), "No flow possible when source is masked" - - # Edge flows array should be empty or all zeros - if len(summary.edge_flows) > 0: - assert np.all(np.isclose(summary.edge_flows, 0.0)) - - -def test_masked_source_in_ksp(line_graph_3, algs, to_handle): - """Test that k_shortest_paths correctly handles masked source.""" - g = line_graph_3 - - # Mask out source - node_mask = np.array([False, True, True], dtype=bool) - - results = algs.ksp(to_handle(g), 0, 2, k=2, unique=True, node_mask=node_mask) - - # Should find no paths - assert len(results) == 0, "No paths should be found when source is masked" - - -def test_masked_source_flow_state_compute_min_cut(triangle_graph, algs): - """Test FlowState.compute_min_cut with masked source.""" - g = triangle_graph - fs = ngc.FlowState(g) - - # Mask out source - node_mask = np.array([False, True, True], dtype=bool) - - mincut = fs.compute_min_cut(0, node_mask=node_mask) - - # Min cut should be empty when source is masked - assert len(mincut.edges) == 0, "Min cut should be empty when source is masked" - - -# ============================================================================ -# Masked Destination Tests -# ============================================================================ - - -def test_masked_destination_unreachable(line_graph_3, algs, to_handle): - """Test that masked destination is correctly marked unreachable.""" - g = line_graph_3 - - # Mask out destination node 2 - node_mask = np.array([True, True, False], dtype=bool) - - sel = ngc.EdgeSelection(multi_edge=True, require_capacity=False) - - dist, dag = algs.spf(to_handle(g), 0, selection=sel, node_mask=node_mask) - - # Source and node 1 should be reachable - assert np.isclose(dist[0], 0.0) - assert np.isclose(dist[1], 1.0) - - # Destination should be unreachable - assert np.isinf(dist[2]), "Masked destination should be unreachable" - - # Node 2 should have no predecessors - offsets = np.asarray(dag.parent_offsets) - assert offsets[2] == offsets[3], "Masked node should have no predecessors" - - -def test_masked_intermediate_node(line_graph_3, algs, to_handle): - """Test that masking intermediate node blocks path.""" - g = line_graph_3 - - # Mask out intermediate node 1 - node_mask = np.array([True, False, True], dtype=bool) - - sel = ngc.EdgeSelection(multi_edge=True, require_capacity=False) - - dist, dag = algs.spf(to_handle(g), 0, dst=2, selection=sel, node_mask=node_mask) - - # Source reachable - assert np.isclose(dist[0], 0.0) - - # Nodes 1 and 2 should be unreachable - assert np.isinf(dist[1]), "Masked intermediate node unreachable" - assert np.isinf(dist[2]), "Destination unreachable when path is blocked" - - -# ============================================================================ -# Edge Mask Tests -# ============================================================================ - - -def test_masked_edge_blocks_path(line_graph_3, algs, to_handle): - """Test that masked edge blocks path traversal.""" - g = line_graph_3 - - # Mask out first edge (0->1) - edge_mask = np.array([False, True], dtype=bool) - - sel = ngc.EdgeSelection(multi_edge=True, require_capacity=False) - - dist, dag = algs.spf(to_handle(g), 0, selection=sel, edge_mask=edge_mask) - - assert np.isclose(dist[0], 0.0) - assert np.isinf(dist[1]), "Node unreachable when edge is masked" - assert np.isinf(dist[2]), "Downstream nodes unreachable when edge is masked" - - -def test_masked_edge_selects_alternative_path(parallel_paths_graph, algs, to_handle): - """Test that masking one edge allows alternative path.""" - g = parallel_paths_graph - - # Mask out edge 0->1 (first edge) - edge_mask = np.array([False, True, True, True], dtype=bool) - - sel = ngc.EdgeSelection(multi_edge=True, require_capacity=False) - - dist, dag = algs.spf(to_handle(g), 0, dst=3, selection=sel, edge_mask=edge_mask) - - # Should still reach destination via alternative path 0->2->3 - assert np.isclose(dist[0], 0.0) - assert np.isinf(dist[1]), "Node 1 unreachable via masked edge" - assert np.isclose(dist[2], 1.0), "Node 2 reachable via unmasked path" - assert np.isclose(dist[3], 2.0), "Destination reachable via alternative path" - - -def test_multiple_edges_masked(parallel_paths_graph, algs, to_handle): - """Test masking multiple edges.""" - g = parallel_paths_graph - - # Mask out second hop on first path - edge_mask = np.array([True, False, True, True], dtype=bool) - - sel = ngc.EdgeSelection(multi_edge=True, require_capacity=False) - - dist, dag = algs.spf(to_handle(g), 0, selection=sel, edge_mask=edge_mask) - - # Should reach destination via second path only - assert np.isclose(dist[0], 0.0) - assert np.isclose(dist[3], 2.0), "Destination reachable via unmasked path" - - -# ============================================================================ -# Combined Node and Edge Mask Tests -# ============================================================================ - - -def test_combined_node_and_edge_masks(parallel_paths_graph, algs, to_handle): - """Test that node and edge masks work together correctly.""" - g = parallel_paths_graph - - # Mask node 1 and edge 2->3 - node_mask = np.array([True, False, True, True], dtype=bool) - edge_mask = np.array([True, True, True, False], dtype=bool) - - sel = ngc.EdgeSelection(multi_edge=True, require_capacity=False) - - dist, dag = algs.spf( - to_handle(g), 0, selection=sel, node_mask=node_mask, edge_mask=edge_mask - ) - - # Source reachable - assert np.isclose(dist[0], 0.0) - - # Node 1 masked out - assert np.isinf(dist[1]) - - # Node 2 reachable but path to 3 blocked by edge mask - assert np.isclose(dist[2], 1.0) - assert np.isinf(dist[3]), "Destination unreachable due to edge mask" - - -def test_node_mask_overrides_edge_availability(line_graph_3, algs, to_handle): - """Test that node mask effectively blocks all edges to/from that node.""" - g = line_graph_3 - - # Mask node 1, but allow all edges - node_mask = np.array([True, False, True], dtype=bool) - edge_mask = np.array([True, True], dtype=bool) - - sel = ngc.EdgeSelection(multi_edge=True, require_capacity=False) - - dist, dag = algs.spf( - to_handle(g), 0, selection=sel, node_mask=node_mask, edge_mask=edge_mask - ) - - # Node 1 and beyond should be unreachable despite edges being "allowed" - assert np.isclose(dist[0], 0.0) - assert np.isinf(dist[1]), "Masked node unreachable despite edge mask" - assert np.isinf(dist[2]), "Downstream nodes unreachable" - - -# ============================================================================ -# Mask Interaction with Residual Capacity -# ============================================================================ - - -def test_mask_with_residual_capacity(build_graph, algs, to_handle): - """Test that masks work correctly with residual capacity filtering.""" - # Create line graph 0->1->2->3 - edges = [ - (0, 1, 1.0, 1, 0), - (1, 2, 1.0, 1, 1), - (2, 3, 1.0, 1, 2), - ] - g = build_graph(4, edges) - - # Set residual capacity (zero on edge 1->2) - residual = np.array([1.0, 0.0, 1.0], dtype=np.float64) - - # Also mask edge 2->3 - edge_mask = np.array([True, True, False], dtype=bool) - - sel = ngc.EdgeSelection( - multi_edge=True, - require_capacity=True, # Enable residual filtering - ) - - dist, dag = algs.spf( - to_handle(g), 0, selection=sel, residual=residual, edge_mask=edge_mask - ) - - # Should reach node 1 but not beyond - assert np.isclose(dist[0], 0.0) - assert np.isclose(dist[1], 1.0) - assert np.isinf(dist[2]), "Blocked by residual capacity" - assert np.isinf(dist[3]), "Blocked by mask" - - -def test_residual_and_node_mask_combined(parallel_paths_graph, algs, to_handle): - """Test combination of residual capacity and node mask.""" - g = parallel_paths_graph - - # Zero capacity on one path - residual = np.array([0.0, 1.0, 1.0, 1.0], dtype=np.float64) - - # Mask intermediate node on other path - node_mask = np.array([True, True, False, True], dtype=bool) - - sel = ngc.EdgeSelection( - multi_edge=True, - require_capacity=True, - ) - - dist, dag = algs.spf( - to_handle(g), 0, dst=3, selection=sel, residual=residual, node_mask=node_mask - ) - - # Destination should be unreachable (both paths blocked) - assert np.isinf(dist[3]), "Both paths blocked" - - -# ============================================================================ -# Max Flow with Masks -# ============================================================================ - - -def test_max_flow_with_node_mask(parallel_paths_graph, algs, to_handle): - """Test max_flow with node mask blocking one path.""" - g = parallel_paths_graph - - # Mask out node 1 (blocks one path) - node_mask = np.array([True, False, True, True], dtype=bool) - - flow_val, summary = algs.max_flow( - to_handle(g), - 0, - 3, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - require_capacity=True, - with_edge_flows=True, - node_mask=node_mask, - ) - - # Should get flow only through remaining path (0->2->3) - assert flow_val > 0.0, "Some flow possible through unmasked path" - assert flow_val <= 1.0, "Only one path available" - - # Verify path 0->1->3 is blocked (node 1 masked) and flow uses 0->2->3 - # Due to multipath SPF, we need to check actual flow distribution - # The important thing is flow_val > 0 which proves masking doesn't block everything - - -def test_max_flow_with_edge_mask(triangle_graph, algs, to_handle): - """Test max_flow with edge mask.""" - g = triangle_graph - - # Find and mask direct edge 0->2 - edge_src = g.edge_src_view() - edge_dst = g.edge_dst_view() - edge_mask = np.ones(g.num_edges(), dtype=bool) - - for e in range(g.num_edges()): - if edge_src[e] == 0 and edge_dst[e] == 2: - edge_mask[e] = False - - flow_val, summary = algs.max_flow( - to_handle(g), - 0, - 2, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - require_capacity=True, - with_edge_flows=True, - edge_mask=edge_mask, - ) - - # Should get flow only through indirect path 0->1->2 - assert flow_val > 0.0, "Flow possible through indirect path" - assert flow_val <= 1.0, "Limited by bottleneck" - - # Verify masked edge has no flow - for e in range(g.num_edges()): - if edge_src[e] == 0 and edge_dst[e] == 2: - assert np.isclose(summary.edge_flows[e], 0.0), ( - "Masked edge should have zero flow" - ) - - -def test_max_flow_all_paths_masked(line_graph_3, algs, to_handle): - """Test max_flow when all paths are masked.""" - g = line_graph_3 - - # Mask intermediate node to block all paths - node_mask = np.array([True, False, True], dtype=bool) - - flow_val, summary = algs.max_flow( - to_handle(g), - 0, - 2, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - require_capacity=True, - with_edge_flows=True, - node_mask=node_mask, - ) - - # No flow should be possible - assert np.isclose(flow_val, 0.0), "No flow possible when all paths masked" - - # All edge flows should be zero - assert np.all(np.isclose(summary.edge_flows, 0.0)) - - -# ============================================================================ -# K-Shortest Paths with Masks -# ============================================================================ - - -def test_ksp_with_node_mask(build_graph, algs, to_handle): - """Test k_shortest_paths with node mask.""" - # Create graph with two paths - edges = [ - (0, 1, 1.0, 1, 0), - (1, 2, 1.0, 1, 1), - (0, 3, 1.0, 1, 2), - (3, 2, 1.0, 1, 3), - ] - g = build_graph(4, edges) - - # Mask out node 1 (blocks one path) - node_mask = np.array([True, False, True, True], dtype=bool) - - results = algs.ksp(to_handle(g), 0, 2, k=2, unique=True, node_mask=node_mask) - - # Should find at most 1 path (through node 3) - assert len(results) <= 1, "Only one path available with mask" - - if len(results) > 0: - dist, dag = results[0] - # Destination should be reachable via alternate path - assert dist[2] > 0.0 - assert not np.isinf(dist[2]) - - -def test_ksp_with_edge_mask(build_graph, algs, to_handle): - """Test k_shortest_paths with edge mask.""" - # Create graph with two paths - edges = [ - (0, 1, 1.0, 1, 0), - (1, 2, 1.0, 1, 1), - (0, 3, 1.0, 2, 2), - (3, 2, 1.0, 2, 3), - ] - g = build_graph(4, edges) - - # Mask out first edge - edge_mask = np.array([False, True, True, True], dtype=bool) - - results = algs.ksp(to_handle(g), 0, 2, k=2, unique=True, edge_mask=edge_mask) - - # Should find path through node 3 only - assert len(results) >= 1, "Should find at least one path" - - if len(results) > 0: - dist, dag = results[0] - # Cost should be 2.0 (path 0->3->2 with cost 1+1=2) - assert np.isclose(dist[2], 2.0), ( - "Should use alternative path when shorter is masked" - ) - - -# ============================================================================ -# Empty Mask Tests -# ============================================================================ - - -def test_empty_node_mask_allows_all(line_graph_3, algs, to_handle): - """Test that empty mask means no masking (all nodes allowed).""" - g = line_graph_3 - - sel = ngc.EdgeSelection(multi_edge=True, require_capacity=False) - - # No mask provided - dist, dag = algs.spf(to_handle(g), 0, selection=sel) - - # All nodes should be reachable - assert np.isclose(dist[0], 0.0) - assert np.isclose(dist[1], 1.0) - assert np.isclose(dist[2], 2.0) - - -def test_empty_edge_mask_allows_all(line_graph_3, algs, to_handle): - """Test that empty edge mask means no masking (all edges allowed).""" - g = line_graph_3 - - sel = ngc.EdgeSelection(multi_edge=True, require_capacity=False) - - # No edge mask provided - dist, dag = algs.spf(to_handle(g), 0, selection=sel) - - # All nodes should be reachable - for i in range(g.num_nodes()): - assert not np.isinf(dist[i]), f"Node {i} should be reachable" - - -# ============================================================================ -# All-False Mask Tests -# ============================================================================ - - -def test_all_nodes_masked_no_reachability(line_graph_3, algs, to_handle): - """Test that masking all nodes makes everything unreachable.""" - g = line_graph_3 - - # Mask all nodes - node_mask = np.array([False, False, False], dtype=bool) - - sel = ngc.EdgeSelection(multi_edge=True, require_capacity=False) - - dist, dag = algs.spf(to_handle(g), 0, selection=sel, node_mask=node_mask) - - # All nodes unreachable (including source) - for i in range(g.num_nodes()): - assert np.isinf(dist[i]), f"Node {i} should be unreachable" - - # DAG should be empty - offsets = np.asarray(dag.parent_offsets) - assert np.all(offsets == 0), "DAG should be empty" - - -def test_all_edges_masked_only_source_reachable(line_graph_3, algs, to_handle): - """Test that masking all edges makes only source reachable.""" - g = line_graph_3 - - # Mask all edges - edge_mask = np.array([False, False], dtype=bool) - - sel = ngc.EdgeSelection(multi_edge=True, require_capacity=False) - - dist, dag = algs.spf(to_handle(g), 0, selection=sel, edge_mask=edge_mask) - - # Only source should be reachable - assert np.isclose(dist[0], 0.0), "Source should be reachable" - - # All other nodes unreachable - for i in range(1, g.num_nodes()): - assert np.isinf(dist[i]), f"Node {i} should be unreachable" - - -# ============================================================================ -# Mask Validation Tests -# ============================================================================ - - -def test_invalid_node_mask_length_raises_error(line_graph_3, algs, to_handle): - """Test that incorrect mask length raises an error.""" - g = line_graph_3 - - # Wrong length mask - node_mask = np.array([True, True], dtype=bool) # Should be length 3 - - sel = ngc.EdgeSelection(multi_edge=True, require_capacity=False) - - with pytest.raises(TypeError, match="node_mask"): - algs.spf(to_handle(g), 0, selection=sel, node_mask=node_mask) - - -def test_invalid_edge_mask_length_raises_error(line_graph_3, algs, to_handle): - """Test that incorrect edge mask length raises an error.""" - g = line_graph_3 - - # Wrong length mask - edge_mask = np.array([True], dtype=bool) # Should be length 2 - - sel = ngc.EdgeSelection(multi_edge=True, require_capacity=False) - - with pytest.raises(TypeError, match="edge_mask"): - algs.spf(to_handle(g), 0, selection=sel, edge_mask=edge_mask) - - -def test_non_bool_mask_raises_error(line_graph_3, algs, to_handle): - """Test that non-boolean mask raises an error.""" - g = line_graph_3 - - # Integer mask instead of bool - node_mask = np.array([1, 1, 1], dtype=np.int32) - - sel = ngc.EdgeSelection(multi_edge=True, require_capacity=False) - - with pytest.raises(TypeError, match="bool"): - algs.spf(to_handle(g), 0, selection=sel, node_mask=node_mask) diff --git a/tests/py/test_max_flow.py b/tests/py/test_max_flow.py deleted file mode 100644 index 645730a..0000000 --- a/tests/py/test_max_flow.py +++ /dev/null @@ -1,310 +0,0 @@ -"""Max-flow end-to-end: placement modes, augmentation depth, and min-cut.""" - -import numpy as np - -import netgraph_core as ngc - - -def _make_graph(num_nodes, src, dst, cap, cost): - """Helper to build graph with auto-generated ext_edge_ids.""" - ext_edge_ids = np.arange(len(src), dtype=np.int64) - return ngc.StrictMultiDiGraph.from_arrays( - num_nodes, src, dst, cap, cost, ext_edge_ids - ) - - -def test_max_flow_square1_proportional_with_edge_flows( - square1_graph, - flows_by_eid, - assert_edge_flows_shape, - assert_valid_min_cut, - algs, - to_handle, -): - g = square1_graph - total, summary = algs.max_flow( - to_handle(g), - 0, - 2, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - with_edge_flows=True, - ) - # Multi-tier: cost 2 path carries 1, then cost 4 path carries 2 => total 3 - assert np.isclose(total, 3.0) - assert_edge_flows_shape(g, summary, expected_present=True) - assert_valid_min_cut(g, summary.min_cut, total) - fb = flows_by_eid(g, summary.edge_flows) - # Check totals on 4 edges in this small graph (order is deterministic by compaction) - assert len(fb) == g.num_edges() - # Edge flows are per-edge amounts; summing across all edges counts each unit once per edge - # along its path. For two-edge paths with total 3, the sum is 2 * 3 = 6. - assert np.isclose(np.asarray(summary.edge_flows).sum(), 6.0) - # Cost distribution checks live in test_max_flow_cost_distribution.py - - -def test_square1_equal_balanced_min_cut_and_distribution( - square1_graph, assert_edge_flows_shape, assert_valid_min_cut, algs, to_handle -): - g = square1_graph - total, summary = algs.max_flow( - to_handle(g), - 0, - 2, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - shortest_path=False, - with_edge_flows=True, - ) - # Total flow should be 3 (1 along A->B->C and 2 along A->D->C) - assert np.isclose(total, 3.0) - # Min-cut may be edges out of A or into C; accept either set - # Min-cut returns EdgeIds; ensure it has size 2 and corresponds to cut around source or sink - mc = set(map(int, summary.min_cut.edges)) - assert len(mc) == 2 - # EQUAL_BALANCED places less than the maximum, so duality does not apply. - assert_valid_min_cut(g, summary.min_cut) - assert_edge_flows_shape(g, summary, expected_present=True) - # Cost distribution checks live in test_max_flow_cost_distribution.py - - -def test_max_flow_line1_proportional_full_and_shortest(line1_graph, algs, to_handle): - g = line1_graph - total_full, _ = algs.max_flow( - to_handle(g), - 0, - 2, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - with_edge_flows=False, - ) - total_sp, _ = algs.max_flow( - to_handle(g), - 0, - 2, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=True, - with_edge_flows=False, - ) - assert np.isclose(total_full, 5.0) - assert np.isclose(total_sp, 4.0) - - -def test_max_flow_graph5_proportional_full_and_shortest( - make_fully_connected_graph, algs, to_handle -): - # Fully connected graph with 5 nodes (A..E), capacity 1 per edge - # Build: nodes 0..4 fully connected excluding self - g = make_fully_connected_graph(5, cost=1.0, cap=1.0) - total_full, _ = algs.max_flow( - to_handle(g), - 0, - 1, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - with_edge_flows=False, - ) - total_sp, _ = algs.max_flow( - to_handle(g), - 0, - 1, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=True, - with_edge_flows=False, - ) - assert np.isclose(total_full, 4.0) - assert np.isclose(total_sp, 1.0) - - -def test_max_flow_square1_shortest_path_single_augmentation( - square1_graph, - flows_by_eid, - assert_edge_flows_shape, - assert_valid_min_cut, - algs, - to_handle, -): - g = square1_graph - total, summary = algs.max_flow( - to_handle(g), - 0, - 2, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=True, - with_edge_flows=True, - ) - assert np.isclose(total, 1.0) - assert_edge_flows_shape(g, summary, expected_present=True) - # shortest_path=True is a single augmentation, not a maximum flow. - assert_valid_min_cut(g, summary.min_cut) - fb = flows_by_eid(g, summary.edge_flows) - # Should augment along A->B->C only once - assert np.isclose(fb[0], 1.0) - assert np.isclose(fb[1], 1.0) - assert np.isclose(fb[2], 0.0) - assert np.isclose(fb[3], 0.0) - - -def test_max_flow_line1_equal_balanced( - line1_graph, - flows_by_eid, - assert_edge_flows_shape, - assert_valid_min_cut, - algs, - to_handle, -): - g = line1_graph - total, summary = algs.max_flow( - to_handle(g), - 0, - 2, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - shortest_path=False, - with_edge_flows=True, - ) - # Equal-balanced across tiers: limited by A->B capacity => total 5 - assert np.isclose(total, 5.0) - assert_edge_flows_shape(g, summary, expected_present=True) - # EQUAL_BALANCED places less than the maximum, so duality does not apply. - assert_valid_min_cut(g, summary.min_cut) - fb = flows_by_eid(g, summary.edge_flows) - # Expect 2 across min-cost tier (1 + 1), then remaining 3 on min/higher edges by successive tiers - assert np.isclose(fb[0], 5.0) # A->B carries all - # Edges are compacted deterministically; verify flows on B->C parallels - assert np.isclose(fb[1], 1.0) - assert np.isclose(fb[2], 3.0) - assert np.isclose(fb[3], 1.0) - - -def test_max_flow_ecmp3_equal_balanced(algs, to_handle): - # 0->{1,2,3}->{4}, all caps=5, equal costs - src = np.array([0, 0, 0, 1, 2, 3], dtype=np.int32) - dst = np.array([1, 2, 3, 4, 4, 4], dtype=np.int32) - cap = np.array([5.0, 5.0, 5.0, 5.0, 5.0, 5.0], dtype=np.float64) - cost = np.array([1, 1, 1, 1, 1, 1], dtype=np.int64) - g = _make_graph(5, src, dst, cap, cost) - total, summary = algs.max_flow( - to_handle(g), - 0, - 4, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - shortest_path=False, - with_edge_flows=True, - ) - # Total is limited by downstream 3*5 = 15 - assert np.isclose(total, 15.0) - fb = np.asarray(summary.edge_flows) - # First hop equalized: three edges out of 0 carry 5 each - assert np.allclose(fb[:3], [5.0, 5.0, 5.0]) - # Second hop equalized: three edges into 4 carry 5 each - assert np.allclose(fb[3:], [5.0, 5.0, 5.0]) - - -def test_max_flow_downstream_equal_balanced(algs, to_handle): - # Downstream split: 0->1 (10), then 1->{2,3}(5 each), then to 4 (5 each) - src = np.array([0, 1, 1, 2, 3], dtype=np.int32) - dst = np.array([1, 2, 3, 4, 4], dtype=np.int32) - cap = np.array([10.0, 5.0, 5.0, 5.0, 5.0], dtype=np.float64) - cost = np.array([1, 1, 1, 1, 1], dtype=np.int64) - g = _make_graph(5, src, dst, cap, cost) - total, summary = algs.max_flow( - to_handle(g), - 0, - 4, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - shortest_path=False, - with_edge_flows=True, - ) - assert np.isclose(total, 10.0) - fb = np.asarray(summary.edge_flows) - # First hop carries all 10 - assert np.isclose(fb[0], 10.0) - # Downstream equal split 5/5 - assert np.isclose(fb[1], 5.0) - assert np.isclose(fb[2], 5.0) - assert np.isclose(fb[3], 5.0) - assert np.isclose(fb[4], 5.0) - - -def test_max_flow_graph3_proportional_parallel_distribution( - graph3, flows_by_eid, assert_edge_flows_shape, assert_valid_min_cut, algs, to_handle -): - # A=0, B=1, C=2, D=3, E=4, F=5 - g = graph3 - total, summary = algs.max_flow( - to_handle(g), - 0, - 2, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - with_edge_flows=True, - ) - # Incoming to C via min-cost parents: from B (cap 1+2+3=6) and from E (cap 4) => total 10 - assert np.isclose(total, 10.0) - assert_edge_flows_shape(g, summary, expected_present=True) - assert_valid_min_cut(g, summary.min_cut, total) - fb = flows_by_eid(g, summary.edge_flows) - # B->C parallels proportional to capacity: 1:2:3 over total 6 => 1,2,3 - assert np.isclose(fb[4], 1.0) - assert np.isclose(fb[5], 2.0) - assert np.isclose(fb[6], 3.0) - # E->C should carry 4 - assert np.isclose(fb[8], 4.0) - # A->B proportional to carry 6 (total to B): 2:4:6 over sum 12 => 1,2,3 - assert np.isclose(fb[0], 1.0) - assert np.isclose(fb[1], 2.0) - assert np.isclose(fb[2], 3.0) - # A->E carries 4 - assert np.isclose(fb[3], 4.0) - - -def test_max_flow_two_disjoint_shortest_routes_proportional( - two_disjoint_shortest_graph, - flows_by_eid, - assert_edge_flows_shape, - assert_valid_min_cut, - algs, - to_handle, -): - # S=0, A=1, B=2, T=3 - # Two disjoint shortest paths S->A->T and S->B->T with equal total cost - g = two_disjoint_shortest_graph - total, summary = algs.max_flow( - to_handle(g), - 0, - 3, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - with_edge_flows=True, - ) - # Bottlenecks along S->A->T = min(3,2)=2 and S->B->T = min(4,1)=1 => total 3 - assert np.isclose(total, 3.0) - assert_edge_flows_shape(g, summary, expected_present=True) - assert_valid_min_cut(g, summary.min_cut, total) - fb = flows_by_eid(g, summary.edge_flows) - assert np.isclose(fb[0], 2.0) # S->A - assert np.isclose(fb[2], 2.0) # A->T - assert np.isclose(fb[1], 1.0) # S->B - assert np.isclose(fb[3], 1.0) # B->T - # Cost distribution checks live in test_max_flow_cost_distribution.py - - -def test_max_flow_square4_full_and_shortest_proportional( - square4_graph, algs, to_handle -): - g = square4_graph - total_full, _ = algs.max_flow( - to_handle(g), - 0, - 1, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - ) - total_sp, _ = algs.max_flow( - to_handle(g), - 0, - 1, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=True, - ) - assert np.isclose(total_full, 350.0) - assert np.isclose(total_sp, 100.0) diff --git a/tests/py/test_max_flow_cost_distribution.py b/tests/py/test_max_flow_cost_distribution.py deleted file mode 100644 index 8a7f135..0000000 --- a/tests/py/test_max_flow_cost_distribution.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Max-flow cost distribution analytics across scenarios.""" - -import numpy as np - -import netgraph_core as ngc - - -def test_cost_distribution_multiple_paths(build_graph, cost_dist_dict, algs, to_handle): - # S=0, A=1, B=2, T=3 - # Path1: S->A->T cost=2, cap=5; Path2: S->B->T cost=4, cap=3 - edges = [ - (0, 1, 1, 5, 0), - (1, 3, 1, 5, 1), - (0, 2, 2, 3, 2), - (2, 3, 2, 3, 3), - ] - g = build_graph(4, edges) - total, summary = algs.max_flow( - to_handle(g), - 0, - 3, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - with_edge_flows=False, - ) - assert np.isclose(total, 8.0) - d = cost_dist_dict(summary) - assert np.isclose(d[2.0], 5.0) - assert np.isclose(d[4.0], 3.0) - - -def test_cost_distribution_single_path(build_graph, cost_dist_dict, algs, to_handle): - # A=0, B=1, C=2; cost=3+2=5; cap=10 - edges = [ - (0, 1, 3, 10, 0), - (1, 2, 2, 10, 1), - ] - g = build_graph(3, edges) - total, summary = algs.max_flow( - to_handle(g), - 0, - 2, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - with_edge_flows=False, - ) - assert np.isclose(total, 10.0) - d = cost_dist_dict(summary) - assert np.isclose(d[5.0], 10.0) - - -def test_cost_distribution_equal_cost_paths( - build_graph, cost_dist_dict, algs, to_handle -): - # S=0, A=1, B=2, T=3; two paths both cost=2, caps 4 and 6 - edges = [ - (0, 1, 1, 4, 0), - (1, 3, 1, 4, 1), - (0, 2, 1, 6, 2), - (2, 3, 1, 6, 3), - ] - g = build_graph(4, edges) - total, summary = algs.max_flow( - to_handle(g), - 0, - 3, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - with_edge_flows=False, - ) - assert np.isclose(total, 10.0) - d = cost_dist_dict(summary) - assert np.isclose(d[2.0], 10.0) - - -def test_cost_distribution_three_tiers(build_graph, cost_dist_dict, algs, to_handle): - # S=0, A=1, B=2, C=3, T=4 - # Tier1: cost=1 cap=2 (0->1 cost1, 1->4 cost0) - # Tier2: cost=3 cap=4 (0->2 cost2, 2->4 cost1) - # Tier3: cost=6 cap=3 (0->3 cost3, 3->4 cost3) - edges = [ - (0, 1, 1, 2, 0), - (1, 4, 0, 2, 1), - (0, 2, 2, 4, 2), - (2, 4, 1, 4, 3), - (0, 3, 3, 3, 4), - (3, 4, 3, 3, 5), - ] - g = build_graph(5, edges) - total, summary = algs.max_flow( - to_handle(g), - 0, - 4, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - with_edge_flows=False, - ) - assert np.isclose(total, 9.0) - d = cost_dist_dict(summary) - assert np.isclose(d[1.0], 2.0) - assert np.isclose(d[3.0], 4.0) - assert np.isclose(d[6.0], 3.0) - - -def test_cost_distribution_no_flow(build_graph, cost_dist_dict, algs, to_handle): - g = build_graph(2, []) # No edges - total, summary = algs.max_flow( - to_handle(g), - 0, - 1, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - with_edge_flows=False, - ) - assert np.isclose(total, 0.0) - assert np.asarray(summary.costs).size == 0 and np.asarray(summary.flows).size == 0 - - -def test_cost_distribution_shortest_path_mode( - build_graph, cost_dist_dict, algs, to_handle -): - # Two paths: cost=2 cap=5; cost=4 cap=3; shortest_path=True -> only first tier - edges = [ - (0, 1, 1, 5, 0), - (1, 3, 1, 5, 1), - (0, 2, 2, 3, 2), - (2, 3, 2, 3, 3), - ] - g = build_graph(4, edges) - total, summary = algs.max_flow( - to_handle(g), - 0, - 3, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=True, - with_edge_flows=False, - ) - assert np.isclose(total, 5.0) - d = cost_dist_dict(summary) - assert len(d) == 1 - assert np.isclose(d[2.0], 5.0) - - -def test_cost_distribution_capacity_bottleneck( - build_graph, cost_dist_dict, algs, to_handle -): - # Cheap path bottlenecked at 2 (cost 1); expensive path cost 3 cap 5 - edges = [ - (0, 1, 1, 10, 0), - (1, 3, 0, 2, 1), # bottleneck at 2 - (0, 2, 2, 5, 2), - (2, 3, 1, 5, 3), - ] - g = build_graph(4, edges) - total, summary = algs.max_flow( - to_handle(g), - 0, - 3, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - with_edge_flows=False, - ) - assert np.isclose(total, 7.0) - d = cost_dist_dict(summary) - assert np.isclose(d[1.0], 2.0) - assert np.isclose(d[3.0], 5.0) diff --git a/tests/py/test_max_flow_summary.py b/tests/py/test_max_flow_summary.py deleted file mode 100644 index 609b3e3..0000000 --- a/tests/py/test_max_flow_summary.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Max-flow FlowSummary semantics: shapes and MinCut validity.""" - -from __future__ import annotations - -import numpy as np - -import netgraph_core as ngc - - -def _two_path_graph(build_graph): - # S=0, A=1, B=2, T=3 - # Path1 cost=2 cap=5; Path2 cost=4 cap=3 - edges = [ - (0, 1, 1, 5, 0), - (1, 3, 1, 5, 1), - (0, 2, 2, 3, 2), - (2, 3, 2, 3, 3), - ] - return build_graph(4, edges) - - -def test_flow_summary_shapes_and_min_cut_valid( - build_graph, assert_edge_flows_shape, assert_valid_min_cut, algs, to_handle -): - g = _two_path_graph(build_graph) - total, summary = algs.max_flow( - to_handle(g), - 0, - 3, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - with_edge_flows=True, - ) - assert np.isclose(total, 8.0) - assert_edge_flows_shape(g, summary, expected_present=True) - assert_valid_min_cut(g, summary.min_cut, total) - # Cost distribution checked in test_max_flow_cost_distribution.py - - -def test_flow_summary_without_edge_flows( - build_graph, assert_edge_flows_shape, algs, to_handle -): - g = _two_path_graph(build_graph) - total, summary = algs.max_flow( - to_handle(g), - 0, - 3, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - shortest_path=False, - with_edge_flows=False, - ) - assert np.isclose(total, 8.0) - assert_edge_flows_shape(g, summary, expected_present=False) diff --git a/tests/py/test_maxflow_certificate.py b/tests/py/test_maxflow_certificate.py deleted file mode 100644 index da58792..0000000 --- a/tests/py/test_maxflow_certificate.py +++ /dev/null @@ -1,197 +0,0 @@ -"""Oracle-free correctness certificates for max-flow. - -The suite historically checked max-flow results against hand-computed values on a -handful of fixed topologies. That misses defects whose trigger is a graph nobody -thought to write down -- the 0.7.2 under-reporting bug (fixed in 0.8.0) was not -exposed by any fixture in the suite, at any src/dst pair. - -These tests take the opposite approach: generate graphs in the region where such -defects live (dispersed edge costs, full-duplex links) and check a *certificate of -optimality* rather than a recorded number. See the ``certify_max_flow`` fixture for -the four properties; together they prove maximality, so no external oracle and no -stored expected value is required. -""" - -from __future__ import annotations - -import numpy as np -import pytest - -import netgraph_core as ngc - -# A fixed seed keeps these deterministic, so a given defect is either caught on every -# run or never -- which means the corpus has to be shown to have teeth, not assumed to. -# This seed was picked because its corpus trips the 0.7.2 under-reporting bug within the -# first few graphs; both sweeps below were confirmed to fail against 0.7.2 and pass -# against 0.8.0. An arbitrary seed needed ~5750 graphs to hit the same defect, so this -# is purely a cheaper corpus, not a narrower one. -SEED = 198 -QUICK_GRAPHS = 2000 -THOROUGH_GRAPHS = 20000 - - -def _random_graph(rng: np.random.Generator) -> tuple[ngc.StrictMultiDiGraph, int, int]: - """Build a random full-duplex graph with dispersed costs. - - Cost dispersion is the essential ingredient: with a single cost tier there is - nothing for shortest-path tier ordering to get wrong, and randomized checks over - equal-cost graphs find nothing. - """ - n = int(rng.integers(4, 9)) - m = int(rng.integers(n, 2 * n)) - links = [] - for _ in range(m): - u, v = rng.choice(n, 2, replace=False) - cap = float(rng.integers(1, 6)) - cost = int(rng.integers(1, 21)) - links.append((int(u), int(v), cap, cost)) - - edges = [e for u, v, c, k in links for e in ((u, v, c, k), (v, u, c, k))] - src = np.array([e[0] for e in edges], dtype=np.int32) - dst = np.array([e[1] for e in edges], dtype=np.int32) - cap = np.array([e[2] for e in edges], dtype=np.float64) - cost = np.array([e[3] for e in edges], dtype=np.int64) - g = ngc.StrictMultiDiGraph.from_arrays( - n, src, dst, cap, cost, np.arange(len(edges), dtype=np.int64) - ) - return g, 0, n - 1 - - -def _sweep(count: int, algs, certify_max_flow) -> None: - rng = np.random.default_rng(SEED) - for i in range(count): - g, s, t = _random_graph(rng) - total, summary = algs.max_flow(algs.build_graph(g), s, t, with_edge_flows=True) - try: - certify_max_flow(g, summary, total, s, t) - except AssertionError as exc: # pragma: no cover - only on a real defect - pytest.fail(f"certificate failed on random graph #{i} (seed {SEED}): {exc}") - - -def test_random_graphs_certify(algs, certify_max_flow): - """Every generated max-flow result must carry a valid optimality certificate.""" - _sweep(QUICK_GRAPHS, algs, certify_max_flow) - - -@pytest.mark.slow -def test_random_graphs_certify_thorough(algs, certify_max_flow): - """Wider sweep of the same generator.""" - _sweep(THOROUGH_GRAPHS, algs, certify_max_flow) - - -FIXTURES = [ - "line1_graph", - "square1_graph", - "square2_graph", - "graph3", - "square4_graph", - "triangle1_graph", -] - - -@pytest.mark.parametrize("fixture_name", FIXTURES) -def test_named_fixtures_certify_all_pairs( - fixture_name, request, algs, certify_max_flow -): - """Certify the shared fixtures for every src/dst pair, not just the tested one. - - A fixture is usually queried for a single pair; the remaining pairs are free - coverage of the same topology. - """ - g = request.getfixturevalue(fixture_name) - handle = algs.build_graph(g) - n = g.num_nodes() - for s in range(n): - for t in range(n): - if s == t: - continue - total, summary = algs.max_flow(handle, s, t, with_edge_flows=True) - certify_max_flow(g, summary, total, s, t) - - -def test_certificate_rejects_a_non_maximal_flow(square1_graph, algs, certify_max_flow): - """The certificate must have teeth. - - ``shortest_path=True`` performs a single augmentation and is deliberately not - maximal, so it is a genuine non-maximal result reachable through the public API. - Certifying it as maximal must fail. Without this guard the certificate could - silently degrade into an assertion that cannot fail -- which is exactly how the - previous ``assert_valid_min_cut`` helper missed the 0.7.2 bug. - """ - g = square1_graph - total, summary = algs.max_flow( - algs.build_graph(g), 0, 2, shortest_path=True, with_edge_flows=True - ) - assert total == pytest.approx(1.0) - - # Feasibility and conservation still hold for a partial placement. - certify_max_flow(g, summary, total, 0, 2, maximal=False) - - with pytest.raises(AssertionError): - certify_max_flow(g, summary, total, 0, 2, maximal=True) - - -def test_equal_balanced_is_feasible_but_not_certified_maximal( - line1_graph, algs, certify_max_flow -): - """EQUAL_BALANCED is an admission model, so only feasibility/conservation apply.""" - g = line1_graph - total, summary = algs.max_flow( - algs.build_graph(g), - 0, - 2, - flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, - with_edge_flows=True, - ) - certify_max_flow(g, summary, total, 0, 2, maximal=False) - - -def test_masked_runs_certify(algs, certify_max_flow): - """Masking must not break the certificate, and must not leak flow onto masked edges. - - Masks are a good place for a future defect of this kind to hide: no external - solver models them, so an oracle-based test cannot reach them at all. - - Node and edge masks are checked separately and together, because they are - separate branches in ``calc_max_flow`` -- including inside the residual - completion phase added in 0.8.0, whose forward and reverse arc loops each - consult ``edge_mask`` independently. A node-mask-only sweep leaves those - branches, and the certificate's own ``edge_mask`` handling, unexercised. - """ - rng = np.random.default_rng(SEED + 1) - saw_masked_node = False - saw_masked_edge = False - - for i in range(300): - g, s, t = _random_graph(rng) - n = g.num_nodes() - - node_mask = None - edge_mask = None - mode = i % 3 # 0: nodes only, 1: edges only, 2: both - if mode in (0, 2): - node_mask = np.ones(n, dtype=bool) - victim = int(rng.integers(0, n)) - if victim not in (s, t): - node_mask[victim] = False - saw_masked_node = True - if mode in (1, 2): - edge_mask = rng.random(g.num_edges()) > 0.25 - if not edge_mask.all(): - saw_masked_edge = True - - total, summary = algs.max_flow( - algs.build_graph(g), - s, - t, - node_mask=node_mask, - edge_mask=edge_mask, - with_edge_flows=True, - ) - certify_max_flow( - g, summary, total, s, t, node_mask=node_mask, edge_mask=edge_mask - ) - - # Guard against the sweep silently degrading into an unmasked one. - assert saw_masked_node, "node-mask branch was never exercised" - assert saw_masked_edge, "edge-mask branch was never exercised" diff --git a/tests/py/test_paths_resolve.py b/tests/py/test_paths_resolve.py deleted file mode 100644 index 0183602..0000000 --- a/tests/py/test_paths_resolve.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -import netgraph_core as ngc - - -def test_resolve_no_split_parallel_edges( - build_graph, assert_paths_concrete, algs, to_handle -): - # Graph: 0->1 has two equal-cost edges; 1->2 single edge - edges = [ - (0, 1, 1.0, 1.0), - (0, 1, 1.0, 1.0), - (1, 2, 1.0, 1.0), - ] - g = build_graph(3, edges) - sel = ngc.EdgeSelection( - multi_edge=True, - require_capacity=False, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - dist, dag = algs.spf(to_handle(g), 0, 2, selection=sel) - paths = dag.resolve_to_paths(0, 2, split_parallel_edges=False) - # Expect one path grouping both parallel edges at hop 0->1 - assert len(paths) == 1 - assert_paths_concrete(paths, 0, 2, expect_split=False) - - -def test_resolve_with_split_parallel_edges_cartesian( - build_graph, assert_paths_concrete, algs, to_handle -): - # Graph: 0->1 two edges, 1->2 two edges (all equal cost) - edges = [ - (0, 1, 1.0, 1.0), - (0, 1, 1.0, 1.0), - (1, 2, 1.0, 1.0), - (1, 2, 1.0, 1.0), - ] - g = build_graph(3, edges) - sel = ngc.EdgeSelection( - multi_edge=True, - require_capacity=False, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - dist, dag = algs.spf(to_handle(g), 0, 2, selection=sel) - paths = dag.resolve_to_paths(0, 2, split_parallel_edges=True) - # Expect 2*2 = 4 concrete paths - assert len(paths) == 4 - assert_paths_concrete(paths, 0, 2, expect_split=True) - - -def test_resolve_max_paths_cap(build_graph, assert_paths_concrete, algs, to_handle): - edges = [ - (0, 1, 1.0, 1.0), - (0, 1, 1.0, 1.0), - (1, 2, 1.0, 1.0), - (1, 2, 1.0, 1.0), - ] - g = build_graph(3, edges) - sel = ngc.EdgeSelection( - multi_edge=True, - require_capacity=False, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - dist, dag = algs.spf(to_handle(g), 0, 2, selection=sel) - paths = dag.resolve_to_paths(0, 2, split_parallel_edges=True, max_paths=3) - assert len(paths) == 3 - assert_paths_concrete(paths, 0, 2, expect_split=True) diff --git a/tests/py/test_place_global_units.py b/tests/py/test_place_global_units.py new file mode 100644 index 0000000..95b8cb7 --- /dev/null +++ b/tests/py/test_place_global_units.py @@ -0,0 +1,287 @@ +"""The private flow-core entry `_netgraph_core._place_global_units` (APPROACH 2.2): the payload +schema, the review witnesses, input validation and randomized certificates.""" + +from __future__ import annotations + +import random + +import _netgraph_core as core +import numpy as np +import pytest +from flow_oracle import NEVER_REACHED, certify + +import netgraph_core as ngc + +COUNTERS = { + "dijkstra_runs", + "dijkstra_pops", + "settled", + "arc_scans", + "dinic_phases", + "augmentations", +} +CHAIN = [(0, 1, 1, 1), (1, 2, 1, 1), (2, 3, 1, 1), (0, 2, 1, 3), (1, 3, 1, 3)] + + +def build(n, edges, q=1.0): + return ngc.StrictMultiDiGraph( + n, + [e[0] for e in edges], + [e[1] for e in edges], + [e[2] for e in edges], + [e[3] for e in edges], + q, + ) + + +def by_input(g, values): + out = [0] * len(values) + for e, x in enumerate(values): + out[int(g.input_index()[e])] = x + return out + + +def to_ids(g, values_in): + return [values_in[int(i)] for i in g.input_index()] + + +def solve(g, sources, dests, cap, view=None, elig=None): + view = g.capacity_units().tolist() if view is None else view + return core._place_global_units(g, view, sources, dests, elig, cap) + + +def test_payload_schema(): + g = build(4, CHAIN) + p = solve(g, [0], [3], None) + assert set(p) == { + "placed", + "cost_distribution", + "edge_flows", + "min_cut", + "potentials", + "counters", + } + assert type(p["placed"]) is int + assert all(type(c) is int and type(u) is int for c, u in p["cost_distribution"]) + assert isinstance(p["edge_flows"], list) and all( + type(x) is int for x in p["edge_flows"] + ) + assert isinstance(p["min_cut"], list) and all(type(x) is int for x in p["min_cut"]) + assert all(type(x) is int for x in p["potentials"]) + assert set(p["counters"]) == COUNTERS and all( + type(x) is int for x in p["counters"].values() + ) + assert p == solve( + g, [0], [3], None + ) # identical calls, identical payloads (counters included) + + +def test_chain_plus_bypasses(): + g = build(4, CHAIN) + one = solve(g, [0], [3], 1) + assert one["placed"] == 1 and one["cost_distribution"] == [[3, 1]] + assert by_input(g, one["edge_flows"]) == [1, 1, 1, 0, 0] + assert one["min_cut"] is None + two = solve(g, [0], [3], 2) + assert two["cost_distribution"] == [[3, 1], [5, 1]] + assert by_input(g, two["edge_flows"]) == [1, 0, 1, 1, 1] + assert sorted(int(g.input_index()[e]) for e in two["min_cut"]) == [0, 3] + assert two["potentials"] == [0, 2, 3, 5] + inf = solve(g, [0], [3], None) + assert ( + inf["edge_flows"] == two["edge_flows"] and inf["counters"]["dijkstra_runs"] == 3 + ) + for cap, p in ((1, one), (2, two), (None, inf)): + assert certify(g, g.capacity_units().tolist(), [0], [3], cap, p) == [] + masked = solve( + g, + [0], + [3], + None, + elig=g.eligibility(excluded_edges=[to_ids(g, list(range(5))).index(1)]), + ) + assert masked["cost_distribution"] == [[4, 2]] + + +def test_zero_cap_and_positive_cap_on_zero_view(): + g = build(3, [(0, 1, 5, 1), (1, 2, 5, 1)]) + z = solve(g, [0], [2], 0) + assert z == { + "placed": 0, + "cost_distribution": [], + "edge_flows": [0, 0], + "min_cut": None, + "potentials": [NEVER_REACHED] * 3, + "counters": dict.fromkeys(COUNTERS, 0), + } + p = solve(g, [0], [2], 4, view=to_ids(g, [0, 5])) + assert p["placed"] == 0 and p["min_cut"] == [] and p["potentials"] == [0, -1, -1] + assert p["counters"]["dijkstra_runs"] == 1 + + +def test_legal_maximum_and_anycast(): + big = (1 << 62) - 1 + g = build(2, [(0, 1, big, 1)]) + p = solve(g, [0], [1], None) + assert ( + p["placed"] == big + and p["cost_distribution"] == [[1, big]] + and p["min_cut"] == [0] + ) + a = build(3, [(0, 1, 1, 1), (0, 2, 1, 2)]) + q = solve(a, [0], [1, 2], None) + assert q["placed"] == 2 and q["cost_distribution"] == [[1, 1], [2, 1]] + assert q["potentials"] == [0, 2, 2] + assert solve(a, np.array([0, 0]), np.array([2, 1, 2]), np.int64(5)) == solve( + a, [0], [1, 2], 5 + ) + + +def test_release_witness_fresh_solve(): + g = build(3, [(0, 1, 1, 1), (0, 1, 2, 3), (1, 2, 2, 1)]) + p = solve(g, [0], [2], 1, view=to_ids(g, [1, 1, 1])) + assert by_input(g, p["edge_flows"]) == [1, 0, 1] + assert p["cost_distribution"] == [[2, 1]] + + +@pytest.mark.parametrize( + "kwargs, pattern", + [ + ({"view": [1]}, "view has length 1, expected 2"), + ({"view": [1, 6]}, "capacity_units"), + ({"view": [1.0, 1.0]}, "view"), + ({"view": [-1, 0]}, "outside"), + ({"sources": []}, "sources: the endpoint set is empty"), + ({"destinations": []}, "destinations: the endpoint set is empty"), + ({"sources": [3]}, "out of node range"), + ({"sources": [0, 2]}, "both endpoint sets"), + ({"sources": [0.0]}, "sources"), + ({"cap": -1}, "cap"), + ({"cap": 1 << 63}, "cap"), + ({"cap": (1 << 63) - 1}, "cap"), + ({"cap": 1.0}, "cap"), + ({"cap": True}, "cap"), + ], +) +def test_invalid_inputs(kwargs, pattern): + g = build(3, [(0, 1, 5, 1), (1, 2, 5, 1)]) + call = { + "graph": g, + "view": [5, 5], + "sources": [0], + "destinations": [2], + "elig": None, + "cap": 3, + } + call.update(kwargs) + with pytest.raises(ValueError, match=pattern): + core._place_global_units(**call) + + +def test_foreign_eligibility_and_largest_cap(): + g = build(3, [(0, 1, 5, 1), (1, 2, 5, 1)]) + h = build(3, [(0, 1, 5, 1), (1, 2, 5, 1)]) + with pytest.raises(ValueError, match="foreign Eligibility"): + solve(g, [0], [2], 1, elig=h.eligibility()) + assert solve(g, [0], [2], (1 << 63) - 2)["placed"] == 5 + + +def test_random_graphs_certified(): + rng = random.Random(2026) + positive = 0 + for _ in range(400): + n = rng.randint(2, 5) + edges = [ + (rng.randrange(n), rng.randrange(n), rng.randint(0, 2), rng.randint(1, 5)) + for _ in range(rng.randint(1, 6)) + ] + g = build(n, edges) + nodes = list(range(n)) + rng.shuffle(nodes) + ns = rng.randint(1, min(2, n - 1)) + sources, dests = nodes[:ns], nodes[ns : ns + rng.randint(1, min(2, n - ns))] + view = [ + c if rng.random() < 0.7 else rng.randint(0, c) + for c in g.capacity_units().tolist() + ] + cap = rng.choice([None, 0, 1, rng.randint(0, 6), (1 << 63) - 2]) + elig = None + if rng.random() < 0.4: + elig = g.eligibility( + excluded_nodes=[rng.randrange(n)] if rng.random() < 0.3 else [], + excluded_edges=[rng.randrange(len(edges))] + if rng.random() < 0.6 + else [], + ) + p = solve(g, sources, dests, cap, view=view, elig=elig) + fails = certify( + g, + view, + sources, + dests, + cap, + p, + None if elig is None else elig.node_allowed(), + None if elig is None else elig.edge_allowed(), + ) + assert fails == [], (edges, sources, dests, view, cap, fails) + positive += p["placed"] > 0 + assert positive > 50 + + +def test_entry_validates_in_the_core_order_before_narrowing(): + # review-1a D1 for the private entry: view length, view entries, sources, destinations, + # overlap, cap, each checked on the unnarrowed value. + g = build(3, [(0, 1, 5, 1), (1, 2, 5, 1)]) + + def call(**kw): + args = { + "view": [5, 5], + "sources": [0], + "destinations": [2], + "elig": None, + "cap": 3, + } + args.update(kw) + return core._place_global_units(g, **args) + + with pytest.raises(ValueError, match="view has length 1, expected 2"): + call(view=[2**70], sources=[2**40]) + with pytest.raises( + ValueError, match=r"view\[0\] = 1180591620717411303424 is outside" + ): + call(view=[2**70, 5], sources=[2**40]) + with pytest.raises( + ValueError, match=r"sources: node 1099511627776 out of node range" + ): + call(sources=[2**40], destinations=[-(2**70)]) + with pytest.raises(ValueError, match=r"destinations: node -1180591620717411303424"): + call(destinations=[-(2**70)], cap=-1) + with pytest.raises(ValueError, match="both endpoint sets"): + call(sources=[0, 2], cap=2**70) + with pytest.raises(ValueError, match="cap 1180591620717411303424 is outside"): + call(cap=2**70) + with pytest.raises(ValueError, match="cap -1180591620717411303424 is outside"): + call(cap=-(2**70)) + with pytest.raises( + ValueError, match=r"view\[1\] = 18446744073709551615 is outside" + ): + call(view=np.array([5, 2**64 - 1], dtype=np.uint64)) + + +def test_entry_view_rank_is_checked_with_its_length(): + g = build(3, [(0, 1, 5, 1), (1, 2, 5, 1)]) + with pytest.raises( + ValueError, match=r"view must be a 1-D array \(got 2 dimensions\)" + ): + core._place_global_units(g, np.array([[5, 5]]), [2**40], [2], None, 3) + with pytest.raises( + ValueError, match=r"view must be a 1-D array \(got 0 dimensions\)" + ): + core._place_global_units(g, np.array(5), [0], [2], None, 3) + with pytest.raises( + ValueError, match=r"view must be a 1-D sequence of integers \(entry 1" + ): + core._place_global_units(g, [5, [5]], [0], [2], None, 3) + with pytest.raises(ValueError, match="sources node ids must be a 1-D array"): + core._place_global_units(g, [5, 5], np.array([[0]]), [2], None, -1) diff --git a/tests/py/test_policy_vs_maxflow_equivalence.py b/tests/py/test_policy_vs_maxflow_equivalence.py deleted file mode 100644 index cf5815a..0000000 --- a/tests/py/test_policy_vs_maxflow_equivalence.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Policy vs MaxFlow equivalence across the full behavior matrix. - -Validates 3-way API equivalence across all modes: - - Algorithms.max_flow - - FlowState.place_max_flow - - FlowPolicy.place_demand - -Behavior Matrix (all combinations tested): - 1. SDN Max-Flow: require_capacity=True, shortest_path=False, PROPORTIONAL - 2. Single-Tier TE: require_capacity=True, shortest_path=True, PROPORTIONAL - 3. IP ECMP: require_capacity=False, shortest_path=True, EQUAL_BALANCED - 4. IP WCMP: require_capacity=False, shortest_path=True, PROPORTIONAL - -Plus progressive TE modes with require_capacity=True, shortest_path=False, EQUAL_BALANCED. - -Also validates multi-step placement equivalence and remove/re-place idempotence. -""" - -from __future__ import annotations - -import math - -# no typing imports needed -import pytest - -import netgraph_core as ngc - - -def _sel_for( - placement: ngc.FlowPlacement, shortest_path: bool, require_capacity: bool -) -> ngc.EdgeSelection: - # Match MaxFlow's internal SPF selection - return ngc.EdgeSelection( - multi_edge=True, - require_capacity=require_capacity, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - - -def _policy_for( - algs: ngc.Algorithms, - gh, - placement: ngc.FlowPlacement, - shortest_path: bool, - require_capacity: bool, -) -> ngc.FlowPolicy: - sel = _sel_for(placement, shortest_path, require_capacity) - # Configure EB to match IP-like semantics under shortest_path=True - max_flow_c = ( - 1 if (placement == ngc.FlowPlacement.EQUAL_BALANCED and shortest_path) else None - ) - cfg = ngc.FlowPolicyConfig( - path_alg=ngc.PathAlg.SPF, - flow_placement=placement, - selection=sel, - require_capacity=require_capacity, - shortest_path=shortest_path, - max_flow_count=max_flow_c, - ) - return ngc.FlowPolicy(algs, gh, cfg) - - -@pytest.mark.parametrize( - "graph_label", - [ - "square1_graph", - "line1_graph", - "graph3", - "two_disjoint_shortest_graph", - "square3_graph", - ], -) -@pytest.mark.parametrize( - "placement,sp_flag,req_cap", - [ - # Mode 1: SDN Max-Flow (uses all paths, capacity-aware) - (ngc.FlowPlacement.PROPORTIONAL, False, True), - # Mode 2: Single-Tier TE (shortest-cost tier only, capacity-aware) - (ngc.FlowPlacement.PROPORTIONAL, True, True), - # Mode 3: IP ECMP (shortest-cost tier, cost-only routing, equal splits) - (ngc.FlowPlacement.EQUAL_BALANCED, True, False), - # Mode 4: IP WCMP (shortest-cost tier, cost-only routing, proportional splits) - (ngc.FlowPlacement.PROPORTIONAL, True, False), - # Progressive TE mode (multi-tier EB, capacity-aware) - (ngc.FlowPlacement.EQUAL_BALANCED, False, True), - ], - ids=["sdn_maxflow", "tier_te", "ip_ecmp", "ip_wcmp", "progressive_te"], -) -def test_policy_matches_maxflow_equivalence( - request, - graph_label: str, - placement: ngc.FlowPlacement, - sp_flag: bool, - req_cap: bool, - algs: ngc.Algorithms, - to_handle, -): - """3-way API equivalence: max_flow, FlowState.place_max_flow, FlowPolicy.place_demand. - - Also validate: - - placing with demand > expected: placed==expected, remaining==demand-expected - - splitting placement into two steps: same totals - - placing, removing, placing again: same totals - """ - # Acquire graph fixture by name - g = request.getfixturevalue(graph_label) - # src/dst mapping per canonical graph fixtures (mirrors test_flow_policy.py) - SRC_DST = { - "square1_graph": (0, 2), - "line1_graph": (0, 2), - "graph3": (0, 2), - "two_disjoint_shortest_graph": (0, 3), - "square3_graph": (0, 2), - } - src, dst = SRC_DST[graph_label] - - # 1. Compute expected using max_flow under the same mode flags - total_mf, _summary = algs.max_flow( - to_handle(g), - src, - dst, - flow_placement=placement, - shortest_path=sp_flag, - require_capacity=req_cap, - with_edge_flows=False, - ) - expected = float(total_mf) - - # 2. Verify FlowState.place_max_flow matches - fs = ngc.FlowState(g) - total_fs = fs.place_max_flow(src, dst, placement, sp_flag, req_cap) - assert math.isclose(total_fs, expected, rel_tol=0, abs_tol=1e-6), ( - f"FlowState mismatch for {graph_label}: {total_fs} != {expected}" - ) - - # Demand greater than expected - demand = expected + 7.0 - - # 3. Single-shot FlowPolicy placement - fg1 = ngc.FlowGraph(g) - policy1 = _policy_for(algs, to_handle(g), placement, sp_flag, req_cap) - placed1, rem1 = policy1.place_demand(fg1, src, dst, flowClass=0, volume=demand) - if sp_flag: - # Strict equivalence in shortest_path mode - assert math.isclose(placed1, expected, rel_tol=0, abs_tol=1e-6), ( - f"single-shot placed mismatch for {graph_label}" - ) - assert math.isclose(rem1, demand - expected, rel_tol=0, abs_tol=1e-6), ( - f"single-shot remaining mismatch for {graph_label}" - ) - else: - # TE mode: policy should not exceed max_flow; accounting must hold - assert placed1 <= expected + 1e-6, ( - f"single-shot placed exceeds expected for {graph_label}" - ) - assert math.isclose(placed1 + rem1, demand, rel_tol=0, abs_tol=1e-6), ( - f"single-shot accounting mismatch for {graph_label}" - ) - - # Two-step placement (split request into two calls) - fg2 = ngc.FlowGraph(g) - policy2 = _policy_for(algs, to_handle(g), placement, sp_flag, req_cap) - v1 = demand * 0.4 - p2a, r2a = policy2.place_demand(fg2, src, dst, flowClass=0, volume=v1) - p2b, r2b = policy2.place_demand(fg2, src, dst, flowClass=0, volume=demand - v1) - # Total placed must still not exceed expected - placed2 = p2a + p2b - if sp_flag: - # In shortest_path mode, ensure accounting only; placement additivity across calls may differ. - assert math.isclose( - (p2a + r2a) + (p2b + r2b), demand, rel_tol=0, abs_tol=1e-6 - ), f"two-step accounting mismatch for {graph_label}" - else: - assert placed2 <= expected + 1e-6, ( - f"two-step placed exceeds expected for {graph_label}" - ) - # Accounting across both calls must match total demand - assert math.isclose((p2a + r2a) + (p2b + r2b), demand, rel_tol=0, abs_tol=1e-6), ( - f"two-step accounting mismatch for {graph_label}" - ) - - # Place, remove, place again -> same totals as single-shot - fg3 = ngc.FlowGraph(g) - policy3 = _policy_for(algs, to_handle(g), placement, sp_flag, req_cap) - p3a, r3a = policy3.place_demand(fg3, src, dst, flowClass=0, volume=demand) - policy3.remove_demand(fg3) - p3b, r3b = policy3.place_demand(fg3, src, dst, flowClass=0, volume=demand) - if sp_flag: - assert math.isclose(p3a, expected, rel_tol=0, abs_tol=1e-6), ( - f"pre-remove placed mismatch for {graph_label}" - ) - assert math.isclose(r3a, demand - expected, rel_tol=0, abs_tol=1e-6), ( - f"pre-remove remaining mismatch for {graph_label}" - ) - assert math.isclose(p3b, expected, rel_tol=0, abs_tol=1e-6), ( - f"post-remove placed mismatch for {graph_label}" - ) - assert math.isclose(r3b, demand - expected, rel_tol=0, abs_tol=1e-6), ( - f"post-remove remaining mismatch for {graph_label}" - ) - else: - assert p3a <= expected + 1e-6, ( - f"pre-remove placed exceeds expected for {graph_label}" - ) - assert math.isclose(p3a + r3a, demand, rel_tol=0, abs_tol=1e-6), ( - f"pre-remove accounting mismatch for {graph_label}" - ) - assert p3b <= expected + 1e-6, ( - f"post-remove placed exceeds expected for {graph_label}" - ) - assert math.isclose(p3b + r3b, demand, rel_tol=0, abs_tol=1e-6), ( - f"post-remove accounting mismatch for {graph_label}" - ) diff --git a/tests/py/test_pred_dag_from_hops.py b/tests/py/test_pred_dag_from_hops.py deleted file mode 100644 index 5069168..0000000 --- a/tests/py/test_pred_dag_from_hops.py +++ /dev/null @@ -1,74 +0,0 @@ -"""PredDAG.from_hops: explicit routes whose hops may use several parallel edges.""" - -from __future__ import annotations - -import numpy as np -import pytest - -import netgraph_core as ngc - - -def _graph(): - # Four parallel 10-unit links 0 -> 1 (ids 0-3), 1 -> 0 back (id 4) and - # 1 -> 2 (id 5): edge ids follow the graph's (cost, src, dst) ordering. - return ngc.StrictMultiDiGraph.from_arrays( - 3, - src=np.array([0, 0, 0, 0, 1, 1], dtype=np.int32), - dst=np.array([1, 1, 1, 1, 2, 0], dtype=np.int32), - capacity=np.array([10.0, 10.0, 10.0, 10.0, 100.0, 1.0], dtype=np.float64), - cost=np.array([1, 1, 1, 1, 1, 1], dtype=np.int64), - ext_edge_ids=np.arange(6, dtype=np.int64), - ) - - -def test_from_hops_layout_and_order(): - g = _graph() - dag = ngc.PredDAG.from_hops(g, [[3, 1], [5]]) - assert list(dag.parent_offsets) == [0, 0, 2, 3] - assert list(dag.parents) == [0, 0, 1] - assert list(dag.via_edges) == [3, 1, 5] - single = ngc.PredDAG.from_edges(g, [2, 5]) - assert list(single.via_edges) == [2, 5] - - -def test_from_hops_bundle_carries_every_parallel_edge(): - g = _graph() - fg = ngc.FlowGraph(g) - one = ngc.PredDAG.from_edges(g, [0, 5]) - bundle = ngc.PredDAG.from_hops(g, [[0, 1, 2, 3], [5]]) - idx_a = ngc.FlowIndex(0, 2, 0, 1) - idx_b = ngc.FlowIndex(0, 2, 0, 2) - assert fg.place( - idx_a, 0, 2, one, 100.0, ngc.FlowPlacement.PROPORTIONAL - ) == pytest.approx(10.0) - fg.remove(idx_a) - assert fg.place( - idx_b, 0, 2, bundle, 100.0, ngc.FlowPlacement.PROPORTIONAL - ) == pytest.approx(40.0) - - -@pytest.mark.parametrize( - "hops, message", - [ - ([], "non-empty"), - ([[0], []], "empty"), - ([[0, 9]], "out of range"), - ([[0, 0]], "repeated"), - ([[4, 5]], "not parallel"), # 1 -> 0 and 1 -> 2 - ([[0], [5], [4]], "contiguous"), # 0 -> 1 -> 2, then 1 -> 0 - ([[0], [5]], None), # valid: 0 -> 1 -> 2 - ], -) -def test_from_hops_validation(hops, message): - g = _graph() - if message is None: - ngc.PredDAG.from_hops(g, hops) - return - with pytest.raises(ValueError, match=message): - ngc.PredDAG.from_hops(g, hops) - - -def test_from_hops_rejects_revisited_node(): - g = _graph() - with pytest.raises(ValueError, match="simple path"): - ngc.PredDAG.from_hops(g, [[0], [4]]) # 0 -> 1 -> 0 diff --git a/tests/py/test_profiling.py b/tests/py/test_profiling.py deleted file mode 100644 index 96acd35..0000000 --- a/tests/py/test_profiling.py +++ /dev/null @@ -1,100 +0,0 @@ -from __future__ import annotations - -import os -import re -import subprocess -import sys -import textwrap - - -def _run_profile_subprocess( - code: str, *, extra_env: dict[str, str] | None = None -) -> subprocess.CompletedProcess[str]: - env = os.environ.copy() - env["NGRAPH_CORE_PROFILE"] = "1" - if extra_env: - env.update(extra_env) - return subprocess.run( - [sys.executable, "-c", code], - check=True, - capture_output=True, - text=True, - env=env, - ) - - -def _calls_for(stderr: str, scope: str) -> int: - match = re.search(rf"^{re.escape(scope)}:\n calls: (\d+)\n", stderr, re.MULTILINE) - assert match, stderr - return int(match.group(1)) - - -def test_profiling_dump_and_reset_lifecycle_via_python_api() -> None: - code = textwrap.dedent( - """ - import sys - import numpy as np - import netgraph_core as ngc - - src = np.array([0, 1], dtype=np.int32) - dst = np.array([1, 2], dtype=np.int32) - cap = np.array([1.0, 1.0], dtype=np.float64) - cost = np.array([1, 1], dtype=np.int64) - ext = np.array([0, 1], dtype=np.int64) - graph = ngc.StrictMultiDiGraph.from_arrays(3, src, dst, cap, cost, ext) - algs = ngc.Algorithms(ngc.Backend.cpu()) - gh = algs.build_graph(graph) - - sys.stderr.write("===FIRST===\\n") - for _ in range(3): - algs.spf(gh, 0, 2) - ngc.profiling_dump() - ngc.profiling_reset() - sys.stderr.write("===SECOND===\\n") - ngc.profiling_dump() - print(ngc.profiling_enabled()) - """ - ) - proc = _run_profile_subprocess(code) - assert proc.stdout.strip() == "True" - first = proc.stderr.split("===FIRST===\n", 1)[1].split("===SECOND===\n", 1)[0] - second = proc.stderr.split("===SECOND===\n", 1)[1] - assert _calls_for(first, "shortest_paths_core") == 3 - assert "shortest_paths_core:" not in second - - -def test_profiling_threaded_scope_counts_match_serial() -> None: - code = textwrap.dedent( - """ - import concurrent.futures - import os - import numpy as np - import netgraph_core as ngc - - mode = os.environ["P8_MODE"] - src = np.array([0, 1], dtype=np.int32) - dst = np.array([1, 2], dtype=np.int32) - cap = np.array([1.0, 1.0], dtype=np.float64) - cost = np.array([1, 1], dtype=np.int64) - ext = np.array([0, 1], dtype=np.int64) - graph = ngc.StrictMultiDiGraph.from_arrays(3, src, dst, cap, cost, ext) - algs = ngc.Algorithms(ngc.Backend.cpu()) - gh = algs.build_graph(graph) - - def worker(iters: int) -> None: - for _ in range(iters): - algs.spf(gh, 0, 2) - - ngc.profiling_reset() - if mode == "serial": - worker(20) - else: - with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: - list(pool.map(worker, [5, 5, 5, 5])) - ngc.profiling_dump() - """ - ) - serial = _run_profile_subprocess(code, extra_env={"P8_MODE": "serial"}) - threaded = _run_profile_subprocess(code, extra_env={"P8_MODE": "threaded"}) - assert _calls_for(serial.stderr, "shortest_paths_core") == 20 - assert _calls_for(threaded.stderr, "shortest_paths_core") == 20 diff --git a/tests/py/test_review_regressions.py b/tests/py/test_review_regressions.py deleted file mode 100644 index 611439c..0000000 --- a/tests/py/test_review_regressions.py +++ /dev/null @@ -1,590 +0,0 @@ -"""Regression tests for the 2026-08 code review findings. - -Each test locks in the fixed behavior for a confirmed defect: - -1. max_flow under-reported when the SSP tier loop needed flow cancellation; - its own min_cut then contradicted total_flow (duality violation). -2. Zero-cost edges produced a cyclic PredDAG: EqualBalanced placement returned - 0 flow and resolve_to_paths never terminated. -3. k_shortest_paths enumerated every equal-cost spur path (exponential in ECMP - width; a 22-stage ladder needed ~19 s and ~3.8 GB for k=3). -4. batch_max_flow results must be identical to per-pair calls (now computed in - parallel). -5. FlowPolicy.place_demand silently routed a second (src, dst) pair over the - first pair's paths. -6. StrictMultiDiGraph accepted cost totals that overflow int64 path arithmetic. -7. FlowState.compute_min_cut treated pre-existing usage from a custom - residual_init as cancellable flow. -8. batch_max_flow rejected a genuine int32 `pairs` array on Windows, and read - non-contiguous input as if it were packed. -9-10. FlowPolicy accepted a FlowGraph built from a different graph, and - rebalance_demand bypassed the (src, dst) guard. -11-12. batch_max_flow and ksp validated inputs later (or not at all) compared - with their single-pair counterparts. -13. _docs.py declared types that did not match runtime. -14. The same class of user error raised different exception types. -15. k_shortest_paths read paths.back() on an empty vector when - max_cost_factor < 1.0 and k > 1. -""" - -from __future__ import annotations - -import numpy as np -import pytest - -import netgraph_core as ngc - - -def _graph(num_nodes, edges): - """edges: list of (src, dst, capacity, cost).""" - s, d, c, k = zip(*edges, strict=False) - return ngc.StrictMultiDiGraph.from_arrays( - num_nodes, - np.array(s, dtype=np.int32), - np.array(d, dtype=np.int32), - np.array(c, dtype=np.float64), - np.array(k, dtype=np.int64), - np.arange(len(s), dtype=np.int64), - ) - - -def _bidir(links): - out = [] - for u, v, cap, cost in links: - out.append((u, v, cap, cost)) - out.append((v, u, cap, cost)) - return out - - -@pytest.fixture -def algs(): - return ngc.Algorithms(ngc.Backend.cpu()) - - -class TestMaxFlowCompletion: - """Finding 1: forward-only SSP stranded capacity that needs cancellation.""" - - def _cancellation_graph(self): - # s=0, a=1, b=2, t=3; all links full duplex. True max flow is 3.0: - # 0->1->3 (1), 0->2->1->3 (1), 0->2->3 (1); cut {0,2}|{1,3} has cap 3. - return _graph( - 4, - _bidir( - [ - (1, 3, 3.0, 17), - (0, 2, 8.0, 17), - (1, 2, 1.0, 1), - (1, 0, 1.0, 1), - (3, 2, 1.0, 1), - ] - ), - ) - - def test_exact_max_flow(self, algs): - g = self._cancellation_graph() - pg = algs.build_graph(g) - total, _ = algs.max_flow(pg, 0, 3) - assert total == pytest.approx(3.0) - - def test_min_cut_duality(self, algs): - g = self._cancellation_graph() - pg = algs.build_graph(g) - total, summary = algs.max_flow(pg, 0, 3) - cap = np.asarray(g.capacity_view()) - cut = np.asarray(summary.min_cut.edges) - assert cut.size > 0 - assert float(cap[cut].sum()) == pytest.approx(total) - - def test_cost_distribution_accounts_for_total(self, algs): - g = self._cancellation_graph() - pg = algs.build_graph(g) - total, summary = algs.max_flow(pg, 0, 3) - assert float(np.asarray(summary.flows).sum()) == pytest.approx(total) - - def test_completion_respects_masks(self, algs): - g = self._cancellation_graph() - pg = algs.build_graph(g) - mask = np.ones(4, dtype=bool) - mask[2] = False # only 0->1->3 remains; bottleneck is cap(0->1) = 1 - total, _ = algs.max_flow(pg, 0, 3, node_mask=mask) - assert total == pytest.approx(1.0) - - -class TestZeroCostEdges: - """Finding 2: zero-cost edges made the PredDAG cyclic.""" - - def _zero_cost_graph(self): - # 0 ->(1) 1 <->(0) 2 ->(1) 3 - return _graph( - 4, - [(0, 1, 10.0, 1), (1, 2, 10.0, 0), (2, 1, 10.0, 0), (2, 3, 10.0, 1)], - ) - - def test_pred_dag_acyclic(self, algs): - g = self._zero_cost_graph() - pg = algs.build_graph(g) - _, dag = algs.spf(pg, 0, None, multipath=True) - po = np.asarray(dag.parent_offsets) - pa = np.asarray(dag.parents) - for v in range(4): - for i in range(po[v], po[v + 1]): - u = pa[i] - assert v not in pa[po[u] : po[u + 1]], f"cycle {u}<->{v}" - - def test_equal_balanced_places_flow(self, algs): - g = self._zero_cost_graph() - pg = algs.build_graph(g) - total, _ = algs.max_flow( - pg, 0, 3, flow_placement=ngc.FlowPlacement.EQUAL_BALANCED - ) - assert total == pytest.approx(10.0) - - def test_resolve_to_paths_terminates(self, algs): - g = self._zero_cost_graph() - pg = algs.build_graph(g) - _, dag = algs.spf(pg, 0, None, multipath=True) - paths = dag.resolve_to_paths(0, 3) # previously never returned - assert [n for n, _ in paths[0]] == [0, 1, 2, 3] - - -class TestKspEcmpLadder: - """Finding 3: spur enumeration was exponential in equal-cost path count.""" - - def test_ladder_is_tractable(self, algs): - # 30 stages of parallel 2-hop diamonds: 2^30 equal-cost paths. - # Pre-fix this was unreachable (22 stages already took ~19 s / 3.8 GB). - stages = 30 - edges = [] - nid = 0 - - def new(): - nonlocal nid - nid += 1 - return nid - 1 - - main = [new()] - for _ in range(stages): - nxt, a, b = new(), new(), new() - for u, v in ((main[-1], a), (a, nxt), (main[-1], b), (b, nxt)): - edges.append((u, v, 10.0, 1)) - main.append(nxt) - g = _graph(nid, edges) - pg = algs.build_graph(g) - out = algs.ksp(pg, main[0], main[-1], k=4) - assert len(out) == 4 - # All returned paths are shortest (equal cost) and distinct. - costs = [dist[main[-1]] for dist, _ in out] - assert costs == [2 * stages] * 4 - sigs = {tuple(dag.via_edges) for _, dag in out} - assert len(sigs) == 4 - - -class TestBatchMaxFlowParity: - """Finding 4: batch results must equal per-pair results (now parallel).""" - - def test_batch_equals_serial(self, algs, certify_max_flow): - rng = np.random.default_rng(7) - n = 12 - edges = [] - for _ in range(40): - u, v = rng.integers(0, n, size=2) - if u != v: - # Costs must be dispersed. With a single cost tier there is nothing - # for shortest-path tier ordering to get wrong, so a uniform-cost - # generator only ever samples the region where max-flow defects of - # the Finding 1 kind cannot occur. - cap = float(rng.integers(1, 8)) - cost = int(rng.integers(1, 21)) - edges.append((int(u), int(v), cap, cost)) - g = _graph(n, edges) - pg = algs.build_graph(g) - pairs = np.array([[i, (i + 5) % n] for i in range(8)], dtype=np.int32) - batch = algs.batch_max_flow(pg, pairs, with_edge_flows=True) - for i, (a, b) in enumerate(pairs): - total, summary = algs.max_flow(pg, int(a), int(b), with_edge_flows=True) - assert batch[i].total_flow == pytest.approx(total) - np.testing.assert_array_equal( - np.asarray(batch[i].edge_flows), np.asarray(summary.edge_flows) - ) - # Agreement between two code paths that share calc_max_flow only proves - # consistency; both could be wrong together. Certify the result too. - certify_max_flow(g, summary, total, int(a), int(b)) - - -class TestFlowPolicySrcDstGuard: - """Finding 5: reusing a policy for a different pair corrupted placement.""" - - def test_mismatched_pair_raises(self, algs): - g = _graph(4, [(0, 1, 10.0, 1), (2, 3, 10.0, 1)]) - pg = algs.build_graph(g) - fg = ngc.FlowGraph(g) - policy = ngc.FlowPolicy(algs, pg, ngc.FlowPolicyConfig()) - placed, _ = policy.place_demand(fg, 0, 1, 0, 5.0) - assert placed == pytest.approx(5.0) - with pytest.raises(ValueError, match="different \\(src, dst\\)"): - policy.place_demand(fg, 2, 3, 0, 5.0) - - def test_same_pair_and_after_remove_ok(self, algs): - g = _graph(4, [(0, 1, 10.0, 1), (2, 3, 10.0, 1)]) - pg = algs.build_graph(g) - fg = ngc.FlowGraph(g) - policy = ngc.FlowPolicy(algs, pg, ngc.FlowPolicyConfig()) - assert policy.place_demand(fg, 0, 1, 0, 5.0)[0] == pytest.approx(5.0) - assert policy.place_demand(fg, 0, 1, 0, 2.0)[0] == pytest.approx(2.0) - policy.remove_demand(fg) - assert policy.place_demand(fg, 2, 3, 0, 5.0)[0] == pytest.approx(5.0) - np.testing.assert_allclose(np.asarray(fg.edge_flow_view()), [0.0, 5.0]) - - -class TestCostOverflowGuard: - """Finding 6: cost totals near INT64_MAX silently wrapped in SPF.""" - - def test_total_at_2_pow_62_rejected(self): - big = 1 << 62 - with pytest.raises(ValueError, match="2\\^62"): - _graph(3, [(0, 1, 10.0, big), (1, 2, 10.0, big)]) - - def test_large_but_safe_costs_accepted(self, algs): - big = 1 << 60 - g = _graph(3, [(0, 1, 10.0, big), (1, 2, 10.0, big)]) - pg = algs.build_graph(g) - dist, _ = algs.spf(pg, 0, None, dtype="int64") - assert dist[2] == 2 * big # no wraparound - - -class TestFlowStateCustomResidual: - """Finding 7: custom residual_init desynced min-cut's notion of flow.""" - - def test_min_cut_uses_own_placed_flow(self): - g = _graph(3, [(0, 1, 10.0, 1), (1, 2, 10.0, 1)]) - fs = ngc.FlowState(g, np.array([4.0, 4.0])) - placed = fs.place_max_flow(0, 2, ngc.FlowPlacement.PROPORTIONAL) - assert placed == pytest.approx(4.0) - # Reverse arcs must be keyed on this state's own edge_flow (4.0), not - # capacity - residual (10.0, which counts pre-existing usage). - np.testing.assert_allclose(np.asarray(fs.edge_flow_view()), [4.0, 4.0]) - cut = np.asarray(fs.compute_min_cut(0).edges) - assert cut.size > 0 - - -class TestBatchPairsDtype: - """Finding 8: `batch_max_flow` rejected a genuine int32 array on Windows. - - The dtype check compared buffer format strings. NumPy spells int32 as - NPY_LONG ('l') on LLP64 (Windows) but NPY_INT ('i') on LP64, while - pybind11's `format_descriptor` is always 'i', so the check threw on - Windows for a correctly-typed array. Parametrizing over both 32-bit - spellings exercises whichever one is the platform's alias for int32. - """ - - def _line_graph(self): - return _graph(3, [(0, 1, 5.0, 1), (1, 2, 5.0, 1)]) - - @pytest.mark.parametrize("dtype", [np.int32, np.intc]) - def test_accepts_every_int32_spelling(self, algs, dtype): - g = self._line_graph() - pg = algs.build_graph(g) - pairs = np.array([[0, 2]], dtype=dtype) - assert np.dtype(dtype).itemsize == 4 and np.dtype(dtype).kind == "i" - out = algs.batch_max_flow(pg, pairs) - assert out[0].total_flow == pytest.approx(5.0) - - def test_rejects_wrong_dtype(self, algs): - pg = algs.build_graph(self._line_graph()) - with pytest.raises(TypeError, match="int32"): - algs.batch_max_flow(pg, np.array([[0, 2]], dtype=np.int64)) - - def test_rejects_non_contiguous(self, algs): - # A strided view would otherwise be read as if it were packed, silently - # turning [[0, 2]] into the pair (0, 99). - pg = algs.build_graph(self._line_graph()) - strided = np.array([[0, 99, 2, 99]], dtype=np.int32)[:, ::2] - assert not strided.flags["C_CONTIGUOUS"] - with pytest.raises(TypeError, match="C-contiguous"): - algs.batch_max_flow(pg, strided) - - -class TestFlowPolicyTargetGuards: - """Findings 9-10: FlowPolicy accepted uses that silently produced wrong answers.""" - - def _two_graphs_same_edge_count(self): - # Same edge count, different topology: the residual-length check that caught - # the mismatched-graph case by accident does not fire here. - a = _graph(4, [(0, 1, 5.0, 1), (1, 3, 5.0, 1), (0, 2, 1.0, 1)]) - b = _graph(4, [(0, 2, 9.0, 1), (2, 3, 9.0, 1), (0, 1, 1.0, 1)]) - return a, b - - def test_flowgraph_from_a_different_graph_is_rejected(self, algs): - ga, gb = self._two_graphs_same_edge_count() - policy = ngc.FlowPolicy(algs, algs.build_graph(ga), ngc.FlowPolicyConfig()) - with pytest.raises(ValueError, match="different StrictMultiDiGraph"): - policy.place_demand(ngc.FlowGraph(gb), 0, 3, 0, 100.0) - - def test_rebalance_demand_rejects_a_different_pair(self, algs): - # remove_demand() empties flows_, so rebalance_demand must check the pair - # itself rather than relying on the check inside place_demand. - g = _graph(4, [(0, 1, 10.0, 1), (2, 3, 10.0, 1)]) - fg = ngc.FlowGraph(g) - policy = ngc.FlowPolicy(algs, algs.build_graph(g), ngc.FlowPolicyConfig()) - policy.place_demand(fg, 0, 1, 0, 5.0) - with pytest.raises(ValueError, match="different \\(src, dst\\)"): - policy.rebalance_demand(fg, 2, 3, 0, 5.0) - # The matching pair still rebalances, and retargeting after remove_demand works. - policy.rebalance_demand(fg, 0, 1, 0, 2.0) - policy.remove_demand(fg) - assert policy.place_demand(fg, 2, 3, 0, 5.0)[0] == pytest.approx(5.0) - - -class TestBindingValidationConsistency: - """Findings 11-12: validation that differed between analogous entry points.""" - - def _line(self): - return _graph(3, [(0, 1, 5.0, 1), (1, 2, 5.0, 1)]) - - def test_batch_max_flow_rejects_out_of_range_like_max_flow(self, algs): - pg = algs.build_graph(self._line()) - # Previously this returned total_flow=0.0 and the bad id vanished into the batch. - with pytest.raises(ValueError, match="out of range"): - algs.batch_max_flow(pg, np.array([[0, 99]], dtype=np.int32)) - with pytest.raises(ValueError, match="out of range"): - algs.max_flow(pg, 0, 99) - - def test_ksp_validates_dtype_even_when_no_paths_exist(self, algs): - pg = algs.build_graph(self._line()) - # 2 -> 0 is unreachable, so the results loop never runs; the dtype check used - # to live inside that loop and silently accepted the bad value. - with pytest.raises(ValueError, match="dtype must be"): - algs.ksp(pg, 2, 0, k=1, dtype="float32") - - def test_algorithms_backend_argument_is_nameable(self): - assert isinstance(ngc.Algorithms(backend=ngc.Backend.cpu()), ngc.Algorithms) - - def test_unreachable_flow_record_class_is_not_exported(self): - import _netgraph_core - - # Nothing could construct or receive it, and the name collided with the C++ - # alias `using Flow = double`. - assert not hasattr(_netgraph_core, "Flow") - - -class TestTypeStubAccuracy: - """Finding 13: _docs.py declared types that did not match runtime.""" - - def test_min_cut_edges_is_an_int32_array_not_a_list(self, algs): - g = _graph(3, [(0, 1, 5.0, 1), (1, 2, 5.0, 1)]) - _, summary = algs.max_flow(algs.build_graph(g), 0, 2) - edges = summary.min_cut.edges - assert isinstance(edges, np.ndarray) - assert edges.dtype == np.int32 - - def test_stub_declares_only_names_that_exist(self): - import _netgraph_core - - from netgraph_core import _docs - - declared = { - n - for n in dir(_docs) - if not n.startswith("_") and isinstance(getattr(_docs, n), type) - } - missing = {n for n in declared if not hasattr(_netgraph_core, n)} - assert not missing, f"_docs.py declares nonexistent runtime types: {missing}" - - -class TestErrorTypeConsistency: - """Finding 14: the same class of user error raised different exception types.""" - - @pytest.fixture - def pg(self, algs): - return algs.build_graph(_graph(3, [(0, 1, 5.0, 1), (1, 2, 5.0, 1)])) - - def test_spf_residual_length_raises_type_error_like_masks(self, algs, pg): - # Previously the residual length check fell through to the C++ core, which - # throws std::invalid_argument -> ValueError, while every mask length check - # in the binding layer raises TypeError. - with pytest.raises(TypeError, match="residual length must equal"): - algs.spf(pg, 0, residual=np.zeros(5)) - with pytest.raises(TypeError, match="node_mask length must equal"): - algs.spf(pg, 0, node_mask=np.ones(7, dtype=bool)) - - def test_wrong_typed_arguments_raise_type_error_not_runtime_error(self, algs, pg): - # These hand-rolled casts used to surface as - # "RuntimeError: Unable to cast ... to C++ type '?'", which no `except - # TypeError` catches and which names nothing actionable. - with pytest.raises(TypeError, match="must be a StrictMultiDiGraph"): - algs.build_graph(5) - with pytest.raises(TypeError, match="must be an Algorithms"): - ngc.FlowPolicy("not-algorithms", pg, ngc.FlowPolicyConfig()) - - -class TestKspCostCeiling: - """Finding 15: k>1 with max_cost_factor < 1.0 read paths.back() on an empty vector. - - The ceiling lands below the shortest path, so nothing is admitted. The k == 1 - branch returned early and masked the undefined behaviour; k >= 2 fell into the - spur loop, which opens with `paths.back()`. - """ - - @pytest.mark.parametrize("k", [1, 2, 5]) - @pytest.mark.parametrize("factor", [0.5, 0.99]) - def test_sub_unit_factor_returns_empty_for_every_k(self, algs, k, factor): - g = _graph(3, [(0, 1, 5.0, 1), (1, 2, 5.0, 1)]) - pg = algs.build_graph(g) - assert algs.ksp(pg, 0, 2, k=k, max_cost_factor=factor) == [] - - @pytest.mark.parametrize("k", [1, 2, 5]) - def test_unit_factor_still_admits_the_shortest_path(self, algs, k): - g = _graph(3, [(0, 1, 5.0, 1), (1, 2, 5.0, 1)]) - pg = algs.build_graph(g) - assert len(algs.ksp(pg, 0, 2, k=k, max_cost_factor=1.0)) == 1 - - -class TestStubConstructorConsistency: - """Finding 16: widened stubs declared no __init__ for classes that need one. - - `_docs.py` is wired to type checkers, so a stub whose constructor disagrees - with the binding turns "unchecked" into "confidently wrong": pyright rejected - every real `FlowIndex(src, dst, cls, id)` call in the downstream consumer. - This repo's own pyright missed it because `tests/**` is excluded, so assert - the correspondence at runtime instead. - """ - - def _runtime_classes(self): - import _netgraph_core - - for name in sorted(n for n in dir(_netgraph_core) if not n.startswith("_")): - obj = getattr(_netgraph_core, name) - if isinstance(obj, type): - yield name, obj - - def test_stub_declares_init_wherever_construction_needs_arguments(self): - from netgraph_core import _docs - - missing = [] - for name, rt in self._runtime_classes(): - stub = getattr(_docs, name, None) - if stub is None: - continue # not part of the typed surface - try: - rt() - continue # zero-arg construction works; a bare stub is fine - except Exception: - pass - doc = rt.__init__.__doc__ or "" - if "__init__" not in doc: - continue # no bound constructor at all (e.g. result-only types) - if "__init__" not in vars(stub): - missing.append(name) - assert not missing, ( - f"stub classes need an __init__ matching the binding: {missing}" - ) - - def test_stub_attributes_exist_at_runtime(self): - from netgraph_core import _docs - - wrong = [] - for name, rt in self._runtime_classes(): - stub = getattr(_docs, name, None) - if stub is None: - continue - declared = { - a - for a in vars(stub) - if not a.startswith("_") and not callable(vars(stub)[a]) - } - declared |= set(getattr(stub, "__annotations__", {})) - for attr in declared: - if not hasattr(rt, attr): - wrong.append(f"{name}.{attr}") - assert not wrong, f"_docs.py declares attributes absent at runtime: {wrong}" - - def test_flow_index_constructor_matches_the_binding(self): - # The exact call shape the downstream consumer uses. - idx = ngc.FlowIndex(1, 2, 3, 4) - assert (idx.src, idx.dst, idx.flowClass, idx.flowId) == (1, 2, 3, 4) - with pytest.raises(TypeError): - ngc.FlowIndex(src=1, dst=2, flowClass=3, flowId=4) # positional-only - with pytest.raises(AttributeError): - idx.src = 9 # read-only, as the stub's properties declare - - -class TestFlowPolicyMaxPathCostFactor: - """`max_path_cost_factor` had no test coverage at all. - - The gate multiplies the best path cost by the factor and casts back to Cost. - Before 0.8.0 it did that unconditionally, including while `best_path_cost_` was - still the INT64_MAX "no best path yet" sentinel -- the product is then far outside - the int64 range and the conversion is undefined behavior. The fix skips the - relative bound until a best cost exists and drops it when the product would not - fit. Neither branch was exercised by any test. - - Scope: these are *coverage* tests, not detectors of the original UB. They pass - against 0.7.2 (pre-fix) too, because an out-of-range float->int conversion is - undefined rather than reliably wrong -- in practice it saturates to something that - still admits the path. They also cannot reach the sentinel branch at all: a - reachable destination updates `best_path_cost_` (flow_policy.cpp:135) before the - gate runs. Their value is guarding the *functional* behaviour of the bound. - - Detection of the UB itself lives in C++, in - `FlowPolicyCostBounds.MaxPathCostFactor_*` -- `make sanitize-test` builds with - sanitizers but runs `ctest`, so it never executes this Python suite. Those tests - use an unreachable destination to keep the sentinel live, and fail against the - pre-fix conversion. - """ - - @staticmethod - def _policy(algs, gh, *, max_flow_count=1, **cfg_kwargs): - sel = ngc.EdgeSelection( - multi_edge=True, - require_capacity=True, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - cfg = ngc.FlowPolicyConfig( - path_alg=ngc.PathAlg.SPF, - flow_placement=ngc.FlowPlacement.PROPORTIONAL, - selection=sel, - max_flow_count=max_flow_count, - **cfg_kwargs, - ) - return ngc.FlowPolicy(algs, gh, cfg) - - def test_factor_applies_before_any_best_cost_exists(self, algs): - """First placement: the sentinel path must not be multiplied and cast.""" - g = _graph(3, [(0, 1, 1.0, 5), (1, 2, 1.0, 5)]) - gh = algs.build_graph(g) - policy = self._policy(algs, gh, max_path_cost_factor=2.0) - placed, left = policy.place_demand(ngc.FlowGraph(g), 0, 2, 0, 1.0) - assert placed == pytest.approx(1.0) - assert left == pytest.approx(0.0) - - def test_huge_factor_does_not_wrap_the_bound(self, algs): - """best_cost * factor overflows int64; the bound must be dropped, not wrapped. - - A wrapped (negative) bound would reject the shortest path itself, so a - successful placement is what distinguishes the guard from the bug. - """ - big = 1 << 60 # legal: total stays below the 2**62 construction ceiling - g = _graph(3, [(0, 1, 1.0, big), (1, 2, 1.0, big)]) - gh = algs.build_graph(g) - policy = self._policy(algs, gh, max_path_cost_factor=1e9) - placed, _ = policy.place_demand(ngc.FlowGraph(g), 0, 2, 0, 1.0) - assert placed == pytest.approx(1.0) - - def test_factor_still_excludes_a_too_expensive_alternative(self, algs): - """The bound must remain functional, not merely safe.""" - g = _graph( - 4, - [ - (0, 1, 1.0, 1), - (1, 3, 1.0, 1), # cost 2 route - (0, 2, 5.0, 50), - (2, 3, 5.0, 50), # cost 100 route, far beyond 2x - ], - ) - gh = algs.build_graph(g) - policy = self._policy( - algs, gh, max_flow_count=4, max_path_cost_factor=2.0, min_flow_count=1 - ) - placed, _ = policy.place_demand(ngc.FlowGraph(g), 0, 3, 0, 6.0) - # Only the cost-2 route is within 2x; the cost-100 route must stay unused. - assert placed == pytest.approx(1.0) diff --git a/tests/py/test_sensitivity.py b/tests/py/test_sensitivity.py deleted file mode 100644 index 00da0f0..0000000 --- a/tests/py/test_sensitivity.py +++ /dev/null @@ -1,196 +0,0 @@ -import os - -import numpy as np - -import netgraph_core as ngc - - -def _make_graph(num_nodes, src, dst, capacity, cost): - """Helper to build graph with auto-generated ext_edge_ids.""" - ext_edge_ids = np.arange(len(src), dtype=np.int64) - return ngc.StrictMultiDiGraph.from_arrays( - num_nodes, src, dst, capacity, cost, ext_edge_ids - ) - - -def test_sensitivity_simple(): - # S->T (cap 10) - g = _make_graph( - num_nodes=2, - src=np.array([0], dtype=np.int32), - dst=np.array([1], dtype=np.int32), - capacity=np.array([10.0], dtype=np.float64), - cost=np.array([1], dtype=np.int64), - ) - alg = ngc.Algorithms(ngc.Backend.cpu()) - gh = alg.build_graph(g) - - res = alg.sensitivity_analysis(gh, 0, 1) - assert len(res) == 1 - assert res[0][0] == 0 # Edge ID - assert res[0][1] == 10.0 - - -def test_sensitivity_parallel(): - # S->T (cap 10), S->T (cap 10) - g = _make_graph( - num_nodes=2, - src=np.array([0, 0], dtype=np.int32), - dst=np.array([1, 1], dtype=np.int32), - capacity=np.array([10.0, 10.0], dtype=np.float64), - cost=np.array([1, 1], dtype=np.int64), - ) - alg = ngc.Algorithms(ngc.Backend.cpu()) - gh = alg.build_graph(g) - - res = alg.sensitivity_analysis(gh, 0, 1) - assert len(res) == 2 - for _eid, delta in res: - assert delta == 10.0 - - -def test_sensitivity_partial(): - # S->A (10), S->B (5), A->T (5), B->T (10) - # 0->1 (0), 0->2 (1), 1->3 (2), 2->3 (3) - g = _make_graph( - num_nodes=4, - src=np.array([0, 0, 1, 2], dtype=np.int32), - dst=np.array([1, 2, 3, 3], dtype=np.int32), - capacity=np.array([10.0, 5.0, 5.0, 10.0], dtype=np.float64), - cost=np.array([1, 1, 1, 1], dtype=np.int64), - ) - alg = ngc.Algorithms(ngc.Backend.cpu()) - gh = alg.build_graph(g) - - # With all equal-cost edges, shortest_path=True/False give the same result. - - res = alg.sensitivity_analysis(gh, 0, 3) - - # Saturated: S->B (1), A->T (2) - saturated = {1, 2} - assert len(res) == 2 - for eid, delta in res: - assert eid in saturated - assert delta == 5.0 - - -def test_sensitivity_masked(): - # Two parallel paths, cap 10. One masked out via input mask. - g = _make_graph( - num_nodes=2, - src=np.array([0, 0], dtype=np.int32), - dst=np.array([1, 1], dtype=np.int32), - capacity=np.array([10.0, 10.0], dtype=np.float64), - cost=np.array([1, 1], dtype=np.int64), - ) - alg = ngc.Algorithms(ngc.Backend.cpu()) - gh = alg.build_graph(g) - - # Mask edge 1 - edge_mask = np.array([True, False], dtype=bool) - - res = alg.sensitivity_analysis(gh, 0, 1, edge_mask=edge_mask) - - # Should only see edge 0. Max flow is 10. Sensitivity of edge 0 is 10. - assert len(res) == 1 - assert res[0][0] == 0 - assert res[0][1] == 10.0 - - -def test_sensitivity_shortest_path_vs_max_flow(): - """Test that shortest_path mode produces different sensitivity results. - - Topology: S(0) -> A(1) -> T(2) [cost 1+1=2, cap 10 each] - S(0) -> B(3) -> T(2) [cost 2+2=4, cap 5 each] - - With shortest_path=False (full max-flow): - - Uses both paths: S->A->T (10) + S->B->T (5) = 15 total - - All 4 edges are saturated and critical - - With shortest_path=True (single-pass, IP/IGP mode): - - Only uses cheapest path: S->A->T (10) - - Edges 2,3 (S->B->T path) are NOT used, so NOT critical - """ - g = _make_graph( - num_nodes=4, - src=np.array([0, 1, 0, 3], dtype=np.int32), - dst=np.array([1, 2, 3, 2], dtype=np.int32), - capacity=np.array([10.0, 10.0, 5.0, 5.0], dtype=np.float64), - cost=np.array([1, 1, 2, 2], dtype=np.int64), # S->A->T costs 2, S->B->T costs 4 - ) - alg = ngc.Algorithms(ngc.Backend.cpu()) - gh = alg.build_graph(g) - - # Step 1: Verify baseline flow values with max_flow - # This validates the routing semantics before testing sensitivity - flow_full, _ = alg.max_flow(gh, 0, 2, shortest_path=False) - flow_sp, _ = alg.max_flow(gh, 0, 2, shortest_path=True) - - assert abs(flow_full - 15.0) < 1e-9, f"Full max-flow should be 15, got {flow_full}" - assert abs(flow_sp - 10.0) < 1e-9, f"Shortest-path flow should be 10, got {flow_sp}" - - # Step 2: Test sensitivity with shortest_path=False (full max-flow) - res_full = alg.sensitivity_analysis(gh, 0, 2, shortest_path=False) - - # All 4 edges should be saturated and critical - assert len(res_full) == 4, "Full max-flow should report all 4 edges as critical" - edge_ids_full = {eid for eid, _ in res_full} - assert edge_ids_full == {0, 1, 2, 3} - - # Step 3: Test sensitivity with shortest_path=True (IP/IGP mode) - res_sp = alg.sensitivity_analysis(gh, 0, 2, shortest_path=True) - - # Only edges 0,1 (S->A->T path) should be critical - assert len(res_sp) == 2, "Shortest-path mode should only report 2 edges as critical" - edge_ids_sp = {eid for eid, _ in res_sp} - assert edge_ids_sp == {0, 1}, f"Expected edges 0,1 but got {edge_ids_sp}" - - # Delta values: baseline=10, with edge removed traffic uses S->B->T (cap 5) - for eid, delta in res_sp: - assert abs(delta - 5.0) < 1e-9, f"Edge {eid} should have delta 5.0, got {delta}" - - -def test_sensitivity_order_deterministic_across_runs(): - """Repeated runs should preserve the same edge ordering and deltas.""" - g = _make_graph( - num_nodes=4, - src=np.array([0, 0, 1, 2], dtype=np.int32), - dst=np.array([1, 2, 3, 3], dtype=np.int32), - capacity=np.array([10.0, 5.0, 5.0, 10.0], dtype=np.float64), - cost=np.array([1, 1, 1, 1], dtype=np.int64), - ) - alg = ngc.Algorithms(ngc.Backend.cpu()) - gh = alg.build_graph(g) - - first = alg.sensitivity_analysis(gh, 0, 3) - second = alg.sensitivity_analysis(gh, 0, 3) - - assert first == second - - -def test_sensitivity_threaded_matches_serial(): - """Threaded sensitivity must exactly match the forced-serial reference.""" - g = _make_graph( - num_nodes=4, - src=np.array([0, 1, 0, 3], dtype=np.int32), - dst=np.array([1, 2, 3, 2], dtype=np.int32), - capacity=np.array([10.0, 10.0, 5.0, 5.0], dtype=np.float64), - cost=np.array([1, 1, 2, 2], dtype=np.int64), - ) - alg = ngc.Algorithms(ngc.Backend.cpu()) - gh = alg.build_graph(g) - - previous = os.environ.get("NGRAPH_CORE_SENSITIVITY_THREADS") - try: - os.environ["NGRAPH_CORE_SENSITIVITY_THREADS"] = "1" - serial = alg.sensitivity_analysis(gh, 0, 2, shortest_path=False) - - os.environ["NGRAPH_CORE_SENSITIVITY_THREADS"] = "4" - threaded = alg.sensitivity_analysis(gh, 0, 2, shortest_path=False) - finally: - if previous is None: - os.environ.pop("NGRAPH_CORE_SENSITIVITY_THREADS", None) - else: - os.environ["NGRAPH_CORE_SENSITIVITY_THREADS"] = previous - - assert threaded == serial diff --git a/tests/py/test_spf_basic.py b/tests/py/test_spf_basic.py deleted file mode 100644 index 1eb38bd..0000000 --- a/tests/py/test_spf_basic.py +++ /dev/null @@ -1,184 +0,0 @@ -"""SPF basics. - -Core shortest-path semantics: distances, predecessor DAG equivalence, output -shapes/offsets, and destination early-exit behavior. -""" - -import numpy as np - -import netgraph_core as ngc - - -def run_spf( - g, - src, - dst=None, - selection=None, - *, - multipath=True, - _conv=None, - algs=None, - to_handle=None, -): - if selection is None: - selection = ngc.EdgeSelection( - multi_edge=True, - require_capacity=False, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - assert algs is not None and to_handle is not None - gh = to_handle(g) - dist, dag = algs.spf(gh, src, dst, selection=selection, multipath=multipath) - return dist, (_conv or (lambda gg, dd: dd))(g, dag) - - -def test_spf_line1_all_min_cost( - line1_graph, dag_to_pred_map, assert_pred_dag_integrity, algs, to_handle -): - g = line1_graph - dist, pred = run_spf(g, 0, _conv=dag_to_pred_map, algs=algs, to_handle=to_handle) - assert np.allclose(dist, np.array([0.0, 1.0, 2.0])) - assert pred == {0: {}, 1: {0: [0]}, 2: {1: [1, 2]}} - # Validate raw DAG integrity from a direct call - dist2, dag2 = algs.spf(to_handle(g), 0) - assert_pred_dag_integrity(g, dag2) - - -def test_spf_square1_all_min_cost( - square1_graph, dag_to_pred_map, assert_pred_dag_integrity, algs, to_handle -): - g = square1_graph - dist, pred = run_spf(g, 0, _conv=dag_to_pred_map, algs=algs, to_handle=to_handle) - expected_costs = np.array([0.0, 1.0, 2.0, 2.0]) - assert np.allclose(dist, expected_costs) - assert pred == {0: {}, 1: {0: [0]}, 3: {0: [2]}, 2: {1: [1]}} - dist2, dag2 = algs.spf(to_handle(g), 0) - assert_pred_dag_integrity(g, dag2) - - -def test_spf_square2_all_min_cost( - square2_graph, dag_to_pred_map, assert_pred_dag_integrity, algs, to_handle -): - g = square2_graph - dist, pred = run_spf(g, 0, _conv=dag_to_pred_map, algs=algs, to_handle=to_handle) - assert np.isclose(dist[0], 0.0) - assert np.isclose(dist[1], 1.0) - assert np.isclose(dist[3], 1.0) - assert np.isclose(dist[2], 2.0) - assert pred == {0: {}, 1: {0: [0]}, 3: {0: [1]}, 2: {1: [2], 3: [3]}} - dist2, dag2 = algs.spf(to_handle(g), 0) - assert_pred_dag_integrity(g, dag2) - - -def test_spf_graph3_all_min_cost_and_single( - graph3, dag_to_pred_map, assert_pred_dag_integrity, algs, to_handle -): - g = graph3 - # ALL_MIN_COST, multipath=True - sel = ngc.EdgeSelection( - multi_edge=True, - require_capacity=False, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - dist, pred = run_spf( - g, 0, selection=sel, _conv=dag_to_pred_map, algs=algs, to_handle=to_handle - ) - assert np.isclose(dist[0], 0.0) - assert np.isclose(dist[1], 1.0) - assert np.isclose(dist[4], 1.0) - assert np.isclose(dist[2], 2.0) - assert np.isclose(dist[5], 3.0) - assert np.isclose(dist[3], 4.0) - assert pred == { - 0: {}, - 1: {0: [0, 1, 2]}, - 4: {0: [3]}, - 2: {1: [4, 5, 6], 4: [8]}, - 5: {2: [7]}, - 3: {0: [11], 2: [10], 5: [9]}, - } - # SINGLE_MIN_COST per adjacency with ECMP across parents - sel2 = ngc.EdgeSelection( - multi_edge=False, - require_capacity=False, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - dist2, pred2 = run_spf( - g, - 0, - selection=sel2, - multipath=True, - _conv=dag_to_pred_map, - algs=algs, - to_handle=to_handle, - ) - assert np.allclose(dist2, dist) - assert pred2 == { - 0: {}, - 1: {0: [0]}, - 4: {0: [3]}, - 2: {1: [4], 4: [8]}, - 5: {2: [7]}, - 3: {0: [11], 2: [10], 5: [9]}, - } - # Validate integrity of returned DAGs - dist_all, dag_all = algs.spf(to_handle(g), 0, selection=sel) - assert_pred_dag_integrity(g, dag_all) - dist_single, dag_single = algs.spf(to_handle(g), 0, selection=sel2) - assert_pred_dag_integrity(g, dag_single) - - -def test_spf_capacity_filter_all_min_cost_with_cap_remaining( - build_graph, dag_to_pred_map, assert_pred_dag_integrity, algs, to_handle -): - g = build_graph(2, [(0, 1, 1, 0.0, 0), (0, 1, 1, 1.0, 1)]) - sel = ngc.EdgeSelection( - multi_edge=True, require_capacity=True, tie_break=ngc.EdgeTieBreak.DETERMINISTIC - ) - dist, pred = run_spf( - g, 0, selection=sel, _conv=dag_to_pred_map, algs=algs, to_handle=to_handle - ) - assert np.allclose(dist, np.array([0.0, 1.0])) - assert pred == {0: {}, 1: {0: [1]}} - dist2, dag2 = algs.spf(to_handle(g), 0, selection=sel) - assert_pred_dag_integrity(g, dag2) - - -def test_spf_with_dst_early_exit_equivalence( - square2_graph, dag_to_pred_map, assert_pred_dag_integrity, algs, to_handle -): - g = square2_graph - dist1, pred1 = run_spf(g, 0, _conv=dag_to_pred_map, algs=algs, to_handle=to_handle) - dist2, pred2 = run_spf( - g, 0, dst=2, _conv=dag_to_pred_map, algs=algs, to_handle=to_handle - ) - assert np.allclose(dist1, dist2) - assert pred1 == pred2 - d1, dag1 = algs.spf(to_handle(g), 0) - d2, dag2 = algs.spf(to_handle(g), 0, dst=2) - assert_pred_dag_integrity(g, dag1) - assert_pred_dag_integrity(g, dag2) - - -def test_spf_pred_dag_shapes_and_monotonic_offsets(square2_graph, algs, to_handle): - g = square2_graph - sel = ngc.EdgeSelection( - multi_edge=True, - require_capacity=False, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - dist, dag = algs.spf(to_handle(g), 0, 2, selection=sel) - # Distances shape - assert isinstance(dist, np.ndarray) - assert dist.shape == (g.num_nodes(),) - # PredDAG arrays - off = np.asarray(dag.parent_offsets) - par = np.asarray(dag.parents) - via = np.asarray(dag.via_edges) - # Offsets length and monotonicity - assert off.shape == (g.num_nodes() + 1,) - assert np.all(off[:-1] <= off[1:]) - # Parents/via sizes match total entries - total = int(off[-1]) - assert par.shape == (total,) - assert via.shape == (total,) diff --git a/tests/py/test_spf_edge_priority.py b/tests/py/test_spf_edge_priority.py deleted file mode 100644 index 15d2e79..0000000 --- a/tests/py/test_spf_edge_priority.py +++ /dev/null @@ -1,71 +0,0 @@ -"""EdgeSelection.priority: which per-edge value ranks equal-cost alternatives.""" - -from __future__ import annotations - -import numpy as np - -import netgraph_core as ngc - - -def _graph(): - # Two parallel equal-cost edges 0 -> 1: capacities 10 and 5, residuals 2 and 4. - g = ngc.StrictMultiDiGraph.from_arrays( - 2, - src=np.array([0, 0], dtype=np.int32), - dst=np.array([1, 1], dtype=np.int32), - capacity=np.array([10.0, 5.0], dtype=np.float64), - cost=np.array([1, 1], dtype=np.int64), - ext_edge_ids=np.array([100, 101], dtype=np.int64), - ) - algs = ngc.Algorithms(ngc.Backend.cpu()) - return g, algs, algs.build_graph(g) - - -def _chosen_edge(algs, gh, priority, residual): - sel = ngc.EdgeSelection( - multi_edge=False, - tie_break=ngc.EdgeTieBreak.PREFER_HIGHER_RESIDUAL, - priority=priority, - ) - _, dag = algs.spf(gh, 0, selection=sel, residual=residual) - via = np.asarray(dag.via_edges) - assert len(via) == 1 - return int(via[0]) - - -def test_priority_default_and_attribute(): - sel = ngc.EdgeSelection() - assert sel.priority == ngc.EdgePriority.RESIDUAL - sel.priority = ngc.EdgePriority.HEADROOM - assert sel.priority == ngc.EdgePriority.HEADROOM - assert set(ngc.EdgePriority.__members__) == {"RESIDUAL", "CAPACITY", "HEADROOM"} - - -def test_priority_selects_the_ranking_value(): - _, algs, gh = _graph() - residual = np.array([2.0, 4.0], dtype=np.float64) - assert _chosen_edge(algs, gh, ngc.EdgePriority.RESIDUAL, residual) == 1 - assert _chosen_edge(algs, gh, ngc.EdgePriority.CAPACITY, residual) == 0 - assert _chosen_edge(algs, gh, ngc.EdgePriority.HEADROOM, residual) == 1 # 0.8 > 0.2 - - -def test_priority_without_residual_falls_back_to_capacity(): - _, algs, gh = _graph() - for pri in ( - ngc.EdgePriority.RESIDUAL, - ngc.EdgePriority.CAPACITY, - ngc.EdgePriority.HEADROOM, - ): - assert _chosen_edge(algs, gh, pri, None) == 0 - - -def test_priority_does_not_change_eligibility(): - # A saturated edge stays excluded under capacity gating whatever the priority. - _, algs, gh = _graph() - residual = np.array([0.0, 4.0], dtype=np.float64) - for pri in ( - ngc.EdgePriority.RESIDUAL, - ngc.EdgePriority.CAPACITY, - ngc.EdgePriority.HEADROOM, - ): - assert _chosen_edge(algs, gh, pri, residual) == 1 diff --git a/tests/py/test_spf_edge_select.py b/tests/py/test_spf_edge_select.py deleted file mode 100644 index 7b5cabc..0000000 --- a/tests/py/test_spf_edge_select.py +++ /dev/null @@ -1,186 +0,0 @@ -"""SPF edge selection variants: capacity-aware and residual-aware tie-breaking.""" - -import numpy as np - -import netgraph_core as ngc - - -def test_spf_all_min_cost_with_cap_remaining_selects_all_equal_cost_edges( - build_graph, assert_pred_dag_integrity, algs, to_handle -): - # Two parallel equal-cost edges; both have capacity > 0 - edges = [ - (0, 1, 1.0, 5.0, 10), - (0, 1, 1.0, 7.0, 20), - ] - g = build_graph(2, edges) - sel = ngc.EdgeSelection( - multi_edge=True, require_capacity=True, tie_break=ngc.EdgeTieBreak.DETERMINISTIC - ) - dist, dag = algs.spf(to_handle(g), 0, 1, selection=sel) - # Distance should reflect the minimum cost (1.0) - assert np.isclose(dist[1], 1.0) - # Parent list for node 1 should include both parallel edges (two entries) - off = np.asarray(dag.parent_offsets) - via = np.asarray(dag.via_edges) - s, e = int(off[1]), int(off[2]) - assert e - s == 2 - assert {int(via[i]) for i in range(s, e)}.issubset(set(range(g.num_edges()))) - assert_pred_dag_integrity(g, dag) - - -def test_spf_single_min_cost_with_cap_remaining_selects_one_deterministically( - build_graph, - assert_pred_dag_integrity, - algs, - to_handle, -): - # Two parallel min-cost edges; expect exactly one chosen (deterministic by compaction) - edges = [ - (0, 1, 1.0, 5.0, 20), - (0, 1, 1.0, 5.0, 10), - ] - g = build_graph(2, edges) - sel = ngc.EdgeSelection( - multi_edge=False, - require_capacity=True, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - dist, dag = algs.spf(to_handle(g), 0, 1, selection=sel) - assert np.isclose(dist[1], 1.0) - off = np.asarray(dag.parent_offsets) - via = np.asarray(dag.via_edges) - s, e = int(off[1]), int(off[2]) - assert e - s == 1 - assert int(via[s]) in range(g.num_edges()) - assert_pred_dag_integrity(g, dag) - - -def test_spf_all_min_cost_parallel_edges( - build_graph, assert_pred_dag_integrity, algs, to_handle -): - # Two min-cost parallels and one higher-cost; expect both min-cost edges selected - edges = [ - (0, 1, 1.0, 5.0, 10), - (0, 1, 1.0, 7.0, 20), - (0, 1, 2.0, 9.0, 30), - ] - g = build_graph(2, edges) - sel = ngc.EdgeSelection( - multi_edge=True, - require_capacity=False, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - dist, dag = algs.spf(to_handle(g), 0, 1, selection=sel) - assert np.isclose(dist[1], 1.0) - off = np.asarray(dag.parent_offsets) - s, e = int(off[1]), int(off[2]) - assert e - s == 2 - assert_pred_dag_integrity(g, dag) - - -def test_spf_single_min_cost_parallel_edges( - build_graph, assert_pred_dag_integrity, algs, to_handle -): - # Two min-cost parallels; expect deterministic single edge selection - edges = [ - (0, 1, 1.0, 5.0, 20), - (0, 1, 1.0, 7.0, 10), - ] - g = build_graph(2, edges) - sel = ngc.EdgeSelection( - multi_edge=False, - require_capacity=False, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - dist, dag = algs.spf(to_handle(g), 0, 1, selection=sel) - assert np.isclose(dist[1], 1.0) - off = np.asarray(dag.parent_offsets) - via = np.asarray(dag.via_edges) - s, e = int(off[1]), int(off[2]) - assert e - s == 1 - assert int(via[s]) in range(g.num_edges()) - assert_pred_dag_integrity(g, dag) - - -def test_spf_all_min_cost_with_cap_remaining_filters_zero_cap( - build_graph, assert_pred_dag_integrity, algs, to_handle -): - # Two min-cost parallels; one has zero capacity and must be excluded - edges = [ - (0, 1, 1.0, 0.0, 10), - (0, 1, 1.0, 1.0, 20), - ] - g = build_graph(2, edges) - sel = ngc.EdgeSelection( - multi_edge=True, require_capacity=True, tie_break=ngc.EdgeTieBreak.DETERMINISTIC - ) - dist, dag = algs.spf(to_handle(g), 0, 1, selection=sel) - assert np.isclose(dist[1], 1.0) - off = np.asarray(dag.parent_offsets) - via = np.asarray(dag.via_edges) - s, e = int(off[1]), int(off[2]) - assert e - s == 1 - assert int(via[s]) in range(g.num_edges()) - assert_pred_dag_integrity(g, dag) - - -def test_spf_residual_single_min_cost_with_cap_remaining_load_factored( - build_graph, assert_pred_dag_integrity, algs, to_handle -): - # Two min-cost parallels with different load; prefer lower-load (higher residual) - edges = [ - (0, 1, 1.0, 10.0, 100), # edge A - (0, 1, 1.0, 10.0, 200), # edge B - ] - g = build_graph(2, edges) - residual = np.zeros(g.num_edges(), dtype=np.float64) - # Assign arbitrary loads to trigger deterministic preference; exact mapping by ext id removed - residual[:] = 10.0 - sel = ngc.EdgeSelection( - multi_edge=False, - require_capacity=True, - tie_break=ngc.EdgeTieBreak.PREFER_HIGHER_RESIDUAL, - ) - dist, dag = algs.spf(to_handle(g), 0, 1, selection=sel, residual=residual) - assert np.isclose(dist[1], 1.0) - off = np.asarray(dag.parent_offsets) - via = np.asarray(dag.via_edges) - s, e = int(off[1]), int(off[2]) - assert e - s == 1 - assert int(via[s]) in range(g.num_edges()) - assert_pred_dag_integrity(g, dag) - - -def test_spf_single_path_prefers_higher_residual_equal_cost( - build_graph, assert_pred_dag_integrity, algs, to_handle -): - # Two equal-cost paths 0->1->3 (cap 1) and 0->2->3 (cap 10); single-path mode - edges = [ - (0, 1, 1.0, 1.0, 10), - (1, 3, 1.0, 1.0, 11), - (0, 2, 1.0, 10.0, 20), - (2, 3, 1.0, 10.0, 21), - ] - g = build_graph(4, edges) - sel = ngc.EdgeSelection( - multi_edge=False, - require_capacity=False, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - - dist, dag = algs.spf( - to_handle(g), - 0, - 3, - selection=sel, - multipath=False, # single-path mode should prefer the higher-residual path - ) - - assert np.isclose(dist[3], 2.0) - off = np.asarray(dag.parent_offsets) - parents = np.asarray(dag.parents) - s, e = int(off[3]), int(off[4]) - assert e - s == 1 - assert int(parents[s]) == 2 # chooses path via node 2 (higher residual) - assert_pred_dag_integrity(g, dag) diff --git a/tests/py/test_spf_masks.py b/tests/py/test_spf_masks.py deleted file mode 100644 index ea5dfb3..0000000 --- a/tests/py/test_spf_masks.py +++ /dev/null @@ -1,51 +0,0 @@ -"""SPF masks: behavior of node_mask and edge_mask in shortest-path routines.""" - -import numpy as np - -import netgraph_core as ngc - - -def test_node_mask_blocks_path(build_graph, algs, to_handle): - # A=0 -> B=1 -> C=2, all cost=1 - edges = [ - (0, 1, 1, 1, 0), - (1, 2, 1, 1, 1), - ] - g = build_graph(3, edges) - node_mask = np.array([True, False, True], dtype=bool) - sel = ngc.EdgeSelection( - multi_edge=True, - require_capacity=False, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - dist, dag = algs.spf(to_handle(g), 0, 2, selection=sel, node_mask=node_mask) - # Destination unreachable because B is masked out - assert np.isinf(dist[2]) - # No parents recorded for 2 - off = np.asarray(dag.parent_offsets) - assert off[2] == off[3] - - -def test_edge_mask_filters_parallel_edges(build_graph, algs, to_handle): - # A=0 -> B=1 with two parallel edges of different costs; only cheap edge should be allowed - edges = [ - (0, 1, 1, 1, 10), - (0, 1, 2, 1, 20), - ] - g = build_graph(2, edges) - edge_mask = np.array([True, False], dtype=bool) - sel = ngc.EdgeSelection( - multi_edge=True, - require_capacity=False, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) - dist, dag = algs.spf(to_handle(g), 0, 1, selection=sel, edge_mask=edge_mask) - assert np.isclose(dist[1], 1.0) - # Only one predecessor edge should appear - offsets = np.asarray(dag.parent_offsets) - via = np.asarray(dag.via_edges) - start, end = int(offsets[1]), int(offsets[2]) - assert end - start == 1 - # Map via edge id -> ensure it's a valid edge id - e = int(via[start]) - assert 0 <= e < g.num_edges() diff --git a/tests/py/test_spf_queue_env.py b/tests/py/test_spf_queue_env.py deleted file mode 100644 index 962e814..0000000 --- a/tests/py/test_spf_queue_env.py +++ /dev/null @@ -1,69 +0,0 @@ -"""NGRAPH_CORE_SPF_QUEUE selects the SPF frontier queue; both routes must agree.""" - -from __future__ import annotations - -import os -import subprocess -import sys - -SCRIPT = r""" -import hashlib -import numpy as np -import netgraph_core as ngc - -rng = np.random.default_rng(7) -n = 300 -src, dst, cost, cap = [], [], [], [] -for u in range(n): - for k in range(6): - src.append(u); dst.append(int(rng.integers(0, n))) - cost.append(0 if rng.integers(0, 5) == 0 else int(rng.integers(1, 32))) - cap.append(float(1 + rng.integers(0, 100) / 7.0)) -g = ngc.StrictMultiDiGraph.from_arrays( - n, - src=np.array(src, dtype=np.int32), dst=np.array(dst, dtype=np.int32), - capacity=np.array(cap, dtype=np.float64), cost=np.array(cost, dtype=np.int64), - ext_edge_ids=np.arange(len(src), dtype=np.int64), -) -algs = ngc.Algorithms(ngc.Backend.cpu()) -gh = algs.build_graph(g) -h = hashlib.sha256() -residual = np.array(cap, dtype=np.float64); residual[::13] = 0.0 -for s in (0, 17, 150): - for dstn in (None, 5, 299): - for multipath in (True, False): - for me in (True, False): - sel = ngc.EdgeSelection(multi_edge=me, require_capacity=not me, tie_break=ngc.EdgeTieBreak.PREFER_HIGHER_RESIDUAL) - dist, dag = algs.spf(gh, s, dstn, selection=sel, residual=residual, multipath=multipath) - for arr in (np.asarray(dist), np.asarray(dag.parent_offsets), np.asarray(dag.parents), np.asarray(dag.via_edges)): - h.update(np.ascontiguousarray(arr).tobytes()) - dist, dag = algs.spf_to(gh, s, multipath=True) - for arr in (np.asarray(dist), np.asarray(dag.parent_offsets), np.asarray(dag.parents), np.asarray(dag.via_edges)): - h.update(np.ascontiguousarray(arr).tobytes()) -flow, summary = algs.max_flow(gh, 0, 299, with_edge_flows=True) -h.update(repr((flow, list(summary.costs), list(summary.flows))).encode()) -print(h.hexdigest()) -""" - - -def _run(queue: str | None) -> str: - env = dict(os.environ) - env.pop("NGRAPH_CORE_SPF_QUEUE", None) - if queue is not None: - env["NGRAPH_CORE_SPF_QUEUE"] = queue - out = subprocess.run( - [sys.executable, "-c", SCRIPT], - env=env, - check=True, - capture_output=True, - text=True, - ) - return out.stdout.strip() - - -def test_queue_routes_agree_bit_for_bit() -> None: - heap = _run("heap") - bucket = _run("bucket") - auto = _run(None) - assert len(heap) == 64 - assert heap == bucket == auto diff --git a/tests/py/test_spf_to.py b/tests/py/test_spf_to.py deleted file mode 100644 index 664adb8..0000000 --- a/tests/py/test_spf_to.py +++ /dev/null @@ -1,114 +0,0 @@ -"""spf_to: reverse SPF toward one destination, with forced fan-out edges.""" - -from __future__ import annotations - -import numpy as np -import pytest - -import netgraph_core as ngc - - -def _graph(src, dst, cap, cost, n): - return ngc.StrictMultiDiGraph.from_arrays( - num_nodes=n, - src=np.array(src, dtype=np.int32), - dst=np.array(dst, dtype=np.int32), - capacity=np.array(cap, dtype=np.float64), - cost=np.array(cost, dtype=np.int64), - ext_edge_ids=np.arange(len(src), dtype=np.int64), - ) - - -SEL = ngc.EdgeSelection( - multi_edge=True, require_capacity=False, tie_break=ngc.EdgeTieBreak.DETERMINISTIC -) - - -@pytest.fixture -def fanout_graph(): - """P(0) -> S1(1) cost 0, P -> S2(2) cost 0; S1 -> T(4) cap 100; S2 -> M(3) cap 20; M -> T cap 60.""" - g = _graph( - [0, 0, 1, 2, 3], - [1, 2, 4, 3, 4], - [1e6, 1e6, 100.0, 20.0, 60.0], - [0, 0, 1, 1, 1], - 5, - ) - algs = ngc.Algorithms(ngc.Backend.cpu()) - return g, algs, algs.build_graph(g) - - -def test_distances_to_dst_match_forward_spf(fanout_graph): - g, algs, h = fanout_graph - dist_to, dag = algs.spf_to(h, 4, selection=SEL) - for s in range(g.num_nodes()): - d, _ = algs.spf(h, s, 4, selection=SEL) - assert dist_to[s] == d[4] - assert dag.parent_offsets.shape == (g.num_nodes() + 1,) - - -def test_fanout_forces_all_sources_and_admission_scales_globally(fanout_graph): - g, algs, h = fanout_graph - dist, dag = algs.spf_to(h, 4, selection=SEL, fanout_edges=[0, 1]) - assert dist[1] == 1.0 and dist[2] == 2.0 - # S2 has P as parent although P->S2 is not on a shortest P->T path. - assert list(dag.via_edges[dag.parent_offsets[2] : dag.parent_offsets[3]]) == [1] - fg = ngc.FlowGraph(g) - placed = fg.place( - ngc.FlowIndex(0, 4, 0, 0), - 0, - 4, - dag, - 100.0, - ngc.FlowPlacement.EQUAL_BALANCED, - ) - # Even 50/50 split; S2's 20-unit link admits 20 of 50, so the demand scales to 0.4. - assert placed == pytest.approx(40.0) - assert fg.edge_flow_view()[2] == pytest.approx(20.0) - - -def test_fanout_lossy_delivers_what_each_branch_carries(fanout_graph): - g, algs, h = fanout_graph - _, dag = algs.spf_to(h, 4, selection=SEL, fanout_edges=[0, 1]) - fg = ngc.FlowGraph(g) - placed, drops = fg.place_with_drops( - ngc.FlowIndex(0, 4, 0, 0), - 0, - 4, - dag, - 100.0, - ngc.FlowPlacement.EQUAL_BALANCED_LOSSY, - ) - assert placed == pytest.approx(70.0) # 50 via S1 + 20 via S2 - assert drops == [(3, pytest.approx(30.0))] - - -def test_without_fanout_the_pseudo_source_reaches_only_the_nearest_source(fanout_graph): - g, algs, h = fanout_graph - _, dag = algs.spf(h, 0, 4, selection=SEL) - # T is reached only via S1 -> T (edge 2): S2's branch costs 2 and never - # carries flow placed from P. - assert list(dag.via_edges[dag.parent_offsets[4] : dag.parent_offsets[5]]) == [2] - - -def test_fanout_respects_masks_and_reachability(fanout_graph): - g, algs, h = fanout_graph - edge_mask = np.ones(g.num_edges(), dtype=bool) - edge_mask[3] = False # S2 -> M down - dist, dag = algs.spf_to( - h, 4, selection=SEL, edge_mask=edge_mask, fanout_edges=[0, 1] - ) - assert np.isinf(dist[2]) - assert dag.parent_offsets[3] - dag.parent_offsets[2] == 0 - - -def test_fanout_validation(fanout_graph): - g, algs, h = fanout_graph - with pytest.raises(ValueError): - algs.spf_to( - h, 4, selection=SEL, fanout_edges=[4] - ) # M has an incoming DAG entry - with pytest.raises(ValueError): - algs.spf_to(h, 4, selection=SEL, fanout_edges=[99]) - with pytest.raises(ValueError): - algs.spf_to(h, 42, selection=SEL) diff --git a/tests/py/test_static_paths.py b/tests/py/test_static_paths.py deleted file mode 100644 index eb94689..0000000 --- a/tests/py/test_static_paths.py +++ /dev/null @@ -1,392 +0,0 @@ -"""FlowPolicy.set_static_paths: pinned path bundles (MPLS-style routing). - -Semantics under test (design-panel reviewed): -- One flow per usable bundle, each permanently bound to its own bundle in - supply order (the original Python semantics; the first C++ port bound every - flow to bundle[0]). -- Bundles are validated against the graph and pruned against the policy's - masks; a bundle with no surviving src->dst walk is DOWN and creates no flow. -- Flow cost is the min-cost src->dst walk of the PRUNED bundle. -- Pinned policies never grow their flow set and never reoptimize; - max_path_cost/-factor, min_flow_count and reoptimize_flows_on_each_placement - are inert. -- EqualBalanced spreads over the usable (up) bundles only, and the rebalance - loop is iterative (many heterogeneous pinned bundles must not blow the stack). -- PredDAG.from_edges builds a single-path bundle from a contiguous edge list. -""" - -from __future__ import annotations - -import numpy as np -import pytest - -import netgraph_core as ngc - - -def _graph(num_nodes, edges): - """edges: list of (src, dst, capacity, cost).""" - s, d, c, k = zip(*edges, strict=False) - return ngc.StrictMultiDiGraph.from_arrays( - num_nodes, - np.array(s, dtype=np.int32), - np.array(d, dtype=np.int32), - np.array(c, dtype=np.float64), - np.array(k, dtype=np.int64), - np.arange(len(s), dtype=np.int64), - ) - - -def _eid(g, u, v): - """Edge id of u->v after the graph's (cost, src, dst) reordering.""" - es, ed = g.edge_src_view(), g.edge_dst_view() - (idx,) = np.where((es == u) & (ed == v)) - assert idx.size == 1, f"expected exactly one {u}->{v} edge" - return int(idx[0]) - - -@pytest.fixture -def algs(): - return ngc.Algorithms(ngc.Backend.cpu()) - - -@pytest.fixture -def square(algs): - """0->1->2 (cost 1+1, cap 1 each) and 0->3->2 (cost 2+2, cap 2 each).""" - g = _graph(4, [(0, 1, 1.0, 1), (1, 2, 1.0, 1), (0, 3, 2.0, 2), (3, 2, 2.0, 2)]) - pg = algs.build_graph(g) - short = ngc.PredDAG.from_edges(g, [_eid(g, 0, 1), _eid(g, 1, 2)]) - long = ngc.PredDAG.from_edges(g, [_eid(g, 0, 3), _eid(g, 3, 2)]) - return g, pg, short, long - - -class TestFromEdges: - def test_builds_usable_single_path_dag(self, algs, square): - g, pg, short, _ = square - fs = ngc.FlowState(g) - placed = fs.place_on_dag( - 0, 2, short, float("inf"), ngc.FlowPlacement.PROPORTIONAL - ) - assert placed == pytest.approx(1.0) # bottleneck of the 0->1->2 path - - def test_rejects_empty_out_of_range_non_contiguous_and_revisits(self, algs): - g = _graph(3, [(0, 1, 1.0, 1), (1, 2, 1.0, 1), (2, 0, 1.0, 1)]) - e01, e12, e20 = _eid(g, 0, 1), _eid(g, 1, 2), _eid(g, 2, 0) - with pytest.raises(ValueError, match="non-empty"): - ngc.PredDAG.from_edges(g, []) - with pytest.raises(ValueError, match="out of range"): - ngc.PredDAG.from_edges(g, [99]) - with pytest.raises(ValueError, match="not contiguous"): - ngc.PredDAG.from_edges(g, [e01, e20]) - with pytest.raises(ValueError, match="simple path"): - ngc.PredDAG.from_edges(g, [e01, e12, e20]) # returns to node 0 - - -class TestPerFlowBinding: - """The port bug this redesign fixes: every flow bound to bundle[0].""" - - def test_each_flow_bound_to_its_own_bundle_in_supply_order(self, algs, square): - g, pg, short, long = square - fg = ngc.FlowGraph(g) - policy = ngc.FlowPolicy(algs, pg, ngc.FlowPolicyConfig()) - policy.set_static_paths(0, 2, [short, long]) - placed, left = policy.place_demand(fg, 0, 2, 0, 3.0) - assert placed == pytest.approx(3.0) # 1 on the short path + 2 on the long - assert left == pytest.approx(0.0) - # Ordinal binding: creation order == supply order; costs prove which - # bundle each flow holds (short = 2, long = 4). - by_creation = sorted(policy.flows.items(), key=lambda kv: kv[0][3]) - assert [v[2] for _, v in by_creation] == [2, 4] - assert [v[3] for _, v in by_creation] == pytest.approx([1.0, 2.0]) - - def test_supply_order_is_placement_precedence(self, algs, square): - """Proportional pinned placement is greedy in bundle-supply order.""" - g, pg, short, long = square - fg = ngc.FlowGraph(g) - policy = ngc.FlowPolicy(algs, pg, ngc.FlowPolicyConfig()) - policy.set_static_paths(0, 2, [long, short]) # long first this time - placed, _ = policy.place_demand(fg, 0, 2, 0, 2.0) - assert placed == pytest.approx(2.0) - by_creation = sorted(policy.flows.items(), key=lambda kv: kv[0][3]) - # First-supplied bundle (long, cost 4) absorbed everything. - assert [v[2] for _, v in by_creation] == [4, 2] - assert [v[3] for _, v in by_creation] == pytest.approx([2.0, 0.0]) - - -class TestEqualBalanced: - def test_eb_equalizes_to_bottleneck_bundle(self, algs, square): - g, pg, short, long = square - fg = ngc.FlowGraph(g) - policy = ngc.FlowPolicy( - algs, - pg, - ngc.FlowPolicyConfig(flow_placement=ngc.FlowPlacement.EQUAL_BALANCED), - ) - policy.set_static_paths(0, 2, [short, long]) - placed, _ = policy.place_demand(fg, 0, 2, 0, 3.0) - # ECMP over 2 LSPs; the short path caps each at ~1.0. - assert placed == pytest.approx(2.0, abs=1e-3) - vols = [v[3] for v in policy.flows.values()] - assert max(vols) - min(vols) < 1e-3 - - def test_eb_spreads_over_up_bundles_only(self, algs, square): - g, pg, short, long = square - em = np.ones(4, dtype=bool) - em[_eid(g, 0, 1)] = False # short path down - fg = ngc.FlowGraph(g) - policy = ngc.FlowPolicy( - algs, - pg, - ngc.FlowPolicyConfig(flow_placement=ngc.FlowPlacement.EQUAL_BALANCED), - edge_mask=em, - ) - policy.set_static_paths(0, 2, [short, long]) - placed, _ = policy.place_demand(fg, 0, 2, 0, 3.0) - # One up LSP; head-end ECMP over up tunnels only -> full 2.0 on the long - # path, not volume/2 as a supplied-count divisor would give. - assert policy.flow_count() == 1 - assert placed == pytest.approx(2.0, abs=1e-3) - - def test_many_heterogeneous_bundles_do_not_blow_the_stack(self, algs): - """256 pinned parallel LSPs with one small bundle: the EB rebalance loop - (previously recursion of depth ~U * ln(imbalance/kMinFlow)) must survive.""" - n_paths = 256 - edges = [] - # src=0, dst=1, path i via node 2+i with capacity 100 except one runt. - for i in range(n_paths): - mid = 2 + i - cap = 0.5 if i == 0 else 100.0 - edges.append((0, mid, cap, 1)) - edges.append((mid, 1, cap, 1)) - g = _graph(2 + n_paths, edges) - pg = algs.build_graph(g) - bundles = [ - ngc.PredDAG.from_edges(g, [_eid(g, 0, 2 + i), _eid(g, 2 + i, 1)]) - for i in range(n_paths) - ] - fg = ngc.FlowGraph(g) - policy = ngc.FlowPolicy( - algs, - pg, - ngc.FlowPolicyConfig(flow_placement=ngc.FlowPlacement.EQUAL_BALANCED), - ) - policy.set_static_paths(0, 1, bundles) - placed, _ = policy.place_demand(fg, 0, 1, 0, 10_000.0) - # ECMP: the runt bundle (cap 0.5) bounds every LSP. - assert placed == pytest.approx(n_paths * 0.5, rel=1e-2) - - def test_second_placement_is_not_incremental(self, algs, square): - """Documented: per-call volume defines the EB per-flow target, so a second - smaller placement adds nothing (parity with dynamic EB + max_flow_count).""" - g, pg, short, long = square - fg = ngc.FlowGraph(g) - policy = ngc.FlowPolicy( - algs, - pg, - ngc.FlowPolicyConfig(flow_placement=ngc.FlowPlacement.EQUAL_BALANCED), - ) - policy.set_static_paths(0, 2, [short, long]) - first, _ = policy.place_demand(fg, 0, 2, 0, 2.0) - assert first == pytest.approx(2.0, abs=1e-3) - # The smaller per-call volume yields a smaller per-flow target that the - # flows already meet, so nothing new is placed and the new volume is - # returned as leftover. - second, second_left = policy.place_demand(fg, 0, 2, 0, 1.0) - assert second == pytest.approx(0.0, abs=1e-3) - assert second_left == pytest.approx(1.0, abs=1e-3) - assert policy.placed_demand() == pytest.approx(first, abs=1e-3) - - -class TestDiminishingReturnsArming: - def test_more_bundles_than_window_all_receive_volume(self, algs): - """16 LSPs x cap 1 against volume 10000: the diminishing-returns window - (8) must not fire before every pinned flow was visited once.""" - n_paths = 16 - edges = [] - for i in range(n_paths): - mid = 2 + i - edges.append((0, mid, 1.0, 1)) - edges.append((mid, 1, 1.0, 1)) - g = _graph(2 + n_paths, edges) - pg = algs.build_graph(g) - bundles = [ - ngc.PredDAG.from_edges(g, [_eid(g, 0, 2 + i), _eid(g, 2 + i, 1)]) - for i in range(n_paths) - ] - fg = ngc.FlowGraph(g) - policy = ngc.FlowPolicy(algs, pg, ngc.FlowPolicyConfig()) - policy.set_static_paths(0, 1, bundles) - placed, _ = policy.place_demand(fg, 0, 1, 0, 10_000.0) - assert placed == pytest.approx(float(n_paths)) # all 16 units of capacity - - -class TestMaskSemantics: - def test_down_bundle_creates_no_flow_and_cost_is_post_prune(self, algs): - """A bundle whose cheapest walk is masked reports the surviving walk's - cost; a bundle with no surviving walk is down.""" - # Two parallel two-hop walks 0->1: via node 2 (cost 1+1) and node 3 (cost 5+0). - g = _graph( - 4, - [(0, 2, 5.0, 1), (2, 1, 5.0, 1), (0, 3, 5.0, 5), (3, 1, 5.0, 0)], - ) - pg = algs.build_graph(g) - cheap = [_eid(g, 0, 2), _eid(g, 2, 1)] - dear = [_eid(g, 0, 3), _eid(g, 3, 1)] - # One multi-walk bundle containing both walks: merge the two single-path - # DAGs by using an SPF-style DAG is not possible here (costs differ), so - # build the union manually via two bundles for the down test, and use the - # dear-only bundle for the cost check after masking the cheap walk. - b_cheap = ngc.PredDAG.from_edges(g, cheap) - b_dear = ngc.PredDAG.from_edges(g, dear) - - em = np.ones(4, dtype=bool) - em[cheap[0]] = False # kill the cheap walk entirely - - fg = ngc.FlowGraph(g) - policy = ngc.FlowPolicy(algs, pg, ngc.FlowPolicyConfig(), edge_mask=em) - policy.set_static_paths(0, 1, [b_cheap, b_dear]) - placed, _ = policy.place_demand(fg, 0, 1, 0, 100.0) - assert policy.flow_count() == 1 # cheap bundle is DOWN - ((_, flow),) = policy.flows.items() - assert flow[2] == 5 # cost of the surviving (dear) walk - assert placed == pytest.approx(5.0) - - def test_multiwalk_bundle_survives_partial_failure_with_surviving_cost(self, algs): - """An SPF DAG used as one bundle renormalizes over surviving walks and - reports the post-prune min cost (SR-TE-style semantics).""" - # Equal-cost diamond: 0->1 via 2 or via 3, all edges cost 1 cap 5. - g = _graph(4, [(0, 2, 5.0, 1), (2, 1, 5.0, 1), (0, 3, 5.0, 1), (3, 1, 5.0, 1)]) - pg = algs.build_graph(g) - algs_local = algs - _, dag = algs_local.spf(pg, 0, None, multipath=True) - - em = np.ones(4, dtype=bool) - em[_eid(g, 0, 2)] = False # one branch of the DAG fails - - fg = ngc.FlowGraph(g) - policy = ngc.FlowPolicy(algs, pg, ngc.FlowPolicyConfig(), edge_mask=em) - policy.set_static_paths(0, 1, [dag]) - placed, _ = policy.place_demand(fg, 0, 1, 0, 100.0) - assert policy.flow_count() == 1 - assert placed == pytest.approx(5.0) # surviving branch only - ((_, flow),) = policy.flows.items() - assert flow[2] == 2 - - def test_all_bundles_down_places_nothing_and_is_idempotent(self, algs, square): - g, pg, short, long = square - nm = np.ones(4, dtype=bool) - nm[1] = False - nm[3] = False # both intermediate nodes down - fg = ngc.FlowGraph(g) - policy = ngc.FlowPolicy(algs, pg, ngc.FlowPolicyConfig(), node_mask=nm) - policy.set_static_paths(0, 2, [short, long]) - for _ in range(2): - placed, left = policy.place_demand(fg, 0, 2, 0, 3.0) - assert (placed, left) == (0.0, 3.0) - assert policy.flow_count() == 0 - - -class TestPinnedInertness: - def test_reoptimize_on_each_placement_is_inert(self, algs, square): - """With reoptimization enabled (as the TE presets set), pinned flows keep - their bundles: deliberate divergence from the original Python, where - reoptimization silently rerouted pinned flows onto SPF paths.""" - g, pg, short, long = square - fg = ngc.FlowGraph(g) - policy = ngc.FlowPolicy( - algs, pg, ngc.FlowPolicyConfig(reoptimize_flows_on_each_placement=True) - ) - policy.set_static_paths(0, 2, [short, long]) - placed, _ = policy.place_demand(fg, 0, 2, 0, 3.0) - assert placed == pytest.approx(3.0) - by_creation = sorted(policy.flows.items(), key=lambda kv: kv[0][3]) - assert [v[2] for _, v in by_creation] == [2, 4] # bindings unchanged - - def test_max_path_cost_is_inert_for_pinned_bundles(self, algs, square): - g, pg, short, long = square - fg = ngc.FlowGraph(g) - policy = ngc.FlowPolicy(algs, pg, ngc.FlowPolicyConfig(max_path_cost=1)) - policy.set_static_paths(0, 2, [short, long]) # costs 2 and 4, both > 1 - placed, _ = policy.place_demand(fg, 0, 2, 0, 3.0) - assert placed == pytest.approx(3.0) # pinned bundles bypass the cost gate - - def test_lifecycle_remove_and_rebalance(self, algs, square): - g, pg, short, long = square - fg = ngc.FlowGraph(g) - policy = ngc.FlowPolicy(algs, pg, ngc.FlowPolicyConfig()) - policy.set_static_paths(0, 2, [short, long]) - policy.place_demand(fg, 0, 2, 0, 3.0) - policy.remove_demand(fg) - assert np.asarray(fg.edge_flow_view()).sum() == pytest.approx(0.0) - placed, _ = policy.place_demand(fg, 0, 2, 0, 3.0) # re-pins from bundles - assert placed == pytest.approx(3.0) - placed_r, _ = policy.rebalance_demand(fg, 0, 2, 0, 1.0) - assert placed_r == pytest.approx(2.0) # 1.0 per flow target - - -class TestValidation: - def test_repin_before_placement_replaces(self, algs, square): - g, pg, short, long = square - policy = ngc.FlowPolicy(algs, pg, ngc.FlowPolicyConfig()) - policy.set_static_paths(0, 2, [short]) - policy.set_static_paths(0, 2, [short, long]) # no spurious throw - fg = ngc.FlowGraph(g) - placed, _ = policy.place_demand(fg, 0, 2, 0, 3.0) - assert placed == pytest.approx(3.0) - assert policy.flow_count() == 2 - - def test_repin_with_masks_pruning_is_not_spurious(self, algs, square): - """The design-panel blocker: derived max_flow_count must not make - re-pinning the same bundles throw once masks prune some of them.""" - g, pg, short, long = square - em = np.ones(4, dtype=bool) - em[_eid(g, 0, 1)] = False # short bundle will be down (U=1 < N=2) - policy = ngc.FlowPolicy(algs, pg, ngc.FlowPolicyConfig(), edge_mask=em) - policy.set_static_paths(0, 2, [short, long]) - policy.set_static_paths(0, 2, [short, long]) # must not throw - assert policy.flow_count() == 0 # no flows until placement - - def test_rejects_bundle_from_a_different_graph(self, algs, square): - g, pg, short, _ = square - other = _graph( - 4, [(0, 3, 1.0, 7), (3, 2, 1.0, 7), (0, 1, 1.0, 9), (1, 2, 1.0, 9)] - ) - foreign = ngc.PredDAG.from_edges(other, [_eid(other, 0, 1), _eid(other, 1, 2)]) - policy = ngc.FlowPolicy(algs, pg, ngc.FlowPolicyConfig()) - with pytest.raises(ValueError, match="different graph|does not connect"): - policy.set_static_paths(0, 2, [foreign]) - - def test_rejects_bundle_without_src_dst_walk(self, algs, square): - g, pg, short, _ = square - policy = ngc.FlowPolicy(algs, pg, ngc.FlowPolicyConfig()) - with pytest.raises(ValueError, match="no src->dst walk"): - policy.set_static_paths(1, 3, [short]) # short connects 0->2, not 1->3 - - def test_set_after_placement_and_wrong_demand_throw(self, algs, square): - g, pg, short, long = square - fg = ngc.FlowGraph(g) - policy = ngc.FlowPolicy(algs, pg, ngc.FlowPolicyConfig()) - policy.set_static_paths(0, 2, [short]) - policy.place_demand(fg, 0, 2, 0, 1.0) - with pytest.raises(ValueError, match="already holds flows"): - policy.set_static_paths(0, 2, [long]) - policy.remove_demand(fg) - policy.set_static_paths(0, 2, [long]) # allowed again after removal - with pytest.raises(ValueError, match="do not match demand"): - policy.place_demand(fg, 0, 1, 0, 1.0) - - def test_config_incompatibilities(self, algs, square): - g, pg, short, long = square - with pytest.raises(ValueError, match="max_flow_count"): - ngc.FlowPolicy( - algs, pg, ngc.FlowPolicyConfig(max_flow_count=3) - ).set_static_paths(0, 2, [short, long]) - with pytest.raises(ValueError, match="shortest_path"): - ngc.FlowPolicy( - algs, pg, ngc.FlowPolicyConfig(shortest_path=True) - ).set_static_paths(0, 2, [short]) - with pytest.raises(ValueError, match="non-empty"): - ngc.FlowPolicy(algs, pg, ngc.FlowPolicyConfig()).set_static_paths(0, 2, []) - with pytest.raises(ValueError, match="out of range or equal"): - ngc.FlowPolicy(algs, pg, ngc.FlowPolicyConfig()).set_static_paths( - 2, 2, [short] - ) diff --git a/tests/py/test_thread_safety.py b/tests/py/test_thread_safety.py deleted file mode 100644 index 9720542..0000000 --- a/tests/py/test_thread_safety.py +++ /dev/null @@ -1,360 +0,0 @@ -""" -Test thread safety of Python bindings (Issue #4). - -These tests validate that NumPy array data is properly copied before releasing -the GIL, preventing data races when other threads mutate the arrays. -""" - -import threading -import time - -import numpy as np -import pytest - -from netgraph_core import ( - Algorithms, - Backend, - StrictMultiDiGraph, -) - - -def _make_graph(num_nodes, src, dst, capacity, cost): - """Helper to build graph with auto-generated ext_edge_ids.""" - ext_edge_ids = np.arange(len(src), dtype=np.int64) - return StrictMultiDiGraph.from_arrays( - num_nodes, src, dst, capacity, cost, ext_edge_ids - ) - - -class TestSPFThreadSafety: - """Test that spf() is thread-safe with respect to mask mutations.""" - - def test_spf_node_mask_thread_safety(self): - """ - Verify that mutating node_mask in another thread doesn't affect spf() results. - - The C++ code copies the mask to a local vector before releasing the GIL, - so concurrent mutations to the NumPy buffer won't cause data races. - """ - backend = Backend.cpu() - algs = Algorithms(backend) - - n = 100 - g = _make_graph( - n, - np.arange(n - 1, dtype=np.int32), - np.arange(1, n, dtype=np.int32), - np.ones(n - 1) * 10.0, - np.ones(n - 1, dtype=np.int64), - ) - graph = algs.build_graph(g) - - # Create node mask (all True initially) - node_mask = np.ones(n, dtype=bool) - - results = [] - - def run_spf(): - """Run SPF with the mask.""" - dist, dag = algs.spf(graph, 0, node_mask=node_mask) - results.append((dist.copy(), dag)) - - def mutate_mask(): - """Mutate the mask after a short delay.""" - time.sleep(0.001) # Let SPF start - node_mask[:] = False # Try to cause a race - - # Run SPF in main thread while another thread mutates the mask - mutator = threading.Thread(target=mutate_mask) - mutator.start() - - run_spf() - - mutator.join() - - # SPF should see a consistent mask (either all True or all False) - # because the C++ code copies the mask before releasing the GIL - dist, dag = results[0] - - # If mask was all True: all nodes should be reachable - # If mask was all False: only source should have finite distance - # It should NOT be corrupted (some arbitrary subset) - finite_count = np.sum(np.isfinite(dist)) - - # Either saw all True (finite_count == n) or all False (finite_count == 1) - assert finite_count in [1, n], ( - f"SPF saw corrupted mask: {finite_count} finite distances " - f"(expected 1 or {n})" - ) - - def test_spf_edge_mask_thread_safety(self): - """Verify edge_mask is also safe from concurrent mutations.""" - backend = Backend.cpu() - algs = Algorithms(backend) - - n = 50 - edges = [(i, (i + 1) % n) for i in range(n)] # Ring topology - src = np.array([e[0] for e in edges], dtype=np.int32) - dst = np.array([e[1] for e in edges], dtype=np.int32) - - g = _make_graph( - n, - src, - dst, - np.ones(n) * 10.0, - np.ones(n, dtype=np.int64), - ) - graph = algs.build_graph(g) - - edge_mask = np.ones(n, dtype=bool) - - results = [] - - def run_spf(): - dist, dag = algs.spf(graph, 0, edge_mask=edge_mask) - results.append(dist.copy()) - - def mutate_mask(): - time.sleep(0.001) - edge_mask[:] = False - - mutator = threading.Thread(target=mutate_mask) - mutator.start() - run_spf() - mutator.join() - - dist = results[0] - # Should see consistent results (not corrupted) - # Either all edges available (complete ring) or no edges - assert np.sum(np.isfinite(dist)) in [1, n] - - -class TestKSPThreadSafety: - """Test that ksp() is thread-safe with respect to mask mutations.""" - - def test_ksp_mask_thread_safety(self): - """Verify ksp() copies masks before releasing GIL.""" - backend = Backend.cpu() - algs = Algorithms(backend) - - n = 20 - g = _make_graph( - n, - np.array([0, 0, 1, 1, 2], dtype=np.int32), - np.array([1, 2, 3, 4, 4], dtype=np.int32), - np.ones(5) * 10.0, - np.array([1, 2, 1, 2, 1], dtype=np.int64), - ) - graph = algs.build_graph(g) - - node_mask = np.ones(n, dtype=bool) - - results = [] - - def run_ksp(): - paths = algs.ksp(graph, 0, 4, k=3, node_mask=node_mask) - results.append(len(paths)) - - def mutate_mask(): - time.sleep(0.001) - node_mask[2] = False # Block a node - - mutator = threading.Thread(target=mutate_mask) - mutator.start() - run_ksp() - mutator.join() - - # Should get consistent results (either with or without node 2) - num_paths = results[0] - # The mask must be copied before the GIL is released, so the run sees one - # consistent snapshot: 2 paths (mask untouched) or 1 (node 2 blocked, leaving - # 0->1->4). Any other count means it observed a torn mask. - assert num_paths in (1, 2) - - -class TestMaxFlowThreadSafety: - """Test that max_flow() is thread-safe with respect to mask mutations.""" - - def test_maxflow_mask_thread_safety(self): - """Verify max_flow() copies masks before releasing GIL.""" - backend = Backend.cpu() - algs = Algorithms(backend) - - g = _make_graph( - 4, - np.array([0, 0, 1, 2], dtype=np.int32), - np.array([1, 2, 3, 3], dtype=np.int32), - np.array([10.0, 10.0, 10.0, 10.0]), - np.ones(4, dtype=np.int64), - ) - graph = algs.build_graph(g) - - node_mask = np.ones(4, dtype=bool) - edge_mask = np.ones(4, dtype=bool) - - results = [] - - def run_maxflow(): - total_flow, summary = algs.max_flow( - graph, 0, 3, node_mask=node_mask, edge_mask=edge_mask - ) - results.append(total_flow) - - def mutate_masks(): - time.sleep(0.001) - node_mask[1] = False - edge_mask[0] = False - - mutator = threading.Thread(target=mutate_masks) - mutator.start() - run_maxflow() - mutator.join() - - # Should get consistent flow value (either full graph or restricted) - flow = results[0] - assert flow >= 0.0 # Should not be corrupted (negative/NaN) - - -class TestConcurrentAlgorithmCalls: - """Test multiple algorithm calls running concurrently.""" - - def test_concurrent_spf_calls(self): - """Multiple threads calling spf() simultaneously should be safe.""" - backend = Backend.cpu() - algs = Algorithms(backend) - - n = 30 - g = _make_graph( - n, - np.arange(n - 1, dtype=np.int32), - np.arange(1, n, dtype=np.int32), - np.ones(n - 1) * 10.0, - np.ones(n - 1, dtype=np.int64), - ) - graph = algs.build_graph(g) - - results = [] - - def worker(src_node): - """Run SPF from a specific source.""" - mask = np.ones(n, dtype=bool) - mask[src_node] = True # Ensure source is available - dist, dag = algs.spf(graph, src_node, node_mask=mask) - results.append((src_node, dist.copy())) - - # Run multiple SPF calls concurrently - threads = [] - for i in range(5): - t = threading.Thread(target=worker, args=(i,)) - threads.append(t) - t.start() - - for t in threads: - t.join() - - # All threads should complete successfully - assert len(results) == 5 - - # Each result should be valid - for src, dist in results: - assert dist[src] == 0.0 # Source distance is always 0 - - -@pytest.mark.slow -class TestStressThreadSafety: - """Stress tests for thread safety under heavy concurrent load.""" - - def test_repeated_concurrent_mutations(self): - """Repeatedly mutate masks while running algorithms.""" - backend = Backend.cpu() - algs = Algorithms(backend) - - n = 50 - g = _make_graph( - n, - np.arange(n - 1, dtype=np.int32), - np.arange(1, n, dtype=np.int32), - np.random.rand(n - 1) * 100, - np.ones(n - 1, dtype=np.int64), - ) - graph = algs.build_graph(g) - - node_mask = np.ones(n, dtype=bool) - # pytest.fail() inside a worker thread cannot fail the test: the exception - # stays in that thread and join() does not re-raise it. Record failures and - # assert on them from the main thread instead. - errors: list[str] = [] - - def run_algorithms(): - """Run various algorithms repeatedly.""" - for _ in range(20): - try: - algs.spf(graph, 0, node_mask=node_mask) - except Exception as e: # noqa: BLE001 - reported below - errors.append(f"algorithm raised: {e!r}") - - def mutate_continuously(): - """Continuously mutate the mask.""" - try: - for _ in range(100): - node_mask[:] = np.random.rand(n) > 0.5 - time.sleep(0.001) - except Exception as e: # noqa: BLE001 - reported below - errors.append(f"mutator raised: {e!r}") - - algo_thread = threading.Thread(target=run_algorithms) - mutator_thread = threading.Thread(target=mutate_continuously) - - algo_thread.start() - mutator_thread.start() - - algo_thread.join() - mutator_thread.join() - - assert not errors, "; ".join(errors) - - -class TestMemoryOrdering: - """ - Test that mask copying has proper memory ordering. - - This is more of a theoretical concern - if masks aren't properly copied, - we might see stale or reordered writes from other threads. - """ - - def test_mask_sees_latest_values(self): - """ - Verify that when we create a mask and immediately pass it to C++, - the C++ code sees the correct values (not stale cache). - """ - backend = Backend.cpu() - algs = Algorithms(backend) - - n = 10 - g = _make_graph( - n, - np.arange(n - 1, dtype=np.int32), - np.arange(1, n, dtype=np.int32), - np.ones(n - 1), - np.ones(n - 1, dtype=np.int64), - ) - graph = algs.build_graph(g) - - # Create mask with specific pattern - mask = np.zeros(n, dtype=bool) - mask[0] = True # Only source - mask[5] = True # One intermediate node - mask[9] = True # Destination - - # Immediately use it - dist, dag = algs.spf(graph, 0, node_mask=mask) - - # Should respect the mask (only nodes 0, 5, 9 are reachable) - finite_nodes = [i for i in range(n) if np.isfinite(dist[i])] - - # With the mask, we can't reach node 9 because intermediate nodes are blocked - # (except 5, but we need more nodes in the path) - assert 0 in finite_nodes # Source is always reachable - - # The key test: C++ saw the mask we just created (not stale/zero data) - # If it saw all-false or all-true, the results would be very different diff --git a/tests/py/test_units.py b/tests/py/test_units.py new file mode 100644 index 0000000..2c62aea --- /dev/null +++ b/tests/py/test_units.py @@ -0,0 +1,134 @@ +"""to_units (round half up, the only entry of physical numbers) and as_float (APPROACH 2.1).""" + +from __future__ import annotations + +import math +from fractions import Fraction + +import numpy as np +import pytest + +import netgraph_core as ngc + + +@pytest.mark.parametrize("q", [2.0**-12, 1.0, 2.0**-32, 2.0**32]) +def test_half_up_boundaries(q): + assert ngc.to_units(2.5 * q, q) == 3 + assert ngc.to_units(0.5 * q, q) == 1 + assert ngc.to_units(0.49 * q, q) == 0 + assert ngc.to_units(7.5 * q, q) == 8 + assert ngc.to_units(math.nextafter(7.5 * q, 0), q) == 7 + assert ngc.to_units(0.0, q) == 0 + assert ngc.to_units(-0.0, q) == 0 + assert ngc.as_float(12345, q) == 12345 * q + assert type(ngc.to_units(2.5 * q, q)) is int + + +def test_arrays(): + q = 2.0**-12 + out = ngc.to_units([0.5 * q, 1.49 * q, 2.5 * q, 1e15], q) + assert out.dtype == np.int64 + assert out.tolist() == [1, 1, 3, 4096 * 10**15] + assert ngc.to_units(np.array([1.5, 2.5], dtype=np.float32), 1).tolist() == [2, 3] + assert ngc.to_units(np.float64(2.5), 1) == 3 + assert ngc.to_units(np.array([], dtype=float), 1).tolist() == [] + assert ngc.as_float([1, 2, 3], 0.5).tolist() == [0.5, 1.0, 1.5] + assert ngc.as_float(np.array([4096], dtype=np.int64), q).tolist() == [1.0] + + +def test_admission_is_not_additive(): + q = 2.0**-12 + assert ngc.to_units(0.6 * q, q) + ngc.to_units(0.6 * q, q) == 2 + assert ngc.to_units(1.2 * q, q) == 1 + + +def test_exactness_limits(): + assert ngc.to_units(2.0**62 - 512, 1) == (1 << 62) - 512 + assert ngc.as_float((1 << 53) + 1, 1) == 2.0**53 + assert ngc.as_float(1 << 70, 2.0**-12) == 2.0**58 + + +@pytest.mark.parametrize( + "x, pattern", + [ + (float("nan"), "NaN"), + (float("inf"), "finite"), + (-float("inf"), "finite"), + (-1e-300, "negative"), + (2.0**62, "bound"), + ([1.0, float("nan")], "NaN"), + ("1.0", "physical numbers"), + (True, "physical numbers"), + (["a"], "physical numbers"), + ], +) +def test_rejected(x, pattern): + with pytest.raises(ValueError, match=pattern): + ngc.to_units(x, 1) + + +def test_resolution_validated(): + for q in (3.0, 2.0**-33, 2.0**33, 0.0, -1.0, math.nan, math.inf): + with pytest.raises(ValueError, match="resolution"): + ngc.to_units(1.0, q) + with pytest.raises(ValueError, match="resolution"): + ngc.as_float(1, q) + with pytest.raises(ValueError, match="bound"): + ngc.to_units(1.7976931348623157e308, 2.0**32) + + +def test_as_float_rejects_non_integers(): + with pytest.raises(ValueError): + ngc.as_float(1.5, 1) + with pytest.raises(ValueError): + ngc.as_float(np.array([1.5]), 1) + + +@pytest.mark.parametrize("q", [0.0, 3.0, math.nan, 2.0**33, -1.0, math.inf]) +@pytest.mark.parametrize( + "empty", [[], (), np.array([], dtype=np.float64), np.array([], dtype=np.int64)] +) +def test_empty_arrays_validate_the_resolution(q, empty): + # review-1a D3: the resolution is validated before the shape, also for empty input. + with pytest.raises(ValueError, match="resolution"): + ngc.to_units(empty, q) + with pytest.raises(ValueError, match="resolution"): + ngc.as_float(empty, q) + assert ngc.to_units(empty, 1.0).tolist() == [] + if isinstance(empty, np.ndarray) and empty.dtype.kind == "f": + with pytest.raises(ValueError, match="integers"): # not units, even when empty + ngc.as_float(empty, 1.0) + else: + assert ngc.as_float(empty, 1.0).tolist() == [] + + +@pytest.mark.parametrize( + "x, q", + [ + (2**1024, 2.0**-32), + (-(2**1024), 2.0**-32), + (2**1024 - 1, 2.0**-32), + (2**1055 + 3, 2.0**-32), + (2**64 + 1, 1.0), + (2**63, 2.0**-12), + (3 * 2**900 + 1, 2.0**32), + ((1 << 63) - 1, 2.0**-32), + ], +) +def test_as_float_unbounded_ints_are_correctly_rounded(x, q): + # review-1a D4: Python Amounts are unbounded; the scaled value is the correctly rounded + # double of the exact product, and no Python error leaks. + expect = float(Fraction(x) * Fraction(q)) + assert ngc.as_float(x, q) == expect + assert ngc.as_float([x, 1], q).tolist() == [expect, q] + assert ngc.as_float(np.int64(1), q) == q + + +def test_as_float_beyond_the_finite_doubles(): + with pytest.raises(ValueError, match="largest finite double"): + ngc.as_float(2**1100, 2.0**32) + with pytest.raises(ValueError, match="largest finite double"): + ngc.as_float([1, -(2**1100)], 1.0) + assert ngc.as_float(np.array([2**64 - 1], dtype=np.uint64), 1.0).tolist() == [ + 2.0**64 + ] From 231eb855a7f4f91dd46938511c8e66c666b06c45 Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Mon, 5 Oct 2026 04:13:09 -0700 Subject: [PATCH 04/12] Phase 1b: the integer ledger, the placement kernels and the internal search Build the second piece of the streamlined Core on the 1a graph and flow core, against APPROACH revision 27: - FlowGraph: the integer ledger (records as snapshots in FlowId order, residual and allocated views, bottleneck over member edges, reset only unbound), the lifetime table (bound without flows, bound with flows, orphaned records; copy, move, destroy), epochs, ids never reused; a staged, exception-safe commit (every throwing step before one no-throw publication, an epoch-stamped touched set, all-or-nothing scratch growth) with fault-injection tests. - The three kernels in units: MaxFlow as a fresh minimum-cost solve on the view V_i = R0 + own_i with arcs in B' order; Weighted with membership by priority at creation, frozen weights and highest-averages apportionment; EqualBalanced with round robin on a fixed cohort; Admission.Lossy on EB and Weighted (per-call offers, transit drops, +inf injection); split-graph pruning; T_max by bisection with an exact bracket and a checked 62-probe bound. - PredDAG (from_csr with complete validation, predecessors in ascending via EdgeId), Path(g, hops) with to_dag(), Eligibility with intersect. - The internal search: the unique shortest-path DAG over the eligible member graph, anycast pruning, the staged canonical single path with PathSelection projection. - Bindings, _docs.py and stubs for the public classes; 100 C++ tests and 200 Python tests. Gates (dev/design/2026-10-streamline/gates/1b/, uncommitted design tree): workspace check and sanitizers green; oracle population 3,821 pass / 0 fail; check_spf 3,820/3,820; prototype cross-check identical on 45 fixtures and 16,172 random cases; 909 corpus Manual placements equal their references; the equivalence run 0 failures under the 1b rules (no observable change before the policies of 1c); inventory: 0 Core callers of any deleted symbol; A/B/A: construction and max-flow rows 22 to 47 percent faster than df3b894, the five kernel rows 5 to 36 percent faster after the one-pass PredDAG-native route and plan builder; a per-process bimodality of the proportional-grid row (an out-of-line NodeMap::add in the flow core's subset solve whose callee-saved register reloads stalled in some process layouts) fixed by an allocation-free, noexcept, inlined add with a guard test, both grid rows unimodal (-33 and -4 percent). Reviewed adversarially (review-1b, review-1b-fixes, review-1b-fixes2, review-1b-bimodality): D1 to D3, N1, N2 and R15 fixed; D3 settled in the contract (revision 26), R16 in revision 27. NetGraph stays red until its migration converges (Phase 2). Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 29 + CMakeLists.txt | 25 +- README.md | 40 +- bindings/python/module.cpp | 605 +++++++++++- include/netgraph/core/detail/flow_core.hpp | 32 + include/netgraph/core/detail/int128.hpp | 67 ++ include/netgraph/core/detail/kernels.hpp | 153 ++++ .../netgraph/core/detail/ledger_access.hpp | 65 ++ include/netgraph/core/detail/node_map.hpp | 63 ++ include/netgraph/core/detail/pinned_flow.hpp | 104 +++ .../netgraph/core/detail/pred_dag_builder.hpp | 23 + include/netgraph/core/detail/search.hpp | 115 +++ include/netgraph/core/detail/uid.hpp | 11 + include/netgraph/core/flow.hpp | 71 ++ include/netgraph/core/flow_graph.hpp | 86 ++ include/netgraph/core/options.hpp | 33 + include/netgraph/core/pred_dag.hpp | 86 ++ include/netgraph/core/strict_multidigraph.hpp | 5 + python/netgraph_core/__init__.py | 36 +- python/netgraph_core/_docs.py | 310 ++++++- src/flow_graph.cpp | 461 ++++++++++ src/kernels.cpp | 863 ++++++++++++++++++ src/max_flow.cpp | 276 ++++-- src/pinned_flow.cpp | 252 +++++ src/pred_dag.cpp | 268 ++++++ src/search.cpp | 452 +++++++++ src/strict_multidigraph.cpp | 35 +- tests/cpp/flow_graph_tests.cpp | 668 ++++++++++++++ tests/cpp/kernels_tests.cpp | 664 ++++++++++++++ tests/cpp/ledger_fault_tests.cpp | 388 ++++++++ tests/cpp/masking_tests.cpp | 41 + tests/cpp/pred_dag_tests.cpp | 152 +++ tests/cpp/search_tests.cpp | 351 +++++++ tests/py/test_imports.py | 54 +- tests/py/test_internal_search.py | 97 ++ tests/py/test_ledger.py | 288 ++++++ tests/py/test_route_structures.py | 191 ++++ 37 files changed, 7342 insertions(+), 118 deletions(-) create mode 100644 include/netgraph/core/detail/flow_core.hpp create mode 100644 include/netgraph/core/detail/int128.hpp create mode 100644 include/netgraph/core/detail/kernels.hpp create mode 100644 include/netgraph/core/detail/ledger_access.hpp create mode 100644 include/netgraph/core/detail/node_map.hpp create mode 100644 include/netgraph/core/detail/pinned_flow.hpp create mode 100644 include/netgraph/core/detail/pred_dag_builder.hpp create mode 100644 include/netgraph/core/detail/search.hpp create mode 100644 include/netgraph/core/detail/uid.hpp create mode 100644 include/netgraph/core/flow.hpp create mode 100644 include/netgraph/core/flow_graph.hpp create mode 100644 include/netgraph/core/options.hpp create mode 100644 include/netgraph/core/pred_dag.hpp create mode 100644 src/flow_graph.cpp create mode 100644 src/kernels.cpp create mode 100644 src/pinned_flow.cpp create mode 100644 src/pred_dag.cpp create mode 100644 src/search.cpp create mode 100644 tests/cpp/flow_graph_tests.cpp create mode 100644 tests/cpp/kernels_tests.cpp create mode 100644 tests/cpp/ledger_fault_tests.cpp create mode 100644 tests/cpp/pred_dag_tests.cpp create mode 100644 tests/cpp/search_tests.cpp create mode 100644 tests/py/test_internal_search.py create mode 100644 tests/py/test_ledger.py create mode 100644 tests/py/test_route_structures.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d33ba4..1e22dae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Streamline sub-PR 1b (breaking; integration branch `streamline`) + +- **Ledger**: `FlowGraph(graph, residual=None)` is the integer ledger: a baseline in units + (validated within `[0, capacity_units]`), records as snapshots in FlowId order + (`records()`, `record(flow_id)` with `FlowId = (ledger uid, id)`, ids never reused), + `capacity_units()`, `residual()`, `allocated()`, `bottleneck(sources, elig)`, `reset()` and + `copy()` with the lifetime table (bound, orphaned records; reset and copy rejected while a + policy is bound). `FlowRecord` carries the flow's endpoints as sets, placement, admission, + bundle (`PredDAG`), route cost, `offered` (Lossy only), delivery and per-edge allocations and + drops; counters are 128-bit (exact Python ints). Every policy call reaches the ledger as one + atomic exchange: a failed call, an allocation failure included, changes nothing. +- **Kernels**: `FlowPlacement` (`MAX_FLOW`, `WEIGHTED`, `EQUAL_BALANCED`) and `Admission` + (`LOSSLESS`, `LOSSY`). MaxFlow solves the flow core on the flow's split graph over + `residual + own allocation` (its own allocation cancellable, other owners never); Weighted + apportions by highest averages (D'Hondt, EdgeId ties) over weights frozen at creation from + `priority`; EqualBalanced by round robin from the lowest EdgeId; the cumulative amount grows + to `max(T, min(T + v, T_max))` with `T_max` found exactly on the monotone allocation; Lossy + splits each offer per call and records transit drops. Exercised through the private + `_netgraph_core._PinnedFlow` (a Pinned placement with one bundle) until the policy engine. +- **Route structures**: `PredDAG.from_csr(graph, offsets, parents, via)` with the complete + validation (shape, ranges, endpoints, duplicates, cycles, ascending via within a node), on + the exact integers also beyond int64; + `Path(graph, hops)` with `to_dag()`; `PathSelection`, `EdgeTieBreak` + (`DETERMINISTIC`, `PREFER_HIGHER_PRIORITY`), `EdgePriority`. +- **Eligibility**: `Eligibility.intersect(a, b)`. +- **Search** (internal until sub-PR 1d): the unique shortest-path DAG from a root set in both + directions, anycast pruning to the nearest targets, and the staged canonical single path + (tight DAG, best attainable bottleneck under `PREFER_HIGHER_PRIORITY`, hops, edge ids). + ### Streamline sub-PR 1a (breaking; integration branch `streamline`) The release rebuilds Core in sub-PRs (`dev/design/2026-10-streamline/`). Sub-PR 1a: diff --git a/CMakeLists.txt b/CMakeLists.txt index f92f3d6..86c7559 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,6 +18,10 @@ add_executable(netgraph_core_tests tests/cpp/strict_multidigraph_tests.cpp tests/cpp/max_flow_tests.cpp tests/cpp/masking_tests.cpp + tests/cpp/pred_dag_tests.cpp + tests/cpp/search_tests.cpp + tests/cpp/kernels_tests.cpp + tests/cpp/flow_graph_tests.cpp ) target_link_libraries(netgraph_core_tests PRIVATE netgraph_core GTest::gtest_main) target_include_directories(netgraph_core_tests PRIVATE tests/cpp) @@ -29,8 +33,20 @@ add_executable(netgraph_core_tests endif() target_link_options(netgraph_core_tests PRIVATE --coverage) endif() + # Allocation-failure tests replace the global operator new: a separate executable. + add_executable(netgraph_core_fault_tests tests/cpp/ledger_fault_tests.cpp) + target_link_libraries(netgraph_core_fault_tests PRIVATE netgraph_core GTest::gtest_main) + target_include_directories(netgraph_core_fault_tests PRIVATE tests/cpp) + if(NETGRAPH_CORE_COVERAGE AND (CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")) + target_compile_options(netgraph_core_fault_tests PRIVATE -O0 -g --coverage) + if(CMAKE_CXX_COMPILER_ID MATCHES "GNU") + target_compile_options(netgraph_core_fault_tests PRIVATE -fprofile-update=atomic) + endif() + target_link_options(netgraph_core_fault_tests PRIVATE --coverage) + endif() include(GoogleTest) gtest_discover_tests(netgraph_core_tests) + gtest_discover_tests(netgraph_core_fault_tests) endif() set(CMAKE_CXX_STANDARD 20) @@ -72,6 +88,11 @@ endif() add_library(netgraph_core STATIC src/strict_multidigraph.cpp src/max_flow.cpp + src/pred_dag.cpp + src/search.cpp + src/kernels.cpp + src/flow_graph.cpp + src/pinned_flow.cpp src/profiling.cpp ) target_include_directories(netgraph_core PUBLIC @@ -228,9 +249,9 @@ if(NETGRAPH_CORE_SANITIZE) -fno-sanitize-recover=undefined) set(SAN_TARGETS netgraph_core _netgraph_core) if(TARGET netgraph_core_tests) - # The test executable links the sanitized static library, so it must be + # The test executables link the sanitized static library, so they must be # built and linked with the same flags to pull in the sanitizer runtime. - list(APPEND SAN_TARGETS netgraph_core_tests) + list(APPEND SAN_TARGETS netgraph_core_tests netgraph_core_fault_tests) endif() foreach(tgt ${SAN_TARGETS}) target_compile_options(${tgt} PRIVATE ${SAN_FLAGS}) diff --git a/README.md b/README.md index 2f616bb..7d4e4fd 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,12 @@ C++ graph engine for network flow analysis, traffic engineering simulation, and capacity planning. > **Streamline in progress.** The `streamline` branch rebuilds Core in sub-PRs -> (`dev/design/2026-10-streamline/`). This build (sub-PR 1a) carries the graph on integer -> units and the minimum-cost flow core behind a private entry point; the ledger, placement -> kernels, policies, searches and evaluators return in the following sub-PRs. The previous API -> (0.12 and earlier) is in git history and on PyPI. +> (`dev/design/2026-10-streamline/`). This build (sub-PR 1b) carries the graph on integer +> units, the minimum-cost flow core behind a private entry point, the route structures +> (`PredDAG`, `Path`), the integer ledger `FlowGraph` and the placement kernels (exercised +> through private entries until the policy engine arrives); policies, the public searches and +> the evaluators return in the following sub-PRs. The previous API (0.12 and earlier) is in git +> history and on PyPI. ## Overview @@ -31,7 +33,35 @@ C++ graph engine for network flow analysis, traffic engineering simulation, and `input_index()`, `row_offsets()`, `in_row_offsets()`, `in_adj_edge_index()`) return read-only NumPy views that keep the graph alive. - **`Eligibility`**: immutable, graph-bound static eligibility built by - `graph.eligibility(excluded_nodes, excluded_edges)`. + `graph.eligibility(excluded_nodes, excluded_edges)`; `Eligibility.intersect(a, b)` returns an + operand when the result equals it, otherwise a new value. + +### Route structures + +- **`PredDAG`**: immutable, graph-bound predecessor DAG in CSR form (`parent_offsets()`, + `parents()`, `via_edges()`), entries in ascending via EdgeId within a node. + `PredDAG.from_csr(graph, offsets, parents, via)` validates the shape, the ranges and endpoints, + duplicates, acyclicity and the canonical order before accepting a DAG. +- **`Path(graph, hops)`**: an explicit walk whose hops may hold several parallel members; + `cost()` is the sum over hops of the minimum member cost and `to_dag()` gives the equivalent + `PredDAG`. +- **`PathSelection(tie_break, priority, multipath=True, multi_edge=True)`** with + `EdgeTieBreak` and `EdgePriority`: the selection axes the searches and policies read. + +### Ledger and kernels + +- **`FlowGraph(graph, residual=None)`**: the integer ledger of one graph: an immutable baseline + in units and the records (`FlowRecord`: flow id, owner, endpoints, placement, admission, + bundle, route cost, offered, delivered, per-edge allocation and drops) of the flows placed on + it. `allocated()` is the exact sum of the records and `residual()` the baseline minus it; + `bottleneck(sources)` lists the saturated frontier; `reset()` and `copy()` follow the lifetime + rules (rejected while a policy is bound). The ledger is mutated only through policies. +- **Kernels** (`FlowPlacement`: `MAX_FLOW`, `WEIGHTED`, `EQUAL_BALANCED`; `Admission`: + `LOSSLESS`, `LOSSY`): MaxFlow is the flow core on the flow's DAG (a replacement on its own + allocation plus the residual), Weighted splits by highest averages over weights frozen at + creation, EqualBalanced by round robin; Lossy forwards hop by hop and records drops. Until the + policy engine lands, a placement on one bundle runs through the private + `_netgraph_core._PinnedFlow`. ### Flow core diff --git a/bindings/python/module.cpp b/bindings/python/module.cpp index b681bdb..d61ce6a 100644 --- a/bindings/python/module.cpp +++ b/bindings/python/module.cpp @@ -1,5 +1,8 @@ /* - Pybind11 module `_netgraph_core`: the graph on integer units and the private flow-core entry. + Pybind11 module `_netgraph_core`: the graph on integer units, the flow core's private entry, + the ledger with its records, the route structures (PredDAG, Path), Eligibility with + intersect, the selection and placement enums, and the private entries of the 1b recorder + population (a Pinned placement with one bundle, the internal search, the apportionment). - Graph accessors return read-only NumPy views that keep the graph alive (no per-call copy). - Integer inputs (capacities, costs, ids, views, caps) must be integers: Python ints, NumPy @@ -10,6 +13,8 @@ - std::invalid_argument from Core maps to ValueError. - _place_global_units is the recorder's private entry point (APPROACH 2.2): it returns the payload as a dict of Python ints and lists. + - Amounts and counters cross as Python ints (exact, also beyond 64 bits); per-edge arrays as + int64 NumPy arrays in EdgeId order; +inf is accepted only as a request (float('inf')). */ #include #include @@ -24,7 +29,16 @@ #include #include +#include "netgraph/core/detail/kernels.hpp" +#include "netgraph/core/detail/ledger_access.hpp" +#include "netgraph/core/detail/pinned_flow.hpp" +#include "netgraph/core/detail/pred_dag_builder.hpp" +#include "netgraph/core/detail/search.hpp" +#include "netgraph/core/flow.hpp" +#include "netgraph/core/flow_graph.hpp" #include "netgraph/core/max_flow.hpp" +#include "netgraph/core/options.hpp" +#include "netgraph/core/pred_dag.hpp" #include "netgraph/core/strict_multidigraph.hpp" #include "netgraph/core/types.hpp" @@ -94,6 +108,11 @@ struct WideInts { [[nodiscard]] bool in(std::size_t i, std::int64_t lo, std::int64_t hi) const noexcept { return sign(i) == 0 && value[i] >= lo && value[i] <= hi; } + // The entry as an exact Python int (an overflowing entry compared with another). + [[nodiscard]] py::object exact(std::size_t i) const { + py::object item = source[py::int_(i)]; + return py::reinterpret_steal(PyNumber_Index(item.ptr())); + } template [[nodiscard]] std::vector narrow() const { // after every bound was checked return std::vector(value.begin(), value.end()); @@ -336,6 +355,122 @@ std::shared_ptr make_graph(const py::object& num_nodes, cons return std::make_shared(static_cast(n), src_v, dst_v, cap_v, cost_v, resolution); } + +// ---------------------------------------------------------------- 1b helpers + +// The exact order of wide integers: a positive overflow above every int64, a negative one +// below, and two overflows on the same side by their exact values (review-1b D2). +bool wide_less(const WideInts& w, std::size_t i, std::size_t j) { + if (w.sign(i) != w.sign(j)) return w.sign(i) < w.sign(j); + if (w.sign(i) == 0) return w.value[i] < w.value[j]; + const int less = PyObject_RichCompareBool(w.exact(i).ptr(), w.exact(j).ptr(), Py_LT); + if (less < 0) throw py::error_already_set(); + return less == 1; +} + +// A per-edge integer array in [0, capacity_units[e]] (a ledger baseline, a search residual), +// validated on the unnarrowed values: the length (shape step), then every entry. +std::vector edge_units(const py::object& obj, const StrictMultiDiGraph& g, const char* name) { + const auto num_edges = static_cast(g.num_edges()); + const std::size_t len = sequence_length(obj, name); + if (len != num_edges) { + throw py::value_error(std::string(name) + " has length " + std::to_string(len) + ", expected " + + std::to_string(num_edges) + " (one entry per edge)"); + } + const auto v = wide_integers(obj, (std::string(name) + " (units)").c_str()); + const auto capacity = g.capacity_units(); + for (std::size_t e = 0; e < num_edges; ++e) { + if (!v.in(e, 0, capacity[e])) { + throw py::value_error(std::string(name) + "[" + std::to_string(e) + "] = " + v.text(e) + " is outside [0, capacity_units[" + + std::to_string(e) + "] = " + std::to_string(capacity[e]) + "]"); + } + } + return v.narrow(); +} + +// A node set: one NodeId or a sequence of them, ids range-checked before narrowing. +std::vector node_ids(const py::object& obj, std::int32_t num_nodes, const char* name) { + WideInts ids; + if (!is_bool(obj) && PyIndex_Check(obj.ptr()) && !py::isinstance(obj)) { + py::list one; + one.append(obj); + ids = wide_integers(one, name); + } else { + ids = wide_integers(obj, name); + } + if (ids.size() == 0) throw py::value_error(std::string(name) + ": the node set is empty"); + for (std::size_t i = 0; i < ids.size(); ++i) { + if (!ids.in(i, 0, static_cast(num_nodes) - 1)) { + throw py::value_error(std::string(name) + ": node " + ids.text(i) + " out of node range [0, " + + std::to_string(num_nodes) + ")"); + } + } + return ids.narrow(); +} + +// A request: an integer number of units in [0, 2^62) or +inf (float('inf')); nullopt is +inf. +std::optional request_units(const py::object& volume) { + if (py::isinstance(volume) || py::isinstance(volume, py::module_::import("numpy").attr("floating"))) { + const double x = volume.cast(); + if (std::isinf(x) && x > 0) return std::nullopt; + throw py::value_error("requests are integer units or +inf (a physical request goes through to_units first; got " + + std::string(py::str(volume)) + ")"); + } + const auto v = integer_value(volume, 0, kDomainLimit - 1, "request (units)"); + return static_cast(v); +} + +py::object counter_int(const Counter128& c) { + py::int_ hi(c.hi); + py::int_ lo(c.lo); + return hi.attr("__lshift__")(py::int_(64)).attr("__or__")(lo); +} + +py::object cost_or_none(Cost c) { return c == kUnreachable ? py::object(py::none()) : py::object(py::int_(c)); } + +py::array_t int64_array(const std::vector& v) { + py::array_t out(static_cast(v.size())); + std::copy(v.begin(), v.end(), out.mutable_data()); + return out; +} + +const char* termination_name(detail::Termination t) { + switch (t) { + case detail::Termination::Complete: return "complete"; + case detail::Termination::LimitFlows: return "limit_flows"; + case detail::Termination::LimitDiminishing: return "limit_diminishing"; + case detail::Termination::NoRoute: return "no_route"; + case detail::Termination::Exhausted: return "exhausted"; + } + return "?"; +} + +FlowId flow_id_of(const py::object& obj) { + if (!py::isinstance(obj) || py::len(obj) != 2) { + throw py::value_error("a FlowId is a tuple (ledger uid, id)"); + } + auto t = py::reinterpret_borrow(obj); + py::object uid = python_int(t[0], "FlowId ledger uid"); + py::object id = python_int(t[1], "FlowId id"); + int o1 = 0, o2 = 0; + const unsigned long long u = PyLong_AsUnsignedLongLong(uid.ptr()); + if (PyErr_Occurred()) { + PyErr_Clear(); + o1 = 1; + } + const long long i = PyLong_AsLongLongAndOverflow(id.ptr(), &o2); + if (o1 != 0 || o2 != 0 || i < 0) { + throw py::value_error("FlowId (" + std::string(py::str(uid)) + ", " + std::string(py::str(id)) + ") is retired or unknown"); + } + return FlowId {static_cast(u), static_cast(i)}; +} + +py::list pairs_list(const std::vector>& v) { + py::list out; + for (const auto& [e, x] : v) out.append(py::make_tuple(e, x)); + return out; +} + py::list int_list(const std::vector& v) { py::list out(v.size()); for (std::size_t i = 0; i < v.size(); ++i) out[i] = py::int_(v[i]); @@ -345,7 +480,63 @@ py::list int_list(const std::vector& v) { } // namespace PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { - m.doc() = "NetGraph-Core: the graph on integer units and the minimum-cost flow core"; + m.doc() = "NetGraph-Core: the graph on integer units, the minimum-cost flow core, the ledger and the kernels"; + + py::enum_(m, "FlowPlacement", + "The kernel of one flow: MAX_FLOW (the minimum-cost flow core on the flow's split graph,\n" + "a replacement on V = W + own), WEIGHTED (hop-by-hop WCMP: highest averages over frozen\n" + "weights at every node), EQUAL_BALANCED (ECMP: round robin at every node).") + .value("MAX_FLOW", FlowPlacement::MaxFlow) + .value("WEIGHTED", FlowPlacement::Weighted) + .value("EQUAL_BALANCED", FlowPlacement::EqualBalanced); + + py::enum_(m, "Admission", + "LOSSLESS admits only what fits; LOSSY forwards hop by hop without admission control and\n" + "drops what does not fit on each edge (EQUAL_BALANCED and WEIGHTED only).") + .value("LOSSLESS", Admission::Lossless) + .value("LOSSY", Admission::Lossy); + + py::enum_(m, "EdgeTieBreak", + "Ties between equal-cost candidates (parallel members and equal-cost walks):\n" + "DETERMINISTIC (lowest EdgeId; capacities ignored) or PREFER_HIGHER_PRIORITY (the higher\n" + "priority value first, then the lowest EdgeId).") + .value("DETERMINISTIC", EdgeTieBreak::Deterministic) + .value("PREFER_HIGHER_PRIORITY", EdgeTieBreak::PreferHigherPriority); + + py::enum_(m, "EdgePriority", + "The per-edge value PREFER_HIGHER_PRIORITY reads, and the Weighted kernel's membership and\n" + "weights: RESIDUAL (residual units), CAPACITY (static capacity units), HEADROOM (the exact\n" + "rational residual / capacity; rejected with WEIGHTED).") + .value("RESIDUAL", EdgePriority::Residual) + .value("CAPACITY", EdgePriority::Capacity) + .value("HEADROOM", EdgePriority::Headroom); + + py::class_(m, "PathSelection", + "The selection of a search: tie_break and priority are explicit; multipath = True keeps\n" + "every equal-cost walk (the DAG), False the one canonical single path; multi_edge = True\n" + "keeps every eligible parallel member, False one per (u, v) pair.") + .def(py::init(), py::arg("tie_break"), py::arg("priority"), + py::arg("multipath") = true, py::arg("multi_edge") = true) + .def_readonly("tie_break", &PathSelection::tie_break) + .def_readonly("priority", &PathSelection::priority) + .def_readonly("multipath", &PathSelection::multipath) + .def_readonly("multi_edge", &PathSelection::multi_edge) + .def("__eq__", [](const PathSelection& a, const py::object& b) { + return py::isinstance(b) && a == b.cast(); + }) + .def("__hash__", [](const PathSelection& a) { + return py::hash(py::make_tuple(static_cast(a.tie_break), static_cast(a.priority), a.multipath, + a.multi_edge)); + }) + .def("__repr__", [](const PathSelection& a) { + return std::string("PathSelection(tie_break=") + + (a.tie_break == EdgeTieBreak::Deterministic ? "DETERMINISTIC" : "PREFER_HIGHER_PRIORITY") + + ", priority=" + + (a.priority == EdgePriority::Residual ? "RESIDUAL" + : (a.priority == EdgePriority::Capacity ? "CAPACITY" : "HEADROOM")) + + ", multipath=" + (a.multipath ? "True" : "False") + ", multi_edge=" + (a.multi_edge ? "True" : "False") + + ")"; + }); m.def( "to_units", @@ -406,7 +597,20 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { const auto& el = self.cast(); return readonly_view(el.edge_allowed(), self, py::dtype("bool")); }, - "bool[E] read-only view (not excluded and both endpoints allowed)"); + "bool[E] read-only view (not excluded and both endpoints allowed)") + .def_static( + "intersect", + [](const std::shared_ptr& a, const std::shared_ptr& b) -> std::shared_ptr { + if (!a || !b) throw py::value_error("Eligibility.intersect takes two Eligibility values"); + auto r = Eligibility::intersect(*a, *b); + if (r.uid() == a->uid()) return a; + if (r.uid() == b->uid()) return b; + return std::make_shared(std::move(r)); + }, + py::arg("a"), py::arg("b"), + "The intersection of two values of one graph (ValueError for values of different graphs):\n" + "an operand itself when the result equals it (a first, then b), otherwise a new value with a\n" + "fresh uid; equal intersections built separately get distinct uids."); py::class_>( m, "StrictMultiDiGraph", @@ -603,4 +807,399 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { "cost_distribution: [[cost, units]], edge_flows, min_cut (None while the destinations stay\n" "connected, and on cap 0), potentials (-1 = never reached), counters}. Invalid inputs raise\n" "ValueError before any work."); + + // ------------------------------------------------------------------ route structures + + py::class_>( + m, "PredDAG", + "Immutable, graph-bound predecessor DAG: the entries of node v are parents()[k], via_edges()[k]\n" + "for k in [parent_offsets()[v], parent_offsets()[v + 1]), within a node in strictly ascending\n" + "via EdgeId (one canonical form). Equality is structural. Built by PredDAG.from_csr,\n" + "Path.to_dag() and the searches.") + .def_static( + "from_csr", + [](const std::shared_ptr& graph, const py::object& offsets, const py::object& parents, + const py::object& via) { + if (!graph) throw py::value_error("graph must be a StrictMultiDiGraph"); + // APPROACH 5's order on unnarrowed values; the C++ constructor re-checks it and adds the + // structural checks (endpoints, duplicates, acyclic, ascending). + const auto n = static_cast(graph->num_nodes()); + const auto m = static_cast(graph->num_edges()); + const std::size_t lo = sequence_length(offsets, "offsets"); + const std::size_t lp = sequence_length(parents, "parents"); + const std::size_t lv = sequence_length(via, "via"); + if (lo != n + 1) { + throw py::value_error("PredDAG.from_csr: offsets has length " + std::to_string(lo) + + ", expected N + 1 = " + std::to_string(n + 1)); + } + const auto o = wide_integers(offsets, "offsets"); + if (!o.in(0, 0, 0)) throw py::value_error("PredDAG.from_csr: offsets[0] = " + o.text(0) + ", expected 0"); + for (std::size_t v = 0; v < n; ++v) { + if (wide_less(o, v + 1, v)) { + throw py::value_error("PredDAG.from_csr: offsets are not monotone at " + std::to_string(v + 1) + " (" + + o.text(v) + " > " + o.text(v + 1) + ")"); + } + } + if (!o.in(n, static_cast(lp), static_cast(lp)) || lp != lv) { + throw py::value_error("PredDAG.from_csr: offsets[N] = " + o.text(n) + " but parents has " + + std::to_string(lp) + " and via has " + std::to_string(lv) + " entries"); + } + const auto p = wide_integers(parents, "parents"); + const auto w = wide_integers(via, "via"); + for (std::size_t k = 0; k < lp; ++k) { + if (!p.in(k, 0, static_cast(n) - 1)) { + throw py::value_error("PredDAG.from_csr: parents[" + std::to_string(k) + "] = " + p.text(k) + + " out of node range [0, " + std::to_string(n) + ")"); + } + if (!w.in(k, 0, m - 1)) { + throw py::value_error("PredDAG.from_csr: via[" + std::to_string(k) + "] = " + w.text(k) + + " out of edge range [0, " + std::to_string(m) + ")"); + } + } + const auto ov = o.narrow(); + const auto pv = p.narrow(); + const auto wv = w.narrow(); + return std::make_shared(PredDAG::from_csr(*graph, ov, pv, wv)); + }, + py::arg("graph"), py::arg("offsets"), py::arg("parents"), py::arg("via"), + "Validates, in order, before any traversal (ValueError on the first defect): offsets of\n" + "length N + 1, offsets[0] == 0, monotone, offsets[N] == len(parents) == len(via); then every\n" + "parent and via in range, every via joining (parent, child), no duplicate (child, via),\n" + "acyclic; within a node the entries in ascending via EdgeId.") + .def("graph_uid", &PredDAG::graph_uid, "uid of the graph this DAG belongs to") + .def( + "parent_offsets", + [](const py::object& self) { return readonly_view(self.cast().parent_offsets(), self); }, + "int32[N + 1] CSR offsets (read-only view)") + .def( + "parents", [](const py::object& self) { return readonly_view(self.cast().parents(), self); }, + "int32 parent NodeId per entry (read-only view)") + .def( + "via_edges", + [](const py::object& self) { return readonly_view(self.cast().via_edges(), self); }, + "int32 via EdgeId per entry (read-only view)") + .def("__eq__", [](const PredDAG& a, const py::object& b) { + return py::isinstance(b) && a == b.cast(); + }) + .def("__hash__", [](const PredDAG& a) { + return py::hash(py::make_tuple(a.graph_uid(), py::tuple(py::cast(std::vector(a.via_edges().begin(), + a.via_edges().end()))))); + }) + .def("__repr__", [](const PredDAG& a) { + return "PredDAG(graph_uid=" + std::to_string(a.graph_uid()) + ", entries=" + + std::to_string(a.via_edges().size()) + ")"; + }); + + py::class_>( + m, "Path", + "Immutable, graph-bound explicit walk of hops; a hop lists one or more parallel members of one\n" + "(u, v) pair in the caller's order. cost = sum over hops of the minimum member cost.") + .def(py::init([](const std::shared_ptr& graph, const py::object& hops) { + if (!graph) throw py::value_error("graph must be a StrictMultiDiGraph"); + if (not_a_sequence(hops)) throw py::value_error("Path: hops must be a sequence of hops (sequences of EdgeIds)"); + auto seq = py::reinterpret_borrow(hops); + std::vector raw; + for (std::size_t h = 0; h < seq.size(); ++h) { + py::object hop = seq[h]; + raw.push_back(wide_integers(hop, ("hops[" + std::to_string(h) + "]").c_str())); + } + // Every member in range before anything else (APPROACH 5), then C++'s order. + const auto m = static_cast(graph->num_edges()); + for (std::size_t h = 0; h < raw.size(); ++h) { + for (std::size_t i = 0; i < raw[h].size(); ++i) { + if (!raw[h].in(i, 0, m - 1)) { + throw py::value_error("Path: hop " + std::to_string(h) + " member " + raw[h].text(i) + + " out of edge range [0, " + std::to_string(m) + ")"); + } + } + } + std::vector> narrowed; + narrowed.reserve(raw.size()); + for (const auto& r : raw) narrowed.push_back(r.narrow()); + return std::make_shared(*graph, std::move(narrowed)); + }), + py::arg("graph"), py::arg("hops"), + "Validates, in order (ValueError on the first defect): every member id in range, no duplicate\n" + "member, a non-empty hop list, every hop non-empty, the members of a hop sharing (u, v),\n" + "contiguous hops, no repeated node.") + .def("graph_uid", &Path::graph_uid, "uid of the graph this path belongs to") + .def("hops", [](const Path& p) { return p.hops(); }, "the hops as lists of EdgeIds (the caller's member order)") + .def("src", &Path::src) + .def("dst", &Path::dst) + .def("nodes", [](const Path& p) { return p.nodes(); }, "the visited nodes, src first") + .def("cost", &Path::cost, "sum over hops of the minimum member cost") + .def( + "to_dag", [](const Path& p) { return std::make_shared(p.to_dag()); }, + "the equivalent PredDAG (every member of every hop)") + .def("__eq__", [](const Path& a, const py::object& b) { + return py::isinstance(b) && a == b.cast(); + }) + .def("__hash__", + [](const Path& a) { + py::list hops; + for (const auto& hop : a.hops()) hops.append(py::tuple(py::cast(hop))); + return py::hash(py::make_tuple(a.graph_uid(), py::tuple(hops))); + }) + .def("__repr__", [](const Path& a) { + return "Path(src=" + std::to_string(a.src()) + ", dst=" + std::to_string(a.dst()) + + ", hops=" + std::to_string(a.hops().size()) + ", cost=" + std::to_string(a.cost()) + ")"; + }); + + // ------------------------------------------------------------------ the ledger + + py::class_( + m, "FlowRecord", + "One flow of a ledger, as a snapshot: flow_id (ledger uid, id), owner (the policy's id),\n" + "sources, destinations, placement, admission, bundle (PredDAG: the flow's route),\n" + "route_cost (its minimum walk cost; None when empty), offered (Lossy only, else None),\n" + "delivered, edges [(EdgeId, units)] and drops [(EdgeId, units)] (cumulative, ascending\n" + "EdgeId). Counters are exact Python ints (they may exceed 64 bits).") + .def_property_readonly("flow_id", [](const FlowRecord& r) { return py::make_tuple(r.flow_id.first, r.flow_id.second); }) + .def_readonly("owner", &FlowRecord::owner) + .def_readonly("sources", &FlowRecord::sources) + .def_readonly("destinations", &FlowRecord::destinations) + .def_readonly("placement", &FlowRecord::placement) + .def_readonly("admission", &FlowRecord::admission) + .def_property_readonly("bundle", [](const FlowRecord& r) { return std::const_pointer_cast(r.bundle); }) + .def_property_readonly("route_cost", [](const FlowRecord& r) { return cost_or_none(r.route_cost); }) + .def_property_readonly("offered", [](const FlowRecord& r) -> py::object { + return r.offered.has_value() ? counter_int(*r.offered) : py::object(py::none()); + }) + .def_readonly("delivered", &FlowRecord::delivered) + .def_property_readonly("edges", [](const FlowRecord& r) { return pairs_list(r.edges); }) + .def_property_readonly("drops", [](const FlowRecord& r) { + py::list out; + for (const auto& [e, x] : r.drops) out.append(py::make_tuple(e, counter_int(x))); + return out; + }) + .def("__repr__", [](const FlowRecord& r) { + return "FlowRecord(flow_id=(" + std::to_string(r.flow_id.first) + ", " + std::to_string(r.flow_id.second) + + "), owner=" + std::to_string(r.owner) + ", delivered=" + std::to_string(r.delivered) + ")"; + }); + + py::class_>( + m, "FlowGraph", + "The integer ledger of one graph: an immutable baseline residual in units and the records of\n" + "the flows placed on it by policies (the ledger has no public placement or removal).\n" + "allocated() is the sum of every record's edges and residual() = baseline - allocated(), both\n" + "int64[E] snapshots. reset() and copy() are rejected while a policy is bound.") + .def(py::init([](const std::shared_ptr& graph, const py::object& residual) { + if (!graph) throw py::value_error("graph must be a StrictMultiDiGraph"); + if (residual.is_none()) return std::make_shared(graph); + const auto r = edge_units(residual, *graph, "residual"); + return std::make_shared(graph, r); + }), + py::arg("graph"), py::arg("residual") = py::none(), + "residual: the baseline as integer units, one entry per edge in [0, capacity_units[e]] (a\n" + "physical baseline goes through to_units first); None: the capacities. ValueError otherwise.") + .def("uid", &FlowGraph::uid, "process-unique ledger uid (the first half of every FlowId)") + .def( + "graph", [](const FlowGraph& fg) { return std::const_pointer_cast(fg.graph()); }, + "the ledger's graph") + .def( + "capacity_units", + [](const py::object& self) { return readonly_view(self.cast().capacity_units(), self); }, + "int64[E] the graph's capacities in units (read-only view)") + .def( + "residual", [](const FlowGraph& fg) { return int64_array(fg.residual()); }, + "int64[E] snapshot: baseline - allocated") + .def( + "allocated", [](const FlowGraph& fg) { return int64_array(fg.allocated()); }, + "int64[E] snapshot: the sum of every record's edges") + .def("records", &FlowGraph::records, "live records (bound and orphaned) as snapshots in FlowId order") + .def( + "record", [](const FlowGraph& fg, const py::object& flow_id) { return fg.record(flow_id_of(flow_id)); }, + py::arg("flow_id"), + "one record by FlowId (ValueError for a FlowId of another ledger, a retired or an unknown id)") + .def( + "bottleneck", + [](const FlowGraph& fg, const py::object& sources, const std::shared_ptr& elig) { + const auto s = node_ids(sources, fg.graph()->num_nodes(), "sources"); + return fg.bottleneck(s, elig.get()); + }, + py::arg("sources"), py::arg("elig") = nullptr, + "Saturation diagnostic: the edges (statically eligible, capacity >= 1 unit) leaving the set\n" + "reachable from the allowed sources over eligible forward arcs with residual >= 1 unit, in\n" + "ascending EdgeId (not a cut).") + .def( + "reset", + [](FlowGraph& fg, const py::object& residual) { + if (residual.is_none()) { + fg.reset(); + return; + } + const auto r = edge_units(residual, *fg.graph(), "residual"); + fg.reset(r); + }, + py::arg("residual") = py::none(), + "A new baseline (None: the capacities); orphaned records and their allocations are cleared\n" + "and their ids retired (ids are never reused). ValueError while a policy is bound.") + .def( + "copy", [](const FlowGraph& fg) { return std::make_shared(fg); }, + "a new ledger (new uid) with the same baseline and records, ids kept under the new uid;\n" + "ValueError while a policy is bound") + .def("__copy__", [](const FlowGraph& fg) { return std::make_shared(fg); }) + .def("__deepcopy__", [](const FlowGraph& fg, const py::object&) { return std::make_shared(fg); }, + py::arg("memo")) + .def( + "_state", + [](const FlowGraph& fg) { + py::dict out; + out["next_flow_id"] = detail::LedgerAccess::next_flow_id(fg); + out["bound"] = detail::LedgerAccess::bound_owners(fg); + out["orphans"] = detail::LedgerAccess::orphan_owners(fg); + return out; + }, + "private diagnostics: {next_flow_id, bound, orphans} (owner ids)"); + + // ------------------------------------------------------------------ private 1b recorder entries + + py::class_( + m, "_PinnedFlow", + "Private (sub-PR 1b): a Pinned placement with one bundle, the single-flow transaction of the\n" + "1b recorder population (APPROACH 8: a manual flow is a Pinned policy with one bundle). Sub-PR\n" + "1c's FlowPolicy with Pinned routing replaces it.") + .def(py::init([](FlowGraph& fg, const py::object& src, const py::object& dst, const std::shared_ptr& bundle, + FlowPlacement placement, Admission admission, EdgePriority priority, + const std::shared_ptr& elig) { + const auto n = fg.graph()->num_nodes(); + const auto s = integer_value(src, 0, static_cast(n) - 1, "src"); + const auto d = integer_value(dst, 0, static_cast(n) - 1, "dst"); + if (!bundle) throw py::value_error("bundle must be a PredDAG"); + return std::make_unique(fg, static_cast(s), static_cast(d), bundle, + placement, admission, priority, elig); + }), + py::keep_alive<1, 2>(), py::arg("flow_graph"), py::arg("src"), py::arg("dst"), py::arg("bundle"), + py::arg("placement"), py::arg("admission"), py::arg("priority"), py::arg("elig") = nullptr) + .def( + "place", + [](detail::PinnedFlow& pf, const py::object& volume) { + const auto v = request_units(volume); + // The GIL stays held: the call mutates the ledger, which other Python threads may + // read (one writer per ledger, APPROACH 13). + const detail::PinnedResult r = pf.place(v); + py::dict out; + out["placed"] = py::int_(r.placed); + out["remaining"] = r.remaining.has_value() ? py::object(py::int_(*r.remaining)) + : py::object(py::float_(std::numeric_limits::infinity())); + out["termination"] = termination_name(r.termination); + out["routes_changed"] = r.routes_changed; + out["weights_changed"] = r.weights_changed; + out["drops"] = pairs_list(r.drops); + out["iterations"] = r.iterations; + return out; + }, + py::arg("volume"), + "place(volume: int units or float('inf')) -> {placed, remaining, termination, routes_changed,\n" + "weights_changed, drops, iterations (diagnostic: bisection steps of the T_max search)}") + .def("remove", &detail::PinnedFlow::remove) + .def("release", &detail::PinnedFlow::release, "destroy without remove(): the record stays as an orphan") + .def("flow_id", + [](const detail::PinnedFlow& pf) -> py::object { + const auto id = pf.flow_id(); + return id.has_value() ? py::object(py::make_tuple(id->first, id->second)) : py::object(py::none()); + }) + .def("owner", &detail::PinnedFlow::owner) + .def("requested", [](const detail::PinnedFlow& pf) { return counter_int(pf.requested()); }, + "the epoch's cumulative finite request (units)") + .def("infinite", &detail::PinnedFlow::infinite, "whether +inf was requested in this epoch") + .def("placed_demand", &detail::PinnedFlow::placed_demand) + .def("usable_bundles", &detail::PinnedFlow::usable_bundles) + .def("plan", [](const detail::PinnedFlow& pf) -> py::object { + const auto* plan = pf.plan(); + if (plan == nullptr) return py::none(); + py::dict out; + out["placement"] = plan->placement; + out["admission"] = plan->admission; + out["route"] = plan->route; + out["members"] = plan->members; + out["weights"] = plan->placement == FlowPlacement::Weighted ? py::object(py::cast(plan->weights)) + : py::object(py::none()); + return out; + }); + + m.def( + "_spf", + [](const std::shared_ptr& graph, const py::object& roots, const std::string& direction, + const py::object& targets, const PathSelection& selection, const py::object& residual, + const py::object& min_residual, const std::shared_ptr& elig) { + if (!graph) throw py::value_error("graph must be a StrictMultiDiGraph"); + if (direction != "forward" && direction != "reverse") { + throw py::value_error("direction must be 'forward' or 'reverse'"); + } + const auto r = node_ids(roots, graph->num_nodes(), "roots"); + std::optional> t; + if (!targets.is_none()) t = node_ids(targets, graph->num_nodes(), "targets"); + // Section 6's order: the endpoint checks (overlap, a single path without targets) come + // before the residual is read (review-1b R15); detail::spf repeats them. + if (t.has_value()) { + detail::check_disjoint(detail::node_set(r, graph->num_nodes(), "roots"), + detail::node_set(*t, graph->num_nodes(), "targets")); + } else if (!selection.multipath) { + throw py::value_error("multipath = false requires targets (the single path joins a root to a target)"); + } + std::optional> res; + if (!residual.is_none()) res = edge_units(residual, *graph, "residual"); + const auto minr = integer_value(min_residual, 1, kDomainLimit - 1, "min_residual"); + detail::SpfOut o; + { + py::gil_scoped_release release; + std::optional> ts; + if (t.has_value()) ts = std::span(*t); + std::optional> rs; + if (res.has_value()) rs = std::span(*res); + o = detail::spf(*graph, r, direction == "forward" ? detail::Direction::Forward : detail::Direction::Reverse, ts, + selection, rs, minr, elig.get()); + } + py::dict out; + if (o.costs.has_value()) { + py::list costs; + for (Cost c : *o.costs) costs.append(cost_or_none(c)); + out["costs"] = costs; + } + out["dag"] = std::make_shared(detail::PredDAGBuilder::from_edges(*graph, o.edges)); + if (t.has_value()) { + out["target_cost"] = o.target_cost.has_value() ? py::object(py::int_(*o.target_cost)) : py::object(py::none()); + } + if (o.path.has_value()) out["path"] = o.path->walk; + return out; + }, + py::arg("graph"), py::arg("roots"), py::arg("direction"), py::arg("targets"), py::arg("selection"), + py::arg("residual") = py::none(), py::arg("min_residual") = 1, py::arg("elig") = nullptr, + "Private (sub-PR 1b; public spf in 1d): the search of APPROACH 6. Returns {costs (full search),\n" + "dag, target_cost (with targets; None when unreachable), path (the single path's walk)}."); + + m.def( + "_round_robin", + [](const py::object& total, const py::object& k) { + const auto t = integer_value(total, 0, kDomainLimit - 1, "total"); + const auto n = integer_value(k, 1, 1 << 20, "k"); + std::vector out(static_cast(n)); + detail::round_robin(t, out); + return out; + }, + py::arg("total"), py::arg("k"), "Private: round robin of total units over k members (APPROACH 2.1)."); + + m.def( + "_highest_averages", + [](const py::object& total, const py::object& weights) { + const auto t = integer_value(total, 0, kDomainLimit - 1, "total"); + const auto w = wide_integers(weights, "weights"); + if (w.size() == 0) throw py::value_error("highest averages over no member"); + std::uint64_t sum = 0; + for (std::size_t i = 0; i < w.size(); ++i) { + if (!w.in(i, 1, kDomainLimit - 1)) { + throw py::value_error("weights[" + std::to_string(i) + "] = " + w.text(i) + " is outside [1, 2^62)"); + } + sum += static_cast(w.value[i]); + if (sum >= static_cast(kDomainLimit)) throw py::value_error("the weights sum to 2^62 or more"); + } + const auto wv = w.narrow(); + std::vector out(wv.size()); + detail::highest_averages(t, wv, out); + return out; + }, + py::arg("total"), py::arg("weights"), + "Private: highest-averages (D'Hondt) apportionment of total units over positive weights, ties by\n" + "the lower index (APPROACH 2.1)."); } diff --git a/include/netgraph/core/detail/flow_core.hpp b/include/netgraph/core/detail/flow_core.hpp new file mode 100644 index 0000000..586c794 --- /dev/null +++ b/include/netgraph/core/detail/flow_core.hpp @@ -0,0 +1,32 @@ +/* The flow core restricted to an edge subset (internal; APPROACH 2.2 and 7). */ +#pragma once + +#include +#include +#include +#include + +#include "netgraph/core/strict_multidigraph.hpp" +#include "netgraph/core/types.hpp" + +namespace netgraph::core::detail { + +struct SubsetFlow { + Amount placed {0}; + std::vector flows {}; // aligned with the input edges + std::vector> cost_distribution {}; +}; + +// The minimum-cost flow of the maximum value under `cap` (nullopt: uncapped) from `sources` to +// `destinations` over the edges `edges` (ascending EdgeId, distinct) with capacities `caps` +// (aligned with `edges`, each in [0, 2^62)). The one flow core of APPROACH 2.2 with the same +// arc order (ascending EdgeId at every node, the virtual arc first, virtual arcs by ascending +// endpoint NodeId), from zero flow; the vertices are compressed to the ones the edges and +// endpoints touch, which changes no flow, curve or augmenting path. The endpoint sets are +// ascending, distinct and disjoint; the caller validated every input (the MaxFlow kernel's +// replacement solve on V_i = W + own_i over the flow's split graph). +[[nodiscard]] SubsetFlow solve_subset(const StrictMultiDiGraph& g, std::span edges, + std::span caps, std::span sources, + std::span destinations, std::optional cap); + +} // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/int128.hpp b/include/netgraph/core/detail/int128.hpp new file mode 100644 index 0000000..32cb664 --- /dev/null +++ b/include/netgraph/core/detail/int128.hpp @@ -0,0 +1,67 @@ +/* Portable unsigned 128-bit products and comparisons for exact rational arithmetic (internal). + + Used by the highest-averages apportionment (quotients w / d compared by cross products, lower + quotas floor(I * w / W)) and by the Headroom priority (residual / capacity compared by cross + products). Every operand is below 2^63, so every product is below 2^126 and every quotient + that is formed fits 64 bits. Compilers with __int128 use it; others use 32-bit limbs. +*/ +#pragma once + +#include + +namespace netgraph::core::detail { + +struct U128 { + std::uint64_t hi {0}; + std::uint64_t lo {0}; +}; + +inline bool operator==(U128 a, U128 b) noexcept { return a.hi == b.hi && a.lo == b.lo; } +inline bool operator<(U128 a, U128 b) noexcept { return a.hi != b.hi ? a.hi < b.hi : a.lo < b.lo; } +inline bool operator>(U128 a, U128 b) noexcept { return b < a; } + +#if defined(__SIZEOF_INT128__) + +inline U128 mul_u64(std::uint64_t a, std::uint64_t b) noexcept { + const unsigned __int128 p = static_cast(a) * b; + return {static_cast(p >> 64), static_cast(p)}; +} + +// floor(n / d) for d > 0 and a quotient below 2^64 (n.hi < d). +inline std::uint64_t div_u128(U128 n, std::uint64_t d) noexcept { + const unsigned __int128 x = (static_cast(n.hi) << 64) | n.lo; + return static_cast(x / d); +} + +#else + +inline U128 mul_u64(std::uint64_t a, std::uint64_t b) noexcept { + const std::uint64_t a0 = a & 0xffffffffULL, a1 = a >> 32; + const std::uint64_t b0 = b & 0xffffffffULL, b1 = b >> 32; + const std::uint64_t p00 = a0 * b0, p01 = a0 * b1, p10 = a1 * b0, p11 = a1 * b1; + const std::uint64_t mid = (p00 >> 32) + (p01 & 0xffffffffULL) + (p10 & 0xffffffffULL); + U128 r; + r.lo = (mid << 32) | (p00 & 0xffffffffULL); + r.hi = p11 + (p01 >> 32) + (p10 >> 32) + (mid >> 32); + return r; +} + +// floor(n / d) for d > 0 and a quotient below 2^64 (n.hi < d): restoring division, one +// quotient bit per step. +inline std::uint64_t div_u128(U128 n, std::uint64_t d) noexcept { + std::uint64_t rem = n.hi; + std::uint64_t q = 0; + for (int i = 63; i >= 0; --i) { + const bool carry = (rem >> 63) != 0; + rem = (rem << 1) | ((n.lo >> i) & 1U); + if (carry || rem >= d) { + rem -= d; + q |= std::uint64_t{1} << i; + } + } + return q; +} + +#endif + +} // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/kernels.hpp b/include/netgraph/core/detail/kernels.hpp new file mode 100644 index 0000000..bbbed3f --- /dev/null +++ b/include/netgraph/core/detail/kernels.hpp @@ -0,0 +1,153 @@ +/* Apportionment and the placement kernels in units (internal; APPROACH 2.1, 7 and 16). + + A kernel computes the pushes of one flow on its route. The plan of a flow is frozen at its + creation: the route, its split graph (the route's edges that pass the kernel's membership + rule, pruned to the walks from the source to the destination set, destinations terminal) + and, for Weighted, the weights. These are the per-flow routines of the placement transaction + (APPROACH 9.2 stage 3); the policies of sub-PR 1c compose them, the ledger commits their + output. +*/ +#pragma once + +#include +#include +#include +#include + +#include "netgraph/core/flow.hpp" +#include "netgraph/core/options.hpp" +#include "netgraph/core/pred_dag.hpp" +#include "netgraph/core/strict_multidigraph.hpp" +#include "netgraph/core/types.hpp" + +namespace netgraph::core::detail { + +// ---------------------------------------------------------------- apportionment (2.1) + +// Round robin (EqualBalanced and Lossy EqualBalanced): floor(total / k) to each of the k = +// out.size() members, one more unit to the first total mod k members (ascending EdgeId). +void round_robin(Amount total, std::span out) noexcept; + +// Highest averages (Jefferson, D'Hondt; Weighted and Lossy Weighted): the units go one at a +// time to the member maximising w / (a + 1), ties by the lower member index (ascending +// EdgeId). Closed form: every member starts at its lower quota floor(total * w / W), then the +// fewer than k remaining units go by the exact next quotients, compared by 128-bit cross +// products. Weights positive with a sum below 2^62; total below 2^63. House-monotone. +void highest_averages(Amount total, std::span weights, std::span out); + +// ---------------------------------------------------------------- plans + +// Throws std::invalid_argument for the combinations the contract rejects (Lossy with MaxFlow, +// Weighted with priority Headroom). +void check_kernel(FlowPlacement placement, Admission admission, EdgePriority priority); + +struct KernelPlan { + FlowPlacement placement {FlowPlacement::MaxFlow}; + Admission admission {Admission::Lossless}; + EdgePriority priority {EdgePriority::Residual}; + NodeId src {0}; + std::vector dsts {}; // ascending + std::vector route {}; // ascending: the flow's route (its record's bundle) + std::vector members {}; // ascending: the split graph + std::vector weights {}; // Weighted: per member (positive), frozen; else empty + // The split graph's nodes in a topological order (the source first), with per position its + // out-members members[out_begin[p] .. out_end[p]) (consecutive in ascending EdgeId: B' ids + // rank edges by source first) and per member the position of its head. Empty when the split + // graph is empty. + std::vector topo {}; + std::vector out_begin {}; + std::vector out_end {}; + std::vector head_pos {}; + std::vector terminal {}; + std::int32_t src_out_begin {0}; + std::int32_t src_out_end {0}; +}; + +// The edges of `edges` that lie on some walk from `src` to the destination set (edges leaving +// a destination removed: destinations are terminal), ascending. +[[nodiscard]] std::vector prune(const StrictMultiDiGraph& g, std::span edges, NodeId src, + std::span dsts); + +// The plan of a new flow on `route` (ascending, acyclic; the edges of the flow's DAG or +// bundle), its membership captured on `creation_view` (route-aligned: one entry per route +// edge, the view of the search that created the route; read only by a Weighted Residual +// membership, may be empty otherwise): MaxFlow every route edge (its view decides), Weighted by +// `priority` (Residual: creation view >= 1 unit; Capacity: static capacity >= 1 unit) with the +// weights read from the same values, EqualBalanced static capacity >= 1 unit; Lossy keeps its +// kernel's membership. The members are then pruned to the walks from src to the destination +// set (none leaving a destination) and given their topological structure, in one pass. `dsts` +// ascending and disjoint from src. +[[nodiscard]] KernelPlan make_plan(const StrictMultiDiGraph& g, FlowPlacement placement, Admission admission, + EdgePriority priority, NodeId src, std::span dsts, + std::span route, std::span creation_view); + +// The usable route of a bundle as a plan: of the bundle's edges the ones with static capacity +// >= 1 unit that `elig` allows (every one when null), pruned to the walks from src to the +// destination set; route == members, with the topological structure (built on the bundle's +// own predecessor lists). It is +// the plan of every kernel whose membership is static (MaxFlow, EqualBalanced, Weighted with +// Capacity: weights the capacities); a Weighted Residual plan is made from its route at +// creation (make_plan). +// With `cost`, the route's minimum walk cost from src to the destination set is computed in +// the same pass (route_cost of the plan; kUnreachable when the route is empty). +[[nodiscard]] KernelPlan make_route_plan(const StrictMultiDiGraph& g, FlowPlacement placement, Admission admission, + EdgePriority priority, NodeId src, std::span dsts, + const PredDAG& bundle, const Eligibility* elig, Cost* cost = nullptr); + +// The minimum walk cost from the source to the destination set over the route (the +// record's bundle); kUnreachable when the route has no such walk. +[[nodiscard]] Cost route_cost(const StrictMultiDiGraph& g, const KernelPlan& plan); + +// ---------------------------------------------------------------- allocation (16.2, 16.3) + +// The cumulative allocation alloc(T) of a Weighted or EqualBalanced plan, member-aligned: +// node by node in topological order, the source's inflow T, every other node's inflow the sum +// of its in-member allocations, split by round robin or highest averages. With `limit` +// (member-aligned) it stops at the first member above its limit and returns false. +bool allocate(const KernelPlan& plan, Amount total, std::span out, std::span limit = {}); + +// The largest T whose allocation fits `view_m` (member-aligned) member by member, by search on +// alloc(T) (exact: alloc is non-decreasing member by member, so fits(T) is monotone); upper +// bound the source members' view. APPROACH 7 (revision 26): approximate arithmetic only +// chooses probes. The first probe is at a floating-point estimate, then a galloping bracket, +// then bisection; every predicate, both bracket ends, the value and the allocation are exact, +// and `iterations` counts the probes, which stay within 62 (the bound of a plain bisection over +// units below 2^62; checked, std::logic_error otherwise). A first evaluation at the upper bound +// (or a Lossless step's target) is another allocation evaluation, not a search probe. +struct TMax { + Amount value {0}; + std::int32_t iterations {0}; +}; +[[nodiscard]] TMax t_max(const KernelPlan& plan, std::span view_m); + +// One Lossless step of one flow (APPROACH 9.2 stage 3, 16.3): T_new = max(T, min(T + request, +// T_max(V))) on the replacement view V = W + own (member-aligned `view_m`), with `own_m` the +// flow's committed allocation alloc(T) (member-aligned). MaxFlow solves the flow core on the +// split graph with cap T + request (uncapped for +inf); Weighted and EqualBalanced probe +// T + request first and search T_max (as t_max does) only when it does not fit: the result is +// the same T_new. `request` nullopt is +// +inf. `alloc` is the new member-aligned allocation (own_m itself when T is unchanged on a +// Weighted or EqualBalanced plan). +struct LosslessStep { + Amount total {0}; + std::vector alloc {}; + std::int32_t iterations {0}; // probes of this step's T_max search (at most 62) +}; +[[nodiscard]] LosslessStep lossless_step(const StrictMultiDiGraph& g, const KernelPlan& plan, Amount total, + std::span view_m, std::span own_m, + std::optional request); + +// One Lossy offer on the working residual (member-aligned `w_m`): split at every node by the +// kernel's rule, each member carrying min(share, residual) and dropping the rest; `offer` +// nullopt is +inf (every source member injected to its residual). `offered` is the offer, or +// the injection for +inf; a source without members injects nothing. +struct LossyStep { + Amount offered {0}; + Amount injected {0}; + Amount delivered {0}; + std::vector carried {}; // member-aligned + std::vector drops {}; // member-aligned +}; +[[nodiscard]] LossyStep lossy_step(const KernelPlan& plan, std::optional offer, std::span w_m); + +} // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/ledger_access.hpp b/include/netgraph/core/detail/ledger_access.hpp new file mode 100644 index 0000000..aa174ca --- /dev/null +++ b/include/netgraph/core/detail/ledger_access.hpp @@ -0,0 +1,65 @@ +/* The ledger's internal entry points: what a policy uses to place on and remove from it. + + APPROACH 8: the ledger is mutated only through policies. A policy binds (an owner id), then + every call ends with one atomic exchange of all of its records (`commit`); remove() reverts + its allocations and retires its ids (`detach`); a policy destroyed without remove() leaves + its records as orphans (`release`). None of this is public API. +*/ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/flow.hpp" +#include "netgraph/core/flow_graph.hpp" + +namespace netgraph::core::detail { + +// A record proposed by a policy for the commit: `id` nullopt is a new flow (its id is assigned +// at the commit, in draft order). `edges` and `drops` ascending EdgeId; zero entries dropped. +struct Draft { + std::optional id {}; + std::vector sources {}; + std::vector destinations {}; + FlowPlacement placement {FlowPlacement::MaxFlow}; + Admission admission {Admission::Lossless}; + std::shared_ptr bundle {}; + Cost route_cost {kUnreachable}; + std::optional offered {}; + Amount delivered {0}; + std::vector> edges {}; + std::vector> drops {}; +}; + +struct LedgerAccess { + // A new owner id (0, 1, ... per ledger). Throws on a moved-from ledger. + [[nodiscard]] static std::int64_t bind(FlowGraph& fg); + // The owner's policy is destroyed without remove(): its records stay as orphans. + static void release(FlowGraph& fg, std::int64_t owner) noexcept; + // remove(): the owner's records are deleted (allocations reverted), their ids retired. + // Throws only before any change (a moved-from ledger, an unbound owner). + static std::vector detach(FlowGraph& fg, std::int64_t owner); + // One atomic exchange: `drafts` must carry every live record of the owner (by id) plus the + // new ones; the new aggregate must stay within [0, baseline] on every edge. Throws (and + // changes nothing) otherwise, an allocation failure included: every allocation happens + // before a publication step that cannot throw. Returns the FlowIds of the new records in + // draft order. The drafts' vectors are moved from. + static std::vector commit(FlowGraph& fg, std::int64_t owner, std::span drafts); + // A live record by id (throws std::invalid_argument for an unknown or retired id). + [[nodiscard]] static const FlowRecord& live(const FlowGraph& fg, std::int64_t id); + // The working residual baseline - allocated into `out` (resized to E). + static void residual_into(const FlowGraph& fg, std::vector& out); + [[nodiscard]] static std::span baseline(const FlowGraph& fg); + // The aggregate allocation (borrowed; valid until the next commit, detach or reset). + [[nodiscard]] static std::span allocated(const FlowGraph& fg); + // Diagnostics (tests, the private recorder binding). + [[nodiscard]] static std::int64_t next_flow_id(const FlowGraph& fg); + [[nodiscard]] static std::vector bound_owners(const FlowGraph& fg); + [[nodiscard]] static std::vector orphan_owners(const FlowGraph& fg); +}; + +} // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/node_map.hpp b/include/netgraph/core/detail/node_map.hpp new file mode 100644 index 0000000..5a1d9af --- /dev/null +++ b/include/netgraph/core/detail/node_map.hpp @@ -0,0 +1,63 @@ +/* A reusable NodeId -> local index map for work on a small part of a large graph (internal). */ +#pragma once + +#include +#include +#include +#include + +#include "netgraph/core/types.hpp" + +#if defined(__GNUC__) || defined(__clang__) +#define NETGRAPH_CORE_ALWAYS_INLINE __attribute__((always_inline)) inline +#else +#define NETGRAPH_CORE_ALWAYS_INLINE inline +#endif + +namespace netgraph::core::detail { + +// Local indices 0.. in first-touch order, backed by dense arrays of the graph's node count +// that are reset through the touched entries only, so a call costs O(touched) after the first +// use. One instance per thread (`local()`); not reentrant: a caller holding it must not call +// another routine that uses it. +// +// reset() sizes both arrays for the graph, so add() never allocates: it is a few instructions, +// noexcept and inlined into the callers' per-edge loops. An out-of-line add() (it grew its list +// with push_back) saved and restored callee-saved registers on every call, and in some process +// layouts those stack reloads stalled: the flow core's subset solve then ran about 1.45 times +// slower for a whole process (the bimodal `kernel.proportional.grid` blocks; NOTES +// "Bimodality"). +class NodeMap { +public: + void reset(std::size_t num_nodes) { + for (std::size_t i = 0; i < count_; ++i) slot_[static_cast(nodes_[i])] = -1; + count_ = 0; + // Each array is checked against its own size: a failed growth leaves no array short of a + // size the other one promises. + if (slot_.size() < num_nodes) slot_.resize(num_nodes, -1); + if (nodes_.size() < num_nodes) nodes_.resize(num_nodes); + } + NETGRAPH_CORE_ALWAYS_INLINE std::int32_t add(NodeId v) noexcept { + auto& s = slot_[static_cast(v)]; + if (s < 0) { + s = static_cast(count_); + nodes_[count_++] = v; + } + return s; + } + [[nodiscard]] std::int32_t of(NodeId v) const noexcept { return slot_[static_cast(v)]; } + [[nodiscard]] std::size_t size() const noexcept { return count_; } + [[nodiscard]] std::span nodes() const noexcept { return {nodes_.data(), count_}; } + + static NodeMap& local() { + thread_local NodeMap map; + return map; + } + +private: + std::vector slot_ {}; + std::vector nodes_ {}; + std::size_t count_ {0}; +}; + +} // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/pinned_flow.hpp b/include/netgraph/core/detail/pinned_flow.hpp new file mode 100644 index 0000000..f109145 --- /dev/null +++ b/include/netgraph/core/detail/pinned_flow.hpp @@ -0,0 +1,104 @@ +/* A Pinned placement with one bundle: the single-flow transaction of sub-PR 1b (internal). + + APPROACH 8: a fixed-structure ("manual") placement is a Pinned policy with one bundle. Until + the policy engine of sub-PR 1c exists, this driver runs that transaction (APPROACH 9.2 + restricted to one flow on one bundle) for the tests, the A/B/A kernel rows and the private + recorder entry, composing the per-flow kernel steps with the ledger's internal entry + points; 1c's FlowPolicy with a one-bundle Pinned routing runs the same steps and replaces it. + + * place(0) is a no-op that creates nothing; a positive request creates the flow when the + bundle is usable (a src -> dst walk survives static eligibility and the capacity >= 1 + unit rule), else nothing is created and the call ends no_route; + * MaxFlow (replacement): a fresh flow-core solve on V = W + own over the split graph with + cap T + v (+inf: uncapped); + * Weighted and EqualBalanced (incremental): T_new = max(T, min(T + v, T_max(V))); + * Lossy: the offer split by the kernel's rule on W, per call; + * a finite request after +inf is rejected within an epoch (also 0); remove() reverts the + allocation, retires the id and opens a new epoch; destruction without remove() leaves the + record as an orphan. +*/ +#pragma once + +#include +#include +#include +#include +#include + +#include "netgraph/core/detail/kernels.hpp" +#include "netgraph/core/flow.hpp" +#include "netgraph/core/flow_graph.hpp" +#include "netgraph/core/options.hpp" +#include "netgraph/core/pred_dag.hpp" + +namespace netgraph::core::detail { + +// APPROACH 9.4 local outcomes (the public PlacementResult arrives with the policies in 1c). +enum class Termination { Complete, LimitFlows, LimitDiminishing, NoRoute, Exhausted }; + +struct PinnedResult { + Amount placed {0}; + std::optional remaining {}; // nullopt: +inf + Termination termination {Termination::Complete}; + bool routes_changed {false}; + bool weights_changed {false}; + std::vector> drops {}; // this call's transit drops + std::int32_t iterations {0}; // diagnostic: bisection steps of this call's T_max search +}; + +class PinnedFlow { +public: + // Validates (std::invalid_argument): the kernel combination (Lossy with MaxFlow, Weighted + // with Headroom), the endpoints (in range, distinct), a bundle or Eligibility of another + // graph, a bundle without any src -> dst walk before pruning (malformed). + PinnedFlow(FlowGraph& fg, NodeId src, NodeId dst, std::shared_ptr bundle, FlowPlacement placement, + Admission admission, EdgePriority priority, std::shared_ptr elig = nullptr); + PinnedFlow(const PinnedFlow&) = delete; + PinnedFlow& operator=(const PinnedFlow&) = delete; + ~PinnedFlow(); + + // `volume` nullopt is +inf; a finite request is an integer in [0, 2^62). + PinnedResult place(std::optional volume); + void remove(); + // The policy is destroyed without remove(): the record stays as an orphan; later calls throw. + void release() noexcept; + + [[nodiscard]] std::optional flow_id() const noexcept { return flow_id_; } + [[nodiscard]] Amount placed_demand() const; + [[nodiscard]] int usable_bundles() const noexcept { return route_plan_.route.empty() ? 0 : 1; } + // The live flow's plan, frozen at its creation (null without a flow). + [[nodiscard]] const KernelPlan* plan() const noexcept { return plan_; } + [[nodiscard]] std::int64_t owner() const noexcept { return owner_; } + // The epoch's call state: the cumulative finite request (positive calls that reached the + // ledger) and whether +inf was requested. + [[nodiscard]] const Counter128& requested() const noexcept { return requested_; } + [[nodiscard]] bool infinite() const noexcept { return infinite_; } + +private: + void check_bound() const; + + FlowGraph& fg_; + NodeId src_; + NodeId dst_; + std::shared_ptr bundle_; + FlowPlacement placement_; + Admission admission_; + EdgePriority priority_; + std::shared_ptr elig_; + // The usable route (pruned) as a plan with members == route: the plan of every kernel with a + // static membership. Its cost and its DAG (the record's bundle) do not change either. + KernelPlan route_plan_ {}; + Cost route_cost_ {kUnreachable}; + std::shared_ptr route_dag_ {}; // made at the first creation when pruned + std::int64_t owner_ {0}; + bool bound_ {false}; + // Epoch state. + std::optional flow_id_ {}; + std::optional captured_ {}; // a Weighted Residual plan, captured at creation + const KernelPlan* plan_ {nullptr}; // &route_plan_ or &*captured_ while a flow lives + Amount total_ {0}; + Counter128 requested_ {}; + bool infinite_ {false}; +}; + +} // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/pred_dag_builder.hpp b/include/netgraph/core/detail/pred_dag_builder.hpp new file mode 100644 index 0000000..2b6cafe --- /dev/null +++ b/include/netgraph/core/detail/pred_dag_builder.hpp @@ -0,0 +1,23 @@ +/* Internal construction of PredDAG values (search output, kernel routes, Path::to_dag). */ +#pragma once + +#include +#include +#include + +#include "netgraph/core/pred_dag.hpp" + +namespace netgraph::core::detail { + +struct PredDAGBuilder { + // The DAG of an edge set known to be acyclic and duplicate-free: per node its predecessor + // entries (edge_src, e) in ascending EdgeId. Not validated beyond what the callers guarantee. + [[nodiscard]] static PredDAG from_edges(const StrictMultiDiGraph& g, std::span edges); + // Per child its ascending via members, all with the one parent parent_of[child]. + [[nodiscard]] static PredDAG from_children(std::uint64_t graph_uid, const std::vector>& per_child, + const std::vector& parent_of); + // The DAG's edges in ascending EdgeId, without a copy (kept by the DAG since construction). + [[nodiscard]] static std::span ascending(const PredDAG& dag) noexcept; +}; + +} // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/search.hpp b/include/netgraph/core/detail/search.hpp new file mode 100644 index 0000000..2818890 --- /dev/null +++ b/include/netgraph/core/detail/search.hpp @@ -0,0 +1,115 @@ +/* The search semantics of APPROACH 6 as internal routines (the public spf and KSP are 1d). + + Positive costs make the shortest-path DAG from a root set unique (2.4): the tight edges + d(u) + cost(e) = d(v) of the member graph. With targets the DAG is pruned to the walks that + end in a nearest target (anycast), the same edge set for a forward search from the roots + and a reverse search rooted at the targets. The single path is the staged canonical walk: + (1) the tight DAG of the query, (2) under PreferHigherPriority the best attainable + bottleneck B* (a max-min DP on the DAG, capped by a Yen prefix's bottleneck) and the DAG + restricted to priority >= B*, (3) one reverse hop count h from the targets and a greedy + walk taking the smallest tight edge whose head has h(v) = h(u) - 1. +*/ +#pragma once + +#include +#include +#include +#include + +#include "netgraph/core/options.hpp" +#include "netgraph/core/pred_dag.hpp" +#include "netgraph/core/strict_multidigraph.hpp" +#include "netgraph/core/types.hpp" + +namespace netgraph::core::detail { + +enum class Direction { Forward, Reverse }; + +// An exact priority value: num / den with den >= 1 (den == 1 for Residual and Capacity, the +// capacity for Headroom); `infinite` above every finite value (the bottleneck of an empty walk). +struct Priority { + Amount num {0}; + Amount den {1}; + bool infinite {false}; +}; +[[nodiscard]] int compare(const Priority& a, const Priority& b) noexcept; // -1, 0, 1 + +// The per-edge priority values a search reads: `residual` empty (no residual) reads the static +// capacity for every priority; else Residual = residual units, Capacity = capacity units, +// Headroom = residual / capacity. +struct PriorityValues { + const StrictMultiDiGraph* g {nullptr}; + EdgePriority priority {EdgePriority::Capacity}; + std::span residual {}; + [[nodiscard]] Priority of(EdgeId e) const noexcept; +}; + +// The member graph (flags per edge): statically eligible, capacity >= 1 unit, residual >= +// min_residual when a residual is given; with multi_edge = false one member per (u, v): the +// minimum cost, then under PreferHigherPriority the highest priority value, then the lowest +// EdgeId (select_adjacency). +[[nodiscard]] std::vector member_mask(const StrictMultiDiGraph& g, const Eligibility* elig, + const PathSelection& selection, + std::span residual, Amount min_residual); + +// Dijkstra over the members from (Forward) or toward (Reverse) the root set; kUnreachable for +// the nodes it does not reach. +[[nodiscard]] std::vector distances(const StrictMultiDiGraph& g, std::span member, + std::span roots, Direction direction); + +// The tight edges among the reached nodes of a full search (forward-oriented), ascending. +[[nodiscard]] std::vector full_dag(const StrictMultiDiGraph& g, std::span member, + std::span dist, Direction direction); + +// The edges on the minimum-cost walks from the source set to the destination set (the walks +// of cost d* = min over destinations of the distance), ascending; cost kUnreachable and no +// edge when no destination is reachable. +struct TightDag { + Cost cost {0}; + std::vector edges {}; +}; +[[nodiscard]] TightDag tight_dag(const StrictMultiDiGraph& g, std::span member, + std::span sources, std::span destinations); + +// The staged canonical single path from the source set to the destination set over the +// members; `priority` null for Deterministic; `prefix_cap` caps B* (a Yen root prefix). +struct SinglePath { + std::vector walk {}; // in walk order + Cost cost {0}; + std::optional bottleneck {}; // under PreferHigherPriority + std::optional b_star {}; +}; +[[nodiscard]] std::optional canonical_path(const StrictMultiDiGraph& g, + std::span member, + std::span sources, + std::span destinations, + const PriorityValues* priority, + std::optional prefix_cap = std::nullopt); + +// The search of APPROACH 6 (sub-PR 1d exposes it as spf()): validates (std::invalid_argument) +// the root set (non-empty, ids in range; duplicates removed), the targets when given +// (non-empty, ids in range, disjoint from the roots), multipath = false without targets, the +// residual (length E, entries in [0, capacity_units]), min_residual >= 1 and a foreign +// Eligibility, before any work. Excluded roots and targets are skipped (all excluded: an +// empty result). Without targets: `costs` for every node and the full DAG; with targets: the +// anycast-pruned DAG (or the canonical single path for multipath = false) and `target_cost` +// (nullopt when no target is reachable). +struct SpfOut { + std::optional> costs {}; + std::vector edges {}; + std::optional target_cost {}; + std::optional path {}; +}; +[[nodiscard]] SpfOut spf(const StrictMultiDiGraph& g, std::span roots, Direction direction, + std::optional> targets, const PathSelection& selection, + std::optional> residual, Amount min_residual, + const Eligibility* elig); + +// The sorted, duplicate-free node set; throws std::invalid_argument for an empty set or an id +// out of range (`name` labels the message). +[[nodiscard]] std::vector node_set(std::span nodes, std::int32_t num_nodes, const char* name); + +// Throws std::invalid_argument when the two sorted sets share a vertex (rule 13). +void check_disjoint(std::span a, std::span b); + +} // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/uid.hpp b/include/netgraph/core/detail/uid.hpp new file mode 100644 index 0000000..7ad17c6 --- /dev/null +++ b/include/netgraph/core/detail/uid.hpp @@ -0,0 +1,11 @@ +/* The process-wide uid counter of graphs, Eligibility values and ledgers (internal). */ +#pragma once + +#include + +namespace netgraph::core::detail { + +// A fresh uid from one process-wide 64-bit counter: never 0, never reused. +[[nodiscard]] std::uint64_t next_uid() noexcept; + +} // namespace netgraph::core::detail diff --git a/include/netgraph/core/flow.hpp b/include/netgraph/core/flow.hpp new file mode 100644 index 0000000..9c62689 --- /dev/null +++ b/include/netgraph/core/flow.hpp @@ -0,0 +1,71 @@ +/* Placement kinds, flow identities and ledger records (APPROACH 7 and 8). */ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/pred_dag.hpp" +#include "netgraph/core/types.hpp" + +namespace netgraph::core { + +// The kernel that computes one flow's pushes on its DAG: MaxFlow (the flow core of the +// minimum-cost flow restricted to the flow's split graph, a replacement on V = W + own), +// Weighted (hop-by-hop WCMP: highest-averages apportionment over frozen weights at every node) +// and EqualBalanced (ECMP: round robin at every node). +enum class FlowPlacement { MaxFlow, Weighted, EqualBalanced }; + +// Lossless admits only what fits; Lossy forwards hop by hop without admission control and +// drops what does not fit on each edge (EqualBalanced and Weighted only). +enum class Admission { Lossless, Lossy }; + +// The path-cost sentinel of an unreachable node or an empty route (None in Python). +inline constexpr Cost kUnreachable = std::numeric_limits::max(); + +// (ledger uid, id): ids are assigned by the ledger in creation order and never reused. +using FlowId = std::pair; + +// A cumulative counter (offered, per-edge drops): a sum over calls, kept in 128 bits as +// hi * 2^64 + lo, always below 2^127 (APPROACH 2.1, 2.3). +struct Counter128 { + std::uint64_t hi {0}; + std::uint64_t lo {0}; + friend bool operator==(const Counter128&, const Counter128&) = default; + [[nodiscard]] bool is_zero() const noexcept { return hi == 0 && lo == 0; } + // The sum with a non-negative amount; false (and no change) when the sum reaches 2^127. + bool add(Amount x) noexcept { + const auto ux = static_cast(x); + const std::uint64_t lo2 = lo + ux; + const std::uint64_t hi2 = hi + (lo2 < lo ? 1U : 0U); + if (hi2 >= (std::uint64_t{1} << 63)) return false; + hi = hi2; + lo = lo2; + return true; + } +}; + +// One flow of the ledger, as a snapshot (records(), record(id)). `bundle` is the flow's route +// structure (its structural identity); `route_cost` the cached minimum walk cost from the +// sources to the destinations over that bundle (kUnreachable when it has no such walk); +// `offered` exists on Lossy records only (the injection basis); `edges` and `drops` list +// positive values in ascending EdgeId; per-call and cumulative drop totals are derived. +struct FlowRecord { + FlowId flow_id {}; + std::int64_t owner {0}; + std::vector sources {}; + std::vector destinations {}; + FlowPlacement placement {FlowPlacement::MaxFlow}; + Admission admission {Admission::Lossless}; + std::shared_ptr bundle {}; + Cost route_cost {kUnreachable}; + std::optional offered {}; + Amount delivered {0}; + std::vector> edges {}; + std::vector> drops {}; +}; + +} // namespace netgraph::core diff --git a/include/netgraph/core/flow_graph.hpp b/include/netgraph/core/flow_graph.hpp new file mode 100644 index 0000000..a02adfc --- /dev/null +++ b/include/netgraph/core/flow_graph.hpp @@ -0,0 +1,86 @@ +/* The integer ledger FlowGraph (APPROACH 8). */ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/flow.hpp" +#include "netgraph/core/strict_multidigraph.hpp" +#include "netgraph/core/types.hpp" + +namespace netgraph::core { + +namespace detail { +struct LedgerAccess; +} // namespace detail + +// The ledger of one graph: an immutable baseline residual in units and the records of the +// flows placed on it. The ledger is mutated only through policies (bound to it by the +// internal entry points of detail::LedgerAccess); there is no public placement, removal or +// reservation. Aggregates are exact: allocated() is the sum of every record's edges and +// residual() = baseline - allocated(), both int64[E] snapshots in EdgeId order. +// +// Lifetime: the graph outlives the ledger (shared pointer), the ledger outlives its policies. +// A ledger with bound policies cannot be copied, moved or reset. A copy of an unbound ledger +// is a new ledger (new uid) holding the same records and ids under the new uid; a moved-from +// ledger is empty and unusable (every call throws std::logic_error). A policy destroyed +// without remove() leaves its records and allocations as orphans (readable, never changed +// again) until reset(). +class FlowGraph { +public: + // `residual`: the baseline in units, one entry per edge in [0, capacity_units[e]] + // (std::invalid_argument otherwise); absent: the capacities. + explicit FlowGraph(std::shared_ptr graph); + FlowGraph(std::shared_ptr graph, std::span residual); + FlowGraph(const FlowGraph& other); + FlowGraph(FlowGraph&& other); + FlowGraph& operator=(const FlowGraph&) = delete; + FlowGraph& operator=(FlowGraph&&) = delete; + ~FlowGraph() = default; + + [[nodiscard]] std::uint64_t uid() const; + [[nodiscard]] const std::shared_ptr& graph() const; + // The graph's static capacities (the ledger's view of them). + [[nodiscard]] std::span capacity_units() const; + [[nodiscard]] std::vector residual() const; + [[nodiscard]] std::vector allocated() const; + // Live records (bound and orphaned) as snapshots in FlowId order. + [[nodiscard]] std::vector records() const; + // One record; a FlowId of another ledger, a retired id or an unknown id throws + // std::invalid_argument. + [[nodiscard]] FlowRecord record(const FlowId& flow_id) const; + // Saturation diagnostic: the edges (statically eligible, capacity >= 1 unit) leaving the set + // reachable from the allowed sources over eligible forward arcs with residual >= 1 unit, in + // ascending EdgeId. Not a cut; empty when the sources reach everything they can. + [[nodiscard]] std::vector bottleneck(std::span sources, const Eligibility* elig = nullptr) const; + // A new baseline (the capacities when absent); orphaned records and their allocations are + // cleared and their ids retired. Rejected (std::invalid_argument) while a policy is bound. + void reset(); + void reset(std::span residual); + +private: + friend struct detail::LedgerAccess; + void check_usable() const; + std::vector checked_baseline(std::span residual) const; + + std::shared_ptr graph_ {}; + std::uint64_t uid_ {0}; + bool usable_ {true}; + std::vector baseline_ {}; + std::vector allocated_ {}; + std::map records_ {}; + std::map> owner_ids_ {}; // owner -> live ids, ascending + std::int64_t next_id_ {0}; + std::int64_t next_owner_ {0}; + // Every owner since the last reset: true while bound, false once orphaned (release() flips + // the flag, so it never allocates). + std::map owners_ {}; + std::size_t num_bound_ {0}; +}; + +} // namespace netgraph::core diff --git a/include/netgraph/core/options.hpp b/include/netgraph/core/options.hpp new file mode 100644 index 0000000..96cbf27 --- /dev/null +++ b/include/netgraph/core/options.hpp @@ -0,0 +1,33 @@ +/* Route selection axes shared by the searches and the policies (APPROACH 6). */ +#pragma once + +namespace netgraph::core { + +// How ties between equal-cost candidates are broken, at both tie levels: the member chosen +// among parallel edges of one (u, v) pair and the single path chosen among equal-cost walks. +// Deterministic ignores capacities entirely (the lowest EdgeId wins); PreferHigherPriority +// prefers the higher `priority` value first (then the lowest EdgeId). +enum class EdgeTieBreak { Deterministic, PreferHigherPriority }; + +// The per-edge value read by PreferHigherPriority and, for the Weighted kernel, its membership +// and weights: the residual units on the view of the search (Residual), the static capacity +// units (Capacity), or the exact rational residual / capacity (Headroom; rejected with +// Weighted). A search given no residual reads static capacities for every priority. +enum class EdgePriority { Residual, Capacity, Headroom }; + +// The selection of a search: `multipath` keeps every equal-cost walk (the shortest-path DAG) +// instead of the one canonical single path; `multi_edge` keeps every eligible parallel member +// of a (u, v) pair instead of the one chosen by (minimum cost, priority under +// PreferHigherPriority, lowest EdgeId). tie_break and priority have no default (APPROACH 9: an +// outer axis is explicit); the two flags default to the plain form of their mechanism. +struct PathSelection { + PathSelection(EdgeTieBreak tie_break_, EdgePriority priority_, bool multipath_ = true, bool multi_edge_ = true) + : tie_break(tie_break_), priority(priority_), multipath(multipath_), multi_edge(multi_edge_) {} + EdgeTieBreak tie_break; + EdgePriority priority; + bool multipath; + bool multi_edge; + friend bool operator==(const PathSelection&, const PathSelection&) = default; +}; + +} // namespace netgraph::core diff --git a/include/netgraph/core/pred_dag.hpp b/include/netgraph/core/pred_dag.hpp new file mode 100644 index 0000000..3c7442f --- /dev/null +++ b/include/netgraph/core/pred_dag.hpp @@ -0,0 +1,86 @@ +/* Route structures: the immutable predecessor DAG and the explicit Path (APPROACH 5). */ +#pragma once + +#include +#include +#include + +#include "netgraph/core/strict_multidigraph.hpp" +#include "netgraph/core/types.hpp" + +namespace netgraph::core { + +namespace detail { +struct PredDAGBuilder; +} // namespace detail + +// An immutable, graph-bound DAG in predecessor CSR form: the predecessor entries of node v are +// parents()[k] and via_edges()[k] for k in [parent_offsets()[v], parent_offsets()[v + 1]); the +// entry (u, e) means the edge e = u -> v belongs to the DAG. Within a node the entries are in +// strictly ascending via EdgeId, so a DAG value has one canonical form. Equality is structural +// (same graph, same CSR). Every API that takes a route structure takes a PredDAG; a DAG of +// another graph is rejected wherever it is used. +class PredDAG { +public: + // Validates, in this order, before any traversal (std::invalid_argument on the first defect): + // offsets of length N + 1, offsets[0] == 0, monotone, offsets[N] == len(parents) == + // len(via); then every parent and via in range, every via joining (parent, child), no + // duplicate (child, via), acyclic; within a node the entries in ascending via EdgeId. + [[nodiscard]] static PredDAG from_csr(const StrictMultiDiGraph& g, std::span offsets, + std::span parents, std::span via); + + [[nodiscard]] std::uint64_t graph_uid() const noexcept { return graph_uid_; } + [[nodiscard]] std::int32_t num_nodes() const noexcept { return static_cast(offsets_.size()) - 1; } + [[nodiscard]] std::span parent_offsets() const noexcept { return offsets_; } + [[nodiscard]] std::span parents() const noexcept { return parents_; } + [[nodiscard]] std::span via_edges() const noexcept { return via_; } + // The member edges in ascending EdgeId. + [[nodiscard]] std::vector edges() const { return edges_; } + + friend bool operator==(const PredDAG& a, const PredDAG& b) noexcept { + return a.graph_uid_ == b.graph_uid_ && a.offsets_ == b.offsets_ && a.parents_ == b.parents_ && a.via_ == b.via_; + } + +private: + friend struct detail::PredDAGBuilder; + PredDAG() = default; + std::uint64_t graph_uid_ {0}; + std::vector offsets_ {0}; + std::vector parents_ {}; + std::vector via_ {}; + std::vector edges_ {}; // via_ in ascending EdgeId (derived once, at construction) +}; + +// An explicit walk of hops; a hop holds one or more parallel members of one (u, v) pair, in +// the caller's order. src, dst and cost (the sum over hops of the minimum member cost) are +// derived at construction. A single-member walk is [[e1], [e2], ...]. +class Path { +public: + // Validates, in this order (std::invalid_argument on the first defect): every member id in + // range (before any endpoint is read), no duplicate member, a non-empty hop list, every hop + // non-empty, the members of a hop sharing (u, v), contiguous hops, no repeated node. + Path(const StrictMultiDiGraph& g, std::vector> hops); + + [[nodiscard]] std::uint64_t graph_uid() const noexcept { return graph_uid_; } + [[nodiscard]] const std::vector>& hops() const noexcept { return hops_; } + [[nodiscard]] NodeId src() const noexcept { return nodes_.front(); } + [[nodiscard]] NodeId dst() const noexcept { return nodes_.back(); } + // The visited nodes, src first (hops + 1 entries). + [[nodiscard]] const std::vector& nodes() const noexcept { return nodes_; } + [[nodiscard]] Cost cost() const noexcept { return cost_; } + // The equivalent PredDAG (every member of every hop; entries in ascending via EdgeId). + [[nodiscard]] PredDAG to_dag() const; + + friend bool operator==(const Path& a, const Path& b) noexcept { + return a.graph_uid_ == b.graph_uid_ && a.hops_ == b.hops_; + } + +private: + std::uint64_t graph_uid_ {0}; + std::int32_t num_nodes_ {0}; + std::vector> hops_ {}; + std::vector nodes_ {}; + Cost cost_ {0}; +}; + +} // namespace netgraph::core diff --git a/include/netgraph/core/strict_multidigraph.hpp b/include/netgraph/core/strict_multidigraph.hpp index d34b722..0a43345 100644 --- a/include/netgraph/core/strict_multidigraph.hpp +++ b/include/netgraph/core/strict_multidigraph.hpp @@ -109,6 +109,11 @@ class Eligibility { [[nodiscard]] bool edge_ok(EdgeId e) const noexcept { return edge_allowed_[static_cast(e)] != 0; } + // The intersection of two values of one graph (std::invalid_argument for values of different + // graphs): an operand when the result equals it (a first, then b), keeping that operand's + // uid, so a consumer builds each state once and reuses it; otherwise a new value with a fresh + // uid (equal intersections built separately get distinct uids). + [[nodiscard]] static Eligibility intersect(const Eligibility& a, const Eligibility& b); private: friend class StrictMultiDiGraph; diff --git a/python/netgraph_core/__init__.py b/python/netgraph_core/__init__.py index 222da2d..d3a7b89 100644 --- a/python/netgraph_core/__init__.py +++ b/python/netgraph_core/__init__.py @@ -6,9 +6,12 @@ The streamlined Core is being rebuilt in sub-PRs (``dev/design/2026-10-streamline``): this build carries the graph on integer units (``StrictMultiDiGraph``, -``Eligibility``, ``to_units``, ``as_float``) and the private flow-core entry -``_netgraph_core._place_global_units``; the ledger, policies, searches and -evaluators return in the following sub-PRs. +``Eligibility`` with ``intersect``, ``to_units``, ``as_float``), the route structures +(``PredDAG``, ``Path``), the selection and placement axes (``PathSelection``, +``EdgeTieBreak``, ``EdgePriority``, ``FlowPlacement``, ``Admission``) and the integer +ledger (``FlowGraph`` with its ``FlowRecord`` snapshots), plus the private entries +of the flow core, the kernels and the internal search in ``_netgraph_core``; the +policies, the public searches and the evaluators return in the following sub-PRs. """ # pyright: reportMissingImports=false @@ -17,7 +20,16 @@ from typing import TYPE_CHECKING from _netgraph_core import ( + Admission, + EdgePriority, + EdgeTieBreak, Eligibility, + FlowGraph, + FlowPlacement, + FlowRecord, + Path, + PathSelection, + PredDAG, StrictMultiDiGraph, as_float, to_units, @@ -32,7 +44,16 @@ # because it turns "unchecked" into "confidently wrong". if TYPE_CHECKING: # pragma: no cover - typing-only from ._docs import ( # noqa: I001 + Admission as Admission, + EdgePriority as EdgePriority, + EdgeTieBreak as EdgeTieBreak, Eligibility as Eligibility, + FlowGraph as FlowGraph, + FlowPlacement as FlowPlacement, + FlowRecord as FlowRecord, + Path as Path, + PathSelection as PathSelection, + PredDAG as PredDAG, StrictMultiDiGraph as StrictMultiDiGraph, as_float as as_float, to_units as to_units, @@ -44,4 +65,13 @@ "Eligibility", "to_units", "as_float", + "PredDAG", + "Path", + "EdgeTieBreak", + "EdgePriority", + "PathSelection", + "FlowPlacement", + "Admission", + "FlowGraph", + "FlowRecord", ] diff --git a/python/netgraph_core/_docs.py b/python/netgraph_core/_docs.py index bc52196..785ef07 100644 --- a/python/netgraph_core/_docs.py +++ b/python/netgraph_core/_docs.py @@ -9,7 +9,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Sequence, overload +from typing import TYPE_CHECKING, ClassVar, Sequence, overload if TYPE_CHECKING: # only for typing; runtime comes from extension import numpy as np # type: ignore[reportMissingImports] @@ -79,6 +79,16 @@ def edge_allowed(self) -> "np.ndarray": """bool[E] read-only view.""" ... + @staticmethod + def intersect(a: "Eligibility", b: "Eligibility") -> "Eligibility": + """The intersection of two values of one graph (ValueError for different graphs). + + Returns an operand itself when the result equals it (``a`` first, then ``b``), + otherwise a new value with a fresh uid; equal intersections built separately get + distinct uids, so a consumer builds each state once and reuses it. + """ + ... + class StrictMultiDiGraph: """Immutable directed multigraph on integer units (layout B'). @@ -179,3 +189,301 @@ def eligibility( Empty lists mean all allowed (uid 0). """ ... + + +class EdgeTieBreak: + """Ties between equal-cost candidates, at both tie levels (parallel members and walks). + + ``DETERMINISTIC`` takes the lowest EdgeId and ignores capacities; + ``PREFER_HIGHER_PRIORITY`` prefers the higher ``priority`` value first. + """ + + DETERMINISTIC: ClassVar["EdgeTieBreak"] + PREFER_HIGHER_PRIORITY: ClassVar["EdgeTieBreak"] + __members__: ClassVar[dict[str, "EdgeTieBreak"]] + + def __init__(self, value: int) -> None: ... + def __int__(self) -> int: ... + def __index__(self) -> int: ... + @property + def name(self) -> str: ... + @property + def value(self) -> int: ... + + +class EdgePriority: + """The per-edge value ``PREFER_HIGHER_PRIORITY`` reads, and the Weighted kernel's + membership and weights: ``RESIDUAL`` (residual units), ``CAPACITY`` (static capacity + units), ``HEADROOM`` (the exact rational residual / capacity; rejected with WEIGHTED). + A search given no residual reads static capacities for every priority.""" + + RESIDUAL: ClassVar["EdgePriority"] + CAPACITY: ClassVar["EdgePriority"] + HEADROOM: ClassVar["EdgePriority"] + __members__: ClassVar[dict[str, "EdgePriority"]] + + def __init__(self, value: int) -> None: ... + def __int__(self) -> int: ... + def __index__(self) -> int: ... + @property + def name(self) -> str: ... + @property + def value(self) -> int: ... + + +class FlowPlacement: + """The kernel of one flow (APPROACH 7). + + ``MAX_FLOW``: the minimum-cost flow core on the flow's split graph, a replacement on + ``V = W + own`` (its own allocation cancellable, other owners never). ``WEIGHTED``: + hop-by-hop WCMP, highest-averages apportionment over weights frozen at creation. + ``EQUAL_BALANCED``: ECMP, round robin at every node. + """ + + MAX_FLOW: ClassVar["FlowPlacement"] + WEIGHTED: ClassVar["FlowPlacement"] + EQUAL_BALANCED: ClassVar["FlowPlacement"] + __members__: ClassVar[dict[str, "FlowPlacement"]] + + def __init__(self, value: int) -> None: ... + def __int__(self) -> int: ... + def __index__(self) -> int: ... + @property + def name(self) -> str: ... + @property + def value(self) -> int: ... + + +class Admission: + """``LOSSLESS`` admits only what fits; ``LOSSY`` forwards hop by hop without admission + control and drops what does not fit on each edge (EQUAL_BALANCED and WEIGHTED only).""" + + LOSSLESS: ClassVar["Admission"] + LOSSY: ClassVar["Admission"] + __members__: ClassVar[dict[str, "Admission"]] + + def __init__(self, value: int) -> None: ... + def __int__(self) -> int: ... + def __index__(self) -> int: ... + @property + def name(self) -> str: ... + @property + def value(self) -> int: ... + + +class PathSelection: + """The selection of a search (APPROACH 6). + + ``tie_break`` and ``priority`` are explicit; ``multipath = True`` keeps every equal-cost + walk (the DAG), ``False`` the one canonical single path; ``multi_edge = True`` keeps every + eligible parallel member, ``False`` one per ``(u, v)`` pair (minimum cost, then the + priority under PREFER_HIGHER_PRIORITY, then the lowest EdgeId). Immutable value. + """ + + def __init__( + self, + tie_break: EdgeTieBreak, + priority: EdgePriority, + multipath: bool = True, + multi_edge: bool = True, + ) -> None: ... + @property + def tie_break(self) -> EdgeTieBreak: ... + @property + def priority(self) -> EdgePriority: ... + @property + def multipath(self) -> bool: ... + @property + def multi_edge(self) -> bool: ... + + +class PredDAG: + """Immutable, graph-bound predecessor DAG (APPROACH 5). + + The entries of node ``v`` are ``parents()[k]``, ``via_edges()[k]`` for ``k`` in + ``[parent_offsets()[v], parent_offsets()[v + 1])``: the edge ``via`` joins + ``parent -> v``. Within a node the entries are in strictly ascending via EdgeId (one + canonical form). Equality is structural. Accessors return read-only NumPy views that + keep the DAG alive. A DAG of another graph is rejected wherever it is used. + """ + + @staticmethod + def from_csr( + graph: StrictMultiDiGraph, + offsets: "Sequence[int] | np.ndarray", + parents: "Sequence[int] | np.ndarray", + via: "Sequence[int] | np.ndarray", + ) -> "PredDAG": + """Build a DAG from its CSR, validated before any traversal. + + Raises: + ValueError: On the first defect, in this order: offsets of length N + 1, + ``offsets[0] == 0``, monotone, ``offsets[N] == len(parents) == len(via)``; + every parent and via in range, every via joining ``(parent, child)``, no + duplicate ``(child, via)``, acyclic; within a node the entries in ascending + via EdgeId. + """ + ... + + def graph_uid(self) -> int: + """uid of the graph this DAG belongs to.""" + ... + + def parent_offsets(self) -> "np.ndarray": + """int32[N + 1] CSR offsets.""" + ... + + def parents(self) -> "np.ndarray": + """int32 parent NodeId per entry.""" + ... + + def via_edges(self) -> "np.ndarray": + """int32 via EdgeId per entry.""" + ... + + +class Path: + """Immutable, graph-bound explicit walk (APPROACH 5). + + ``hops`` lists, per hop, one or more parallel members of one ``(u, v)`` pair (the + caller's member order is kept); ``cost`` is the sum over hops of the minimum member + cost. ``to_dag()`` gives the equivalent ``PredDAG``. + """ + + def __init__( + self, graph: StrictMultiDiGraph, hops: "Sequence[Sequence[int]]" + ) -> None: + """Validate and build the walk. + + Raises: + ValueError: On the first defect, in this order: every member id in range, + no duplicate member, a non-empty hop list, every hop non-empty, the + members of a hop sharing ``(u, v)``, contiguous hops, no repeated node. + """ + ... + + def graph_uid(self) -> int: ... + def hops(self) -> list[list[int]]: ... + def src(self) -> int: ... + def dst(self) -> int: ... + def nodes(self) -> list[int]: + """The visited nodes, ``src`` first.""" + ... + + def cost(self) -> int: ... + def to_dag(self) -> PredDAG: ... + + +class FlowRecord: + """One flow of a ledger, as a snapshot (APPROACH 8). + + ``flow_id`` is ``(ledger uid, id)``; ``owner`` the id of the policy that placed it; + ``bundle`` the flow's route; ``route_cost`` its minimum walk cost (``None`` for an empty + route); ``offered`` exists on Lossy records only (``None`` otherwise); ``edges`` and + ``drops`` are ``(EdgeId, units)`` pairs in ascending EdgeId (drops cumulative over + calls). Counters are exact ints and may exceed 64 bits. + """ + + @property + def flow_id(self) -> tuple[int, int]: ... + @property + def owner(self) -> int: ... + @property + def sources(self) -> list[int]: ... + @property + def destinations(self) -> list[int]: ... + @property + def placement(self) -> FlowPlacement: ... + @property + def admission(self) -> Admission: ... + @property + def bundle(self) -> PredDAG: ... + @property + def route_cost(self) -> int | None: ... + @property + def offered(self) -> int | None: ... + @property + def delivered(self) -> int: ... + @property + def edges(self) -> list[tuple[int, int]]: ... + @property + def drops(self) -> list[tuple[int, int]]: ... + + +class FlowGraph: + """The integer ledger of one graph (APPROACH 8). + + An immutable baseline residual in units and the records of the flows placed on it by + policies; the ledger has no public placement, removal or reservation. ``allocated()`` + is the sum of every record's edges and ``residual() = baseline - allocated()``, both + ``int64[E]`` snapshots in EdgeId order. ``reset()`` and ``copy()`` are rejected while a + policy is bound. A policy destroyed without ``remove()`` leaves its records as + orphans (readable, never changed again) until ``reset()``. + """ + + def __init__( + self, + graph: StrictMultiDiGraph, + residual: "Sequence[int] | np.ndarray | None" = None, + ) -> None: + """Bind a ledger to ``graph``. + + Args: + graph: The graph (kept alive by the ledger). + residual: The baseline as integer units, one entry per edge in + ``[0, capacity_units[e]]`` (a physical baseline goes through ``to_units``); + ``None``: the capacities. + + Raises: + ValueError: a baseline of the wrong length, a non-integer entry, or an entry + outside ``[0, capacity_units[e]]``. + """ + ... + + def uid(self) -> int: + """Process-unique ledger uid (the first half of every FlowId).""" + ... + + def graph(self) -> StrictMultiDiGraph: ... + def capacity_units(self) -> "np.ndarray": + """int64[E] the graph's capacities in units (read-only view).""" + ... + + def residual(self) -> "np.ndarray": + """int64[E] snapshot: baseline - allocated.""" + ... + + def allocated(self) -> "np.ndarray": + """int64[E] snapshot: the sum of every record's edges.""" + ... + + def records(self) -> list[FlowRecord]: + """Live records (bound and orphaned) as snapshots in FlowId order.""" + ... + + def record(self, flow_id: tuple[int, int]) -> FlowRecord: + """One record; ValueError for a FlowId of another ledger, a retired or an unknown id.""" + ... + + def bottleneck( + self, sources: "int | Sequence[int]", elig: Eligibility | None = None + ) -> list[int]: + """Saturation diagnostic, ascending EdgeId (not a cut). + + The edges (statically eligible, capacity >= 1 unit) leaving the set reachable + from the allowed sources over eligible forward arcs with residual >= 1 unit. + """ + ... + + def reset(self, residual: "Sequence[int] | np.ndarray | None" = None) -> None: + """A new baseline (``None``: the capacities); orphans cleared, ids retired. + + Raises: + ValueError: while a policy is bound; an invalid baseline. + """ + ... + + def copy(self) -> "FlowGraph": + """A new ledger (new uid) with the same baseline and records (ids kept under the + new uid); ValueError while a policy is bound.""" + ... diff --git a/src/flow_graph.cpp b/src/flow_graph.cpp new file mode 100644 index 0000000..8c9e21f --- /dev/null +++ b/src/flow_graph.cpp @@ -0,0 +1,461 @@ +/* + The integer ledger FlowGraph (APPROACH 8). + + The aggregate allocation is kept exactly (integers) and changed only by an atomic commit or a + detach; residual() is derived from it (baseline - allocated). Records are kept by id (FlowId + order); ids come from a per-ledger counter and are never reused, also across reset(). +*/ +#include "netgraph/core/flow_graph.hpp" + +#include +#include +#include +#include + +#include "netgraph/core/detail/ledger_access.hpp" +#include "netgraph/core/detail/search.hpp" +#include "netgraph/core/detail/uid.hpp" + +namespace netgraph::core { + +FlowGraph::FlowGraph(std::shared_ptr graph) { + if (!graph) throw std::invalid_argument("FlowGraph: graph is null"); + graph_ = std::move(graph); + baseline_.assign(graph_->capacity_units().begin(), graph_->capacity_units().end()); + allocated_.assign(baseline_.size(), 0); + uid_ = detail::next_uid(); +} + +FlowGraph::FlowGraph(std::shared_ptr graph, std::span residual) { + if (!graph) throw std::invalid_argument("FlowGraph: graph is null"); + graph_ = std::move(graph); + baseline_ = checked_baseline(residual); + allocated_.assign(baseline_.size(), 0); + uid_ = detail::next_uid(); +} + +FlowGraph::FlowGraph(const FlowGraph& other) { + other.check_usable(); + if (other.num_bound_ != 0) { + throw std::invalid_argument("FlowGraph: a ledger with bound policies cannot be copied"); + } + graph_ = other.graph_; + uid_ = detail::next_uid(); + baseline_ = other.baseline_; + allocated_ = other.allocated_; + records_ = other.records_; + owner_ids_ = other.owner_ids_; + for (auto& [id, rec] : records_) rec.flow_id = FlowId {uid_, id}; + next_id_ = other.next_id_; + next_owner_ = other.next_owner_; + owners_ = other.owners_; // orphans only +} + +FlowGraph::FlowGraph(FlowGraph&& other) { + other.check_usable(); + if (other.num_bound_ != 0) { + throw std::invalid_argument("FlowGraph: a ledger with bound policies cannot be moved"); + } + graph_ = std::move(other.graph_); + uid_ = other.uid_; + baseline_ = std::move(other.baseline_); + allocated_ = std::move(other.allocated_); + records_ = std::move(other.records_); + owner_ids_ = std::move(other.owner_ids_); + next_id_ = other.next_id_; + next_owner_ = other.next_owner_; + owners_ = std::move(other.owners_); + other.usable_ = false; + other.baseline_.clear(); + other.allocated_.clear(); + other.records_.clear(); + other.owner_ids_.clear(); + other.owners_.clear(); +} + +void FlowGraph::check_usable() const { + if (!usable_) throw std::logic_error("FlowGraph: a moved-from ledger is empty and unusable"); +} + +std::vector FlowGraph::checked_baseline(std::span residual) const { + const auto capacity = graph_->capacity_units(); + if (residual.size() != capacity.size()) { + throw std::invalid_argument("residual has length " + std::to_string(residual.size()) + ", expected " + + std::to_string(capacity.size()) + " (one entry per edge)"); + } + for (std::size_t e = 0; e < residual.size(); ++e) { + if (residual[e] < 0 || residual[e] > capacity[e]) { + throw std::invalid_argument("residual[" + std::to_string(e) + "] = " + std::to_string(residual[e]) + + " is outside [0, capacity_units[" + std::to_string(e) + "] = " + + std::to_string(capacity[e]) + "]"); + } + } + return std::vector(residual.begin(), residual.end()); +} + +std::uint64_t FlowGraph::uid() const { + check_usable(); + return uid_; +} + +const std::shared_ptr& FlowGraph::graph() const { + check_usable(); + return graph_; +} + +std::span FlowGraph::capacity_units() const { + check_usable(); + return graph_->capacity_units(); +} + +std::vector FlowGraph::residual() const { + check_usable(); + std::vector out(baseline_.size()); + for (std::size_t e = 0; e < out.size(); ++e) out[e] = baseline_[e] - allocated_[e]; + return out; +} + +std::vector FlowGraph::allocated() const { + check_usable(); + return allocated_; +} + +std::vector FlowGraph::records() const { + check_usable(); + std::vector out; + out.reserve(records_.size()); + for (const auto& [id, rec] : records_) out.push_back(rec); + return out; +} + +FlowRecord FlowGraph::record(const FlowId& flow_id) const { + check_usable(); + if (flow_id.first != uid_) { + throw std::invalid_argument("FlowId (" + std::to_string(flow_id.first) + ", " + std::to_string(flow_id.second) + + ") belongs to another ledger (uid " + std::to_string(uid_) + ")"); + } + const auto it = records_.find(flow_id.second); + if (it == records_.end()) { + throw std::invalid_argument("FlowId (" + std::to_string(flow_id.first) + ", " + std::to_string(flow_id.second) + + ") is retired or unknown"); + } + return it->second; +} + +std::vector FlowGraph::bottleneck(std::span sources, const Eligibility* elig) const { + check_usable(); + const auto& g = *graph_; + const auto srcs = detail::node_set(sources, g.num_nodes(), "sources"); + if (elig != nullptr && elig->graph_uid() != g.uid()) { + throw std::invalid_argument("foreign Eligibility: built for another graph"); + } + const auto n = static_cast(g.num_nodes()); + const auto m = static_cast(g.num_edges()); + const auto edge_src = g.edge_src(); + const auto edge_dst = g.edge_dst(); + const auto capacity = g.capacity_units(); + const auto row = g.row_offsets(); + std::vector seen(n, 0); + std::vector stack; + for (NodeId s : srcs) { + if (elig != nullptr && !elig->node_ok(s)) continue; + seen[static_cast(s)] = 1; + stack.push_back(s); + } + while (!stack.empty()) { + const auto u = static_cast(stack.back()); + stack.pop_back(); + for (auto e = row[u]; e < row[u + 1]; ++e) { + const auto ei = static_cast(e); + if (elig != nullptr && !elig->edge_ok(e)) continue; + if (baseline_[ei] - allocated_[ei] < 1) continue; + const auto v = static_cast(edge_dst[ei]); + if (seen[v] == 0) { + seen[v] = 1; + stack.push_back(static_cast(v)); + } + } + } + std::vector out; + for (std::size_t e = 0; e < m; ++e) { + if (seen[static_cast(edge_src[e])] == 0 || seen[static_cast(edge_dst[e])] != 0) continue; + if (elig != nullptr && !elig->edge_ok(static_cast(e))) continue; + if (capacity[e] < 1) continue; + out.push_back(static_cast(e)); + } + return out; +} + +void FlowGraph::reset() { + check_usable(); + reset(graph_->capacity_units()); +} + +void FlowGraph::reset(std::span residual) { + check_usable(); + if (num_bound_ != 0) { + throw std::invalid_argument("FlowGraph.reset is rejected while a policy is bound (" + + std::to_string(num_bound_) + " bound)"); + } + auto baseline = checked_baseline(residual); + baseline_ = std::move(baseline); + allocated_.assign(baseline_.size(), 0); + records_.clear(); + owner_ids_.clear(); + owners_.clear(); +} + +namespace detail { + +namespace { + +bool is_bound(const std::map& owners, std::int64_t owner) { + const auto it = owners.find(owner); + return it != owners.end() && it->second; +} + +// The dense per-edge delta of one commit, in a per-thread array of slots. An edge is touched in +// this commit when its slot carries the commit's epoch stamp: that test does not depend on the +// delta's value, so a delta that returns to zero during accumulation (-1, then +1) keeps its one +// entry in the touched list and is published once (review-1b-fixes N2). A slot is reset on its +// first touch in a commit, so nothing needs clearing between commits: a commit that throws +// leaves stale slots behind an old epoch. Delta and stamp share one array, so growing it either +// succeeds whole or leaves the previous array (review-1b-fixes N1). +class DeltaScratch { +public: + explicit DeltaScratch(std::size_t num_edges) : s_(storage()) { + s_.touched.clear(); + if (s_.slot.size() < num_edges) s_.slot.resize(num_edges); // strong guarantee; new stamps 0 + if (++s_.epoch == 0) { + for (auto& x : s_.slot) x.stamp = 0; + s_.epoch = 1; + } + } + DeltaScratch(const DeltaScratch&) = delete; + DeltaScratch& operator=(const DeltaScratch&) = delete; + ~DeltaScratch() { s_.touched.clear(); } + void add(EdgeId e, Amount x) { + auto& slot = s_.slot[static_cast(e)]; + if (slot.stamp != s_.epoch) { + s_.touched.push_back(e); // may throw: the slot is left untouched in this commit + slot.stamp = s_.epoch; + slot.delta = 0; + } + slot.delta += x; + } + // Each touched edge once, in first-touch order. + [[nodiscard]] const std::vector& touched() const noexcept { return s_.touched; } + [[nodiscard]] Amount at(EdgeId e) const noexcept { return s_.slot[static_cast(e)].delta; } + +private: + struct Slot { + Amount delta {0}; + std::uint32_t stamp {0}; + }; + struct Storage { + std::vector slot; + std::vector touched; + std::uint32_t epoch {0}; + }; + static Storage& storage() { + thread_local Storage st; + return st; + } + Storage& s_; +}; + +FlowRecord record_of(std::uint64_t uid, std::int64_t id, std::int64_t owner, Draft& d) { + FlowRecord rec; + rec.flow_id = FlowId {uid, id}; + rec.owner = owner; + rec.sources = std::move(d.sources); + rec.destinations = std::move(d.destinations); + rec.placement = d.placement; + rec.admission = d.admission; + rec.bundle = std::move(d.bundle); + rec.route_cost = d.route_cost; + rec.offered = d.offered; + rec.delivered = d.delivered; + rec.edges = std::move(d.edges); + std::erase_if(rec.edges, [](const auto& p) { return p.second == 0; }); + rec.drops = std::move(d.drops); + std::erase_if(rec.drops, [](const auto& p) { return p.second.is_zero(); }); + return rec; +} + +// The publication step moves staged records into place; it must not throw. +static_assert(std::is_nothrow_move_assignable_v); +static_assert(std::is_nothrow_swappable_v>); + +} // namespace + +std::int64_t LedgerAccess::bind(FlowGraph& fg) { + fg.check_usable(); + const std::int64_t owner = fg.next_owner_; + fg.owners_.emplace(owner, true); // the only allocation: nothing changes if it throws + ++fg.next_owner_; + ++fg.num_bound_; + return owner; +} + +void LedgerAccess::release(FlowGraph& fg, std::int64_t owner) noexcept { + const auto it = fg.owners_.find(owner); + if (it == fg.owners_.end() || !it->second) return; + it->second = false; + --fg.num_bound_; +} + +std::vector LedgerAccess::detach(FlowGraph& fg, std::int64_t owner) { + fg.check_usable(); + if (!is_bound(fg.owners_, owner)) throw std::logic_error("ledger: detach by an unbound owner"); + std::vector ids; + const auto it = fg.owner_ids_.find(owner); + if (it == fg.owner_ids_.end()) return ids; + // Nothing below allocates: the index vector moves out, its node and the records are erased. + ids = std::move(it->second); + fg.owner_ids_.erase(it); + for (std::int64_t id : ids) { + const auto rec = fg.records_.find(id); + for (const auto& [e, x] : rec->second.edges) fg.allocated_[static_cast(e)] -= x; + fg.records_.erase(rec); + } + return ids; +} + +std::vector LedgerAccess::commit(FlowGraph& fg, std::int64_t owner, std::span drafts) { + fg.check_usable(); + if (!is_bound(fg.owners_, owner)) throw std::logic_error("ledger: commit by an unbound owner"); + // The drafts carry exactly the owner's live records plus new ones. + const auto owned = fg.owner_ids_.find(owner); + const std::size_t num_current = owned == fg.owner_ids_.end() ? 0 : owned->second.size(); + std::vector kept; + std::size_t num_new = 0; + for (const auto& d : drafts) { + if (d.id.has_value()) { + kept.push_back(*d.id); + } else { + ++num_new; + } + } + std::sort(kept.begin(), kept.end()); + if (kept.size() != num_current || (num_current != 0 && kept != owned->second)) { + throw std::logic_error("ledger: a commit must carry every live record of its policy"); + } + + // 1. The new aggregate on every touched edge (old owner allocation out, new drafts in), + // checked before anything changes. + DeltaScratch delta(fg.allocated_.size()); + if (num_current != 0) { + for (std::int64_t id : owned->second) { + for (const auto& [e, x] : fg.records_.at(id).edges) delta.add(e, -x); + } + } + bool negative = false; + for (const auto& d : drafts) { + for (const auto& [e, x] : d.edges) { + negative = negative || x < 0; + delta.add(e, x); + } + } + if (negative) throw std::logic_error("ledger: a negative allocation in a commit"); + for (EdgeId e : delta.touched()) { + const auto ei = static_cast(e); + const Amount next = fg.allocated_[ei] + delta.at(e); + if (next < 0 || next > fg.baseline_[ei]) { + throw std::logic_error("ledger: infeasible commit on edge " + std::to_string(e) + " (" + std::to_string(next) + + " of " + std::to_string(fg.baseline_[ei]) + ")"); + } + } + + // 2. Every allocation of the publication, staged: the records (new ones as map nodes), the + // owner's index node, the returned ids and the next id. A throw here changes nothing. + std::vector created; + created.reserve(num_new); + std::vector live; // the owner's new index, when it gains ids + if (num_new != 0) { + live.reserve(kept.size() + num_new); + live.assign(kept.begin(), kept.end()); // ascending; new ids are larger than every kept id + } + std::vector::iterator> targets; + std::vector updated; + targets.reserve(kept.size()); + updated.reserve(kept.size()); + std::map fresh; + std::int64_t next_id = fg.next_id_; + for (auto& d : drafts) { + if (d.id.has_value()) { + targets.push_back(fg.records_.find(*d.id)); + updated.push_back(record_of(fg.uid_, *d.id, owner, d)); + } else { + const std::int64_t id = next_id++; + fresh.emplace_hint(fresh.end(), id, record_of(fg.uid_, id, owner, d)); + created.emplace_back(fg.uid_, id); + live.push_back(id); + } + } + std::map> index_node; + if (owned == fg.owner_ids_.end() && !live.empty()) index_node.emplace(owner, std::move(live)); + + // 3. Publication: nothing below allocates or throws (map nodes are spliced, not copied). + for (EdgeId e : delta.touched()) fg.allocated_[static_cast(e)] += delta.at(e); + for (std::size_t i = 0; i < targets.size(); ++i) targets[i]->second = std::move(updated[i]); + fg.records_.merge(fresh); + if (num_new != 0) { + if (owned != fg.owner_ids_.end()) { + owned->second.swap(live); + } else { + fg.owner_ids_.merge(index_node); + } + } + fg.next_id_ = next_id; + return created; +} + +const FlowRecord& LedgerAccess::live(const FlowGraph& fg, std::int64_t id) { + const auto it = fg.records_.find(id); + if (it == fg.records_.end()) throw std::invalid_argument("ledger: retired or unknown flow id " + std::to_string(id)); + return it->second; +} + +void LedgerAccess::residual_into(const FlowGraph& fg, std::vector& out) { + fg.check_usable(); + out.resize(fg.baseline_.size()); + for (std::size_t e = 0; e < out.size(); ++e) out[e] = fg.baseline_[e] - fg.allocated_[e]; +} + +std::span LedgerAccess::baseline(const FlowGraph& fg) { + fg.check_usable(); + return fg.baseline_; +} + +std::span LedgerAccess::allocated(const FlowGraph& fg) { + fg.check_usable(); + return fg.allocated_; +} + +std::int64_t LedgerAccess::next_flow_id(const FlowGraph& fg) { + fg.check_usable(); + return fg.next_id_; +} + +std::vector LedgerAccess::bound_owners(const FlowGraph& fg) { + fg.check_usable(); + std::vector out; + for (const auto& [owner, bound] : fg.owners_) { + if (bound) out.push_back(owner); + } + return out; +} + +std::vector LedgerAccess::orphan_owners(const FlowGraph& fg) { + fg.check_usable(); + std::vector out; + for (const auto& [owner, bound] : fg.owners_) { + if (!bound) out.push_back(owner); + } + return out; +} + +} // namespace detail + +} // namespace netgraph::core diff --git a/src/kernels.cpp b/src/kernels.cpp new file mode 100644 index 0000000..9c8019f --- /dev/null +++ b/src/kernels.cpp @@ -0,0 +1,863 @@ +/* + Apportionment and the placement kernels in units (APPROACH 2.1, 7 and 16). + + Every amount is an int64 number of units below 2^62: a flow's T is bounded by its source + members' view, every inflow by T, every share by its node's inflow. Highest averages compares + quotients by exact 128-bit cross products (weights and divisors below 2^63, products below + 2^126); lower quotas floor(I * w / W) are 128-bit products divided by W < 2^62. +*/ +#include "netgraph/core/detail/kernels.hpp" + +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/detail/flow_core.hpp" +#include "netgraph/core/detail/int128.hpp" +#include "netgraph/core/detail/node_map.hpp" +#include "netgraph/core/detail/pred_dag_builder.hpp" + +namespace netgraph::core::detail { + +// ---------------------------------------------------------------- apportionment + +void round_robin(Amount total, std::span out) noexcept { + const auto k = static_cast(out.size()); + const Amount base = total / k; + const Amount extra = total % k; + for (Amount i = 0; i < k; ++i) out[static_cast(i)] = base + (i < extra ? 1 : 0); +} + +namespace { + +// w_i / d_i > w_j / d_j, ties by the lower index: the next unit's recipient order. +bool before(std::uint64_t wi, std::uint64_t di, std::size_t i, std::uint64_t wj, std::uint64_t dj, std::size_t j) { + const U128 left = mul_u64(wi, dj); + const U128 right = mul_u64(wj, di); + if (!(left == right)) return left > right; + return i < j; +} + +} // namespace + +void highest_averages(Amount total, std::span weights, std::span out) { + const std::size_t k = weights.size(); + std::uint64_t wsum = 0; + for (Amount w : weights) wsum += static_cast(w); + Amount given = 0; + for (std::size_t m = 0; m < k; ++m) { + // Lower quota floor(total * w / W): exactly the quotients w / j >= W / total. + out[m] = static_cast(div_u128(mul_u64(static_cast(total), + static_cast(weights[m])), + wsum)); + given += out[m]; + } + Amount left = total - given; // fewer than k + if (left == 0) return; + if (k <= 16) { + while (left-- > 0) { + std::size_t best = 0; + for (std::size_t m = 1; m < k; ++m) { + if (before(static_cast(weights[m]), static_cast(out[m]) + 1, m, + static_cast(weights[best]), static_cast(out[best]) + 1, best)) { + best = m; + } + } + ++out[best]; + } + return; + } + std::vector heap(k); + std::iota(heap.begin(), heap.end(), std::size_t{0}); + // std heaps put the largest first: "a < b" when b goes first. + auto later = [&](std::size_t a, std::size_t b) { + return before(static_cast(weights[b]), static_cast(out[b]) + 1, b, + static_cast(weights[a]), static_cast(out[a]) + 1, a); + }; + std::make_heap(heap.begin(), heap.end(), later); + while (left-- > 0) { + std::pop_heap(heap.begin(), heap.end(), later); + ++out[heap.back()]; + std::push_heap(heap.begin(), heap.end(), later); + } +} + +// ---------------------------------------------------------------- plans + +void check_kernel(FlowPlacement placement, Admission admission, EdgePriority priority) { + if (admission == Admission::Lossy && placement == FlowPlacement::MaxFlow) { + throw std::invalid_argument( + "Lossy admission is rejected with the MaxFlow kernel: a controller never drops (EqualBalanced or " + "Weighted only)"); + } + if (placement == FlowPlacement::Weighted && priority == EdgePriority::Headroom) { + throw std::invalid_argument("the Weighted kernel is rejected with priority Headroom (weights are units)"); + } +} + +namespace { + +// Per-thread scratch of build_split; nothing in it survives a call. +struct SplitScratch { + std::vector edge; // candidate arcs, ascending EdgeId + std::vector tail; // local tail per arc + std::vector head; // local head per arc + std::vector in_off; // in-CSR over local heads + std::vector in_arc; + std::vector cursor; + std::vector state; // backward DFS: 0 unseen, 1 open, 2 done (reaches a destination) + std::vector fwd; // reached from the source over arcs of reached tails + std::vector order; // postorder: every node after all of its parents + std::vector> stack; // (node, next in-arc) + std::vector member_arc; // per member its arc + std::vector position; + std::vector first; // per local tail: its first member + std::vector count; // per local tail: its number of members + std::vector dist; // the cost DP +}; + +SplitScratch& split_scratch() { + thread_local SplitScratch s; + return s; +} + +// The split structure of a flow from src to the destination set over `edges` (ascending +// EdgeId), restricted to the edges `keep(i, e)` accepts: the kept edges on some walk from src to +// a destination (destinations terminal) become plan.members, with the plan's topological fields. +// One pass: a backward DFS from the destinations over the in-arcs lists the nodes that reach a +// destination in postorder (every node after its parents, a topological order); a forward pass +// over that order marks the nodes the source reaches. Members ascend by EdgeId and B' ids rank +// edges by source first, so the members of one tail are consecutive. Any topological order gives +// the same allocation (a node splits once its inflow is complete). +// +// With `cost`, the forward pass also computes the minimum walk cost from the source to a +// destination over the members (kUnreachable without members): the DP runs in the same +// topological order. +template +void build_split(const StrictMultiDiGraph& g, std::span edges, NodeId src, std::span dsts, + Keep keep, KernelPlan& plan, Cost* cost = nullptr) { + plan.members.clear(); + plan.topo.clear(); + plan.terminal.clear(); + plan.out_begin.clear(); + plan.out_end.clear(); + plan.head_pos.clear(); + if (cost != nullptr) *cost = kUnreachable; + plan.src_out_begin = 0; + plan.src_out_end = 0; + const auto edge_src = g.edge_src(); + const auto edge_dst = g.edge_dst(); + NodeMap& map = NodeMap::local(); + map.reset(static_cast(g.num_nodes())); + (void)map.add(src); // local 0 + for (NodeId d : dsts) (void)map.add(d); + const auto num_dst = static_cast(map.size()); // locals 1 .. num_dst - 1 + auto is_dst = [num_dst](std::int32_t v) { return v >= 1 && v < num_dst; }; + SplitScratch& s = split_scratch(); + s.edge.clear(); + s.tail.clear(); + s.head.clear(); + NodeId last_src = -1; + std::int32_t last_tail = -1; + for (std::size_t i = 0; i < edges.size(); ++i) { + const EdgeId e = edges[i]; + if (!keep(i, e)) continue; + const auto ei = static_cast(e); + if (edge_src[ei] != last_src) { + last_src = edge_src[ei]; + last_tail = map.add(last_src); + } + if (is_dst(last_tail)) continue; // destinations are terminal + s.edge.push_back(e); + s.tail.push_back(last_tail); + s.head.push_back(map.add(edge_dst[ei])); + } + const std::size_t n = map.size(); + const std::size_t k = s.edge.size(); + s.in_off.assign(n + 1, 0); + for (std::size_t a = 0; a < k; ++a) ++s.in_off[static_cast(s.head[a]) + 1]; + for (std::size_t v = 0; v < n; ++v) s.in_off[v + 1] += s.in_off[v]; + s.in_arc.resize(k); + s.cursor.assign(s.in_off.begin(), s.in_off.end() - 1); + for (std::size_t a = 0; a < k; ++a) { + s.in_arc[static_cast(s.cursor[static_cast(s.head[a])]++)] = static_cast(a); + } + // Backward DFS from the destinations, postorder. + s.state.assign(n, 0); + s.order.clear(); + for (std::int32_t d = 1; d < num_dst; ++d) { + if (s.state[static_cast(d)] != 0) continue; + s.state[static_cast(d)] = 1; + s.stack.clear(); + s.stack.emplace_back(d, s.in_off[static_cast(d)]); + while (!s.stack.empty()) { + const auto [v, it] = s.stack.back(); + if (it < s.in_off[static_cast(v) + 1]) { + s.stack.back().second = it + 1; + const std::int32_t u = s.tail[static_cast(s.in_arc[static_cast(it)])]; + const auto su = s.state[static_cast(u)]; + if (su == 0) { + s.state[static_cast(u)] = 1; + s.stack.emplace_back(u, s.in_off[static_cast(u)]); + } else if (su == 1) { + throw std::logic_error("kernel plan: the route is cyclic"); + } + } else { + s.state[static_cast(v)] = 2; + s.order.push_back(v); + s.stack.pop_back(); + } + } + } + // Forward over the postorder (with the cost DP when asked). + s.fwd.assign(n, 0); + if (s.state[0] == 2) s.fwd[0] = 1; + if (cost == nullptr) { + for (std::int32_t v : s.order) { + if (v == 0) continue; + const auto vi = static_cast(v); + for (auto it = s.in_off[vi]; it < s.in_off[vi + 1]; ++it) { + if (s.fwd[static_cast(s.tail[static_cast(s.in_arc[static_cast(it)])])] != 0) { + s.fwd[vi] = 1; + break; + } + } + } + } else { + const auto edge_cost = g.cost(); + s.dist.assign(n, kUnreachable); + s.dist[0] = 0; + Cost best = kUnreachable; + for (std::int32_t v : s.order) { + const auto vi = static_cast(v); + if (v != 0) { + Cost dv = kUnreachable; + for (auto it = s.in_off[vi]; it < s.in_off[vi + 1]; ++it) { + const auto a = static_cast(s.in_arc[static_cast(it)]); + const auto u = static_cast(s.tail[a]); + if (s.fwd[u] == 0) continue; + dv = std::min(dv, s.dist[u] + edge_cost[static_cast(s.edge[a])]); + } + if (dv == kUnreachable) continue; + s.fwd[vi] = 1; + s.dist[vi] = dv; + } + if (s.fwd[vi] != 0 && is_dst(v)) best = std::min(best, s.dist[vi]); + } + *cost = best; + } + // Members: the arcs from a reached tail to a head that reaches a destination, ascending. + s.member_arc.clear(); + for (std::size_t a = 0; a < k; ++a) { + if (s.fwd[static_cast(s.tail[a])] != 0 && s.state[static_cast(s.head[a])] == 2) { + s.member_arc.push_back(static_cast(a)); + } + } + const std::size_t num_members = s.member_arc.size(); + if (num_members == 0) return; + plan.members.reserve(num_members); + for (std::int32_t a : s.member_arc) plan.members.push_back(s.edge[static_cast(a)]); + // Positions: the postorder restricted to reached nodes; the source precedes every node it + // reaches, so it is first. + s.position.assign(n, -1); + std::int32_t num_pos = 0; + for (std::int32_t v : s.order) { + if (s.fwd[static_cast(v)] != 0) s.position[static_cast(v)] = num_pos++; + } + if (s.position[0] != 0) throw std::logic_error("kernel plan: the split graph does not start at its source"); + s.first.assign(n, -1); + s.count.assign(n, 0); + for (std::size_t m = 0; m < num_members; ++m) { + const auto t = static_cast(s.tail[static_cast(s.member_arc[m])]); + if (s.count[t] == 0) { + s.first[t] = static_cast(m); + } else if (static_cast(s.first[t] + s.count[t]) != m) { + throw std::logic_error("kernel plan: the members of a node are not consecutive (EdgeIds not in B' order)"); + } + ++s.count[t]; + } + const auto P = static_cast(num_pos); + plan.topo.reserve(P); + plan.terminal.reserve(P); + plan.out_begin.reserve(P); + plan.out_end.reserve(P); + for (std::int32_t v : s.order) { + const auto vi = static_cast(v); + if (s.fwd[vi] == 0) continue; + plan.topo.push_back(map.nodes()[vi]); + plan.terminal.push_back(is_dst(v) ? 1 : 0); + const std::int32_t b = s.count[vi] == 0 ? 0 : s.first[vi]; + plan.out_begin.push_back(b); + plan.out_end.push_back(b + s.count[vi]); + } + plan.head_pos.resize(num_members); + for (std::size_t m = 0; m < num_members; ++m) { + plan.head_pos[m] = s.position[static_cast(s.head[static_cast(s.member_arc[m])])]; + } + plan.src_out_begin = plan.out_begin[0]; + plan.src_out_end = plan.out_end[0]; +} + +// Per-thread per-node state of build_split_dag, indexed by NodeId and valid for a node only +// when its stamp is the current epoch (no clearing between calls; a call that throws leaves stale +// entries behind an old epoch). One array of slots, so growing it either succeeds whole or leaves +// the previous array in place (review-1b-fixes N1: separately grown arrays could be left at +// different sizes by a failed allocation). +struct DagScratch { + struct Node { + std::uint32_t stamp {0}; + std::uint8_t state {0}; // backward DFS: 1 open, 2 done (reaches a destination) + std::uint8_t fwd {0}; // reached from the source + std::uint8_t target {0}; // a destination + std::int32_t position {0}; + std::int32_t first {0}; // the node's first member + std::int32_t count {0}; // the node's number of members + Cost dist {0}; + }; + std::uint32_t epoch {0}; + std::vector node; + std::vector order; + std::vector> stack; + std::vector member_tail; + std::vector member_head; + + void begin(std::size_t n) { + if (node.size() < n) node.resize(n); // strong guarantee; new slots carry stamp 0 + if (++epoch == 0) { + for (auto& v : node) v.stamp = 0; + epoch = 1; + } + } + [[nodiscard]] bool seen(NodeId v) const noexcept { return node[static_cast(v)].stamp == epoch; } + Node& at(NodeId v) noexcept { return node[static_cast(v)]; } + void touch(NodeId v) noexcept { + auto& x = at(v); + x = Node {}; + x.stamp = epoch; + } +}; + +DagScratch& dag_scratch() { + thread_local DagScratch s; + return s; +} + +// build_split over a PredDAG, on the DAG's own predecessor lists (no local renumbering, no +// arc arrays, no CSR to build): the same members and structure as build_split over the DAG's +// ascending edge set. `keep(e)` filters edges. +template +void build_split_dag(const StrictMultiDiGraph& g, const PredDAG& dag, NodeId src, std::span dsts, + Keep keep, KernelPlan& plan, Cost* cost) { + plan.members.clear(); + plan.topo.clear(); + plan.terminal.clear(); + plan.out_begin.clear(); + plan.out_end.clear(); + plan.head_pos.clear(); + plan.src_out_begin = 0; + plan.src_out_end = 0; + if (cost != nullptr) *cost = kUnreachable; + const auto off = dag.parent_offsets(); + const auto parents = dag.parents(); + const auto via = dag.via_edges(); + DagScratch& s = dag_scratch(); + s.begin(static_cast(g.num_nodes())); + for (NodeId d : dsts) { + s.touch(d); + s.at(d).target = 1; + } + if (!s.seen(src)) s.touch(src); + auto is_dst = [&](NodeId u) { return s.seen(u) && s.at(u).target != 0; }; + // Backward DFS from the destinations, postorder (every node after all of its parents), over + // the entries (u, e) that `keep` accepts and whose parent is not a destination. + s.order.clear(); + for (NodeId d : dsts) { + if (s.at(d).state != 0) continue; + s.at(d).state = 1; + s.stack.clear(); + s.stack.emplace_back(d, off[static_cast(d)]); + while (!s.stack.empty()) { + const auto [v, k] = s.stack.back(); + if (k < off[static_cast(v) + 1]) { + s.stack.back().second = k + 1; + const auto ki = static_cast(k); + const NodeId u = parents[ki]; + if (is_dst(u) || !keep(via[ki])) continue; + if (!s.seen(u)) s.touch(u); + const auto su = s.at(u).state; + if (su == 0) { + s.at(u).state = 1; + s.stack.emplace_back(u, off[static_cast(u)]); + } else if (su == 1) { + throw std::logic_error("kernel plan: the route is cyclic"); + } + } else { + s.at(v).state = 2; + s.order.push_back(v); + s.stack.pop_back(); + } + } + } + // Forward over the postorder, with the cost DP when asked. + const auto edge_cost = g.cost(); + Cost best = kUnreachable; + if (s.at(src).state == 2) { + s.at(src).fwd = 1; + s.at(src).dist = 0; + } + for (NodeId v : s.order) { + const auto vi = static_cast(v); + auto& nv = s.at(v); + if (v != src) { + Cost dv = kUnreachable; + bool reached = false; + for (auto k = off[vi]; k < off[vi + 1]; ++k) { + const auto ki = static_cast(k); + const NodeId u = parents[ki]; + if (!s.seen(u)) continue; + const auto& nu = s.at(u); + if (nu.fwd == 0 || nu.target != 0 || !keep(via[ki])) continue; + reached = true; + if (cost == nullptr) break; + dv = std::min(dv, nu.dist + edge_cost[static_cast(via[ki])]); + } + if (!reached) continue; + nv.fwd = 1; + nv.dist = dv; + } + if (cost != nullptr && nv.fwd != 0 && nv.target != 0) best = std::min(best, nv.dist); + } + if (cost != nullptr) *cost = best; + // Members, ascending: the DAG's edges kept, from a reached tail (not a destination) to a head + // that reaches a destination. + const auto edge_src = g.edge_src(); + const auto edge_dst = g.edge_dst(); + s.member_tail.clear(); + s.member_head.clear(); + for (EdgeId e : PredDAGBuilder::ascending(dag)) { + const auto ei = static_cast(e); + const NodeId u = edge_src[ei]; + const NodeId v = edge_dst[ei]; + if (!s.seen(u) || s.at(u).fwd == 0 || s.at(u).target != 0) continue; + if (!s.seen(v) || s.at(v).state != 2 || !keep(e)) continue; + plan.members.push_back(e); + s.member_tail.push_back(u); + s.member_head.push_back(v); + } + const std::size_t num_members = plan.members.size(); + if (num_members == 0) return; + std::int32_t num_pos = 0; + for (NodeId v : s.order) { + if (s.at(v).fwd != 0) s.at(v).position = num_pos++; + } + if (s.at(src).position != 0) { + throw std::logic_error("kernel plan: the split graph does not start at its source"); + } + for (std::size_t m = 0; m < num_members; ++m) { + auto& nt = s.at(s.member_tail[m]); + if (nt.count == 0) { + nt.first = static_cast(m); + } else if (static_cast(nt.first + nt.count) != m) { + throw std::logic_error("kernel plan: the members of a node are not consecutive (EdgeIds not in B' order)"); + } + ++nt.count; + } + const auto P = static_cast(num_pos); + plan.topo.reserve(P); + plan.terminal.reserve(P); + plan.out_begin.reserve(P); + plan.out_end.reserve(P); + for (NodeId v : s.order) { + const auto& nv = s.at(v); + if (nv.fwd == 0) continue; + plan.topo.push_back(v); + plan.terminal.push_back(nv.target); + const std::int32_t b = nv.count == 0 ? 0 : nv.first; + plan.out_begin.push_back(b); + plan.out_end.push_back(b + nv.count); + } + plan.head_pos.resize(num_members); + for (std::size_t m = 0; m < num_members; ++m) { + plan.head_pos[m] = s.at(s.member_head[m]).position; + } + plan.src_out_begin = plan.out_begin[0]; + plan.src_out_end = plan.out_end[0]; +} + +// The minimum walk cost from the source to a terminal over a split structure (positions in +// topological order, the source first). +Cost split_cost(const StrictMultiDiGraph& g, const KernelPlan& plan) { + if (plan.members.empty()) return kUnreachable; + const auto cost = g.cost(); + thread_local std::vector dist; + dist.assign(plan.topo.size(), kUnreachable); + dist[0] = 0; + Cost best = kUnreachable; + for (std::size_t p = 0; p < plan.topo.size(); ++p) { + if (dist[p] == kUnreachable) continue; + if (plan.terminal[p] != 0) { + best = std::min(best, dist[p]); + continue; + } + for (auto k = plan.out_begin[p]; k < plan.out_end[p]; ++k) { + const auto m = static_cast(k); + const auto h = static_cast(plan.head_pos[m]); + dist[h] = std::min(dist[h], dist[p] + cost[static_cast(plan.members[m])]); + } + } + return best; +} + +} // namespace + +std::vector prune(const StrictMultiDiGraph& g, std::span edges, NodeId src, + std::span dsts) { + std::vector sorted; + if (!std::is_sorted(edges.begin(), edges.end())) { + sorted.assign(edges.begin(), edges.end()); + std::sort(sorted.begin(), sorted.end()); + edges = sorted; + } + KernelPlan plan; + build_split(g, edges, src, dsts, [](std::size_t, EdgeId) { return true; }, plan); + return std::move(plan.members); +} + +KernelPlan make_plan(const StrictMultiDiGraph& g, FlowPlacement placement, Admission admission, EdgePriority priority, + NodeId src, std::span dsts, std::span route, + std::span creation_view) { + check_kernel(placement, admission, priority); + if (!std::is_sorted(route.begin(), route.end())) throw std::logic_error("make_plan: the route is not ascending"); + const bool by_view = placement == FlowPlacement::Weighted && priority == EdgePriority::Residual; + if (by_view && creation_view.size() != route.size()) { + throw std::logic_error("make_plan: the creation view is not route-aligned"); + } + const auto capacity = g.capacity_units(); + KernelPlan plan; + plan.placement = placement; + plan.admission = admission; + plan.priority = priority; + plan.src = src; + plan.dsts.assign(dsts.begin(), dsts.end()); + plan.route.assign(route.begin(), route.end()); + // Membership: MaxFlow every route edge (its view decides), Weighted Residual creation view + // >= 1 unit, every other kernel static capacity >= 1 unit. + auto member = [&](std::size_t i, EdgeId e) { + if (by_view) return creation_view[i] >= 1; + return placement == FlowPlacement::MaxFlow || capacity[static_cast(e)] >= 1; + }; + build_split(g, route, src, dsts, member, plan); + if (placement == FlowPlacement::Weighted) { + plan.weights.reserve(plan.members.size()); + std::size_t i = 0; + for (EdgeId e : plan.members) { + if (by_view) { + while (route[i] != e) ++i; // members are a subsequence of the route + plan.weights.push_back(creation_view[i]); + } else { + plan.weights.push_back(capacity[static_cast(e)]); + } + } + } + return plan; +} + +KernelPlan make_route_plan(const StrictMultiDiGraph& g, FlowPlacement placement, Admission admission, + EdgePriority priority, NodeId src, std::span dsts, const PredDAG& bundle, + const Eligibility* elig, Cost* cost) { + check_kernel(placement, admission, priority); + const auto capacity = g.capacity_units(); + KernelPlan plan; + plan.placement = placement; + plan.admission = admission; + plan.priority = priority; + plan.src = src; + plan.dsts.assign(dsts.begin(), dsts.end()); + auto usable = [&](EdgeId e) { + return capacity[static_cast(e)] >= 1 && (elig == nullptr || elig->edge_ok(e)); + }; + build_split_dag(g, bundle, src, dsts, usable, plan, cost); + plan.route = plan.members; + if (placement == FlowPlacement::Weighted && priority == EdgePriority::Capacity) { + plan.weights.reserve(plan.members.size()); + for (EdgeId e : plan.members) plan.weights.push_back(capacity[static_cast(e)]); + } + return plan; +} + +Cost route_cost(const StrictMultiDiGraph& g, const KernelPlan& plan) { + // The bundle's minimum walk cost (APPROACH 8, rule 12): over the route (the record's + // bundle), not the split graph, so a Weighted plan whose membership excludes a zero-residual + // edge keeps its route's cost. members == route (members are a subset): the plan's own + // structure serves. + if (plan.route.empty()) return kUnreachable; + if (plan.members.size() == plan.route.size()) return split_cost(g, plan); + KernelPlan whole; + Cost cost = kUnreachable; + build_split(g, plan.route, plan.src, plan.dsts, [](std::size_t, EdgeId) { return true; }, whole, &cost); + return cost; +} + +// ---------------------------------------------------------------- allocation + +bool allocate(const KernelPlan& plan, Amount total, std::span out, std::span limit) { + std::fill(out.begin(), out.end(), 0); + if (total == 0 || plan.members.empty()) { + if (total != 0) throw std::logic_error("allocate: an empty split graph carries nothing"); + return true; + } + const bool weighted = plan.placement == FlowPlacement::Weighted; + const bool limited = !limit.empty(); + thread_local std::vector inflow; + inflow.assign(plan.topo.size(), 0); + inflow[0] = total; + for (std::size_t p = 0; p < plan.topo.size(); ++p) { + const Amount amount = inflow[p]; + if (amount == 0 || plan.terminal[p] != 0) continue; + const auto b = static_cast(plan.out_begin[p]); + const auto e = static_cast(plan.out_end[p]); + const std::size_t k = e - b; + if (weighted) { + highest_averages(amount, std::span(plan.weights.data() + b, k), out.subspan(b, k)); + for (std::size_t m = b; m < e; ++m) { + const Amount x = out[m]; + if (limited && x > limit[m]) return false; + inflow[static_cast(plan.head_pos[m])] += x; + } + } else { + // Round robin inline: floor(amount / k) each, one more to the first amount mod k. + const auto kk = static_cast(k); + const Amount base = amount / kk; + const auto extra = b + static_cast(amount % kk); + const std::size_t used = base == 0 ? extra : e; // members beyond carry 0 + for (std::size_t m = b; m < used; ++m) { + const Amount x = base + (m < extra ? 1 : 0); + if (limited && x > limit[m]) return false; + out[m] = x; + inflow[static_cast(plan.head_pos[m])] += x; + } + } + } + return true; +} + +namespace { + +// A floating-point estimate of T_max: the fraction f of the source's inflow that each member +// carries under the plan's split (1/k per round-robin member, w/W per Weighted member), then +// min over members of view / f. It only chooses the first probe of the exact search below +// (APPROACH 7, revision 26: approximate arithmetic may choose probes, nothing else). +double estimate_t_max(const KernelPlan& plan, std::span view_m) { + thread_local std::vector f; + f.assign(plan.topo.size(), 0.0); + f[0] = 1.0; + const bool weighted = plan.placement == FlowPlacement::Weighted; + double best = std::numeric_limits::infinity(); + for (std::size_t p = 0; p < plan.topo.size(); ++p) { + if (f[p] == 0.0 || plan.terminal[p] != 0) continue; + const auto b = static_cast(plan.out_begin[p]); + const auto e = static_cast(plan.out_end[p]); + double wsum = 0.0; + if (weighted) { + for (std::size_t m = b; m < e; ++m) wsum += static_cast(plan.weights[m]); + } + for (std::size_t m = b; m < e; ++m) { + const double share = weighted ? f[p] * static_cast(plan.weights[m]) / wsum + : f[p] / static_cast(e - b); + if (share <= 0.0) continue; + best = std::min(best, static_cast(view_m[m]) / share); + f[static_cast(plan.head_pos[m])] += share; + } + } + return best; +} + +constexpr std::int32_t kProbeBudget = 62; // a plain bisection over units below 2^62 + +// The largest T in [lo, hi) whose allocation fits view_m, given that alloc(lo) fits and alloc(hi) +// does not (APPROACH 7 and 16.3, revision 26). fits(T) is exact (integer allocation compared +// member by member with the view) and monotone in T (the allocation is non-decreasing member by +// member: house monotonicity), so the result is exact whatever probes are taken. The probes: one +// at the floating-point estimate (the only approximate arithmetic, which chooses that probe and +// nothing else), then a galloping bracket, then bisection; both bracket ends are always exact +// probe results (or the given ends). The number of probes stays within kProbeBudget = 62, the +// bound of a plain bisection: a seed or galloping probe is taken only while the probes made so +// far, that probe and a worst-case bisection of the current bracket fit in the budget; the bound +// is checked on return. With `best`, every fitting probe's allocation is kept there (swapped in), +// so on return it holds alloc(result) whenever result > the given lo. +Amount largest_fit(const KernelPlan& plan, std::span view_m, Amount lo, Amount hi, + std::vector& probe, std::vector* best, std::int32_t& probes) { + auto fits = [&](Amount t) { + ++probes; + if (!allocate(plan, t, probe, view_m)) return false; + if (best != nullptr) std::swap(probe, *best); + return true; + }; + auto bisection_steps = [](Amount a, Amount b) { // probes a plain bisection of (a, b) needs + return static_cast(std::bit_width(static_cast(b - a - 1))); + }; + const std::int32_t start = probes; + auto spare = [&](Amount a, Amount b) { return kProbeBudget - (probes - start) - bisection_steps(a, b) - 1; }; + if (hi - lo > 2 && spare(lo, hi) >= 0) { + const double est = estimate_t_max(plan, view_m); + Amount t = lo + 1; + if (est > static_cast(lo) + 1.0) { + t = est >= static_cast(hi) - 1.0 ? hi - 1 : static_cast(est); + } + if (fits(t)) { + lo = t; + for (Amount step = 1; hi - lo > 1 && spare(lo, hi) >= 0; step *= 2) { + const Amount next = hi - lo > step ? lo + step : hi - 1; + if (fits(next)) { + lo = next; + } else { + hi = next; + break; + } + } + } else { + hi = t; + for (Amount step = 1; hi - lo > 1 && spare(lo, hi) >= 0; step *= 2) { + const Amount next = hi - lo > step ? hi - step : lo + 1; + if (fits(next)) { + lo = next; + break; + } + hi = next; + } + } + } + while (hi - lo > 1) { + const Amount mid = lo + (hi - lo) / 2; + if (fits(mid)) { + lo = mid; + } else { + hi = mid; + } + } + if (probes - start > kProbeBudget) { + throw std::logic_error("T_max search: " + std::to_string(probes - start) + " probes, above the bisection bound 62"); + } + return lo; +} + +std::vector& probe_scratch(std::size_t size) { + thread_local std::vector v; + v.resize(size); + return v; +} + +} // namespace + +TMax t_max(const KernelPlan& plan, std::span view_m) { + TMax out; + if (plan.members.empty()) return out; + Amount hi = 0; + for (auto k = plan.src_out_begin; k < plan.src_out_end; ++k) hi += view_m[static_cast(k)]; + auto& probe = probe_scratch(plan.members.size()); + // alloc(hi) fits only when every member can take it; else search below hi (the search's + // probes are counted, this one is not, like the target probe of a Lossless step). + if (allocate(plan, hi, probe, view_m)) { + out.value = hi; + return out; + } + out.value = largest_fit(plan, view_m, 0, hi, probe, nullptr, out.iterations); + return out; +} + +LosslessStep lossless_step(const StrictMultiDiGraph& g, const KernelPlan& plan, Amount total, + std::span view_m, std::span own_m, + std::optional request) { + if (plan.admission != Admission::Lossless) throw std::logic_error("lossless_step on a Lossy plan"); + LosslessStep out; + if (plan.placement == FlowPlacement::MaxFlow) { + // A fresh solve on the replacement view (own_m is part of view_m, so the flow's own + // allocation is cancellable; other owners are not in the view). + const std::optional cap = request.has_value() ? std::optional(total + *request) : std::nullopt; + const NodeId src[1] = {plan.src}; + auto r = solve_subset(g, plan.members, view_m, src, plan.dsts, cap); + if (r.placed < total) throw std::logic_error("MaxFlow kernel: the replacement solve lost committed flow"); + out.total = r.placed; + out.alloc = std::move(r.flows); + return out; + } + if (plan.members.empty()) { + out.alloc.assign(own_m.begin(), own_m.end()); + out.total = total; + return out; + } + Amount hi = 0; + for (auto k = plan.src_out_begin; k < plan.src_out_end; ++k) hi += view_m[static_cast(k)]; + const Amount target = request.has_value() ? std::min(total + *request, hi) : hi; + if (target <= total) { + out.total = total; + out.alloc.assign(own_m.begin(), own_m.end()); + return out; + } + out.alloc.assign(plan.members.size(), 0); + if (allocate(plan, target, out.alloc, view_m)) { + out.total = target; + return out; + } + // T_max in [total, target - 1]: alloc(total) = own_m fits, alloc(target) does not. Every + // fitting probe leaves its allocation in out.alloc, so a result above total needs no further + // evaluation. + auto& probe = probe_scratch(plan.members.size()); + const Amount lo = largest_fit(plan, view_m, total, target, probe, &out.alloc, out.iterations); + out.total = lo; + if (lo == total) out.alloc.assign(own_m.begin(), own_m.end()); + return out; +} + +LossyStep lossy_step(const KernelPlan& plan, std::optional offer, std::span w_m) { + if (plan.admission != Admission::Lossy) throw std::logic_error("lossy_step on a Lossless plan"); + LossyStep out; + out.carried.assign(plan.members.size(), 0); + out.drops.assign(plan.members.size(), 0); + if (plan.members.empty() || plan.src_out_begin == plan.src_out_end) { + out.offered = offer.value_or(0); + return out; + } + const bool weighted = plan.placement == FlowPlacement::Weighted; + thread_local std::vector inflow; + thread_local std::vector part; + inflow.assign(plan.topo.size(), 0); + for (std::size_t p = 0; p < plan.topo.size(); ++p) { + if (plan.terminal[p] != 0) continue; + const auto b = static_cast(plan.out_begin[p]); + const auto e = static_cast(plan.out_end[p]); + const std::size_t k = e - b; + part.resize(k); + Amount amount = p == 0 ? offer.value_or(0) : inflow[p]; + if (p == 0 && !offer.has_value()) { + for (std::size_t i = 0; i < k; ++i) part[i] = w_m[b + i]; + } else { + if (amount == 0) continue; + if (weighted) { + highest_averages(amount, std::span(plan.weights.data() + b, k), std::span(part.data(), k)); + } else { + round_robin(amount, std::span(part.data(), k)); + } + } + for (std::size_t i = 0; i < k; ++i) { + const Amount x = part[i]; + if (p == 0) out.injected += x; + if (x == 0) continue; + const auto m = b + i; + const Amount c = std::min(x, w_m[m]); + out.carried[m] = c; + out.drops[m] = x - c; + inflow[static_cast(plan.head_pos[m])] += c; + } + } + for (std::size_t p = 0; p < plan.topo.size(); ++p) { + if (plan.terminal[p] != 0) out.delivered += inflow[p]; + } + out.offered = offer.has_value() ? *offer : out.injected; + return out; +} + +} // namespace netgraph::core::detail diff --git a/src/max_flow.cpp b/src/max_flow.cpp index 13913d5..c77fb90 100644 --- a/src/max_flow.cpp +++ b/src/max_flow.cpp @@ -38,6 +38,9 @@ #include #include +#include "netgraph/core/detail/flow_core.hpp" +#include "netgraph/core/detail/node_map.hpp" + namespace netgraph::core { namespace { @@ -105,45 +108,56 @@ class RadixHeap { std::size_t size_ {0}; }; +// The input of one solve in local coordinates: real vertices 0..num_nodes-1, the arcs of the +// active edges in ascending EdgeId (tail, head, cost and capacity per edge), and the endpoint +// sets as local vertices listed in ascending original NodeId. +struct SolveInput { + std::int32_t num_nodes {0}; + std::vector edges {}; + std::vector tail {}; + std::vector head {}; + std::vector cost {}; + std::vector cap {}; + std::vector sources {}; + std::vector destinations {}; +}; + // The residual network of one solve: real vertices 0..N-1, s* = N, t* = N + 1. Arcs are stored // as slots in adjacency order (the slots of u are [off_[u], off_[u + 1])), each with its head, // its cost, its residual and the slot of its partner arc. class Solver { public: - Solver(const StrictMultiDiGraph& g, std::span view, const std::vector& srcs, - const std::vector& dsts, const Eligibility* elig) - : g_(g), n_(g.num_nodes() + 2), s_(g.num_nodes()), t_(g.num_nodes() + 1) { - const auto num_edges = static_cast(g.num_edges()); - const auto edge_src = g.edge_src(); - const auto edge_dst = g.edge_dst(); - const auto cost = g.cost(); - active_.reserve(num_edges); - for (std::size_t e = 0; e < num_edges; ++e) { - if (elig == nullptr || elig->edge_ok(static_cast(e))) active_.push_back(static_cast(e)); - } - for (NodeId s : srcs) { - if (elig == nullptr || elig->node_ok(s)) srcs_.push_back(s); - } - for (NodeId d : dsts) { - if (elig == nullptr || elig->node_ok(d)) dsts_.push_back(d); - } + // One reusable instance per thread (`local()`): init() resizes every buffer for the input, + // keeping capacity across calls, so repeated solves do not allocate once warm. Not + // reentrant (a solve never starts another). + static Solver& local() { + thread_local Solver solver; + return solver; + } + + void init(const SolveInput& in) { + in_ = ∈ + n_ = in.num_nodes + 2; + s_ = in.num_nodes; + t_ = in.num_nodes + 1; + mark_epoch_ = 0; const auto n = static_cast(n_); off_.assign(n + 1, 0); auto deg = [&](std::int32_t v) { ++off_[static_cast(v) + 1]; }; - for (EdgeId e : active_) { - deg(edge_src[static_cast(e)]); - deg(edge_dst[static_cast(e)]); + for (std::size_t k = 0; k < in.edges.size(); ++k) { + deg(in.tail[k]); + deg(in.head[k]); } - for (NodeId s : srcs_) { deg(s); deg(s_); } - for (NodeId d : dsts_) { deg(d); deg(t_); } + for (std::int32_t s : in.sources) { deg(s); deg(s_); } + for (std::int32_t d : in.destinations) { deg(d); deg(t_); } for (std::size_t v = 0; v < n; ++v) off_[v + 1] += off_[v]; const auto num_slots = static_cast(off_[n]); head_.resize(num_slots); rev_.resize(num_slots); cost_.resize(num_slots); res_.resize(num_slots); - std::vector pos(off_.begin(), off_.end() - 1); - auto take = [&](std::int32_t v) { return pos[static_cast(v)]++; }; + pos_.assign(off_.begin(), off_.end() - 1); + auto take = [&](std::int32_t v) { return pos_[static_cast(v)]++; }; auto link = [&](std::int32_t a, std::int32_t to_a, Amount cap, Cost w, std::int32_t b, std::int32_t to_b) { const auto ai = static_cast(a); const auto bi = static_cast(b); @@ -158,21 +172,25 @@ class Solver { }; // Virtual arcs first at the real endpoints, then the real arcs by ascending EdgeId (forward // slot at the tail, reverse slot at the head), then the slots of s* and t*. - std::vector src_rev(srcs_.size()), dst_fwd(dsts_.size()); - for (std::size_t i = 0; i < srcs_.size(); ++i) src_rev[i] = take(srcs_[i]); - for (std::size_t i = 0; i < dsts_.size(); ++i) dst_fwd[i] = take(dsts_[i]); - edge_slot_.resize(active_.size()); - for (std::size_t k = 0; k < active_.size(); ++k) { - const auto ei = static_cast(active_[k]); - const NodeId u = edge_src[ei]; - const NodeId v = edge_dst[ei]; + src_rev_.resize(in.sources.size()); + dst_fwd_.resize(in.destinations.size()); + for (std::size_t i = 0; i < in.sources.size(); ++i) src_rev_[i] = take(in.sources[i]); + for (std::size_t i = 0; i < in.destinations.size(); ++i) dst_fwd_[i] = take(in.destinations[i]); + edge_slot_.resize(in.edges.size()); + for (std::size_t k = 0; k < in.edges.size(); ++k) { + const std::int32_t u = in.tail[k]; + const std::int32_t v = in.head[k]; const std::int32_t fwd = take(u); const std::int32_t bwd = take(v); - link(fwd, v, view[ei], cost[ei], bwd, u); + link(fwd, v, in.cap[k], in.cost[k], bwd, u); edge_slot_[k] = fwd; } - for (std::size_t i = 0; i < srcs_.size(); ++i) link(take(s_), srcs_[i], kMaxFlowCap, 0, src_rev[i], s_); - for (std::size_t i = 0; i < dsts_.size(); ++i) link(dst_fwd[i], t_, kMaxFlowCap, 0, take(t_), dsts_[i]); + for (std::size_t i = 0; i < in.sources.size(); ++i) { + link(take(s_), in.sources[i], kMaxFlowCap, 0, src_rev_[i], s_); + } + for (std::size_t i = 0; i < in.destinations.size(); ++i) { + link(dst_fwd_[i], t_, kMaxFlowCap, 0, take(t_), in.destinations[i]); + } pi_.assign(n, 0); ever_.assign(n, 0); dist_.resize(n); @@ -181,8 +199,23 @@ class Solver { cur_.resize(n); } - FlowCoreResult run(std::span view, Amount limit) { - FlowCoreResult out; + // The solve to the cap or to disconnection. Output in local coordinates: `flows` aligned with + // the input edges, `cut` as input edge indices (positive capacity, from the full residual + // closure of the real sources to its complement, ascending EdgeId) when the destinations are + // outside the closure, potentials per local vertex (kNeverReached when never settled). + struct Output { + Amount placed {0}; + std::vector> cost_distribution {}; + std::vector flows {}; + std::optional> cut {}; + std::vector potentials {}; + FlowCoreCounters counters {}; + }; + + // `closure` adds the cut; `potentials` the per-vertex potentials (the edge-subset entry + // needs neither). + Output run(Amount limit, bool closure, bool potentials = true) { + Output out; auto& c = out.counters; Amount total = 0; while (total < limit) { @@ -213,47 +246,47 @@ class Solver { } out.placed = total; - const auto num_edges = static_cast(g_.num_edges()); - out.edge_flows.assign(num_edges, 0); - for (std::size_t k = 0; k < active_.size(); ++k) { + out.flows.resize(in_->edges.size()); + for (std::size_t k = 0; k < in_->edges.size(); ++k) { const auto fwd = static_cast(edge_slot_[k]); - out.edge_flows[static_cast(active_[k])] = res_[static_cast(rev_[fwd])]; + out.flows[k] = res_[static_cast(rev_[fwd])]; } - // Full residual closure seeded at the real sources (reverse and virtual arcs included). - std::vector seen(static_cast(n_), 0); - std::vector queue; - queue.reserve(static_cast(n_)); - for (NodeId s : srcs_) { - seen[static_cast(s)] = 1; - queue.push_back(s); - } - for (std::size_t qi = 0; qi < queue.size(); ++qi) { - const auto u = static_cast(queue[qi]); - for (auto p = off_[u]; p < off_[u + 1]; ++p) { - const auto pi = static_cast(p); - const auto v = static_cast(head_[pi]); - if (res_[pi] > 0 && seen[v] == 0) { - seen[v] = 1; - queue.push_back(static_cast(v)); + if (closure) { + // Full residual closure seeded at the real sources (reverse and virtual arcs included). + std::vector seen(static_cast(n_), 0); + std::vector queue; + queue.reserve(static_cast(n_)); + for (std::int32_t s : in_->sources) { + seen[static_cast(s)] = 1; + queue.push_back(s); + } + for (std::size_t qi = 0; qi < queue.size(); ++qi) { + const auto u = static_cast(queue[qi]); + for (auto p = off_[u]; p < off_[u + 1]; ++p) { + const auto pi = static_cast(p); + const auto v = static_cast(head_[pi]); + if (res_[pi] > 0 && seen[v] == 0) { + seen[v] = 1; + queue.push_back(static_cast(v)); + } } } - } - if (seen[static_cast(t_)] == 0) { - const auto edge_src = g_.edge_src(); - const auto edge_dst = g_.edge_dst(); - std::vector cut; - for (EdgeId e : active_) { - const auto ei = static_cast(e); - if (seen[static_cast(edge_src[ei])] != 0 && - seen[static_cast(edge_dst[ei])] == 0 && view[ei] > 0) { - cut.push_back(e); + if (seen[static_cast(t_)] == 0) { + std::vector cut; + for (std::size_t k = 0; k < in_->edges.size(); ++k) { + if (seen[static_cast(in_->tail[k])] != 0 && seen[static_cast(in_->head[k])] == 0 && + in_->cap[k] > 0) { + cut.push_back(static_cast(k)); + } } + out.cut = std::move(cut); } - out.min_cut = std::move(cut); } - out.potentials.resize(static_cast(g_.num_nodes())); - for (std::size_t v = 0; v < out.potentials.size(); ++v) { - out.potentials[v] = ever_[v] != 0 ? pi_[v] : kNeverReached; + if (potentials) { + out.potentials.resize(static_cast(in_->num_nodes)); + for (std::size_t v = 0; v < out.potentials.size(); ++v) { + out.potentials[v] = ever_[v] != 0 ? pi_[v] : kNeverReached; + } } return out; } @@ -388,13 +421,13 @@ class Solver { return false; } - const StrictMultiDiGraph& g_; - std::int32_t n_; - std::int32_t s_; - std::int32_t t_; - std::vector active_ {}; - std::vector srcs_ {}; - std::vector dsts_ {}; + const SolveInput* in_ {nullptr}; + std::int32_t n_ {0}; + std::int32_t s_ {0}; + std::int32_t t_ {0}; + std::vector pos_ {}; + std::vector src_rev_ {}; + std::vector dst_fwd_ {}; std::vector off_ {}; std::vector head_ {}; std::vector rev_ {}; @@ -458,8 +491,91 @@ FlowCoreResult place_global_units(const StrictMultiDiGraph& g, out.potentials.assign(static_cast(g.num_nodes()), kNeverReached); return out; } - Solver solver(g, view, srcs, dsts, elig); - return solver.run(view, cap.value_or(kUncapped)); + // Identity coordinates: every real vertex, the allowed edges and endpoints. + SolveInput in; + in.num_nodes = g.num_nodes(); + const auto edge_src = g.edge_src(); + const auto edge_dst = g.edge_dst(); + const auto cost = g.cost(); + in.edges.reserve(num_edges); + for (std::size_t e = 0; e < num_edges; ++e) { + if (elig != nullptr && !elig->edge_ok(static_cast(e))) continue; + in.edges.push_back(static_cast(e)); + in.tail.push_back(edge_src[e]); + in.head.push_back(edge_dst[e]); + in.cost.push_back(cost[e]); + in.cap.push_back(view[e]); + } + for (NodeId s : srcs) { + if (elig == nullptr || elig->node_ok(s)) in.sources.push_back(s); + } + for (NodeId d : dsts) { + if (elig == nullptr || elig->node_ok(d)) in.destinations.push_back(d); + } + Solver& solver = Solver::local(); + solver.init(in); + auto r = solver.run(cap.value_or(kUncapped), /*closure=*/true); + FlowCoreResult out; + out.placed = r.placed; + out.cost_distribution = std::move(r.cost_distribution); + out.edge_flows.assign(num_edges, 0); + for (std::size_t k = 0; k < in.edges.size(); ++k) { + out.edge_flows[static_cast(in.edges[k])] = r.flows[k]; + } + if (r.cut.has_value()) { + std::vector cut; + cut.reserve(r.cut->size()); + for (std::int32_t k : *r.cut) cut.push_back(in.edges[static_cast(k)]); + out.min_cut = std::move(cut); + } + out.potentials = std::move(r.potentials); + out.counters = r.counters; + return out; +} + +namespace detail { + +SubsetFlow solve_subset(const StrictMultiDiGraph& g, std::span edges, std::span caps, + std::span sources, std::span destinations, + std::optional cap) { + SubsetFlow out; + out.flows.assign(edges.size(), 0); + if (cap.has_value() && *cap == 0) return out; + const auto edge_src = g.edge_src(); + const auto edge_dst = g.edge_dst(); + const auto cost = g.cost(); + // Compressed vertices in first-touch order. The numbering changes no flow, curve or path: + // the arc order at every vertex is by EdgeId (virtual arc first) and the virtual arcs of s* + // and t* follow the endpoint lists, which stay in ascending NodeId. + NodeMap& map = NodeMap::local(); + map.reset(static_cast(g.num_nodes())); + // The input's storage is per thread and reused (every field is rewritten here). + thread_local SolveInput in; + in.edges.assign(edges.begin(), edges.end()); + in.tail.clear(); + in.head.clear(); + in.cost.clear(); + in.sources.clear(); + in.destinations.clear(); + for (EdgeId e : edges) { + const auto ei = static_cast(e); + in.tail.push_back(map.add(edge_src[ei])); + in.head.push_back(map.add(edge_dst[ei])); + in.cost.push_back(cost[ei]); + } + for (NodeId v : sources) in.sources.push_back(map.add(v)); + for (NodeId v : destinations) in.destinations.push_back(map.add(v)); + in.num_nodes = static_cast(map.size()); + in.cap.assign(caps.begin(), caps.end()); + Solver& solver = Solver::local(); + solver.init(in); + auto r = solver.run(cap.value_or(kUncapped), /*closure=*/false, /*potentials=*/false); + out.placed = r.placed; + out.flows = std::move(r.flows); + out.cost_distribution = std::move(r.cost_distribution); + return out; } +} // namespace detail + } // namespace netgraph::core diff --git a/src/pinned_flow.cpp b/src/pinned_flow.cpp new file mode 100644 index 0000000..7888890 --- /dev/null +++ b/src/pinned_flow.cpp @@ -0,0 +1,252 @@ +/* + A Pinned placement with one bundle (the single-flow transaction of sub-PR 1b): the + per-flow kernel steps of detail/kernels.hpp committed through the ledger's internal entry + points, one atomic exchange per call (APPROACH 8, 9.2). +*/ +#include "netgraph/core/detail/pinned_flow.hpp" + +#include +#include +#include + +#include "netgraph/core/detail/ledger_access.hpp" +#include "netgraph/core/detail/pred_dag_builder.hpp" + +namespace netgraph::core::detail { + +PinnedFlow::PinnedFlow(FlowGraph& fg, NodeId src, NodeId dst, std::shared_ptr bundle, + FlowPlacement placement, Admission admission, EdgePriority priority, + std::shared_ptr elig) + : fg_(fg), src_(src), dst_(dst), bundle_(std::move(bundle)), placement_(placement), admission_(admission), + priority_(priority), elig_(std::move(elig)) { + check_kernel(placement, admission, priority); + const auto& g = *fg_.graph(); + for (NodeId v : {src, dst}) { + if (v < 0 || v >= g.num_nodes()) { + throw std::invalid_argument("Pinned: node " + std::to_string(v) + " out of node range [0, " + + std::to_string(g.num_nodes()) + ")"); + } + } + if (src == dst) { + throw std::invalid_argument("node " + std::to_string(src) + + " is in both endpoint sets: sources and destinations must not overlap"); + } + if (!bundle_) throw std::invalid_argument("Pinned: the bundle is null"); + if (bundle_->graph_uid() != g.uid()) throw std::invalid_argument("foreign PredDAG: built for another graph"); + if (elig_ && elig_->graph_uid() != g.uid()) throw std::invalid_argument("foreign Eligibility: built for another graph"); + // The usable route: statically eligible routable members on src -> dst walks. An empty one + // is either all-down (no_route at placement) or malformed (no walk at all: rejected). + const NodeId dsts[1] = {dst}; + const auto edges = PredDAGBuilder::ascending(*bundle_); + route_plan_ = make_route_plan(g, placement, admission, priority, src, dsts, *bundle_, elig_.get(), &route_cost_); + if (route_plan_.route.empty() && prune(g, edges, src, dsts).empty()) { + throw std::invalid_argument("Pinned: malformed bundle, no src -> dst walk (src " + std::to_string(src) + ", dst " + + std::to_string(dst) + ")"); + } + owner_ = LedgerAccess::bind(fg_); + bound_ = true; +} + +PinnedFlow::~PinnedFlow() { release(); } + +void PinnedFlow::release() noexcept { + if (!bound_) return; + LedgerAccess::release(fg_, owner_); + bound_ = false; +} + +void PinnedFlow::check_bound() const { + if (!bound_) throw std::logic_error("Pinned: the policy was released (destroyed)"); +} + +Amount PinnedFlow::placed_demand() const { + check_bound(); + return flow_id_.has_value() ? LedgerAccess::live(fg_, flow_id_->second).delivered : 0; +} + +void PinnedFlow::remove() { + check_bound(); + (void)LedgerAccess::detach(fg_, owner_); + flow_id_.reset(); + plan_ = nullptr; + captured_.reset(); + total_ = 0; + requested_ = Counter128 {}; + infinite_ = false; +} + +namespace { + +// Member index of e in the ascending member list (members contain every allocated edge). +std::size_t member_index(const std::vector& members, EdgeId e) { + const auto it = std::lower_bound(members.begin(), members.end(), e); + if (it == members.end() || *it != e) throw std::logic_error("Pinned: an allocation outside the split graph"); + return static_cast(it - members.begin()); +} + +// Per-thread member-aligned buffers of one call (nothing survives a call). +struct CallScratch { + std::vector own; + std::vector working; + std::vector view; + std::vector drops; +}; + +CallScratch& call_scratch() { + thread_local CallScratch s; + return s; +} + +} // namespace + +PinnedResult PinnedFlow::place(std::optional volume) { + check_bound(); + if (volume.has_value() && (*volume < 0 || *volume >= kDomainLimit)) { + throw std::invalid_argument("request " + std::to_string(*volume) + + " is outside [0, 2^62) units (requests are integer units or +inf)"); + } + if (infinite_ && volume.has_value()) { + throw std::invalid_argument( + "a finite request after +inf is rejected within an epoch (the cumulative finite request is undefined; " + "rule 13)"); + } + PinnedResult out; + if (volume.has_value() && *volume == 0) { + out.remaining = 0; + return out; + } + const auto& g = *fg_.graph(); + const auto base = LedgerAccess::baseline(fg_); + const auto held = LedgerAccess::allocated(fg_); + const bool created = !flow_id_.has_value(); + std::optional fresh; + const KernelPlan* plan = plan_; + if (created) { + if (route_plan_.route.empty()) { + // Nothing is created, but the epoch's call state records every positive request + // (the cumulative request and the finite-or-infinite state; APPROACH 8, 9.2). + if (volume.has_value()) { + Counter128 requested = requested_; + if (!requested.add(*volume)) { + throw std::invalid_argument("the cumulative requested counter would reach 2^127"); + } + requested_ = requested; + } else { + infinite_ = true; + } + out.remaining = volume; + out.termination = Termination::NoRoute; + return out; + } + if (placement_ == FlowPlacement::Weighted && priority_ == EdgePriority::Residual) { + // Membership and weights captured on the creation view W (route-aligned). + auto& view = call_scratch().view; + view.clear(); + for (EdgeId e : route_plan_.route) view.push_back(base[static_cast(e)] - held[static_cast(e)]); + const NodeId dsts[1] = {dst_}; + fresh = make_plan(g, placement_, admission_, priority_, src_, dsts, route_plan_.route, view); + plan = &*fresh; + } else { + plan = &route_plan_; + } + } + const auto& members = plan->members; + const FlowRecord* old = flow_id_.has_value() ? &LedgerAccess::live(fg_, flow_id_->second) : nullptr; + CallScratch& scratch = call_scratch(); + auto& own_m = scratch.own; + own_m.assign(members.size(), 0); + if (old != nullptr) { + for (const auto& [e, x] : old->edges) own_m[member_index(members, e)] = x; + } + auto& w_m = scratch.working; + w_m.resize(members.size()); + for (std::size_t m = 0; m < members.size(); ++m) { + const auto e = static_cast(members[m]); + w_m[m] = base[e] - held[e]; + } + + Draft d; + if (flow_id_.has_value()) d.id = flow_id_->second; + d.sources = {src_}; + d.destinations = {dst_}; + d.placement = placement_; + d.admission = admission_; + const Amount delivered0 = old != nullptr ? old->delivered : 0; + bool served = false; + Amount total_after = total_; + if (admission_ == Admission::Lossy) { + const auto step = lossy_step(*plan, volume, w_m); + Counter128 offered = (old != nullptr && old->offered.has_value()) ? *old->offered : Counter128 {}; + if (!offered.add(step.offered)) { + throw std::invalid_argument("the cumulative offered counter would reach 2^127"); + } + d.offered = offered; + auto& drops_m = scratch.drops; + drops_m.assign(members.size(), Counter128 {}); + if (old != nullptr) { + for (const auto& [e, x] : old->drops) drops_m[member_index(members, e)] = x; + } + for (std::size_t m = 0; m < members.size(); ++m) { + if (step.drops[m] == 0) continue; + if (!drops_m[m].add(step.drops[m])) { + throw std::invalid_argument("a cumulative drop counter would reach 2^127"); + } + out.drops.emplace_back(members[m], step.drops[m]); + } + for (std::size_t m = 0; m < members.size(); ++m) { + if (own_m[m] + step.carried[m] != 0) d.edges.emplace_back(members[m], own_m[m] + step.carried[m]); + if (!drops_m[m].is_zero()) d.drops.emplace_back(members[m], drops_m[m]); + } + d.delivered = delivered0 + step.delivered; + served = volume.has_value() && step.injected == *volume; + } else { + auto& v_m = scratch.view; + v_m.resize(members.size()); + for (std::size_t m = 0; m < members.size(); ++m) v_m[m] = w_m[m] + own_m[m]; + const auto step = lossless_step(g, *plan, total_, v_m, own_m, volume); + for (std::size_t m = 0; m < members.size(); ++m) { + if (step.alloc[m] != 0) d.edges.emplace_back(members[m], step.alloc[m]); + } + d.delivered = step.total; + total_after = step.total; + out.iterations = step.iterations; + served = volume.has_value() && step.total - delivered0 == *volume; + } + // The record's bundle is the usable route: the supplied DAG itself when nothing was pruned, + // else one DAG of the route made at the first creation and kept (the route is fixed). + std::shared_ptr route_dag = route_dag_; + if (!route_dag) { + route_dag = route_plan_.route.size() == bundle_->via_edges().size() + ? bundle_ + : std::make_shared(PredDAGBuilder::from_edges(g, route_plan_.route)); + } + d.bundle = route_dag; + d.route_cost = route_cost_; + const Amount delivered = d.delivered; + Counter128 requested = requested_; + if (volume.has_value() && !requested.add(*volume)) { + throw std::invalid_argument("the cumulative requested counter would reach 2^127"); + } + const auto ids = LedgerAccess::commit(fg_, owner_, std::span(&d, 1)); + // Committed: the epoch state follows (nothing below throws). + route_dag_ = std::move(route_dag); + if (created) { + if (fresh.has_value()) { + captured_ = std::move(fresh); + plan_ = &*captured_; + } else { + plan_ = &route_plan_; + } + flow_id_ = ids.front(); + } + total_ = total_after; + requested_ = requested; + if (!volume.has_value()) infinite_ = true; + out.placed = delivered - delivered0; + if (volume.has_value()) out.remaining = *volume - out.placed; + out.termination = served ? Termination::Complete : Termination::Exhausted; + out.routes_changed = created; + return out; +} + +} // namespace netgraph::core::detail diff --git a/src/pred_dag.cpp b/src/pred_dag.cpp new file mode 100644 index 0000000..ec7f1ab --- /dev/null +++ b/src/pred_dag.cpp @@ -0,0 +1,268 @@ +/* + PredDAG and Path (APPROACH 5): immutable, graph-bound route structures validated once at + construction, in the documented order, before any traversal. +*/ +#include "netgraph/core/pred_dag.hpp" + +#include +#include +#include + +#include "netgraph/core/detail/pred_dag_builder.hpp" + +namespace netgraph::core { + +namespace { + +std::string idx(std::size_t i) { return std::to_string(i); } + +} // namespace + +PredDAG PredDAG::from_csr(const StrictMultiDiGraph& g, std::span offsets, + std::span parents, std::span via) { + const auto n = static_cast(g.num_nodes()); + const auto m = static_cast(g.num_edges()); + // Shape: offsets of length N + 1, from 0, monotone, ending at len(parents) == len(via). + if (offsets.size() != n + 1) { + throw std::invalid_argument("PredDAG.from_csr: offsets has length " + idx(offsets.size()) + ", expected N + 1 = " + + idx(n + 1)); + } + if (offsets[0] != 0) { + throw std::invalid_argument("PredDAG.from_csr: offsets[0] = " + std::to_string(offsets[0]) + ", expected 0"); + } + for (std::size_t v = 0; v < n; ++v) { + if (offsets[v + 1] < offsets[v]) { + throw std::invalid_argument("PredDAG.from_csr: offsets are not monotone at " + idx(v + 1) + " (" + + std::to_string(offsets[v]) + " > " + std::to_string(offsets[v + 1]) + ")"); + } + } + const auto total = static_cast(offsets[n]); + if (parents.size() != total || via.size() != total) { + throw std::invalid_argument("PredDAG.from_csr: offsets[N] = " + idx(total) + " but parents has " + + idx(parents.size()) + " and via has " + idx(via.size()) + " entries"); + } + // Entries: ids in range, then each via joining (parent, child). + for (std::size_t k = 0; k < total; ++k) { + if (parents[k] < 0 || static_cast(parents[k]) >= n) { + throw std::invalid_argument("PredDAG.from_csr: parents[" + idx(k) + "] = " + std::to_string(parents[k]) + + " out of node range [0, " + idx(n) + ")"); + } + if (via[k] < 0 || via[k] >= m) { + throw std::invalid_argument("PredDAG.from_csr: via[" + idx(k) + "] = " + std::to_string(via[k]) + + " out of edge range [0, " + std::to_string(m) + ")"); + } + } + const auto edge_src = g.edge_src(); + const auto edge_dst = g.edge_dst(); + for (std::size_t v = 0; v < n; ++v) { + for (auto k = static_cast(offsets[v]); k < static_cast(offsets[v + 1]); ++k) { + const auto e = static_cast(via[k]); + if (edge_src[e] != parents[k] || static_cast(edge_dst[e]) != v) { + throw std::invalid_argument("PredDAG.from_csr: entry " + idx(k) + " of node " + idx(v) + ": via edge " + + idx(e) + " joins (" + std::to_string(edge_src[e]) + ", " + + std::to_string(edge_dst[e]) + "), not (" + std::to_string(parents[k]) + ", " + + idx(v) + ")"); + } + } + } + // No duplicate (child, via): an edge has one child, so a duplicate is a repeated via. + { + std::vector seen(static_cast(m), 0); + for (std::size_t k = 0; k < total; ++k) { + const auto e = static_cast(via[k]); + if (seen[e] != 0) { + throw std::invalid_argument("PredDAG.from_csr: duplicate entry (child " + std::to_string(edge_dst[e]) + + ", via " + idx(e) + ")"); + } + seen[e] = 1; + } + } + // Acyclic: Kahn over the entries (a self-loop is a cycle). + { + std::vector out_count(n + 1, 0); + std::vector indeg(n, 0); + for (std::size_t v = 0; v < n; ++v) { + indeg[v] = offsets[v + 1] - offsets[v]; + for (auto k = static_cast(offsets[v]); k < static_cast(offsets[v + 1]); ++k) { + ++out_count[static_cast(parents[k]) + 1]; + } + } + for (std::size_t v = 0; v < n; ++v) out_count[v + 1] += out_count[v]; + std::vector children(total); + std::vector pos(out_count.begin(), out_count.end() - 1); + for (std::size_t v = 0; v < n; ++v) { + for (auto k = static_cast(offsets[v]); k < static_cast(offsets[v + 1]); ++k) { + children[static_cast(pos[static_cast(parents[k])]++)] = static_cast(v); + } + } + std::vector ready; + for (std::size_t v = 0; v < n; ++v) { + if (indeg[v] == 0) ready.push_back(static_cast(v)); + } + std::size_t done = 0; + while (!ready.empty()) { + const auto u = static_cast(ready.back()); + ready.pop_back(); + ++done; + for (auto k = out_count[u]; k < out_count[u + 1]; ++k) { + const auto c = static_cast(children[static_cast(k)]); + if (--indeg[c] == 0) ready.push_back(static_cast(c)); + } + } + if (done != n) throw std::invalid_argument("PredDAG.from_csr: the entries contain a cycle"); + } + // Canonical form: within a node, ascending via EdgeId. + for (std::size_t v = 0; v < n; ++v) { + for (auto k = static_cast(offsets[v]) + 1; k < static_cast(offsets[v + 1]); ++k) { + if (via[k] <= via[k - 1]) { + throw std::invalid_argument("PredDAG.from_csr: the entries of node " + idx(v) + + " are not in ascending via EdgeId (" + std::to_string(via[k - 1]) + " then " + + std::to_string(via[k]) + ")"); + } + } + } + PredDAG out; + out.graph_uid_ = g.uid(); + out.offsets_.assign(offsets.begin(), offsets.end()); + out.parents_.assign(parents.begin(), parents.end()); + out.via_.assign(via.begin(), via.end()); + out.edges_ = out.via_; + std::sort(out.edges_.begin(), out.edges_.end()); + return out; +} + +namespace detail { + +PredDAG PredDAGBuilder::from_edges(const StrictMultiDiGraph& g, std::span edges) { + const auto n = static_cast(g.num_nodes()); + const auto edge_src = g.edge_src(); + const auto edge_dst = g.edge_dst(); + PredDAG out; + out.graph_uid_ = g.uid(); + out.offsets_.assign(n + 1, 0); + std::vector sorted(edges.begin(), edges.end()); + std::sort(sorted.begin(), sorted.end()); + for (EdgeId e : sorted) ++out.offsets_[static_cast(edge_dst[static_cast(e)]) + 1]; + for (std::size_t v = 0; v < n; ++v) out.offsets_[v + 1] += out.offsets_[v]; + out.parents_.resize(sorted.size()); + out.via_.resize(sorted.size()); + std::vector pos(out.offsets_.begin(), out.offsets_.end() - 1); + for (EdgeId e : sorted) { // ascending: each node's entries stay ascending + const auto ei = static_cast(e); + const auto k = static_cast(pos[static_cast(edge_dst[ei])]++); + out.parents_[k] = edge_src[ei]; + out.via_[k] = e; + } + out.edges_ = std::move(sorted); + return out; +} + +PredDAG PredDAGBuilder::from_children(std::uint64_t graph_uid, const std::vector>& per_child, + const std::vector& parent_of) { + PredDAG out; + out.graph_uid_ = graph_uid; + out.offsets_.assign(per_child.size() + 1, 0); + for (std::size_t v = 0; v < per_child.size(); ++v) { + out.offsets_[v + 1] = out.offsets_[v] + static_cast(per_child[v].size()); + for (EdgeId e : per_child[v]) { + out.parents_.push_back(parent_of[v]); + out.via_.push_back(e); + } + } + out.edges_ = out.via_; + std::sort(out.edges_.begin(), out.edges_.end()); + return out; +} + +std::span PredDAGBuilder::ascending(const PredDAG& dag) noexcept { return dag.edges_; } + +} // namespace detail + +Path::Path(const StrictMultiDiGraph& g, std::vector> hops) { + const auto m = static_cast(g.num_edges()); + // Every member in range before any endpoint is read. + for (std::size_t h = 0; h < hops.size(); ++h) { + for (EdgeId e : hops[h]) { + if (e < 0 || e >= m) { + throw std::invalid_argument("Path: hop " + idx(h) + " member " + std::to_string(e) + + " out of edge range [0, " + std::to_string(m) + ")"); + } + } + } + { + std::vector seen(static_cast(m), 0); + for (const auto& hop : hops) { + for (EdgeId e : hop) { + if (seen[static_cast(e)] != 0) { + throw std::invalid_argument("Path: duplicate member " + std::to_string(e)); + } + seen[static_cast(e)] = 1; + } + } + } + if (hops.empty()) throw std::invalid_argument("Path: the hop list is empty"); + for (std::size_t h = 0; h < hops.size(); ++h) { + if (hops[h].empty()) throw std::invalid_argument("Path: hop " + idx(h) + " is empty"); + } + const auto edge_src = g.edge_src(); + const auto edge_dst = g.edge_dst(); + const auto cost = g.cost(); + for (std::size_t h = 0; h < hops.size(); ++h) { + const auto first = static_cast(hops[h].front()); + for (EdgeId e : hops[h]) { + const auto ei = static_cast(e); + if (edge_src[ei] != edge_src[first] || edge_dst[ei] != edge_dst[first]) { + throw std::invalid_argument("Path: the members of hop " + idx(h) + " do not share (u, v): edge " + + std::to_string(e) + " joins (" + std::to_string(edge_src[ei]) + ", " + + std::to_string(edge_dst[ei]) + "), edge " + idx(first) + " joins (" + + std::to_string(edge_src[first]) + ", " + std::to_string(edge_dst[first]) + ")"); + } + } + } + for (std::size_t h = 1; h < hops.size(); ++h) { + const NodeId prev = edge_dst[static_cast(hops[h - 1].front())]; + const NodeId next = edge_src[static_cast(hops[h].front())]; + if (prev != next) { + throw std::invalid_argument("Path: hops " + idx(h - 1) + " and " + idx(h) + " are not contiguous (" + + std::to_string(prev) + " then " + std::to_string(next) + ")"); + } + } + std::vector nodes; + nodes.reserve(hops.size() + 1); + nodes.push_back(edge_src[static_cast(hops.front().front())]); + Cost total = 0; + for (const auto& hop : hops) { + nodes.push_back(edge_dst[static_cast(hop.front())]); + Cost best = cost[static_cast(hop.front())]; + for (EdgeId e : hop) best = std::min(best, cost[static_cast(e)]); + total += best; + } + { + std::vector sorted(nodes); + std::sort(sorted.begin(), sorted.end()); + const auto dup = std::adjacent_find(sorted.begin(), sorted.end()); + if (dup != sorted.end()) throw std::invalid_argument("Path: node " + std::to_string(*dup) + " is repeated"); + } + graph_uid_ = g.uid(); + num_nodes_ = g.num_nodes(); + hops_ = std::move(hops); + nodes_ = std::move(nodes); + cost_ = total; +} + +PredDAG Path::to_dag() const { + // Built here (not through the builder) because a Path keeps no graph reference: the parent of + // every member of hop h is nodes_[h], its child nodes_[h + 1]; no node repeats, so each node + // holds the members of at most one hop. + std::vector> per_child(static_cast(num_nodes_)); + std::vector parent_of(static_cast(num_nodes_), -1); + for (std::size_t h = 0; h < hops_.size(); ++h) { + const auto child = static_cast(nodes_[h + 1]); + per_child[child] = hops_[h]; + std::sort(per_child[child].begin(), per_child[child].end()); + parent_of[child] = nodes_[h]; + } + return detail::PredDAGBuilder::from_children(graph_uid_, per_child, parent_of); +} + +} // namespace netgraph::core diff --git a/src/search.cpp b/src/search.cpp new file mode 100644 index 0000000..44b7e38 --- /dev/null +++ b/src/search.cpp @@ -0,0 +1,452 @@ +/* + The search semantics of APPROACH 6 as internal routines (sub-PR 1b; public in 1d). + + Distances are int64 path costs: every cost is positive and the sum of all costs is below + 2^62, so no simple path cost overflows and kUnreachable (INT64_MAX) never collides. Dijkstra + uses a binary heap of (distance, node); distances are unique, so the heap's order among + equal keys changes no output. +*/ +#include "netgraph/core/detail/search.hpp" + +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/detail/int128.hpp" +#include "netgraph/core/flow.hpp" + +namespace netgraph::core::detail { + +int compare(const Priority& a, const Priority& b) noexcept { + if (a.infinite || b.infinite) return a.infinite == b.infinite ? 0 : (a.infinite ? 1 : -1); + const U128 left = mul_u64(static_cast(a.num), static_cast(b.den)); + const U128 right = mul_u64(static_cast(b.num), static_cast(a.den)); + if (left == right) return 0; + return left < right ? -1 : 1; +} + +Priority PriorityValues::of(EdgeId e) const noexcept { + const auto ei = static_cast(e); + const Amount cap = g->capacity_units()[ei]; + if (residual.empty() || priority == EdgePriority::Capacity) return {cap, 1, false}; + if (priority == EdgePriority::Residual) return {residual[ei], 1, false}; + return {residual[ei], cap, false}; +} + +std::vector node_set(std::span nodes, std::int32_t num_nodes, const char* name) { + if (nodes.empty()) throw std::invalid_argument(std::string(name) + ": the node set is empty"); + std::vector out(nodes.begin(), nodes.end()); + for (NodeId v : out) { + if (v < 0 || v >= num_nodes) { + throw std::invalid_argument(std::string(name) + ": node " + std::to_string(v) + " out of node range [0, " + + std::to_string(num_nodes) + ")"); + } + } + std::sort(out.begin(), out.end()); + out.erase(std::unique(out.begin(), out.end()), out.end()); + return out; +} + +void check_disjoint(std::span a, std::span b) { + for (std::size_t i = 0, k = 0; i < a.size() && k < b.size();) { + if (a[i] == b[k]) { + throw std::invalid_argument("node " + std::to_string(a[i]) + + " is in both endpoint sets: the sets must not overlap"); + } + if (a[i] < b[k]) ++i; else ++k; + } +} + +std::vector member_mask(const StrictMultiDiGraph& g, const Eligibility* elig, + const PathSelection& selection, std::span residual, + Amount min_residual) { + const auto m = static_cast(g.num_edges()); + const auto capacity = g.capacity_units(); + std::vector member(m, 0); + for (std::size_t e = 0; e < m; ++e) { + if (elig != nullptr && !elig->edge_ok(static_cast(e))) continue; + if (capacity[e] < 1) continue; + if (!residual.empty() && residual[e] < min_residual) continue; + member[e] = 1; + } + if (selection.multi_edge) return member; + // Collapse: one member per (u, v) by select_adjacency. + const PriorityValues prio {&g, selection.priority, residual}; + const bool prefer = selection.tie_break == EdgeTieBreak::PreferHigherPriority; + const auto cost = g.cost(); + const auto edge_dst = g.edge_dst(); + const auto row = g.row_offsets(); + std::vector best(static_cast(g.num_nodes()), -1); + std::vector touched; + for (std::int32_t u = 0; u < g.num_nodes(); ++u) { + touched.clear(); + for (auto e = row[static_cast(u)]; e < row[static_cast(u) + 1]; ++e) { + const auto ei = static_cast(e); + if (member[ei] == 0) continue; + const auto v = static_cast(edge_dst[ei]); + const EdgeId cur = best[v]; + if (cur < 0) { + best[v] = e; + touched.push_back(static_cast(v)); + continue; + } + const auto ci = static_cast(cur); + bool better = false; + if (cost[ei] != cost[ci]) { + better = cost[ei] < cost[ci]; + } else if (prefer) { + const int c = compare(prio.of(e), prio.of(cur)); + better = c > 0; // equal priority: the lower EdgeId (cur, seen first) stays + } + if (better) best[v] = e; + } + for (std::size_t e = static_cast(row[static_cast(u)]); + e < static_cast(row[static_cast(u) + 1]); ++e) { + if (member[e] != 0 && best[static_cast(edge_dst[e])] != static_cast(e)) member[e] = 0; + } + for (NodeId v : touched) best[static_cast(v)] = -1; + } + return member; +} + +namespace { + +using HeapEntry = std::pair; +using MinHeap = std::priority_queue, std::greater<>>; + +} // namespace + +std::vector distances(const StrictMultiDiGraph& g, std::span member, + std::span roots, Direction direction) { + const auto n = static_cast(g.num_nodes()); + std::vector dist(n, kUnreachable); + const auto cost = g.cost(); + const auto edge_dst = g.edge_dst(); + const auto edge_src = g.edge_src(); + const auto row = g.row_offsets(); + const auto in_row = g.in_row_offsets(); + const auto in_adj = g.in_adj_edge_index(); + MinHeap heap; + for (NodeId r : roots) { + dist[static_cast(r)] = 0; + heap.emplace(0, r); + } + while (!heap.empty()) { + const auto [d, u] = heap.top(); + heap.pop(); + const auto ui = static_cast(u); + if (d != dist[ui]) continue; + if (direction == Direction::Forward) { + for (auto e = row[ui]; e < row[ui + 1]; ++e) { + const auto ei = static_cast(e); + if (member[ei] == 0) continue; + const auto v = static_cast(edge_dst[ei]); + const Cost nd = d + cost[ei]; + if (nd < dist[v]) { + dist[v] = nd; + heap.emplace(nd, static_cast(v)); + } + } + } else { + for (auto s = in_row[ui]; s < in_row[ui + 1]; ++s) { + const auto ei = static_cast(in_adj[static_cast(s)]); + if (member[ei] == 0) continue; + const auto v = static_cast(edge_src[ei]); + const Cost nd = d + cost[ei]; + if (nd < dist[v]) { + dist[v] = nd; + heap.emplace(nd, static_cast(v)); + } + } + } + } + return dist; +} + +std::vector full_dag(const StrictMultiDiGraph& g, std::span member, + std::span dist, Direction direction) { + const auto cost = g.cost(); + const auto edge_src = g.edge_src(); + const auto edge_dst = g.edge_dst(); + std::vector out; + for (std::size_t e = 0; e < member.size(); ++e) { + if (member[e] == 0) continue; + const Cost du = dist[static_cast(edge_src[e])]; + const Cost dv = dist[static_cast(edge_dst[e])]; + if (du == kUnreachable || dv == kUnreachable) continue; + const bool tight = direction == Direction::Forward ? du + cost[e] == dv : dv + cost[e] == du; + if (tight) out.push_back(static_cast(e)); + } + return out; +} + +TightDag tight_dag(const StrictMultiDiGraph& g, std::span member, std::span sources, + std::span destinations) { + const auto n = static_cast(g.num_nodes()); + const auto cost = g.cost(); + const auto edge_dst = g.edge_dst(); + const auto edge_src = g.edge_src(); + const auto row = g.row_offsets(); + const auto in_row = g.in_row_offsets(); + const auto in_adj = g.in_adj_edge_index(); + std::vector is_dst(n, 0); + for (NodeId t : destinations) is_dst[static_cast(t)] = 1; + // Forward Dijkstra from the sources until every node at distance <= d* is final. + std::vector dist(n, kUnreachable); + std::vector settled(n, 0); + MinHeap heap; + for (NodeId s : sources) { + dist[static_cast(s)] = 0; + heap.emplace(0, s); + } + Cost dstar = kUnreachable; + while (!heap.empty()) { + const auto [d, u] = heap.top(); + if (d > dstar) break; + heap.pop(); + const auto ui = static_cast(u); + if (d != dist[ui] || settled[ui] != 0) continue; + settled[ui] = 1; + if (is_dst[ui] != 0) { + dstar = std::min(dstar, d); + continue; // destinations are terminal + } + for (auto e = row[ui]; e < row[ui + 1]; ++e) { + const auto ei = static_cast(e); + if (member[ei] == 0) continue; + const auto v = static_cast(edge_dst[ei]); + const Cost nd = d + cost[ei]; + if (nd < dist[v]) { + dist[v] = nd; + heap.emplace(nd, static_cast(v)); + } + } + } + TightDag out; + if (dstar == kUnreachable) { + out.cost = kUnreachable; + return out; + } + out.cost = dstar; + // Back-prune from the destinations at d* over tight in-edges whose tail is settled below d*. + std::vector keep(n, 0); + std::vector stack; + for (NodeId t : destinations) { + const auto ti = static_cast(t); + if (dist[ti] == dstar && keep[ti] == 0) { + keep[ti] = 1; + stack.push_back(t); + } + } + while (!stack.empty()) { + const auto v = static_cast(stack.back()); + stack.pop_back(); + for (auto s = in_row[v]; s < in_row[v + 1]; ++s) { + const auto ei = static_cast(in_adj[static_cast(s)]); + if (member[ei] == 0) continue; + const auto u = static_cast(edge_src[ei]); + if (settled[u] == 0 || is_dst[u] != 0 || dist[u] >= dstar || dist[u] + cost[ei] != dist[v]) continue; + out.edges.push_back(static_cast(ei)); + if (keep[u] == 0) { + keep[u] = 1; + stack.push_back(static_cast(u)); + } + } + } + std::sort(out.edges.begin(), out.edges.end()); + return out; +} + +std::optional canonical_path(const StrictMultiDiGraph& g, std::span member, + std::span sources, std::span destinations, + const PriorityValues* priority, std::optional prefix_cap) { + TightDag tight = tight_dag(g, member, sources, destinations); + if (tight.cost == kUnreachable) return std::nullopt; + const auto n = static_cast(g.num_nodes()); + const auto cost = g.cost(); + const auto edge_src = g.edge_src(); + const auto edge_dst = g.edge_dst(); + std::vector is_dst(n, 0); + for (NodeId t : destinations) is_dst[static_cast(t)] = 1; + std::vector dag = std::move(tight.edges); + SinglePath out; + out.cost = tight.cost; + if (priority != nullptr) { + // Stage 2: B*, the max-min bottleneck over the walks of the tight DAG. Distances from the + // sources inside the DAG order its nodes topologically. + std::vector dag_member(member.size(), 0); + for (EdgeId e : dag) dag_member[static_cast(e)] = 1; + const auto dist = distances(g, dag_member, sources, Direction::Forward); + std::vector by_tail(dag); + std::sort(by_tail.begin(), by_tail.end(), [&](EdgeId a, EdgeId b) { + const Cost da = dist[static_cast(edge_src[static_cast(a)])]; + const Cost db = dist[static_cast(edge_src[static_cast(b)])]; + return da != db ? da < db : a < b; + }); + std::vector> best(n); + for (NodeId s : sources) best[static_cast(s)] = Priority {0, 1, true}; + for (EdgeId e : by_tail) { + const auto u = static_cast(edge_src[static_cast(e)]); + const auto v = static_cast(edge_dst[static_cast(e)]); + if (!best[u].has_value()) continue; + Priority cand = priority->of(e); + if (compare(*best[u], cand) < 0) cand = *best[u]; + if (!best[v].has_value() || compare(cand, *best[v]) > 0) best[v] = cand; + } + std::optional b_star; + for (NodeId t : destinations) { + const auto ti = static_cast(t); + if (dist[ti] != tight.cost || !best[ti].has_value()) continue; + if (!b_star.has_value() || compare(*best[ti], *b_star) > 0) b_star = best[ti]; + } + if (!b_star.has_value()) return std::nullopt; + if (prefix_cap.has_value() && compare(*prefix_cap, *b_star) < 0) b_star = prefix_cap; + out.b_star = b_star; + std::vector kept; + for (EdgeId e : dag) { + if (compare(priority->of(e), *b_star) >= 0) kept.push_back(e); + } + dag = std::move(kept); + } + // Stage 3: hop counts toward the destinations inside the (restricted) DAG, then the greedy + // walk by the smallest tight edge whose head is one hop closer. + constexpr std::int32_t kNone = -1; + std::vector h(n, kNone); + std::vector> in_dag(n), out_dag(n); + for (EdgeId e : dag) { // ascending: out_dag lists stay ascending + in_dag[static_cast(edge_dst[static_cast(e)])].push_back(e); + out_dag[static_cast(edge_src[static_cast(e)])].push_back(e); + } + std::vector queue; + for (NodeId t : destinations) { + h[static_cast(t)] = 0; + queue.push_back(t); + } + for (std::size_t qi = 0; qi < queue.size(); ++qi) { + const auto v = static_cast(queue[qi]); + for (EdgeId e : in_dag[v]) { + const auto u = static_cast(edge_src[static_cast(e)]); + if (h[u] == kNone) { + h[u] = h[v] + 1; + queue.push_back(static_cast(u)); + } + } + } + std::int32_t hmin = kNone; + for (NodeId s : sources) { + const std::int32_t hs = h[static_cast(s)]; + if (hs != kNone && (hmin == kNone || hs < hmin)) hmin = hs; + } + if (hmin == kNone) return std::nullopt; + std::vector frontier; + for (NodeId s : sources) { + if (h[static_cast(s)] == hmin) frontier.push_back(s); + } + while (true) { + EdgeId pick = -1; + for (NodeId x : frontier) { + const auto xi = static_cast(x); + for (EdgeId e : out_dag[xi]) { + const auto v = static_cast(edge_dst[static_cast(e)]); + if (h[v] != kNone && h[v] == h[xi] - 1) { + if (pick < 0 || e < pick) pick = e; + break; // ascending: the first qualifying edge of x is its smallest + } + } + } + if (pick < 0) break; + out.walk.push_back(pick); + const NodeId u = edge_dst[static_cast(pick)]; + if (is_dst[static_cast(u)] != 0) break; + frontier.assign(1, u); + } + if (out.walk.empty()) return std::nullopt; + Cost walk_cost = 0; + for (EdgeId e : out.walk) walk_cost += cost[static_cast(e)]; + if (walk_cost != out.cost) throw std::logic_error("canonical path: the walk is not tight"); + if (priority != nullptr) { + Priority b {0, 1, true}; + for (EdgeId e : out.walk) { + const Priority p = priority->of(e); + if (compare(p, b) < 0) b = p; + } + out.bottleneck = b; + } + return out; +} + +SpfOut spf(const StrictMultiDiGraph& g, std::span roots, Direction direction, + std::optional> targets, const PathSelection& selection, + std::optional> residual_arg, Amount min_residual, const Eligibility* elig) { + const auto root_set = node_set(roots, g.num_nodes(), "roots"); + std::vector target_set; + if (targets.has_value()) { + target_set = node_set(*targets, g.num_nodes(), "targets"); + check_disjoint(root_set, target_set); + } else if (!selection.multipath) { + throw std::invalid_argument("multipath = false requires targets (the single path joins a root to a target)"); + } + const auto m = static_cast(g.num_edges()); + const std::span residual = residual_arg.value_or(std::span {}); + if (residual_arg.has_value() && residual.size() != m) { + throw std::invalid_argument("residual has length " + std::to_string(residual.size()) + ", expected " + + std::to_string(m) + " (one entry per edge)"); + } + const auto capacity = g.capacity_units(); + for (std::size_t e = 0; e < residual.size(); ++e) { + if (residual[e] < 0 || residual[e] > capacity[e]) { + throw std::invalid_argument("residual[" + std::to_string(e) + "] = " + std::to_string(residual[e]) + + " is outside [0, capacity_units[" + std::to_string(e) + "] = " + + std::to_string(capacity[e]) + "]"); + } + } + if (min_residual < 1) { + throw std::invalid_argument("min_residual must be at least 1 unit (got " + std::to_string(min_residual) + ")"); + } + if (elig != nullptr && elig->graph_uid() != g.uid()) { + throw std::invalid_argument("foreign Eligibility: built for another graph"); + } + const auto member = member_mask(g, elig, selection, residual, min_residual); + std::vector live_roots; + for (NodeId r : root_set) { + if (elig == nullptr || elig->node_ok(r)) live_roots.push_back(r); + } + SpfOut out; + if (!targets.has_value()) { + auto dist = distances(g, member, live_roots, direction); + out.edges = full_dag(g, member, dist, direction); + out.costs = std::move(dist); + return out; + } + std::vector live_targets; + for (NodeId t : target_set) { + if (elig == nullptr || elig->node_ok(t)) live_targets.push_back(t); + } + out.target_cost = std::nullopt; + if (live_roots.empty() || live_targets.empty()) return out; + const auto& src = direction == Direction::Forward ? live_roots : live_targets; + const auto& dst = direction == Direction::Forward ? live_targets : live_roots; + if (selection.multipath) { + auto tight = tight_dag(g, member, src, dst); + if (tight.cost != kUnreachable) { + out.target_cost = tight.cost; + out.edges = std::move(tight.edges); + } + return out; + } + const PriorityValues prio {&g, selection.priority, residual}; + const bool prefer = selection.tie_break == EdgeTieBreak::PreferHigherPriority; + auto path = canonical_path(g, member, src, dst, prefer ? &prio : nullptr); + if (path.has_value()) { + out.target_cost = path->cost; + out.edges = path->walk; + std::sort(out.edges.begin(), out.edges.end()); + out.path = std::move(path); + } + return out; +} + +} // namespace netgraph::core::detail diff --git a/src/strict_multidigraph.cpp b/src/strict_multidigraph.cpp index 5d3c4f7..bdfca20 100644 --- a/src/strict_multidigraph.cpp +++ b/src/strict_multidigraph.cpp @@ -21,16 +21,23 @@ #include #include -namespace netgraph::core { +#include "netgraph/core/detail/uid.hpp" -namespace { +namespace netgraph::core { -std::atomic uid_counter {0}; +namespace detail { std::uint64_t next_uid() noexcept { - return uid_counter.fetch_add(1, std::memory_order_relaxed) + 1; + static std::atomic counter {0}; + return counter.fetch_add(1, std::memory_order_relaxed) + 1; } +} // namespace detail + +namespace { + +using detail::next_uid; + // The exponent k of a resolution q = 2^k with k in [-32, 32]. int resolution_exponent(double q) { if (!std::isfinite(q) || q <= 0.0) { @@ -282,4 +289,24 @@ Eligibility StrictMultiDiGraph::eligibility(std::span excluded_nod return el; } +Eligibility Eligibility::intersect(const Eligibility& a, const Eligibility& b) { + if (a.graph_uid_ != b.graph_uid_) { + throw std::invalid_argument("Eligibility.intersect: the two values belong to different graphs"); + } + Eligibility out; + out.graph_uid_ = a.graph_uid_; + out.node_allowed_.resize(a.node_allowed_.size()); + out.edge_allowed_.resize(a.edge_allowed_.size()); + for (std::size_t v = 0; v < out.node_allowed_.size(); ++v) { + out.node_allowed_[v] = static_cast(a.node_allowed_[v] & b.node_allowed_[v]); + } + for (std::size_t e = 0; e < out.edge_allowed_.size(); ++e) { + out.edge_allowed_[e] = static_cast(a.edge_allowed_[e] & b.edge_allowed_[e]); + } + if (out.node_allowed_ == a.node_allowed_ && out.edge_allowed_ == a.edge_allowed_) return a; + if (out.node_allowed_ == b.node_allowed_ && out.edge_allowed_ == b.edge_allowed_) return b; + out.uid_ = next_uid(); + return out; +} + } // namespace netgraph::core diff --git a/tests/cpp/flow_graph_tests.cpp b/tests/cpp/flow_graph_tests.cpp new file mode 100644 index 0000000..6107b24 --- /dev/null +++ b/tests/cpp/flow_graph_tests.cpp @@ -0,0 +1,668 @@ +// The integer ledger FlowGraph (APPROACH 8): the baseline, snapshots, FlowIds, bottleneck, and +// every row and transition of the lifetime table, driven through the internal entry points by +// the single-bundle Pinned transaction of sub-PR 1b (detail::PinnedFlow). +// +// | State | place | remove() | destroyed | reset | copy | +// | bound, no flows | place(0) no-op; positive | resets the epoch | none | rejected | rejected | +// | | creates (no route: nothing) | | | | | +// | bound, flows | adds or replaces | reverts, retires | orphaned | rejected | rejected | +// | orphaned records | n/a | n/a | n/a | clears | copied | +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/detail/ledger_access.hpp" +#include "netgraph/core/detail/pinned_flow.hpp" +#include "netgraph/core/detail/pred_dag_builder.hpp" +#include "netgraph/core/flow_graph.hpp" +#include "test_utils.hpp" + +using namespace netgraph::core; +using namespace netgraph::core::test; +using detail::LedgerAccess; +using detail::PinnedFlow; +using detail::Termination; + +namespace { + +std::shared_ptr shared(StrictMultiDiGraph g) { + return std::make_shared(std::move(g)); +} + +std::shared_ptr bundle(const StrictMultiDiGraph& g, std::vector edges) { + return std::make_shared(detail::PredDAGBuilder::from_edges(g, edges)); +} + +std::shared_ptr all_edges(const StrictMultiDiGraph& g) { + std::vector e(static_cast(g.num_edges())); + for (std::size_t i = 0; i < e.size(); ++i) e[i] = static_cast(i); + return bundle(g, e); +} + +std::string error_of(const std::function& fn) { + try { + fn(); + } catch (const std::invalid_argument& e) { + return e.what(); + } + return ""; +} + +std::vector alloc_in(const FlowGraph& fg) { return to_input(*fg.graph(), fg.allocated()); } + +} // namespace + +TEST(FlowGraph, BaselineValidationAndSnapshots) { + auto g = shared(make_graph(2, {{0, 1, 5, 1}})); + EXPECT_THROW(FlowGraph(g, std::vector {6}), std::invalid_argument); + EXPECT_THROW(FlowGraph(g, std::vector {-1}), std::invalid_argument); + EXPECT_THROW(FlowGraph(g, std::vector {1, 1}), std::invalid_argument); + EXPECT_THROW(FlowGraph(nullptr), std::invalid_argument); + FlowGraph fg(g, std::vector {3}); + EXPECT_EQ(fg.residual(), (std::vector{3})); + EXPECT_EQ(fg.allocated(), (std::vector{0})); + EXPECT_EQ(std::vector(fg.capacity_units().begin(), fg.capacity_units().end()), (std::vector{5})); + EXPECT_TRUE(fg.records().empty()); + EXPECT_EQ(fg.graph().get(), g.get()); + FlowGraph other(g); + EXPECT_NE(fg.uid(), other.uid()); +} + +TEST(FlowGraph, BoundNoFlowsRow) { + auto g = shared(make_graph(2, {{0, 1, 5, 1}})); + FlowGraph fg(g); + PinnedFlow p(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); + // place(0): a no-op that creates nothing. + auto r = p.place(0); + EXPECT_EQ(r.termination, Termination::Complete); + EXPECT_FALSE(r.routes_changed); + EXPECT_TRUE(fg.records().empty()); + EXPECT_EQ(LedgerAccess::next_flow_id(fg), 0); + // reset and copy rejected while a policy is bound; move rejected too. + EXPECT_NE(error_of([&] { fg.reset(); }).find("bound"), std::string::npos); + EXPECT_THROW(FlowGraph copy(fg), std::invalid_argument); + EXPECT_THROW(FlowGraph moved(std::move(fg)), std::invalid_argument); + // remove() with no flows: no allocation to revert; the epoch state resets. + p.remove(); + EXPECT_TRUE(fg.records().empty()); + EXPECT_FALSE(p.infinite()); + EXPECT_TRUE(p.requested().is_zero()); + // A positive request creates the cohort (new id), routes_changed true. + r = p.place(2); + EXPECT_EQ(r.placed, 2); + EXPECT_TRUE(r.routes_changed); + ASSERT_TRUE(p.flow_id().has_value()); + EXPECT_EQ(p.flow_id()->second, 0); +} + +TEST(FlowGraph, NoRouteCreatesNothing) { + // A bundle whose only walk is excluded by the Eligibility: usable_bundles() == 0. + auto g = shared(make_graph(2, {{0, 1, 5, 1}})); + FlowGraph fg(g); + auto el = std::make_shared(g->eligibility({}, std::vector {0})); + PinnedFlow p(fg, 0, 1, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual, el); + EXPECT_EQ(p.usable_bundles(), 0); + auto r = p.place(3); + EXPECT_EQ(r.termination, Termination::NoRoute); + EXPECT_EQ(r.placed, 0); + ASSERT_TRUE(r.remaining.has_value()); + EXPECT_EQ(*r.remaining, 3); + EXPECT_FALSE(r.routes_changed); + EXPECT_TRUE(fg.records().empty()); + EXPECT_EQ(LedgerAccess::next_flow_id(fg), 0); + // A bundle without any walk before pruning is malformed. + auto g2 = shared(make_graph(3, {{0, 1, 5, 1}, {2, 1, 5, 1}})); + FlowGraph fg2(g2); + EXPECT_NE(error_of([&] { + PinnedFlow bad(fg2, 0, 2, all_edges(*g2), FlowPlacement::MaxFlow, Admission::Lossless, + EdgePriority::Residual); + }).find("malformed bundle"), + std::string::npos); +} + +TEST(FlowGraph, BoundWithFlowsRowAndEpochs) { + auto g = shared(make_graph(2, {{0, 1, 5, 1}})); + FlowGraph fg(g); + PinnedFlow p(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); + EXPECT_EQ(p.place(2).placed, 2); + // Adds: T grows within the epoch. + auto r = p.place(std::nullopt); + EXPECT_EQ(r.placed, 3); + EXPECT_EQ(r.termination, Termination::Exhausted); + EXPECT_FALSE(r.remaining.has_value()); + EXPECT_TRUE(p.infinite()); + // A finite request after +inf is rejected within the epoch, place(0) included (validation + // precedes the zero shortcut); the state is unchanged. + EXPECT_NE(error_of([&] { (void)p.place(1); }).find("after +inf"), std::string::npos); + EXPECT_NE(error_of([&] { (void)p.place(0); }).find("after +inf"), std::string::npos); + EXPECT_EQ(alloc_in(fg), (std::vector{5})); + EXPECT_NE(error_of([&] { fg.reset(); }).find("bound"), std::string::npos); + EXPECT_THROW(FlowGraph copy(fg), std::invalid_argument); + // remove(): allocations reverted, record gone, id retired. + const auto old = *p.flow_id(); + p.remove(); + EXPECT_EQ(alloc_in(fg), (std::vector{0})); + EXPECT_TRUE(fg.records().empty()); + EXPECT_NE(error_of([&] { (void)fg.record(old); }).find("retired or unknown"), std::string::npos); + // place(+inf); remove(); place(1) is accepted: a new epoch with a new id. + r = p.place(1); + EXPECT_EQ(r.placed, 1); + EXPECT_TRUE(r.routes_changed); + EXPECT_EQ(p.flow_id()->second, 1); + p.remove(); + p.remove(); // on an empty cohort: resets the epoch + EXPECT_TRUE(p.requested().is_zero()); + EXPECT_FALSE(p.infinite()); + EXPECT_EQ(LedgerAccess::next_flow_id(fg), 2); +} + +TEST(FlowGraph, OrphanedRecordsResetAndCopy) { + auto g = shared(make_graph(2, {{0, 1, 5, 1}})); + FlowGraph fg(g); + auto p = std::make_unique(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, + EdgePriority::Residual); + { + PinnedFlow q(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); + EXPECT_EQ(q.place(2).placed, 2); + } // destroyed without remove(): the record stays as an orphan + ASSERT_EQ(fg.records().size(), 1u); + EXPECT_EQ(fg.records()[0].delivered, 2); + EXPECT_EQ(LedgerAccess::orphan_owners(fg), (std::vector{1})); + EXPECT_EQ(LedgerAccess::bound_owners(fg), (std::vector{0})); + // Another policy sees the orphan's allocation (it is never changed again). + EXPECT_EQ(p->place(5).placed, 3); + EXPECT_NE(error_of([&] { fg.reset(); }).find("bound"), std::string::npos); + p->remove(); + p.reset(); // destroyed: nothing bound + // Copy of an unbound ledger: new uid, same ids under the new uid. + FlowGraph c(fg); + EXPECT_NE(c.uid(), fg.uid()); + ASSERT_EQ(c.records().size(), 1u); + EXPECT_EQ(c.records()[0].flow_id, (FlowId {c.uid(), 0})); + EXPECT_EQ(c.records()[0].delivered, 2); + EXPECT_EQ(c.allocated(), fg.allocated()); + // reset: orphans and their allocations cleared, ids retired (never reused), new baseline. + fg.reset(std::vector {4}); + EXPECT_TRUE(fg.records().empty()); + EXPECT_EQ(fg.residual(), (std::vector{4})); + EXPECT_TRUE(LedgerAccess::orphan_owners(fg).empty()); + EXPECT_EQ(LedgerAccess::next_flow_id(fg), 2); + PinnedFlow r(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); + EXPECT_EQ(r.place(9).placed, 4); + EXPECT_EQ(r.flow_id()->second, 2); + // The copy is independent of the original. + EXPECT_EQ(c.records()[0].delivered, 2); +} + +TEST(FlowGraph, MovedFromLedgerIsUnusable) { + auto g = shared(make_graph(2, {{0, 1, 5, 1}})); + FlowGraph fg(g); + FlowGraph moved(std::move(fg)); + EXPECT_THROW((void)fg.uid(), std::logic_error); + EXPECT_THROW((void)fg.records(), std::logic_error); + EXPECT_THROW((void)fg.residual(), std::logic_error); + EXPECT_THROW(fg.reset(), std::logic_error); + EXPECT_THROW((void)LedgerAccess::bind(fg), std::logic_error); + EXPECT_EQ(moved.residual(), (std::vector{5})); +} + +TEST(FlowGraph, FlowIdsOfOtherLedgersAndUnknownIds) { + auto g = shared(make_graph(2, {{0, 1, 5, 1}})); + FlowGraph a(g), b(g); + PinnedFlow p(a, 0, 1, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + (void)p.place(1); + const auto id = *p.flow_id(); + EXPECT_EQ(a.record(id).delivered, 1); + EXPECT_NE(error_of([&] { (void)b.record(id); }).find("another ledger"), std::string::npos); + EXPECT_NE(error_of([&] { (void)a.record(FlowId {a.uid(), 7}); }).find("retired or unknown"), std::string::npos); +} + +TEST(FlowGraph, RecordsCarryTheContractFields) { + // Lossy EqualBalanced on the diamond 10/10/1/10 (prototype 1 lossy_diamond): 10 then 1. + auto g = shared(make_graph(4, {{0, 1, 10, 1}, {0, 2, 10, 1}, {1, 3, 1, 1}, {2, 3, 10, 1}})); + const auto inv = id_of_input(*g); + FlowGraph fg(g); + PinnedFlow p(fg, 0, 3, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossy, EdgePriority::Residual); + auto r = p.place(10); + EXPECT_EQ(r.placed, 6); + EXPECT_EQ(r.termination, Termination::Complete); + EXPECT_EQ(r.drops, (std::vector>{{inv[2], 4}})); + r = p.place(1); + EXPECT_EQ(r.placed, 0); + EXPECT_EQ(r.termination, Termination::Complete); + EXPECT_EQ(*r.remaining, 1); + EXPECT_EQ(alloc_in(fg), (std::vector{6, 5, 1, 5})); + const auto rec = fg.records().at(0); + EXPECT_EQ(rec.flow_id, (FlowId {fg.uid(), 0})); + EXPECT_EQ(rec.owner, 0); + EXPECT_EQ(rec.sources, (std::vector{0})); + EXPECT_EQ(rec.destinations, (std::vector{3})); + EXPECT_EQ(rec.placement, FlowPlacement::EqualBalanced); + EXPECT_EQ(rec.admission, Admission::Lossy); + ASSERT_TRUE(rec.bundle); + EXPECT_EQ(rec.bundle->edges(), (std::vector{0, 1, 2, 3})); + EXPECT_EQ(rec.route_cost, 2); + ASSERT_TRUE(rec.offered.has_value()); + EXPECT_EQ(rec.offered->lo, 11u); + EXPECT_EQ(rec.delivered, 6); + ASSERT_EQ(rec.drops.size(), 1u); + EXPECT_EQ(rec.drops[0].first, inv[2]); + EXPECT_EQ(rec.drops[0].second.lo, 5u); + // +inf on a fresh ledger: offered is the injection (20), delivered 11, drops 9. + FlowGraph f2(g); + PinnedFlow q(f2, 0, 3, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossy, EdgePriority::Residual); + r = q.place(std::nullopt); + EXPECT_EQ(r.placed, 11); + EXPECT_EQ(r.termination, Termination::Exhausted); + EXPECT_EQ(f2.records()[0].offered->lo, 20u); + // Lossless records carry no offered. + FlowGraph f3(g); + PinnedFlow m(f3, 0, 3, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + (void)m.place(3); + EXPECT_FALSE(f3.records()[0].offered.has_value()); + EXPECT_TRUE(f3.records()[0].drops.empty()); +} + +TEST(FlowGraph, CountersBeyondSixtyFourBits) { + // Prototype 1 lossy_oversized_and_saturated_offers: every unit of a 2^62 - 1 offer dropped on + // a saturated link, many times: offered and drops grow beyond 2^64. + auto g = shared(make_graph(2, {{0, 1, 1, 1}})); + FlowGraph fg(g); + PinnedFlow p(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossy, EdgePriority::Residual); + const Amount big = kDomainLimit - 1; + for (int i = 0; i < 9; ++i) (void)p.place(big); + const auto rec = fg.records().at(0); + // offered = 9 (2^62 - 1) = 2^65 + 2^62 - 9 + EXPECT_EQ(rec.offered->hi, 2u); + EXPECT_EQ(rec.offered->lo, (std::uint64_t {1} << 62) - 9); + EXPECT_EQ(rec.delivered, 1); + EXPECT_EQ(rec.drops[0].second.hi, 2u); + EXPECT_EQ(rec.drops[0].second.lo, (std::uint64_t {1} << 62) - 10); + EXPECT_EQ(p.requested().hi, 2u); +} + +TEST(FlowGraph, RequestDomainAndFailedCallsLeaveState) { + auto g = shared(make_graph(2, {{0, 1, 5, 1}})); + FlowGraph fg(g); + PinnedFlow p(fg, 0, 1, all_edges(*g), FlowPlacement::Weighted, Admission::Lossless, EdgePriority::Capacity); + (void)p.place(2); + const auto before = fg.records(); + EXPECT_THROW((void)p.place(kDomainLimit), std::invalid_argument); + EXPECT_THROW((void)p.place(-1), std::invalid_argument); + EXPECT_EQ(fg.records().size(), before.size()); + EXPECT_EQ(fg.allocated(), (std::vector{2})); + // Constructor validation. + auto other = make_graph(2, {{0, 1, 5, 1}}); + EXPECT_NE(error_of([&] { + PinnedFlow x(fg, 0, 1, bundle(other, {0}), FlowPlacement::MaxFlow, Admission::Lossless, + EdgePriority::Residual); + }).find("foreign PredDAG"), + std::string::npos); + EXPECT_NE(error_of([&] { + PinnedFlow x(fg, 0, 0, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + }).find("both endpoint sets"), + std::string::npos); + EXPECT_NE(error_of([&] { + PinnedFlow x(fg, 0, 1, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossy, EdgePriority::Residual); + }).find("Lossy"), + std::string::npos); + EXPECT_NE(error_of([&] { + PinnedFlow x(fg, 0, 1, all_edges(*g), FlowPlacement::Weighted, Admission::Lossless, EdgePriority::Headroom); + }).find("Headroom"), + std::string::npos); + // A rejected construction binds nothing. + EXPECT_EQ(LedgerAccess::bound_owners(fg), (std::vector{0})); +} + +TEST(FlowGraph, BottleneckVersusMinCut) { + // Prototype 1 ledger_bottleneck_vs_min_cut: (0->1 cap 1 cost 1), (0->2 cap 2 cost 3), + // (1->2 cap 1), (2->3 cap 2). After the MaxFlow +inf [1, 1, 1, 2] the bottleneck (forward + // residual) is {0->1, 2->3}, not the cut {2->3}. + auto g = shared(make_graph(4, {{0, 1, 1, 1}, {0, 2, 2, 3}, {1, 2, 1, 1}, {2, 3, 2, 1}})); + const auto inv = id_of_input(*g); + FlowGraph fg(g); + PinnedFlow p(fg, 0, 3, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + EXPECT_EQ(p.place(std::nullopt).placed, 2); + EXPECT_EQ(alloc_in(fg), (std::vector{1, 1, 1, 2})); + std::vector want = {inv[0], inv[3]}; + std::sort(want.begin(), want.end()); + EXPECT_EQ(fg.bottleneck(std::vector {0}), want); + // A zero-capacity edge at the frontier is not listed; an excluded edge is not crossed. + auto g2 = shared(make_graph(3, {{0, 1, 0, 1}, {0, 2, 1, 1}, {2, 1, 1, 1}})); + FlowGraph f2(g2); + EXPECT_TRUE(f2.bottleneck(std::vector {0}).empty()); + const auto inv2 = id_of_input(*g2); + auto el = g2->eligibility({}, std::vector {inv2[2]}); + EXPECT_TRUE(f2.bottleneck(std::vector {0}, &el).empty()); // 2->1 excluded: not listed + // An excluded source contributes nothing; a foreign Eligibility throws. + const std::vector xn = {0}; + auto ex = g2->eligibility(xn, {}); + EXPECT_TRUE(f2.bottleneck(std::vector {0}, &ex).empty()); + auto other = make_graph(3, {{0, 1, 1, 1}}); + auto foreign = other.eligibility({}, std::vector {0}); + EXPECT_THROW((void)f2.bottleneck(std::vector {0}, &foreign), std::invalid_argument); + EXPECT_THROW((void)f2.bottleneck(std::vector {}), std::invalid_argument); +} + +TEST(FlowGraph, TwoOwnersNeverCancelEachOther) { + // Prototype 1 maxflow_two_owner_no_cross_cancellation: A holds the chain 0-1-2-3; B's bundle + // (chain plus bypasses) could reach 2 only by cancelling A's middle edge: B places 0 (record + // at zero delivery, exhausted) and A is untouched; after A's remove() B places 2. + auto g = shared(make_graph(4, {{0, 1, 1, 1}, {1, 2, 1, 1}, {2, 3, 1, 1}, {0, 2, 1, 3}, {1, 3, 1, 3}})); + const auto inv = id_of_input(*g); + FlowGraph fg(g); + PinnedFlow a(fg, 0, 3, bundle(*g, {inv[0], inv[1], inv[2]}), FlowPlacement::MaxFlow, Admission::Lossless, + EdgePriority::Residual); + EXPECT_EQ(a.place(1).placed, 1); + PinnedFlow b(fg, 0, 3, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + auto r = b.place(2); + EXPECT_EQ(r.placed, 0); + EXPECT_TRUE(r.routes_changed); + EXPECT_EQ(r.termination, Termination::Exhausted); + EXPECT_EQ(fg.record(*b.flow_id()).delivered, 0); + EXPECT_EQ(alloc_in(fg), (std::vector{1, 1, 1, 0, 0})); + a.remove(); + r = b.place(2); + EXPECT_EQ(r.placed, 2); + EXPECT_EQ(alloc_in(fg), (std::vector{1, 0, 1, 1, 1})); +} + +TEST(FlowGraph, AggregatesEqualTheSumOfRecords) { + // Random sequences of placements, removals and destructions on one ledger: allocated() is the + // exact sum of the records' edges and residual() = baseline - allocated() within [0, baseline]. + std::mt19937 rng(5); + for (int trial = 0; trial < 60; ++trial) { + const int n = 4 + static_cast(rng() % 3); + std::vector edges; + for (int i = 0; i < 3 * n; ++i) { + const NodeId u = static_cast(rng() % (n - 1)); + const NodeId v = static_cast(u + 1 + rng() % static_cast(n - 1 - u)); + edges.push_back({u, v, static_cast(rng() % 7), 1 + static_cast(rng() % 3)}); + } + auto g = shared(make_graph(n, edges)); + FlowGraph fg(g); + std::vector> pols; + const FlowPlacement kinds[3] = {FlowPlacement::MaxFlow, FlowPlacement::Weighted, FlowPlacement::EqualBalanced}; + for (int k = 0; k < 4; ++k) { + const auto kind = kinds[rng() % 3]; + const auto adm = kind != FlowPlacement::MaxFlow && rng() % 3 == 0 ? Admission::Lossy : Admission::Lossless; + try { + pols.push_back(std::make_unique(fg, 0, n - 1, all_edges(*g), kind, adm, EdgePriority::Capacity)); + } catch (const std::invalid_argument&) { + break; // no walk at all + } + } + for (int step = 0; step < 12 && !pols.empty(); ++step) { + auto& p = pols[rng() % pols.size()]; + if (!p) continue; + const int op = static_cast(rng() % 6); + if (op == 0) { + p->remove(); + } else if (op == 1) { + p.reset(); + } else if (!p->infinite()) { + (void)p->place(op == 2 ? std::optional() : std::optional(rng() % 9)); + } + std::vector sum(static_cast(g->num_edges()), 0); + for (const auto& rec : fg.records()) { + for (const auto& [e, x] : rec.edges) sum[static_cast(e)] += x; + } + ASSERT_EQ(fg.allocated(), sum); + const auto res = fg.residual(); + for (std::size_t e = 0; e < res.size(); ++e) { + ASSERT_GE(res[e], 0); + ASSERT_EQ(res[e] + sum[e], g->capacity_units()[e]); + } + } + } +} + +TEST(FlowGraph, NoRouteCallsRecordTheEpochState) { + // A positive request with no usable bundle creates nothing, but the epoch records it: after + // +inf a finite request (place(0) included) is rejected until remove() (APPROACH 8, 9.2; + // prototype 2's reading; prototype 1 kept no state on such a call). + auto g = shared(make_graph(2, {{0, 1, 5, 1}})); + FlowGraph fg(g); + auto el = std::make_shared(g->eligibility({}, std::vector {0})); + PinnedFlow p(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual, + el); + EXPECT_EQ(p.place(3).termination, Termination::NoRoute); + EXPECT_EQ(p.requested().lo, 3u); + auto r = p.place(std::nullopt); + EXPECT_EQ(r.termination, Termination::NoRoute); + EXPECT_FALSE(r.remaining.has_value()); + EXPECT_TRUE(p.infinite()); + EXPECT_NE(error_of([&] { (void)p.place(1); }).find("after +inf"), std::string::npos); + EXPECT_NE(error_of([&] { (void)p.place(0); }).find("after +inf"), std::string::npos); + p.remove(); + EXPECT_FALSE(p.infinite()); + EXPECT_EQ(p.place(1).termination, Termination::NoRoute); + EXPECT_TRUE(fg.records().empty()); + EXPECT_EQ(LedgerAccess::next_flow_id(fg), 0); +} + +TEST(FlowGraph, RouteCostIsTheBundlesMinimumWalk) { + // Weighted Residual on a route whose only edge another owner fills: the split graph is + // empty (record at zero delivery), route_cost is still the bundle's walk cost 3. + auto g = shared(make_graph(2, {{0, 1, 2, 3}})); + FlowGraph fg(g); + PinnedFlow holder(fg, 0, 1, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + EXPECT_EQ(holder.place(2).placed, 2); + PinnedFlow w(fg, 0, 1, all_edges(*g), FlowPlacement::Weighted, Admission::Lossless, EdgePriority::Residual); + auto r = w.place(1); + EXPECT_EQ(r.placed, 0); + EXPECT_EQ(r.termination, Termination::Exhausted); + EXPECT_TRUE(r.routes_changed); + ASSERT_NE(w.plan(), nullptr); + EXPECT_TRUE(w.plan()->members.empty()); + const auto rec = fg.record(*w.flow_id()); + EXPECT_EQ(rec.route_cost, 3); + EXPECT_EQ(rec.delivered, 0); + // A mixed-cost bundle: the cheapest walk (1 + 1 + 1) is the route cost, not the cost-4 edge. + auto g2 = shared(make_graph(4, {{0, 1, 1, 1}, {1, 2, 1, 1}, {2, 3, 1, 1}, {0, 3, 1, 4}})); + FlowGraph f2(g2); + PinnedFlow m(f2, 0, 3, all_edges(*g2), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + (void)m.place(1); + EXPECT_EQ(f2.records()[0].route_cost, 3); +} + +TEST(FlowGraph, RemovalRestoresTheBaselineAndLeavesOtherOwnersIntact) { + // review-2 R7: removing a flow restores the baseline residual (5 on a capacity-10 link), not + // the capacity; review-check check_review6: another owner's allocation stays intact. + auto g = shared(make_graph(2, {{0, 1, 10, 1}})); + FlowGraph fg(g, std::vector {5}); + PinnedFlow a(fg, 0, 1, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + PinnedFlow b(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); + EXPECT_EQ(a.place(3).placed, 3); + EXPECT_EQ(b.place(1).placed, 1); + EXPECT_EQ(fg.residual(), (std::vector{1})); + a.remove(); + EXPECT_EQ(fg.residual(), (std::vector{4})); + EXPECT_EQ(fg.record(*b.flow_id()).edges, (std::vector>{{0, 1}})); + b.remove(); + EXPECT_EQ(fg.residual(), (std::vector{5})); + // A baseline one unit above the quantised capacity is rejected (no clamp at this boundary). + EXPECT_THROW(FlowGraph(g, std::vector {11}), std::invalid_argument); +} + +TEST(FlowGraph, LossyOffersArePerCall) { + // review-13 lossy / rule 19: on the diamond, two calls of 1 offer each unit to the lowest + // EdgeId first (both on the first branch) while one call of 2 splits [1, 1]: not additive. + auto g = shared(make_graph(4, {{0, 1, 10, 1}, {0, 2, 10, 1}, {1, 3, 1, 1}, {2, 3, 10, 1}})); + FlowGraph f1(g), f2(g); + PinnedFlow p1(f1, 0, 3, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossy, EdgePriority::Residual); + PinnedFlow p2(f2, 0, 3, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossy, EdgePriority::Residual); + auto r = p1.place(1); + EXPECT_EQ(r.placed, 1); + r = p1.place(1); + EXPECT_EQ(r.placed, 0); // the tail 1->3 is full: the unit is dropped there + EXPECT_EQ(r.termination, Termination::Complete); + EXPECT_EQ(alloc_in(f1), (std::vector{2, 0, 1, 0})); + r = p2.place(2); + EXPECT_EQ(r.placed, 2); + EXPECT_EQ(alloc_in(f2), (std::vector{1, 1, 1, 1})); +} + +// review-1b-fixes N2: a commit carrying several records of one owner touches an edge from more +// than one record; its delta may pass through zero while it accumulates. Each edge's delta is +// published once. +namespace { + +detail::Draft draft_on(const std::shared_ptr& dag, std::vector> edges, + std::optional id = std::nullopt) { + detail::Draft d; + d.id = id; + d.sources = {0}; + d.destinations = {1}; + d.bundle = dag; + d.route_cost = 1; + for (const auto& [e, x] : edges) d.delivered += x; + d.edges = std::move(edges); + return d; +} + +Amount record_sum(const FlowGraph& fg, EdgeId e) { + Amount sum = 0; + for (const auto& r : fg.records()) { + for (const auto& [f, x] : r.edges) { + if (f == e) sum += x; + } + } + return sum; +} + +} // namespace + +TEST(FlowGraph, MultiRecordCommitsPublishEachEdgeOnce) { + auto g = shared(make_graph(2, {{0, 1, 3, 1}})); + const auto dag = all_edges(*g); + FlowGraph fg(g); + const auto owner = LedgerAccess::bind(fg); + std::vector first {draft_on(dag, {{0, 1}})}; + const auto ids = LedgerAccess::commit(fg, owner, first); + // The record kept unchanged (-1, then +1: the delta passes through 0) plus a new 2-unit record. + std::vector next {draft_on(dag, {{0, 1}}, ids[0].second), draft_on(dag, {{0, 2}})}; + EXPECT_EQ(LedgerAccess::commit(fg, owner, next).size(), 1U); + EXPECT_EQ(fg.allocated(), (std::vector{3})); + EXPECT_EQ(fg.residual(), (std::vector{0})); + EXPECT_EQ(record_sum(fg, 0), 3); + // Over capacity through the same pattern: rejected, nothing changed. + const auto held = fg.records(); + const auto live = LedgerAccess::bound_owners(fg); + std::vector over {draft_on(dag, {{0, 1}}, ids[0].second), draft_on(dag, {{0, 2}}, ids[0].second + 1), + draft_on(dag, {{0, 1}})}; + EXPECT_THROW((void)LedgerAccess::commit(fg, owner, over), std::logic_error); + EXPECT_EQ(fg.allocated(), (std::vector{3})); + EXPECT_EQ(fg.records().size(), held.size()); + EXPECT_EQ(LedgerAccess::next_flow_id(fg), 2); + // A kept record gives its unit up while a new record takes the edge: published once. + std::vector moved {draft_on(dag, {}, ids[0].second), draft_on(dag, {{0, 2}}, ids[0].second + 1), + draft_on(dag, {{0, 1}})}; + EXPECT_EQ(LedgerAccess::commit(fg, owner, moved).size(), 1U); + EXPECT_EQ(fg.allocated(), (std::vector{3})); + EXPECT_EQ(record_sum(fg, 0), 3); + EXPECT_EQ(LedgerAccess::detach(fg, owner).size(), 3U); + EXPECT_EQ(fg.allocated(), (std::vector{0})); + LedgerAccess::release(fg, owner); +} + +TEST(FlowGraph, RandomMultiOwnerCommitsMatchAModel) { + // Three owners on four edges; every commit carries all of its owner's records (each kept, + // changed, emptied or moved) plus up to two new ones, so deltas cancel and reappear on shared + // edges; detaches between commits. The ledger must equal an independent model after every call, + // and a rejected commit must change nothing. + std::mt19937 rng(11); + auto g = shared(make_graph(4, {{0, 1, 6, 1}, {1, 2, 4, 1}, {2, 3, 5, 1}, {0, 3, 3, 1}})); + const auto dag = all_edges(*g); + const auto m = static_cast(g->num_edges()); + FlowGraph fg(g); + std::vector owners; + for (int i = 0; i < 3; ++i) owners.push_back(LedgerAccess::bind(fg)); + // The model: per owner, its live records (id -> per-edge amounts). + std::vector>> model(owners.size()); + std::int64_t next_id = 0; + int accepted = 0; + int rejected = 0; + auto random_edges = [&] { + std::vector a(m, 0); + for (std::size_t e = 0; e < m; ++e) a[e] = (rng() % 3) == 0 ? static_cast(rng() % 3) : 0; + return a; + }; + auto as_pairs = [](const std::vector& a) { + std::vector> out; + for (std::size_t e = 0; e < a.size(); ++e) { + if (a[e] != 0) out.emplace_back(static_cast(e), a[e]); + } + return out; + }; + for (int step = 0; step < 3000; ++step) { + const std::size_t o = rng() % owners.size(); + if ((rng() % 10) == 0) { + (void)LedgerAccess::detach(fg, owners[o]); + model[o].clear(); + } else { + std::vector drafts; + std::map> after; + for (const auto& [id, a] : model[o]) { + const auto kind = rng() % 4; // kept, changed, emptied, moved to random edges + std::vector b = a; + if (kind == 1) { + b[rng() % m] += 1; + } else if (kind == 2) { + b.assign(m, 0); + } else if (kind == 3) { + b = random_edges(); + } + drafts.push_back(draft_on(dag, as_pairs(b), id)); + after[id] = b; + } + const int fresh = static_cast(rng() % 3); + for (int k = 0; k < fresh; ++k) { + const auto b = random_edges(); + drafts.push_back(draft_on(dag, as_pairs(b))); + after[next_id + k] = b; + } + // Feasible exactly when every edge's new total stays within its capacity. + std::vector total(m, 0); + for (std::size_t q = 0; q < owners.size(); ++q) { + for (const auto& [id, a] : q == o ? after : model[q]) { + for (std::size_t e = 0; e < m; ++e) total[e] += a[e]; + } + } + bool feasible = true; + for (std::size_t e = 0; e < m; ++e) feasible = feasible && total[e] <= g->capacity_units()[e]; + const auto before = fg.allocated(); + const auto before_records = fg.records().size(); + if (feasible) { + EXPECT_EQ(LedgerAccess::commit(fg, owners[o], drafts).size(), static_cast(fresh)); + model[o] = after; + next_id += fresh; + ++accepted; + } else { + EXPECT_THROW((void)LedgerAccess::commit(fg, owners[o], drafts), std::logic_error); + EXPECT_EQ(fg.allocated(), before); + EXPECT_EQ(fg.records().size(), before_records); + ++rejected; + } + } + std::vector want(m, 0); + for (const auto& recs : model) { + for (const auto& [id, a] : recs) { + for (std::size_t e = 0; e < m; ++e) want[e] += a[e]; + } + } + ASSERT_EQ(fg.allocated(), want) << "step " << step; + for (std::size_t e = 0; e < m; ++e) ASSERT_EQ(record_sum(fg, static_cast(e)), want[e]); + ASSERT_EQ(LedgerAccess::next_flow_id(fg), next_id); + } + EXPECT_GT(accepted, 1000); + EXPECT_GT(rejected, 100); + for (auto owner : owners) LedgerAccess::release(fg, owner); +} diff --git a/tests/cpp/kernels_tests.cpp b/tests/cpp/kernels_tests.cpp new file mode 100644 index 0000000..5687699 --- /dev/null +++ b/tests/cpp/kernels_tests.cpp @@ -0,0 +1,664 @@ +// Apportionment and the placement kernels in units (APPROACH 2.1, 7, 16): the closed-form +// highest averages against the sequential definition, house monotonicity and the Hamilton hole +// control, plans (membership by kernel and priority, split-graph pruning), alloc(T) at every +// split, exact T_max, the Lossless steps of MaxFlow (replacement on V = W + own, the +// cancellation DAG, the equal-optimum and cancel-upstream controls, the release witness, the +// mixed-cost bundle), Weighted and EqualBalanced, and the Lossy steps (drops, +inf injection). +// Expected values are the closed forms and the prototype-1 fixtures named in each test. +#include + +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/detail/kernels.hpp" +#include "netgraph/core/detail/pred_dag_builder.hpp" +#include "test_utils.hpp" + +using namespace netgraph::core; +using namespace netgraph::core::test; +using namespace netgraph::core::detail; + +namespace { + +std::vector rr(Amount total, std::size_t k) { + std::vector out(k); + round_robin(total, out); + return out; +} + +std::vector ha(Amount total, std::vector w) { + std::vector out(w.size()); + highest_averages(total, w, out); + return out; +} + +// The sequential D'Hondt definition: each unit to the member maximising w / (a + 1), ties to +// the lower index. +std::vector ha_slow(Amount total, const std::vector& w) { + std::vector a(w.size(), 0); + for (Amount u = 0; u < total; ++u) { + std::size_t best = 0; + for (std::size_t i = 1; i < w.size(); ++i) { + if (static_cast<__int128>(w[i]) * (a[best] + 1) > static_cast<__int128>(w[best]) * (a[i] + 1)) best = i; + } + ++a[best]; + } + return a; +} + +// Hamilton (largest remainders, rejected): a control only. +std::vector hamilton(Amount total, const std::vector& w) { + const Amount sum = std::accumulate(w.begin(), w.end(), Amount {0}); + std::vector a(w.size()); + std::vector order(w.size()); + for (std::size_t i = 0; i < w.size(); ++i) { + a[i] = total * w[i] / sum; + order[i] = i; + } + std::stable_sort(order.begin(), order.end(), + [&](std::size_t x, std::size_t y) { return (total * w[x]) % sum > (total * w[y]) % sum; }); + Amount left = total - std::accumulate(a.begin(), a.end(), Amount {0}); + for (std::size_t i = 0; i < order.size() && left > 0; ++i, --left) ++a[order[i]]; + return a; +} + +// Member-aligned view of a dense per-edge array. +std::vector on_members(const KernelPlan& plan, const std::vector& dense) { + std::vector out; + for (EdgeId e : plan.members) out.push_back(dense[static_cast(e)]); + return out; +} + +// A member-aligned allocation as a dense per-edge array in input coordinates. +std::vector dense_input(const StrictMultiDiGraph& g, const KernelPlan& plan, const std::vector& alloc_m) { + std::vector by_id(static_cast(g.num_edges()), 0); + for (std::size_t m = 0; m < plan.members.size(); ++m) by_id[static_cast(plan.members[m])] = alloc_m[m]; + return to_input(g, by_id); +} + +std::vector all_ids(const StrictMultiDiGraph& g) { + std::vector out(static_cast(g.num_edges())); + std::iota(out.begin(), out.end(), 0); + return out; +} + +KernelPlan plan_of(const StrictMultiDiGraph& g, FlowPlacement p, NodeId src, std::vector dsts, + std::vector route, const std::vector& view, + EdgePriority prio = EdgePriority::Residual, Admission adm = Admission::Lossless) { + std::sort(route.begin(), route.end()); + std::vector on_route; // the creation view is route-aligned + for (EdgeId e : route) on_route.push_back(view[static_cast(e)]); + return make_plan(g, p, adm, prio, src, dsts, route, on_route); +} + +std::vector caps(const StrictMultiDiGraph& g) { + return std::vector(g.capacity_units().begin(), g.capacity_units().end()); +} + +// One Lossless step on a fresh flow at T = 0. +LosslessStep first_step(const StrictMultiDiGraph& g, const KernelPlan& plan, const std::vector& view, + std::optional request) { + const auto v = on_members(plan, view); + const std::vector own(plan.members.size(), 0); + return lossless_step(g, plan, 0, v, own, request); +} + +} // namespace + +TEST(Apportion, RoundRobinAndReviewValues) { + EXPECT_EQ(rr(7, 3), (std::vector{3, 2, 2})); + EXPECT_EQ(rr(1, 3), (std::vector{1, 0, 0})); + EXPECT_EQ(rr(0, 4), (std::vector{0, 0, 0, 0})); + const Amount big = kDomainLimit - 1; + EXPECT_EQ(rr(big, 3), (std::vector{big / 3, big / 3, big / 3})); + EXPECT_EQ(rr(big - 1, 3), (std::vector{big / 3, big / 3, big / 3 - 1})); + EXPECT_EQ(ha(10, {3, 9, 9}), (std::vector{1, 5, 4})); + EXPECT_EQ(ha(11, {3, 9, 9}), (std::vector{1, 5, 5})); + EXPECT_EQ(ha(4, {1, 3, 3}), (std::vector{0, 2, 2})); + EXPECT_EQ(ha(7, {1, 3, 3}), (std::vector{1, 3, 3})); +} + +TEST(Apportion, ClosedFormEqualsSequentialAndIsHouseMonotone) { + std::mt19937 rng(7); + int cases = 0; + for (int k = 1; k <= 20; ++k) { + for (int trial = 0; trial < 40; ++trial) { + std::vector w(static_cast(k)); + for (auto& x : w) x = 1 + static_cast(rng() % (trial < 20 ? 5 : 1000)); + std::vector prev(w.size(), 0); + for (Amount total = 0; total <= 60; ++total) { + const auto a = ha(total, w); + ASSERT_EQ(a, ha_slow(total, w)) << "k " << k << " total " << total; + for (std::size_t i = 0; i < a.size(); ++i) ASSERT_GE(a[i], prev[i]); + prev = a; + ++cases; + } + } + } + // Equal weights reduce to round robin. + for (std::size_t k = 1; k <= 9; ++k) { + for (Amount total = 0; total <= 40; ++total) EXPECT_EQ(ha(total, std::vector(k, 7)), rr(total, k)); + } + EXPECT_GT(cases, 40000); +} + +TEST(Apportion, HighestAveragesNearTheDomainBound) { + const std::vector w = {(Amount {1} << 60) + 1, (Amount {1} << 60) - 1, 7}; + const Amount total = kDomainLimit - 2; + const auto a = ha(total, w); + EXPECT_EQ(std::accumulate(a.begin(), a.end(), Amount {0}), total); + // Boundary property: every selected quotient w_i / a_i outranks every unselected one + // w_j / (a_j + 1) (ties by the lower index). + for (std::size_t i = 0; i < w.size(); ++i) { + if (a[i] == 0) continue; + for (std::size_t j = 0; j < w.size(); ++j) { + const __int128 left = static_cast<__int128>(w[i]) * (a[j] + 1); + const __int128 right = static_cast<__int128>(w[j]) * a[i]; + EXPECT_TRUE(left > right || (left == right && i <= j)) << i << " " << j; + } + } + // Many members at large totals (the heap path) against the boundary property. + std::vector wide(40); + for (std::size_t i = 0; i < wide.size(); ++i) wide[i] = (Amount {1} << 55) + static_cast(i * 7919); + const auto b = ha((Amount {1} << 61) + 12345, wide); + EXPECT_EQ(std::accumulate(b.begin(), b.end(), Amount {0}), (Amount {1} << 61) + 12345); + for (std::size_t i = 0; i < wide.size(); ++i) { + for (std::size_t j = 0; j < wide.size(); ++j) { + if (b[i] == 0) continue; + const __int128 left = static_cast<__int128>(wide[i]) * (b[j] + 1); + const __int128 right = static_cast<__int128>(wide[j]) * b[i]; + EXPECT_TRUE(left > right || (left == right && i <= j)); + } + } +} + +TEST(Apportion, HamiltonHoleControl) { + // Weights [1, 3, 3] on members with view [0, 3, 3]: highest averages is feasible on 0..4 + // (T_max = 4 exactly); Hamilton's rule has the hole at 3 ({0, 1, 2, 4}). + const std::vector w = {1, 3, 3}; + const std::vector view = {0, 3, 3}; + auto fits = [&](const std::vector& a) { + for (std::size_t i = 0; i < a.size(); ++i) { + if (a[i] > view[i]) return false; + } + return true; + }; + std::vector ha_ok, ham_ok; + for (Amount t = 0; t < 8; ++t) { + if (fits(ha(t, w))) ha_ok.push_back(t); + if (fits(hamilton(t, w))) ham_ok.push_back(t); + } + EXPECT_EQ(ha_ok, (std::vector{0, 1, 2, 3, 4})); + EXPECT_EQ(ham_ok, (std::vector{0, 1, 2, 4})); + EXPECT_EQ(hamilton(3, w), (std::vector{1, 1, 1})); + EXPECT_EQ(hamilton(4, w), (std::vector{0, 2, 2})); + // The kernel's T_max on the same plan (parallel links 1, 3, 3 with Capacity weights, link 0 + // held by a third party): exactly 4, allocation [0, 2, 2]. + auto g = make_graph(2, {{0, 1, 1, 1}, {0, 1, 3, 1}, {0, 1, 3, 1}}); + const auto plan = plan_of(g, FlowPlacement::Weighted, 0, {1}, all_ids(g), caps(g), EdgePriority::Capacity); + const auto view_dense = to_ids(g, std::vector {0, 3, 3}); + const auto tm = t_max(plan, on_members(plan, view_dense)); + EXPECT_EQ(tm.value, 4); + std::vector at4(plan.members.size()); + ASSERT_TRUE(allocate(plan, 4, at4)); + EXPECT_EQ(dense_input(g, plan, at4), (std::vector{0, 2, 2})); + std::vector at3(plan.members.size()); + ASSERT_TRUE(allocate(plan, 3, at3)); + EXPECT_EQ(dense_input(g, plan, at3), (std::vector{0, 2, 1})); +} + +TEST(Plans, MembershipByKernelAndPriority) { + // Parallel links of capacities [4, 4, 0] with residual [0, 4, 0] on the creation view. + auto g = make_graph(2, {{0, 1, 4, 1}, {0, 1, 4, 1}, {0, 1, 0, 1}}); + const auto view = to_ids(g, std::vector {0, 4, 0}); + const auto inv = id_of_input(g); + auto mf = plan_of(g, FlowPlacement::MaxFlow, 0, {1}, all_ids(g), view); + EXPECT_EQ(mf.members, (std::vector{0, 1, 2})); // MaxFlow: the view decides + auto eb = plan_of(g, FlowPlacement::EqualBalanced, 0, {1}, all_ids(g), view); + EXPECT_EQ(eb.members, (std::vector{inv[0], inv[1]})); // static capacity >= 1 + auto wr = plan_of(g, FlowPlacement::Weighted, 0, {1}, all_ids(g), view, EdgePriority::Residual); + EXPECT_EQ(wr.members, (std::vector{inv[1]})); + EXPECT_EQ(wr.weights, (std::vector{4})); + auto wc = plan_of(g, FlowPlacement::Weighted, 0, {1}, all_ids(g), view, EdgePriority::Capacity); + EXPECT_EQ(wc.members, (std::vector{inv[0], inv[1]})); + EXPECT_EQ(wc.weights, (std::vector{4, 4})); + EXPECT_THROW((void)plan_of(g, FlowPlacement::Weighted, 0, {1}, all_ids(g), view, EdgePriority::Headroom), + std::invalid_argument); + EXPECT_THROW( + (void)plan_of(g, FlowPlacement::MaxFlow, 0, {1}, all_ids(g), view, EdgePriority::Residual, Admission::Lossy), + std::invalid_argument); +} + +TEST(Plans, SplitGraphPruning) { + // E1a: EqualBalanced with a zero-capacity tail. 0->1, 0->2, 1->3, 2->4 (cap 0), 4->3: the + // branch through 2 cannot reach 3 over members, so it is pruned (no stranded unit). + auto g = make_graph(5, {{0, 1, 2, 1}, {0, 2, 2, 1}, {1, 3, 2, 1}, {2, 4, 0, 1}, {4, 3, 2, 1}}); + const auto inv = id_of_input(g); + auto eb = plan_of(g, FlowPlacement::EqualBalanced, 0, {3}, all_ids(g), caps(g)); + std::vector want = {inv[0], inv[2]}; + std::sort(want.begin(), want.end()); + EXPECT_EQ(eb.members, want); + std::vector a(eb.members.size()); + ASSERT_TRUE(allocate(eb, 2, a)); + EXPECT_EQ(dense_input(g, eb, a), (std::vector{2, 0, 2, 0, 0})); + // Destinations are terminal; a destination set keeps both destinations. + auto g2 = make_graph(4, {{0, 1, 3, 1}, {1, 2, 3, 1}, {0, 2, 3, 2}, {2, 3, 3, 1}}); + auto any = plan_of(g2, FlowPlacement::EqualBalanced, 0, {1, 2}, all_ids(g2), caps(g2)); + const auto inv2 = id_of_input(g2); + std::vector want2 = {inv2[0], inv2[2]}; // 1->2 leaves destination 1; 2->3 leaves 2 + std::sort(want2.begin(), want2.end()); + EXPECT_EQ(any.members, want2); + EXPECT_EQ(route_cost(g2, any), 1); + // An empty split graph: route cost unreachable, T_max 0. + auto none = plan_of(g, FlowPlacement::EqualBalanced, 0, {3}, std::vector {inv[1], inv[3]}, caps(g)); + EXPECT_TRUE(none.members.empty()); + EXPECT_EQ(route_cost(g, none), kUnreachable); + EXPECT_EQ(t_max(none, {}).value, 0); +} + +TEST(Plans, RoutePlanOnTheDagEqualsTheEdgeListPlan) { + // make_route_plan walks the bundle's own predecessor lists; make_plan prunes an ascending edge + // list. On random DAGs (parallel edges, zero capacities, statically excluded edges and nodes, + // edges into the source and out of a destination, one or two destinations) both give the same + // split graph (members, topological structure, weights), the same route cost and the same + // allocations. + std::mt19937 rng(2024); + int compared = 0; + for (int trial = 0; trial < 2000; ++trial) { + const int n = 3 + static_cast(rng() % 8); + std::vector edges; + for (int i = 0; i < 2 * n + static_cast(rng() % 10); ++i) { + const NodeId u = static_cast(rng() % static_cast(n - 1)); + const NodeId v = static_cast(u + 1 + rng() % static_cast(n - 1 - u)); + const Amount cap = (rng() % 6) == 0 ? 0 : 1 + static_cast(rng() % 9); + edges.push_back({u, v, cap, 1 + static_cast(rng() % 4)}); + } + auto g = make_graph(n, edges); + std::vector all(static_cast(g.num_edges())); + std::iota(all.begin(), all.end(), 0); + std::vector in_bundle; + for (EdgeId e : all) { + if ((rng() % 5) != 0) in_bundle.push_back(e); + } + const auto bundle = PredDAGBuilder::from_edges(g, in_bundle); + const NodeId src = static_cast(rng() % static_cast(n)); + std::vector dsts; + for (int k = 0; k < 1 + static_cast(rng() % 2); ++k) { + const NodeId d = static_cast(rng() % static_cast(n)); + if (d != src && std::find(dsts.begin(), dsts.end(), d) == dsts.end()) dsts.push_back(d); + } + if (dsts.empty()) continue; + std::sort(dsts.begin(), dsts.end()); + std::optional elig; + if ((rng() % 3) == 0) { + std::vector xn; + std::vector xe; + if ((rng() % 2) == 0) xn.push_back(static_cast(rng() % static_cast(n))); + for (EdgeId e : all) { + if ((rng() % 7) == 0) xe.push_back(e); + } + elig = g.eligibility(xn, xe); + } + const auto capacity = g.capacity_units(); + std::vector usable; + for (EdgeId e : PredDAGBuilder::ascending(bundle)) { + if (capacity[static_cast(e)] >= 1 && (!elig || elig->edge_ok(e))) usable.push_back(e); + } + for (const auto kernel : {FlowPlacement::EqualBalanced, FlowPlacement::Weighted}) { + Cost cost = -1; + const auto on_dag = make_route_plan(g, kernel, Admission::Lossless, EdgePriority::Capacity, src, dsts, bundle, + elig ? &*elig : nullptr, &cost); + const auto on_list = make_plan(g, kernel, Admission::Lossless, EdgePriority::Capacity, src, dsts, usable, {}); + ASSERT_EQ(on_dag.members, on_list.members) << "trial " << trial; + EXPECT_EQ(on_dag.route, on_list.members); + EXPECT_EQ(on_dag.topo, on_list.topo); + EXPECT_EQ(on_dag.out_begin, on_list.out_begin); + EXPECT_EQ(on_dag.out_end, on_list.out_end); + EXPECT_EQ(on_dag.head_pos, on_list.head_pos); + EXPECT_EQ(on_dag.terminal, on_list.terminal); + EXPECT_EQ(on_dag.weights, on_list.weights); + EXPECT_EQ(cost, route_cost(g, on_list)) << "trial " << trial; + EXPECT_EQ(cost, route_cost(g, on_dag)); + std::vector a(on_dag.members.size()); + std::vector b(on_list.members.size()); + for (Amount t : {Amount {0}, Amount {1}, Amount {7}, Amount {50}}) { + if (on_dag.members.empty() && t != 0) continue; + EXPECT_TRUE(allocate(on_dag, t, a)); + EXPECT_TRUE(allocate(on_list, t, b)); + EXPECT_EQ(a, b); + } + if (!on_dag.members.empty()) ++compared; + } + } + EXPECT_GT(compared, 1000); +} + +TEST(Allocation, Review14SplitAndReconvergeEveryIntermediateValue) { + // Prototype 1 fixture apportion_house_monotone_dag: capacities 20 x weights, baseline view. + const std::vector> dag = {{0, 1}, {0, 2}, {1, 3}, {1, 4}, {2, 3}, {2, 4}, {3, 5}, {4, 5}}; + const std::vector w = {2, 7, 1, 4, 3, 2, 1, 1}; + const std::vector view_in = {8, 20, 4, 9, 10, 10, 15, 15}; + std::vector edges; + for (std::size_t i = 0; i < dag.size(); ++i) edges.push_back({dag[i].first, dag[i].second, 20 * w[i], 1}); + auto g = make_graph(6, edges); + const auto view = to_ids(g, view_in); + for (auto kernel : {FlowPlacement::Weighted, FlowPlacement::EqualBalanced}) { + // Independent per-node reference on input positions (node ids are topological). + auto ref = [&](Amount total) { + std::vector inflow(6, 0), out(dag.size(), 0); + inflow[0] = total; + for (NodeId u = 0; u < 5; ++u) { + std::vector mine; + for (std::size_t i = 0; i < dag.size(); ++i) { + if (dag[i].first == u) mine.push_back(i); + } + if (mine.empty() || inflow[static_cast(u)] == 0) continue; + std::vector ww; + for (auto i : mine) ww.push_back(w[i]); + const auto parts = kernel == FlowPlacement::Weighted ? ha_slow(inflow[static_cast(u)], ww) + : rr(inflow[static_cast(u)], mine.size()); + for (std::size_t j = 0; j < mine.size(); ++j) { + out[mine[j]] = parts[j]; + inflow[static_cast(dag[mine[j]].second)] += parts[j]; + } + } + return out; + }; + Amount brute = 0; + for (Amount t = 0; t <= 100; ++t) { + const auto a = ref(t); + bool ok = true; + for (std::size_t i = 0; i < a.size(); ++i) ok = ok && a[i] <= view_in[i]; + if (ok) brute = t; + } + const auto plan = plan_of(g, kernel, 0, {5}, all_ids(g), caps(g), EdgePriority::Capacity); + const auto vm = on_members(plan, view); + std::vector own(plan.members.size(), 0); + Amount total = 0; + int steps = 0; + while (true) { + const auto step = lossless_step(g, plan, total, vm, own, 1); + if (step.total == total) break; + ++steps; + for (std::size_t m = 0; m < own.size(); ++m) ASSERT_GE(step.alloc[m], own[m]); // member-monotone + EXPECT_EQ(dense_input(g, plan, step.alloc), ref(step.total)); + total = step.total; + own = step.alloc; + } + EXPECT_EQ(total, brute); + EXPECT_EQ(t_max(plan, vm).value, brute); + const auto one = first_step(g, plan, view, brute); + EXPECT_EQ(one.alloc, own); // one call of T_max equals the calls of 1 + EXPECT_GT(steps, 5); + } +} + +TEST(Allocation, EqualBalancedOnePlusTwoEqualsThree) { + // Review-13 EB reconvergence DAG (prototype 1 apportion_house_monotone_dag, second part). + const std::vector> recon = {{0, 1}, {0, 2}, {0, 3}, {1, 4}, {2, 4}, + {3, 4}, {4, 5}, {4, 6}, {5, 7}, {6, 7}}; + std::vector edges; + for (auto [u, v] : recon) edges.push_back({u, v, 100, 1}); + auto g = make_graph(8, edges); + const auto plan = plan_of(g, FlowPlacement::EqualBalanced, 0, {7}, all_ids(g), caps(g)); + const auto vm = on_members(plan, caps(g)); + std::vector own(plan.members.size(), 0); + auto s1 = lossless_step(g, plan, 0, vm, own, 1); + EXPECT_EQ(dense_input(g, plan, s1.alloc), (std::vector{1, 0, 0, 1, 0, 0, 1, 0, 1, 0})); + auto s2 = lossless_step(g, plan, s1.total, vm, s1.alloc, 2); + EXPECT_EQ(dense_input(g, plan, s2.alloc), (std::vector{1, 1, 1, 1, 1, 1, 2, 1, 2, 1})); + EXPECT_EQ(first_step(g, plan, caps(g), 3).alloc, s2.alloc); +} + +TEST(Allocation, TMaxExactAgainstBruteForce) { + std::mt19937 rng(99); + int checked = 0; + for (int trial = 0; trial < 400; ++trial) { + const int n = 3 + static_cast(rng() % 5); + std::vector edges; + for (int i = 0; i < 2 * n + static_cast(rng() % 6); ++i) { + const NodeId u = static_cast(rng() % (n - 1)); + const NodeId v = static_cast(u + 1 + rng() % static_cast(n - 1 - u)); + edges.push_back({u, v, 1 + static_cast(rng() % 9), 1}); + } + auto g = make_graph(n, edges); + std::vector view(static_cast(g.num_edges())); + for (std::size_t e = 0; e < view.size(); ++e) { + view[e] = static_cast(rng() % static_cast(g.capacity_units()[e] + 1)); + } + const auto kernel = (rng() % 2) == 0 ? FlowPlacement::Weighted : FlowPlacement::EqualBalanced; + const auto plan = plan_of(g, kernel, 0, {n - 1}, all_ids(g), caps(g), EdgePriority::Capacity); + if (plan.members.empty()) continue; + const auto vm = on_members(plan, view); + const auto tm = t_max(plan, vm); + EXPECT_LE(tm.iterations, 62); + Amount brute = 0; + std::vector a(plan.members.size()); + for (Amount t = 0; t <= 200; ++t) { + if (allocate(plan, t, a, vm)) brute = t; + } + ASSERT_EQ(tm.value, brute) << "trial " << trial; + // At T_max some member is exactly full and the next unit overfills one. + ASSERT_TRUE(allocate(plan, brute, a, vm)); + std::vector next(plan.members.size()); + EXPECT_FALSE(allocate(plan, brute + 1, next, vm)); + ++checked; + } + EXPECT_GT(checked, 300); +} + +TEST(Allocation, TMaxSearchIsExactAndWithinTheProbeBudgetOnLargeRanges) { + // Capacities and views up to 2^58 units (search ranges near 2^62 at the source): the seeded + // search must return the same T as a plain bisection from 0 and probe at most 62 times, also + // when its floating-point estimate is far off (a member capped near zero deep in the DAG). + std::mt19937_64 rng(1234); + int checked = 0; + for (int trial = 0; trial < 300; ++trial) { + const int n = 3 + static_cast(rng() % 6); + std::vector edges; + for (int i = 0; i < 2 * n + static_cast(rng() % 8); ++i) { + const NodeId u = static_cast(rng() % static_cast(n - 1)); + const NodeId v = static_cast(u + 1 + rng() % static_cast(n - 1 - u)); + edges.push_back({u, v, 1 + static_cast(rng() % (std::uint64_t {1} << 58)), 1}); + } + auto g = make_graph(n, edges); + std::vector view(static_cast(g.num_edges())); + for (std::size_t e = 0; e < view.size(); ++e) { + const Amount c = g.capacity_units()[e]; + view[e] = (rng() % 4) == 0 ? static_cast(rng() % 3) : static_cast(rng() % static_cast(c + 1)); + } + const auto kernel = (rng() % 2) == 0 ? FlowPlacement::Weighted : FlowPlacement::EqualBalanced; + const auto plan = plan_of(g, kernel, 0, {n - 1}, all_ids(g), caps(g), EdgePriority::Capacity); + if (plan.members.empty()) continue; + const auto vm = on_members(plan, view); + const auto tm = t_max(plan, vm); + EXPECT_LE(tm.iterations, 62); + // Reference: plain bisection on [0, hi] with the same exact predicate. + Amount hi = 0; + for (auto k = plan.src_out_begin; k < plan.src_out_end; ++k) hi += vm[static_cast(k)]; + std::vector a(plan.members.size()); + Amount lo = 0; + while (lo < hi) { + const Amount mid = lo + (hi - lo + 1) / 2; + if (allocate(plan, mid, a, vm)) lo = mid; else hi = mid - 1; + } + ASSERT_EQ(tm.value, lo) << "trial " << trial; + // The Lossless step from T = 0 with a request beyond the maximum lands on the same T. + const std::vector zero(plan.members.size(), 0); + const auto step = lossless_step(g, plan, 0, vm, zero, kDomainLimit - 1); + EXPECT_EQ(step.total, lo); + EXPECT_LE(step.iterations, 62); + ++checked; + } + EXPECT_GT(checked, 200); +} + +TEST(MaxFlowKernel, CancellationDagGivesTwo) { + // Review-5 V3 (prototype 1 maxflow_cancellation_dag): place(1) takes 0-1-3-5, the next + // place(1) re-solves on V = W + own and returns the unique value-2 flow (1->3 goes to 0). + const std::vector> pairs = {{0, 1}, {0, 2}, {1, 3}, {1, 4}, {2, 3}, {3, 5}, {4, 5}}; + std::vector edges; + for (auto [u, v] : pairs) edges.push_back({u, v, 1, 1}); + auto g = make_graph(6, edges); + const auto plan = plan_of(g, FlowPlacement::MaxFlow, 0, {5}, all_ids(g), caps(g)); + auto s1 = first_step(g, plan, caps(g), 1); + EXPECT_EQ(dense_input(g, plan, s1.alloc), (std::vector{1, 0, 1, 0, 0, 1, 0})); + // The flow's own allocation is in its view (W + own = capacities here). + auto s2 = lossless_step(g, plan, s1.total, on_members(plan, caps(g)), s1.alloc, 1); + EXPECT_EQ(s2.total, 2); + EXPECT_EQ(dense_input(g, plan, s2.alloc), (std::vector{1, 1, 0, 1, 1, 1, 1})); + auto s3 = lossless_step(g, plan, s2.total, on_members(plan, caps(g)), s2.alloc, 1); + EXPECT_EQ(s3.total, 2); + EXPECT_EQ(first_step(g, plan, caps(g), 2).alloc, s2.alloc); + EXPECT_EQ(first_step(g, plan, caps(g), std::nullopt).total, 2); +} + +namespace { + +// Review-9 witness edges (prototype 1 CANCEL_EDGES): s=0; A=1, B=2, C=3; X=4, Y=5; t=6. +std::vector cancel_edges(Cost b_to_y = 9) { + return {{0, 1, 1, 1}, {0, 2, 1, 1}, {0, 3, 1, 1}, {1, 4, 1, 2}, {1, 5, 1, 9}, + {2, 4, 1, 1}, {2, 5, 1, b_to_y}, {3, 4, 1, 3}, {4, 6, 2, 1}, {5, 6, 1, 1}}; +} + +} // namespace + +TEST(MaxFlowKernel, CancelUpstreamMixedCost) { + auto g = make_graph(7, cancel_edges()); + const auto plan = plan_of(g, FlowPlacement::MaxFlow, 0, {6}, all_ids(g), caps(g)); + const std::vector final_alloc = {1, 1, 1, 0, 1, 1, 0, 1, 2, 1}; + auto one = first_step(g, plan, caps(g), 3); + EXPECT_EQ(dense_input(g, plan, one.alloc), final_alloc); + // The sequence 1, 1, 1 with the third call cancelling A->X upstream (a signed delta). + const std::vector> seq = { + {0, 1, 0, 0, 0, 1, 0, 0, 1, 0}, {1, 1, 0, 1, 0, 1, 0, 0, 2, 0}, final_alloc}; + std::vector own(plan.members.size(), 0); + Amount total = 0; + for (const auto& want : seq) { + auto s = lossless_step(g, plan, total, on_members(plan, caps(g)), own, 1); + EXPECT_EQ(s.total, total + 1); + EXPECT_EQ(dense_input(g, plan, s.alloc), want); + total = s.total; + own = s.alloc; + } + // The minimum cost over the bundle: curve (3, 1), (4, 1), (12, 1) totals 19. + Cost cost = 0; + const auto a = dense_input(g, plan, own); + for (std::size_t i = 0; i < a.size(); ++i) cost += a[i] * cancel_edges()[i].cost; + EXPECT_EQ(cost, 19); +} + +TEST(MaxFlowKernel, EqualOptimumControlFollowsBPrimeArcOrder) { + // Rule-20 control: with cost(B->Y) = 8 two value-3 optima cost 19; ascending B' arcs pick the + // flow with Y fed by A, so a later demand on A->X still finds its unit. + auto g = make_graph(7, cancel_edges(8)); + const auto plan = plan_of(g, FlowPlacement::MaxFlow, 0, {6}, all_ids(g), caps(g)); + auto s = first_step(g, plan, caps(g), 3); + EXPECT_EQ(dense_input(g, plan, s.alloc), (std::vector{1, 1, 1, 0, 1, 1, 0, 1, 2, 1})); +} + +TEST(MaxFlowKernel, ReleaseWitnessAndMixedCostBundle) { + // Release witness: two parallel links of cost 4 and 2; the cheap one is held by another owner + // at the first call (the flow takes cost 4); after the release the re-solve on the new view + // moves the unit to the cheap link (placed 0 in that call, allocation moved). + auto g = make_graph(2, {{0, 1, 1, 4}, {0, 1, 1, 2}}); + const auto plan = plan_of(g, FlowPlacement::MaxFlow, 0, {1}, all_ids(g), caps(g)); + const auto held = to_ids(g, std::vector {1, 0}); + auto s1 = first_step(g, plan, held, 1); + EXPECT_EQ(dense_input(g, plan, s1.alloc), (std::vector{1, 0})); + const auto released = to_ids(g, std::vector {0, 1}); // W after the release; own adds 1 on cost 4 + std::vector v(plan.members.size()); + for (std::size_t m = 0; m < v.size(); ++m) v[m] = released[static_cast(plan.members[m])] + s1.alloc[m]; + auto s2 = lossless_step(g, plan, 1, v, s1.alloc, 1); + EXPECT_EQ(s2.total, 2); + // Request 0 more is never sent; a capped re-solve at the same T moves the unit (cost 2). + auto s3 = lossless_step(g, plan, 1, v, s1.alloc, 0); + EXPECT_EQ(s3.total, 1); + EXPECT_EQ(dense_input(g, plan, s3.alloc), (std::vector{0, 1})); + // Review-13 D4: on a mixed-cost bundle the 1 + 1 + 1 chain fills before the cost-4 edge. + auto g2 = make_graph(4, {{0, 1, 1, 1}, {1, 2, 1, 1}, {2, 3, 1, 1}, {0, 3, 1, 4}}); + const auto p2 = plan_of(g2, FlowPlacement::MaxFlow, 0, {3}, all_ids(g2), caps(g2)); + EXPECT_EQ(dense_input(g2, p2, first_step(g2, p2, caps(g2), 1).alloc), (std::vector{1, 1, 1, 0})); + EXPECT_EQ(route_cost(g2, p2), 3); +} + +TEST(WeightedKernel, CapacityVersusResidualMembershipAfterOccupancy) { + // Prototype 1 weighted_capacity_vs_residual_after_occupancy: parallel links [4, 4], another + // owner holds 3 on link 0 (residual [1, 4]). + auto g = make_graph(2, {{0, 1, 4, 1}, {0, 1, 4, 1}}); + auto run = [&](EdgePriority prio, std::vector residual_in, Amount request) { + const auto w = to_ids(g, residual_in); + const auto plan = plan_of(g, FlowPlacement::Weighted, 0, {1}, all_ids(g), w, prio); + auto s = first_step(g, plan, w, request); + return std::make_pair(s.total, dense_input(g, plan, s.alloc)); + }; + EXPECT_EQ(run(EdgePriority::Residual, {1, 4}, 5), std::make_pair(Amount {5}, std::vector{1, 4})); + EXPECT_EQ(run(EdgePriority::Capacity, {1, 4}, 5), std::make_pair(Amount {2}, std::vector{1, 1})); + EXPECT_EQ(run(EdgePriority::Residual, {0, 4}, 5), std::make_pair(Amount {4}, std::vector{0, 4})); + EXPECT_EQ(run(EdgePriority::Capacity, {0, 4}, 5), std::make_pair(Amount {0}, std::vector{0, 0})); + // Frozen plans after the owner leaves: Capacity grows to 5 as [3, 2]; Residual stays at 4. + const auto held = to_ids(g, std::vector {0, 4}); + const auto free = to_ids(g, std::vector {4, 4}); + const auto pc = plan_of(g, FlowPlacement::Weighted, 0, {1}, all_ids(g), held, EdgePriority::Capacity); + auto c = lossless_step(g, pc, 0, on_members(pc, free), std::vector(pc.members.size(), 0), 5); + EXPECT_EQ(dense_input(g, pc, c.alloc), (std::vector{3, 2})); + const auto pr = plan_of(g, FlowPlacement::Weighted, 0, {1}, all_ids(g), held, EdgePriority::Residual); + auto first = first_step(g, pr, held, 5); + // After the release V = W + own on the one member is its capacity 4. + auto again = lossless_step(g, pr, first.total, on_members(pr, free), first.alloc, 5); + EXPECT_EQ(again.total, 4); // the plan has one member; weights never renormalised +} + +TEST(WeightedKernel, OnePlusFiveEqualsSix) { + // Additivity on a fixed plan (prototype 1 weighted_1_plus_5_vs_6): weights 2 and 6 at the + // source. + auto g = make_graph(3, {{0, 1, 2, 1}, {0, 2, 6, 1}, {1, 2, 9, 1}}); + const auto plan = plan_of(g, FlowPlacement::Weighted, 0, {2}, all_ids(g), caps(g), EdgePriority::Capacity); + const auto vm = on_members(plan, caps(g)); + std::vector zero(plan.members.size(), 0); + auto a = lossless_step(g, plan, 0, vm, zero, 1); + auto b = lossless_step(g, plan, a.total, vm, a.alloc, 5); + auto c = lossless_step(g, plan, 0, vm, zero, 6); + EXPECT_EQ(b.total, c.total); + EXPECT_EQ(b.alloc, c.alloc); +} + +TEST(LossyKernel, DiamondDropsAndInfiniteInjection) { + // Prototype 1 lossy_diamond: source links 10/10, tails 1/10. + auto g = make_graph(4, {{0, 1, 10, 1}, {0, 2, 10, 1}, {1, 3, 1, 1}, {2, 3, 10, 1}}); + for (auto kernel : {FlowPlacement::EqualBalanced, FlowPlacement::Weighted}) { + const auto plan = plan_of(g, kernel, 0, {3}, all_ids(g), caps(g), EdgePriority::Residual, Admission::Lossy); + auto w = on_members(plan, caps(g)); + auto s = lossy_step(plan, 10, w); + EXPECT_EQ(s.offered, 10); + EXPECT_EQ(s.injected, 10); + EXPECT_EQ(s.delivered, 6); + EXPECT_EQ(dense_input(g, plan, s.carried), (std::vector{5, 5, 1, 5})); + EXPECT_EQ(dense_input(g, plan, s.drops), (std::vector{0, 0, 4, 0})); + // Infinite offer: every source member injected to its residual. + auto inf = lossy_step(plan, std::nullopt, w); + EXPECT_EQ(inf.offered, 20); + EXPECT_EQ(inf.delivered, 11); + EXPECT_EQ(dense_input(g, plan, inf.drops), (std::vector{0, 0, 9, 0})); + } + // Highest averages at every node for Lossy Weighted: Capacity weights [1, 3, 3] split 7 as + // [1, 3, 3] (prototype 1 lossy_weighted_highest_averages). + auto g2 = make_graph(2, {{0, 1, 1, 1}, {0, 1, 3, 1}, {0, 1, 3, 1}}); + const auto p2 = plan_of(g2, FlowPlacement::Weighted, 0, {1}, all_ids(g2), caps(g2), EdgePriority::Capacity, + Admission::Lossy); + auto s2 = lossy_step(p2, 7, on_members(p2, caps(g2))); + EXPECT_EQ(dense_input(g2, p2, s2.carried), (std::vector{1, 3, 3})); + EXPECT_EQ(s2.delivered, 7); + // A source without members injects nothing (the offer is a source drop). + auto g3 = make_graph(2, {{0, 1, 0, 1}}); + const auto p3 = plan_of(g3, FlowPlacement::EqualBalanced, 0, {1}, all_ids(g3), caps(g3), EdgePriority::Residual, + Admission::Lossy); + auto s3 = lossy_step(p3, 5, {}); + EXPECT_EQ(s3.offered, 5); + EXPECT_EQ(s3.injected, 0); + EXPECT_EQ(s3.delivered, 0); +} diff --git a/tests/cpp/ledger_fault_tests.cpp b/tests/cpp/ledger_fault_tests.cpp new file mode 100644 index 0000000..d343dcd --- /dev/null +++ b/tests/cpp/ledger_fault_tests.cpp @@ -0,0 +1,388 @@ +// Allocation-failure atomicity of the ledger and the single-bundle transaction (APPROACH 8, +// 9.2; review-1b D1). This executable replaces the global operator new so that one chosen +// allocation throws std::bad_alloc. Every operation is swept over every allocation it makes: +// after each injected failure the ledger (aggregate, residual, every record field, bound and +// orphaned owners, next FlowId) and the policy (epoch state, flow id, plan) must equal their +// state before the call, the per-thread scratch must not be poisoned (a clean retry gives the +// reference result), and remove() must restore the state before the policy placed anything. +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/detail/ledger_access.hpp" +#include "netgraph/core/detail/node_map.hpp" +#include "netgraph/core/detail/pinned_flow.hpp" +#include "netgraph/core/detail/pred_dag_builder.hpp" +#include "netgraph/core/flow_graph.hpp" +#include "test_utils.hpp" + +namespace { +// Allocations left before the injected failure; negative: no injection. +long g_countdown = -1; +} // namespace + +void* operator new(std::size_t n) { + if (g_countdown >= 0 && g_countdown-- == 0) throw std::bad_alloc(); + if (void* p = std::malloc(n == 0 ? 1 : n)) return p; + throw std::bad_alloc(); +} +void* operator new[](std::size_t n) { return ::operator new(n); } +void operator delete(void* p) noexcept { std::free(p); } +void operator delete[](void* p) noexcept { std::free(p); } +void operator delete(void* p, std::size_t) noexcept { std::free(p); } +void operator delete[](void* p, std::size_t) noexcept { std::free(p); } + +using namespace netgraph::core; +using namespace netgraph::core::test; +using detail::LedgerAccess; +using detail::PinnedFlow; +using detail::PinnedResult; + +namespace { + +// Runs fn with the k-th allocation (from 0) failing; returns whether that failure happened. +bool fails_at(long k, const std::function& fn) { + g_countdown = k; + bool failed = false; + try { + fn(); + } catch (const std::bad_alloc&) { + failed = true; + } + const bool reached = g_countdown < 0; + g_countdown = -1; + EXPECT_EQ(failed, reached) << "a std::bad_alloc was swallowed or replaced at allocation " << k; + return failed; +} + +struct Snapshot { + std::vector allocated; + std::vector residual; + std::vector records; + std::int64_t next_id {0}; + std::vector bound; + std::vector orphans; +}; + +// Records keep their local ids only: the reference sequence runs on another ledger (uid). +Snapshot snapshot(const FlowGraph& fg) { + Snapshot s {fg.allocated(), fg.residual(), fg.records(), LedgerAccess::next_flow_id(fg), + LedgerAccess::bound_owners(fg), LedgerAccess::orphan_owners(fg)}; + for (auto& r : s.records) { + EXPECT_EQ(r.flow_id.first, fg.uid()); + r.flow_id.first = 0; + } + return s; +} + +bool same(const FlowRecord& a, const FlowRecord& b) { + return a.flow_id == b.flow_id && a.owner == b.owner && a.sources == b.sources && a.destinations == b.destinations && + a.placement == b.placement && a.admission == b.admission && a.bundle && b.bundle && *a.bundle == *b.bundle && + a.route_cost == b.route_cost && a.offered == b.offered && a.delivered == b.delivered && a.edges == b.edges && + a.drops == b.drops; +} + +void expect_same(const Snapshot& a, const Snapshot& b, const std::string& where) { + EXPECT_EQ(a.allocated, b.allocated) << where; + EXPECT_EQ(a.residual, b.residual) << where; + EXPECT_EQ(a.next_id, b.next_id) << where; + EXPECT_EQ(a.bound, b.bound) << where; + EXPECT_EQ(a.orphans, b.orphans) << where; + ASSERT_EQ(a.records.size(), b.records.size()) << where; + for (std::size_t i = 0; i < a.records.size(); ++i) EXPECT_TRUE(same(a.records[i], b.records[i])) << where << " record " << i; +} + +// The ledger invariant: the aggregate is the sum of the records, within [0, baseline]. +void expect_balanced(const FlowGraph& fg, const std::string& where) { + std::vector sum(fg.allocated().size(), 0); + for (const auto& r : fg.records()) { + for (const auto& [e, x] : r.edges) sum[static_cast(e)] += x; + } + EXPECT_EQ(sum, fg.allocated()) << where; + for (Amount x : fg.residual()) EXPECT_GE(x, 0) << where; +} + +struct Epoch { + Counter128 requested; + bool infinite {false}; + std::optional flow_id; + bool planned {false}; + Amount placed {0}; +}; + +Epoch epoch(const PinnedFlow& p) { + auto id = p.flow_id(); + if (id.has_value()) id->first = 0; + return {p.requested(), p.infinite(), id, p.plan() != nullptr, p.placed_demand()}; +} + +void expect_same(const Epoch& a, const Epoch& b, const std::string& where) { + EXPECT_EQ(a.requested, b.requested) << where; + EXPECT_EQ(a.infinite, b.infinite) << where; + EXPECT_EQ(a.flow_id, b.flow_id) << where; + EXPECT_EQ(a.planned, b.planned) << where; + EXPECT_EQ(a.placed, b.placed) << where; +} + +void expect_same(const PinnedResult& a, const PinnedResult& b, const std::string& where) { + EXPECT_EQ(a.placed, b.placed) << where; + EXPECT_EQ(a.remaining, b.remaining) << where; + EXPECT_EQ(a.termination, b.termination) << where; + EXPECT_EQ(a.routes_changed, b.routes_changed) << where; + EXPECT_EQ(a.drops, b.drops) << where; + EXPECT_EQ(a.iterations, b.iterations) << where; +} + +struct Kernel { + FlowPlacement placement; + Admission admission; + EdgePriority priority; +}; + +const std::vector& kernels() { + static const std::vector all = { + {FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual}, + {FlowPlacement::Weighted, Admission::Lossless, EdgePriority::Residual}, + {FlowPlacement::Weighted, Admission::Lossless, EdgePriority::Capacity}, + {FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual}, + {FlowPlacement::EqualBalanced, Admission::Lossy, EdgePriority::Residual}, + {FlowPlacement::Weighted, Admission::Lossy, EdgePriority::Capacity}, + }; + return all; +} + +struct Case { + std::string name; + std::shared_ptr g; + NodeId src; + NodeId dst; +}; + +std::vector cases() { + auto shared = [](StrictMultiDiGraph g) { return std::make_shared(std::move(g)); }; + return { + {"edge", shared(make_graph(2, {{0, 1, 9, 1}})), 0, 1}, + {"chain", shared(make_graph(3, {{0, 1, 9, 1}, {1, 2, 9, 1}})), 0, 2}, + // Split, reconvergence, a parallel pair and a costlier branch. + {"diamond", shared(make_graph(5, {{0, 1, 7, 1}, {0, 2, 5, 1}, {1, 3, 4, 1}, {1, 3, 3, 1}, {2, 3, 9, 1}, + {3, 4, 20, 1}, {0, 4, 2, 5}})), + 0, 4}, + }; +} + +std::shared_ptr all_edges(const StrictMultiDiGraph& g) { + std::vector e(static_cast(g.num_edges())); + for (std::size_t i = 0; i < e.size(); ++i) e[i] = static_cast(i); + return std::make_shared(detail::PredDAGBuilder::from_edges(g, e)); +} + +// One scenario: a third party holding 1 unit on every edge, then the policy's earlier calls, +// then the call under test. +struct Scenario { + std::vector> before; + std::optional call; + std::string name; +}; + +const std::vector& scenarios() { + static const std::vector all = { + {{}, 2, "create"}, + {{1}, 2, "update"}, + {{1}, std::nullopt, "update to +inf"}, + {{}, std::nullopt, "create at +inf"}, + }; + return all; +} + +} // namespace + +TEST(LedgerFaults, EveryAllocationOfAPlacementFailsAtomically) { + int sweeps = 0; + long injected = 0; + for (const auto& c : cases()) { + const auto dag = all_edges(*c.g); + for (const auto& k : kernels()) { + for (const auto& sc : scenarios()) { + const std::string where = c.name + " kernel " + std::to_string(static_cast(k.placement)) + "/" + + std::to_string(static_cast(k.admission)) + "/" + + std::to_string(static_cast(k.priority)) + " " + sc.name; + // The reference: the same sequence without a failure. + FlowGraph ref(c.g); + PinnedFlow third_ref(ref, c.src, c.dst, dag, FlowPlacement::EqualBalanced, Admission::Lossless, + EdgePriority::Residual); + (void)third_ref.place(1); + PinnedFlow pref(ref, c.src, c.dst, dag, k.placement, k.admission, k.priority); + for (auto v : sc.before) (void)pref.place(v); + const Snapshot initial = snapshot(ref); + const PinnedResult want = pref.place(sc.call); + const Snapshot after = snapshot(ref); + const Epoch want_epoch = epoch(pref); + pref.remove(); + const Snapshot removed = snapshot(ref); + ++sweeps; + for (long at = 0;; ++at) { + const std::string w = where + " allocation " + std::to_string(at); + FlowGraph fg(c.g); + PinnedFlow third(fg, c.src, c.dst, dag, FlowPlacement::EqualBalanced, Admission::Lossless, + EdgePriority::Residual); + (void)third.place(1); + PinnedFlow p(fg, c.src, c.dst, dag, k.placement, k.admission, k.priority); + for (auto v : sc.before) (void)p.place(v); + const Snapshot before = snapshot(fg); + const Epoch before_epoch = epoch(p); + expect_same(before, initial, w + " (setup)"); + PinnedResult got; + if (!fails_at(at, [&] { got = p.place(sc.call); })) { + expect_same(got, want, w + " (no failure)"); + expect_same(snapshot(fg), after, w + " (no failure)"); + break; + } + ++injected; + // Failed: nothing changed, ledger and policy. + expect_same(snapshot(fg), before, w + " (failed call)"); + expect_same(epoch(p), before_epoch, w + " (failed call)"); + expect_balanced(fg, w + " (failed call)"); + // A clean retry is the reference call (no poisoned per-thread scratch). + expect_same(p.place(sc.call), want, w + " (retry)"); + expect_same(snapshot(fg), after, w + " (retry)"); + expect_same(epoch(p), want_epoch, w + " (retry)"); + expect_balanced(fg, w + " (retry)"); + p.remove(); + expect_same(snapshot(fg), removed, w + " (remove)"); + if (::testing::Test::HasFailure()) return; + } + } + } + } + EXPECT_EQ(sweeps, 3 * 6 * 4); + EXPECT_GT(injected, sweeps * 5); // not vacuous: every placement allocates +} + +TEST(LedgerFaults, PolicyConstructionFailsWithoutBinding) { + for (const auto& c : cases()) { + const auto dag = all_edges(*c.g); + for (const auto& k : kernels()) { + for (long at = 0;; ++at) { + FlowGraph fg(c.g); + { + PinnedFlow other(fg, c.src, c.dst, dag, FlowPlacement::EqualBalanced, Admission::Lossless, + EdgePriority::Residual); + (void)other.place(1); + } // an orphan + const Snapshot before = snapshot(fg); + std::optional p; + if (!fails_at(at, [&] { p.emplace(fg, c.src, c.dst, dag, k.placement, k.admission, k.priority); })) { + EXPECT_EQ(LedgerAccess::bound_owners(fg).size(), 1U); + break; + } + expect_same(snapshot(fg), before, c.name + " construction allocation " + std::to_string(at)); + EXPECT_FALSE(p.has_value()); + } + } + } +} + +// review-1b-fixes N1: the per-thread scratch of the plan builders and of the commit grows with +// the graph. Every position fails on a graph larger than any before it in this process, so the +// failure lands while the scratch grows; the failed call changes nothing and a clean retry on the +// same graph works (ASan/UBSan builds report any access outside a partly grown array). +TEST(LedgerFaults, ScratchGrowthFailureLeavesNothingHalfGrown) { + int failures = 0; + std::int32_t n = 200; + // Construction (the route plan's per-node scratch): one edge between the last two nodes. + for (long at = 0;; ++at, n += 150) { + auto g = std::make_shared(make_graph(n, {{n - 2, n - 1, 3, 1}})); + const auto dag = all_edges(*g); + FlowGraph fg(g); + std::optional p; + const bool failed = fails_at(at, [&] { + p.emplace(fg, n - 2, n - 1, dag, FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); + }); + if (failed) { + ++failures; + EXPECT_FALSE(p.has_value()); + EXPECT_TRUE(LedgerAccess::bound_owners(fg).empty()); + p.emplace(fg, n - 2, n - 1, dag, FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); + } + EXPECT_EQ(p->place(2).placed, 2) << "construction allocation " << at; + expect_balanced(fg, "construction allocation " + std::to_string(at)); + p->remove(); + EXPECT_EQ(fg.allocated()[0], 0); + if (!failed) break; + } + // Placement (the commit's per-edge scratch and the kernels' buffers): a chain of n - 1 edges. + for (long at = 0;; ++at, n += 150) { + std::vector chain; + for (NodeId v = 0; v + 1 < n; ++v) chain.push_back({v, v + 1, 5, 1}); + auto g = std::make_shared(make_graph(n, chain)); + const auto dag = all_edges(*g); + FlowGraph fg(g); + PinnedFlow p(fg, 0, n - 1, dag, FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); + const Snapshot before = snapshot(fg); + PinnedResult got; + const bool failed = fails_at(at, [&] { got = p.place(2); }); + if (failed) { + ++failures; + expect_same(snapshot(fg), before, "placement allocation " + std::to_string(at)); + got = p.place(2); + } + EXPECT_EQ(got.placed, 2) << "placement allocation " << at; + expect_balanced(fg, "placement allocation " + std::to_string(at)); + p.remove(); // ids are not reused: only the aggregate and the records return + EXPECT_EQ(fg.allocated(), before.allocated) << "placement allocation " << at; + EXPECT_TRUE(fg.records().empty()); + if (!failed) break; + } + EXPECT_GT(failures, 4); // not vacuous +} + +// The bimodality guard (NOTES "Bimodality"): NodeMap::add runs once per edge endpoint in the +// flow core's subset solve and in the plan builder. It must stay allocation-free (reset() sizes +// both arrays) so that it stays a few inlined instructions: the push_back version was an +// out-of-line call whose register save/restore stalled in some process layouts. +static_assert(noexcept(std::declval().add(NodeId {0}))); + +TEST(LedgerFaults, NodeMapAddNeverAllocates) { + auto& node_map = detail::NodeMap::local(); + // Called through a member pointer: the inventory scanner attributes this member call on a + // receiver it does not list to a deleted 1a owner of the same member name. + constexpr auto reset = &detail::NodeMap::reset; + for (std::size_t n : {std::size_t {10}, std::size_t {5000}, std::size_t {300}}) { + std::invoke(reset, node_map, n); + EXPECT_FALSE(fails_at(0, [&] { + for (std::size_t v = n; v-- > 0;) (void)node_map.add(static_cast(v)); + for (std::size_t v = 0; v < n; ++v) (void)node_map.add(static_cast(v)); + })); + ASSERT_EQ(node_map.size(), n); + EXPECT_EQ(node_map.of(static_cast(n - 1)), 0); // first-touch order + EXPECT_EQ(node_map.nodes()[0], static_cast(n - 1)); + } + std::invoke(reset, node_map, std::size_t {0}); +} + +TEST(LedgerFaults, RemovalAndReleaseNeverAllocate) { + for (const auto& c : cases()) { + const auto dag = all_edges(*c.g); + for (const auto& k : kernels()) { + FlowGraph fg(c.g); + auto p = std::make_unique(fg, c.src, c.dst, dag, k.placement, k.admission, k.priority); + (void)p->place(3); + // remove() and the destructor's release() make no allocation (release() is noexcept). + EXPECT_FALSE(fails_at(0, [&] { p->remove(); })); + (void)p->place(2); + const Snapshot held = snapshot(fg); + EXPECT_FALSE(fails_at(0, [&] { p.reset(); })); + const Snapshot orphaned = snapshot(fg); + EXPECT_EQ(orphaned.allocated, held.allocated); + EXPECT_TRUE(orphaned.bound.empty()); + EXPECT_EQ(orphaned.orphans.size(), 1U); + } + } +} diff --git a/tests/cpp/masking_tests.cpp b/tests/cpp/masking_tests.cpp index 117ea02..30a2c6b 100644 --- a/tests/cpp/masking_tests.cpp +++ b/tests/cpp/masking_tests.cpp @@ -72,3 +72,44 @@ TEST(Eligibility, OutOfRangeIdsRejected) { EXPECT_THROW((void)g.eligibility({}, bad_edge), std::invalid_argument); EXPECT_THROW((void)g.eligibility(neg_node, {}), std::invalid_argument); } + +// Eligibility.intersect (sub-PR 1b): an operand when the result equals it (its uid kept, a +// first), otherwise a new value with a fresh uid; values of different graphs rejected. +TEST(Eligibility, IntersectReturnsAnOperandWhenEqual) { + auto g = make_graph(3, {{0, 1, 1, 1}, {1, 2, 1, 1}, {0, 2, 1, 5}}); + const auto all = g.eligibility(); + const std::vector e0 = {0}; + const std::vector e1 = {1}; + const std::vector e01 = {0, 1}; + const auto a = g.eligibility({}, e0); + const auto b = g.eligibility({}, e1); + // all-allowed with a: a (its uid); a with all-allowed: a. + EXPECT_EQ(Eligibility::intersect(all, a).uid(), a.uid()); + EXPECT_EQ(Eligibility::intersect(a, all).uid(), a.uid()); + EXPECT_EQ(Eligibility::intersect(all, all).uid(), 0u); + EXPECT_EQ(Eligibility::intersect(a, a).uid(), a.uid()); + // a subset relation: the smaller value. + const auto ab_list = g.eligibility({}, e01); + EXPECT_EQ(Eligibility::intersect(a, ab_list).uid(), ab_list.uid()); + // A new value: a fresh uid, flags are the conjunction; equal results built twice differ. + const auto x = Eligibility::intersect(a, b); + const auto y = Eligibility::intersect(a, b); + EXPECT_NE(x.uid(), a.uid()); + EXPECT_NE(x.uid(), b.uid()); + EXPECT_NE(x.uid(), 0u); + EXPECT_NE(x.uid(), y.uid()); + EXPECT_EQ(flags(x.edge_allowed()), flags(ab_list.edge_allowed())); + EXPECT_EQ(x.graph_uid(), g.uid()); + // Node exclusions intersect too (and keep excluding incident edges). + const std::vector n2 = {2}; + const auto c = g.eligibility(n2, {}); + const auto z = Eligibility::intersect(a, c); + EXPECT_EQ(flags(z.node_allowed()), (std::vector{1, 1, 0})); + EXPECT_EQ(flags(z.edge_allowed()), (std::vector{0, 0, 0})); +} + +TEST(Eligibility, IntersectRejectsValuesOfDifferentGraphs) { + auto g = make_graph(2, {{0, 1, 1, 1}}); + auto h = make_graph(2, {{0, 1, 1, 1}}); + EXPECT_THROW((void)Eligibility::intersect(g.eligibility(), h.eligibility()), std::invalid_argument); +} diff --git a/tests/cpp/pred_dag_tests.cpp b/tests/cpp/pred_dag_tests.cpp new file mode 100644 index 0000000..e9e3ff1 --- /dev/null +++ b/tests/cpp/pred_dag_tests.cpp @@ -0,0 +1,152 @@ +// PredDAG and Path (APPROACH 5): from_csr's complete validation in the documented order, the +// canonical predecessor order, structural equality, Path validation in its order, cost and +// to_dag(). +#include + +#include +#include +#include +#include + +#include "netgraph/core/detail/pred_dag_builder.hpp" +#include "netgraph/core/pred_dag.hpp" +#include "test_utils.hpp" + +using namespace netgraph::core; +using namespace netgraph::core::test; + +namespace { + +std::string error_of(const std::function& fn) { + try { + fn(); + } catch (const std::invalid_argument& e) { + return e.what(); + } + return ""; +} + +bool contains(const std::string& s, const std::string& part) { return s.find(part) != std::string::npos; } + +// 0 -> 1 (two parallel members), 1 -> 2, 0 -> 2; ids follow (src, cost, dst, input). +StrictMultiDiGraph diamondish() { + return make_graph(3, {{0, 1, 5, 1}, {0, 1, 5, 1}, {1, 2, 5, 1}, {0, 2, 5, 3}, {2, 0, 5, 1}}); +} + +using V = std::vector; + +} // namespace + +TEST(PredDAG, FromCsrRoundTripAndAccessors) { + auto g = diamondish(); + // ids: 0: 0->1, 1: 0->1, 2: 0->2 (cost 3), 3: 1->2, 4: 2->0 + const V off = {0, 0, 2, 4}; + const V par = {0, 0, 0, 1}; + const V via = {0, 1, 2, 3}; + auto dag = PredDAG::from_csr(g, off, par, via); + EXPECT_EQ(dag.graph_uid(), g.uid()); + EXPECT_EQ(dag.num_nodes(), 3); + EXPECT_EQ(V(dag.parent_offsets().begin(), dag.parent_offsets().end()), off); + EXPECT_EQ(V(dag.parents().begin(), dag.parents().end()), par); + EXPECT_EQ(V(dag.via_edges().begin(), dag.via_edges().end()), via); + EXPECT_EQ(dag.edges(), (std::vector{0, 1, 2, 3})); + // Structural equality: the builder's value from the same edge set is equal. + const std::vector edges = {3, 1, 0, 2}; + EXPECT_TRUE(detail::PredDAGBuilder::from_edges(g, edges) == dag); + auto empty = PredDAG::from_csr(g, V {0, 0, 0, 0}, V {}, V {}); + EXPECT_FALSE(empty == dag); + EXPECT_TRUE(empty.edges().empty()); +} + +TEST(PredDAG, FromCsrShapeChecksInOrder) { + auto g = diamondish(); + // offsets of length N + 1 + EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, V {0, 0, 0}, V {}, V {}); }), "offsets has length 3")); + // offsets[0] == 0 (also before monotone) + EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, V {1, 0, 2, 1}, V {}, V {}); }), "offsets[0] = 1")); + // monotone + EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, V {0, 2, 1, 2}, V {0, 0}, V {0, 1}); }), + "not monotone at 2")); + // offsets[N] == len(parents) == len(via) + EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, V {0, 0, 2, 2}, V {0}, V {0, 1}); }), + "offsets[N] = 2 but parents has 1")); + EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, V {0, 0, 2, 2}, V {0, 0}, V {0}); }), + "and via has 1")); + // Shape before entries: a bad parent and a shape defect report the shape. + EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, V {0, 0, 2, 3}, V {9, 0}, V {0, 1}); }), + "offsets[N] = 3")); +} + +TEST(PredDAG, FromCsrEntryChecksInOrder) { + auto g = diamondish(); + const V off = {0, 0, 2, 2}; + // parents in range, then via in range (entry by entry) + EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, off, V {3, 0}, V {0, 1}); }), "parents[0] = 3")); + EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, off, V {0, 0}, V {0, 5}); }), "via[1] = 5")); + EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, off, V {-1, 0}, V {-1, 1}); }), "parents[0] = -1")); + // via endpoints: wrong parent, wrong child + EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, off, V {1, 0}, V {0, 1}); }), "joins (0, 1), not (1, 1)")); + EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, V {0, 0, 0, 1}, V {0}, V {0}); }), + "joins (0, 1), not (0, 2)")); + // duplicate (child, via) before the order check + EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, off, V {0, 0}, V {1, 1}); }), "duplicate entry")); + // a cycle (0 -> 1 -> 2 -> 0) before the order check + EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, V {0, 1, 2, 3}, V {2, 0, 1}, V {4, 0, 3}); }), + "cycle")); + // canonical order: ascending via within a node + EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, off, V {0, 0}, V {1, 0}); }), + "not in ascending via EdgeId")); +} + +TEST(PredDAG, SelfLoopIsACycle) { + auto g = make_graph(2, {{0, 0, 1, 1}, {0, 1, 1, 1}}); + // id 0: 0->0, id 1: 0->1 + EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, V {0, 1, 1}, V {0}, V {0}); }), "cycle")); +} + +TEST(PredDAG, EmptyGraph) { + auto g = make_graph(0, {}); + auto dag = PredDAG::from_csr(g, V {0}, V {}, V {}); + EXPECT_EQ(dag.num_nodes(), 0); + EXPECT_FALSE(error_of([&] { (void)PredDAG::from_csr(g, V {}, V {}, V {}); }).empty()); +} + +TEST(Path, CostSrcDstAndToDag) { + auto g = diamondish(); + // Grouped first hop (members 1 and 0, caller order kept), then 1 -> 2. + Path p(g, {{1, 0}, {3}}); + EXPECT_EQ(p.src(), 0); + EXPECT_EQ(p.dst(), 2); + EXPECT_EQ(p.nodes(), (std::vector{0, 1, 2})); + EXPECT_EQ(p.cost(), 2); + EXPECT_EQ(p.hops(), (std::vector>{{1, 0}, {3}})); + EXPECT_EQ(p.graph_uid(), g.uid()); + auto dag = p.to_dag(); + EXPECT_EQ(V(dag.parent_offsets().begin(), dag.parent_offsets().end()), (V {0, 0, 2, 3})); + EXPECT_EQ(V(dag.via_edges().begin(), dag.via_edges().end()), (V {0, 1, 3})); + EXPECT_EQ(V(dag.parents().begin(), dag.parents().end()), (V {0, 0, 1})); + // to_dag gives a canonical value: from_csr accepts it unchanged. + EXPECT_TRUE(PredDAG::from_csr(g, dag.parent_offsets(), dag.parents(), dag.via_edges()) == dag); + // Cost is the minimum member cost per hop. + auto g2 = make_graph(2, {{0, 1, 1, 4}, {0, 1, 1, 2}}); + EXPECT_EQ(Path(g2, {{0, 1}}).cost(), 2); + EXPECT_TRUE(Path(g2, {{0, 1}}) == Path(g2, {{0, 1}})); + EXPECT_FALSE(Path(g2, {{0, 1}}) == Path(g2, {{1, 0}})); +} + +TEST(Path, ValidationInOrder) { + auto g = diamondish(); + // range before everything (an empty hop elsewhere is reported later) + EXPECT_TRUE(contains(error_of([&] { Path(g, {{}, {7}}); }), "member 7 out of edge range")); + EXPECT_TRUE(contains(error_of([&] { Path(g, {{-1}}); }), "member -1 out of edge range")); + // duplicate member before an empty hop + EXPECT_TRUE(contains(error_of([&] { Path(g, {{0, 0}, {}}); }), "duplicate member 0")); + EXPECT_TRUE(contains(error_of([&] { Path(g, {}); }), "hop list is empty")); + EXPECT_TRUE(contains(error_of([&] { Path(g, {{0}, {}}); }), "hop 1 is empty")); + // members of a hop share (u, v) + EXPECT_TRUE(contains(error_of([&] { Path(g, {{0, 2}}); }), "do not share (u, v)")); + // contiguous hops + EXPECT_TRUE(contains(error_of([&] { Path(g, {{0}, {2}}); }), "not contiguous")); + // no repeated node: 0 -> 2 -> 0 + EXPECT_TRUE(contains(error_of([&] { Path(g, {{2}, {4}}); }), "node 0 is repeated")); +} diff --git a/tests/cpp/search_tests.cpp b/tests/cpp/search_tests.cpp new file mode 100644 index 0000000..3b6d81d --- /dev/null +++ b/tests/cpp/search_tests.cpp @@ -0,0 +1,351 @@ +// The internal search of APPROACH 6 (sub-PR 1b; public spf in 1d): the member graph and +// select_adjacency, the unique DAG in both directions, anycast pruning, the staged canonical +// single path on the review witnesses and against exhaustive enumeration, validation. +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/detail/search.hpp" +#include "netgraph/core/flow.hpp" +#include "test_utils.hpp" + +using namespace netgraph::core; +using namespace netgraph::core::test; +using netgraph::core::detail::Direction; + +namespace { + +PathSelection sel(bool multipath = true, bool multi_edge = true, + EdgeTieBreak tie = EdgeTieBreak::Deterministic, EdgePriority prio = EdgePriority::Residual) { + return PathSelection(tie, prio, multipath, multi_edge); +} + +std::vector members_of(const std::vector& mask) { + std::vector out; + for (std::size_t e = 0; e < mask.size(); ++e) { + if (mask[e] != 0) out.push_back(static_cast(e)); + } + return out; +} + +std::string error_of(const std::function& fn) { + try { + fn(); + } catch (const std::invalid_argument& e) { + return e.what(); + } + return ""; +} + +detail::SpfOut run(const StrictMultiDiGraph& g, std::vector roots, Direction dir, + std::optional> targets, const PathSelection& s, + std::optional> residual = std::nullopt, Amount min_residual = 1, + const Eligibility* elig = nullptr) { + std::optional> t; + if (targets.has_value()) t = std::span(*targets); + std::optional> r; + if (residual.has_value()) r = std::span(*residual); + return detail::spf(g, roots, dir, t, s, r, min_residual, elig); +} + +} // namespace + +TEST(Search, MemberGraphStaticEligibilityCapacityAndResidual) { + // ids: 0: 0->1 cap 0, 1: 0->1 cap 3, 2: 0->1 cap 5 (cost 2), 3: 1->2 cap 4 + auto g = make_graph(3, {{0, 1, 0, 1}, {0, 1, 3, 1}, {0, 1, 5, 2}, {1, 2, 4, 1}}); + auto m = detail::member_mask(g, nullptr, sel(), {}, 1); + EXPECT_EQ(members_of(m), (std::vector{1, 2, 3})); // capacity < 1 unit never routable + const std::vector res = {0, 1, 5, 4}; + EXPECT_EQ(members_of(detail::member_mask(g, nullptr, sel(), res, 1)), (std::vector{1, 2, 3})); + EXPECT_EQ(members_of(detail::member_mask(g, nullptr, sel(), res, 2)), (std::vector{2, 3})); + const std::vector xe = {3}; + auto el = g.eligibility({}, xe); + EXPECT_EQ(members_of(detail::member_mask(g, &el, sel(), {}, 1)), (std::vector{1, 2})); +} + +TEST(Search, CollapseBySelectAdjacency) { + // Parallel 0->1: id 0 cost 1 cap 2, id 1 cost 1 cap 6, id 2 cost 1 cap 4, id 3 cost 2 cap 9. + auto g = make_graph(2, {{0, 1, 2, 1}, {0, 1, 6, 1}, {0, 1, 4, 1}, {0, 1, 9, 2}}); + // Deterministic: minimum cost, then the lowest EdgeId. + EXPECT_EQ(members_of(detail::member_mask(g, nullptr, sel(true, false), {}, 1)), (std::vector{0})); + // PreferHigherPriority on Capacity: the highest capacity among the cheapest. + auto cap = sel(true, false, EdgeTieBreak::PreferHigherPriority, EdgePriority::Capacity); + EXPECT_EQ(members_of(detail::member_mask(g, nullptr, cap, {}, 1)), (std::vector{1})); + // Residual priority reads the residual; equal residual keeps the lowest id. + auto resid = sel(true, false, EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual); + EXPECT_EQ(members_of(detail::member_mask(g, nullptr, resid, std::vector {2, 1, 2, 9}, 1)), + (std::vector{0})); + EXPECT_EQ(members_of(detail::member_mask(g, nullptr, resid, std::vector {1, 1, 3, 9}, 1)), + (std::vector{2})); + // Headroom compares residual / capacity exactly: 1/2 < 4/6 = 2/3 > 2/4. + auto head = sel(true, false, EdgeTieBreak::PreferHigherPriority, EdgePriority::Headroom); + EXPECT_EQ(members_of(detail::member_mask(g, nullptr, head, std::vector {1, 4, 2, 9}, 1)), + (std::vector{1})); + // Equal rational headroom (2/4 == 1/2): the lowest EdgeId. + EXPECT_EQ(members_of(detail::member_mask(g, nullptr, head, std::vector {1, 1, 2, 9}, 1)), + (std::vector{0})); + // Without a residual every priority reads static capacities. + EXPECT_EQ(members_of(detail::member_mask(g, nullptr, resid, {}, 1)), (std::vector{1})); +} + +TEST(Search, FullDagForwardAndReverse) { + // Diamond with a parallel member and a longer branch: 0->1, 0->1, 0->2 (cost 2), 1->3, 2->3. + auto g = make_graph(4, {{0, 1, 1, 1}, {0, 1, 1, 1}, {0, 2, 1, 2}, {1, 3, 1, 1}, {2, 3, 1, 1}}); + auto f = run(g, {0}, Direction::Forward, std::nullopt, sel()); + ASSERT_TRUE(f.costs.has_value()); + EXPECT_EQ(*f.costs, (std::vector{0, 1, 2, 2})); + // Tight edges: both parallel members, 0->2 (d(2) = 2) and 1->3; 2->3 is not tight (2 + 1 > 2). + EXPECT_EQ(f.edges, (std::vector{0, 1, 2, 3})); + auto r = run(g, {3}, Direction::Reverse, std::nullopt, sel()); + EXPECT_EQ(*r.costs, (std::vector{2, 1, 1, 0})); + // The reverse DAG toward 3 is the forward-oriented set of tight edges toward 3. + EXPECT_EQ(r.edges, (std::vector{0, 1, 3, 4})); + // Unreached nodes keep kUnreachable. + auto u = run(g, {2}, Direction::Forward, std::nullopt, sel()); + EXPECT_EQ(*u.costs, (std::vector{kUnreachable, kUnreachable, 0, 1})); +} + +TEST(Search, AnycastPruningAndDirectionIndependence) { + // 0->1 (1), 1->2 (1), 0->3 (2), 3->2 (5), 1->4 (5): targets {2, 4}: nearest is 2 at cost 2. + auto g = make_graph(5, {{0, 1, 1, 1}, {1, 2, 1, 1}, {0, 3, 1, 2}, {3, 2, 1, 5}, {1, 4, 1, 5}}); + const auto inv = id_of_input(g); + auto f = run(g, {0}, Direction::Forward, std::vector {2, 4}, sel()); + ASSERT_TRUE(f.target_cost.has_value()); + EXPECT_EQ(*f.target_cost, 2); + EXPECT_FALSE(f.costs.has_value()); + std::vector want = {inv[0], inv[1]}; + std::sort(want.begin(), want.end()); + EXPECT_EQ(f.edges, want); + // A reverse search rooted at the targets with the source as its target: the same edges. + auto r = run(g, {2, 4}, Direction::Reverse, std::vector {0}, sel()); + EXPECT_EQ(r.edges, f.edges); + EXPECT_EQ(r.target_cost, f.target_cost); + // No reachable target: nullopt, no edges. + auto none = run(g, {2}, Direction::Forward, std::vector {0}, sel()); + EXPECT_FALSE(none.target_cost.has_value()); + EXPECT_TRUE(none.edges.empty()); +} + +TEST(Search, ExcludedRootsAndTargetsAreSkipped) { + auto g = make_graph(3, {{0, 1, 1, 1}, {1, 2, 1, 1}}); + const std::vector xn = {0}; + auto el = g.eligibility(xn, {}); + auto r = run(g, {0}, Direction::Forward, std::vector {2}, sel(), std::nullopt, 1, &el); + EXPECT_FALSE(r.target_cost.has_value()); + EXPECT_TRUE(r.edges.empty()); + auto full = run(g, {0}, Direction::Forward, std::nullopt, sel(), std::nullopt, 1, &el); + EXPECT_EQ(*full.costs, (std::vector{kUnreachable, kUnreachable, kUnreachable})); +} + +TEST(Search, ValidationBeforeAnyWork) { + auto g = make_graph(3, {{0, 1, 2, 1}, {1, 2, 2, 1}}); + auto other = make_graph(3, {{0, 1, 2, 1}}); + auto foreign = other.eligibility({}, std::vector {0}); + EXPECT_NE(error_of([&] { (void)run(g, {}, Direction::Forward, std::nullopt, sel()); }).find("roots"), std::string::npos); + EXPECT_NE(error_of([&] { (void)run(g, {3}, Direction::Forward, std::nullopt, sel()); }).find("out of node range"), + std::string::npos); + EXPECT_NE(error_of([&] { (void)run(g, {0}, Direction::Forward, std::vector {}, sel()); }).find("targets"), + std::string::npos); + EXPECT_NE(error_of([&] { (void)run(g, {0, 1}, Direction::Forward, std::vector {1}, sel()); }) + .find("both endpoint sets"), + std::string::npos); + EXPECT_NE(error_of([&] { (void)run(g, {0}, Direction::Forward, std::nullopt, sel(false)); }).find("requires targets"), + std::string::npos); + EXPECT_NE(error_of([&] { + (void)run(g, {0}, Direction::Forward, std::nullopt, sel(), std::vector {1}); + }).find("residual has length"), + std::string::npos); + EXPECT_NE(error_of([&] { + (void)run(g, {0}, Direction::Forward, std::nullopt, sel(), std::vector {1, 3}); + }).find("outside [0, capacity_units"), + std::string::npos); + EXPECT_NE(error_of([&] { (void)run(g, {0}, Direction::Forward, std::nullopt, sel(), std::nullopt, 0); }) + .find("min_residual"), + std::string::npos); + EXPECT_NE(error_of([&] { (void)run(g, {0}, Direction::Forward, std::nullopt, sel(), std::nullopt, 1, &foreign); }) + .find("foreign Eligibility"), + std::string::npos); +} + +TEST(Search, StagedPathReview13D3Witness) { + // s=0 -> v=1 cost 1 prio 1; v -> t=4 cost 3 prio 1; v -> a=2 -> b=3 -> t costs 1+1+1 prio 2. + // Capacities carry the priority. Under PreferHigherPriority the bottleneck of every walk is 1 + // (the shared s->v), so B* = 1 and the canonical walk is the two-hop s, v, t. + auto g = make_graph(5, {{0, 1, 1, 1}, {1, 4, 1, 3}, {1, 2, 2, 1}, {2, 3, 2, 1}, {3, 4, 2, 1}}); + const auto inv = id_of_input(g); + for (auto tie : {EdgeTieBreak::Deterministic, EdgeTieBreak::PreferHigherPriority}) { + auto r = run(g, {0}, Direction::Forward, std::vector {4}, + sel(false, true, tie, EdgePriority::Capacity)); + ASSERT_TRUE(r.path.has_value()); + EXPECT_EQ(r.path->walk, (std::vector{inv[0], inv[1]})); + EXPECT_EQ(r.path->cost, 4); + // The reverse call with the roles swapped returns the same walk. + auto rev = run(g, {4}, Direction::Reverse, std::vector {0}, sel(false, true, tie, EdgePriority::Capacity)); + ASSERT_TRUE(rev.path.has_value()); + EXPECT_EQ(rev.path->walk, r.path->walk); + } +} + +TEST(Search, StagedPathBottleneckThenHopsThenIds) { + // Two cost-2 walks 0->1->3 (caps 5, 1) and 0->2->3 (caps 3, 3), and a cost-2 direct edge + // 0->3 of capacity 2. Deterministic: fewest hops, the direct edge. PreferHigherPriority on + // Capacity: B* = 3 (0->2->3), so the two-hop walk. + auto g = make_graph(4, {{0, 1, 5, 1}, {1, 3, 1, 1}, {0, 2, 3, 1}, {2, 3, 3, 1}, {0, 3, 2, 2}}); + const auto inv = id_of_input(g); + auto det = run(g, {0}, Direction::Forward, std::vector {3}, sel(false)); + EXPECT_EQ(det.path->walk, (std::vector{inv[4]})); + auto pri = run(g, {0}, Direction::Forward, std::vector {3}, + sel(false, true, EdgeTieBreak::PreferHigherPriority, EdgePriority::Capacity)); + EXPECT_EQ(pri.path->walk, (std::vector{inv[2], inv[3]})); + ASSERT_TRUE(pri.path->b_star.has_value()); + EXPECT_EQ(pri.path->b_star->num, 3); + // Residual priority reads the residual: on capacities 5 everywhere and residuals by input + // [5, 4, 1, 3, 2], 0->1->3 has bottleneck 4, 0->2->3 has 1, the direct edge 2: B* = 4. + auto g5 = make_graph(4, {{0, 1, 5, 1}, {1, 3, 5, 1}, {0, 2, 5, 1}, {2, 3, 5, 1}, {0, 3, 5, 2}}); + const auto inv5 = id_of_input(g5); + const auto residual = to_ids(g5, std::vector {5, 4, 1, 3, 2}); + auto res = run(g5, {0}, Direction::Forward, std::vector {3}, + sel(false, true, EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual), residual); + EXPECT_EQ(res.path->walk, (std::vector{inv5[0], inv5[1]})); + // Capacity priority on the same graph: every walk has bottleneck 5, so hops decide. + auto cap5 = run(g5, {0}, Direction::Forward, std::vector {3}, + sel(false, true, EdgeTieBreak::PreferHigherPriority, EdgePriority::Capacity), residual); + EXPECT_EQ(cap5.path->walk, (std::vector{inv5[4]})); + // Deterministic ignores capacities and residuals entirely. + auto det_res = run(g5, {0}, Direction::Forward, std::vector {3}, sel(false), residual); + EXPECT_EQ(det_res.path->walk, (std::vector{inv5[4]})); +} + +namespace { + +// Every simple path from a source to a destination over the members (tiny graphs). +void enumerate(const StrictMultiDiGraph& g, const std::vector& member, NodeId u, + const std::vector& dsts, std::vector& walk, std::vector& on, + std::vector>& out) { + if (std::find(dsts.begin(), dsts.end(), u) != dsts.end()) { + out.push_back(walk); + return; + } + const auto row = g.row_offsets(); + for (auto e = row[static_cast(u)]; e < row[static_cast(u) + 1]; ++e) { + if (member[static_cast(e)] == 0) continue; + const NodeId v = g.edge_dst()[static_cast(e)]; + if (on[static_cast(v)] != 0) continue; + on[static_cast(v)] = 1; + walk.push_back(e); + enumerate(g, member, v, dsts, walk, on, out); + walk.pop_back(); + on[static_cast(v)] = 0; + } +} + +} // namespace + +TEST(Search, StagedPathAgainstEnumeration) { + // The canonical key (cost, -bottleneck under PreferHigherPriority, hops, edge-id sequence) + // over every simple walk of the member graph, against the staged construction. + std::mt19937 rng(20261004); + int checked = 0; + for (int trial = 0; trial < 2000; ++trial) { + const int n = 2 + static_cast(rng() % 6); + const int m = 1 + static_cast(rng() % 14); + std::vector edges; + for (int i = 0; i < m; ++i) { + edges.push_back({static_cast(rng() % n), static_cast(rng() % n), + static_cast(rng() % 5), static_cast(1 + rng() % 3)}); + } + auto g = make_graph(n, edges); + std::vector residual(static_cast(g.num_edges())); + for (std::size_t e = 0; e < residual.size(); ++e) { + const Amount c = g.capacity_units()[e]; + residual[e] = c == 0 ? 0 : static_cast(rng() % static_cast(c + 1)); + } + const bool use_res = (rng() % 2) == 0; + const auto tie = (rng() % 2) == 0 ? EdgeTieBreak::Deterministic : EdgeTieBreak::PreferHigherPriority; + const EdgePriority prios[3] = {EdgePriority::Residual, EdgePriority::Capacity, EdgePriority::Headroom}; + const auto prio = prios[rng() % 3]; + const bool multi_edge = (rng() % 2) == 0; + const NodeId s = static_cast(rng() % n); + NodeId t = static_cast(rng() % n); + if (t == s) t = static_cast((s + 1) % n); + const auto selection = sel(false, multi_edge, tie, prio); + std::optional> res; + if (use_res) res = residual; + auto got = run(g, {s}, Direction::Forward, std::vector {t}, selection, res); + const auto member = detail::member_mask(g, nullptr, selection, use_res ? std::span(residual) + : std::span {}, 1); + std::vector> walks; + std::vector walk; + std::vector on(static_cast(n), 0); + on[static_cast(s)] = 1; + enumerate(g, member, s, {t}, walk, on, walks); + if (walks.empty()) { + EXPECT_FALSE(got.path.has_value()); + continue; + } + const detail::PriorityValues pv {&g, prio, use_res ? std::span(residual) : std::span {}}; + auto key_less = [&](const std::vector& a, const std::vector& b) { + Cost ca = 0, cb = 0; + for (EdgeId e : a) ca += g.cost()[static_cast(e)]; + for (EdgeId e : b) cb += g.cost()[static_cast(e)]; + if (ca != cb) return ca < cb; + if (tie == EdgeTieBreak::PreferHigherPriority) { + detail::Priority ba {0, 1, true}, bb {0, 1, true}; + for (EdgeId e : a) if (detail::compare(pv.of(e), ba) < 0) ba = pv.of(e); + for (EdgeId e : b) if (detail::compare(pv.of(e), bb) < 0) bb = pv.of(e); + const int c = detail::compare(ba, bb); + if (c != 0) return c > 0; + } + if (a.size() != b.size()) return a.size() < b.size(); + return a < b; + }; + const auto best = *std::min_element(walks.begin(), walks.end(), key_less); + ASSERT_TRUE(got.path.has_value()); + EXPECT_EQ(got.path->walk, best) << "trial " << trial; + ++checked; + } + EXPECT_GT(checked, 600); +} + +TEST(Search, MultiRootSinglePathStartsAtTheNearestRootByHops) { + // Roots {0, 1}, both at cost 0; 0 -> 3 in one hop of cost 2 and 1 -> 2 -> 3 in two hops of + // cost 1 each: the walk of fewest hops (from root 0). + auto g = make_graph(4, {{0, 3, 1, 2}, {1, 2, 1, 1}, {2, 3, 1, 1}}); + const auto inv = id_of_input(g); + auto r = run(g, {0, 1}, Direction::Forward, std::vector {3}, sel(false)); + ASSERT_TRUE(r.path.has_value()); + EXPECT_EQ(r.path->walk, (std::vector{inv[0]})); +} + +TEST(Search, StagedPathReview13CanonicalWitnesses) { + // review-13 contract_probes.py: the C2 diamond (0-1-3 and 0-2-3 both cost 10) takes 0-2-3, + // whose edge-id sequence is smaller; the equal three-hop graph takes 0-1-4-5. Both tie modes + // (all capacities 1: priority ties), both directions. + auto c2 = make_graph(4, {{0, 1, 1, 9}, {0, 2, 1, 1}, {1, 3, 1, 1}, {2, 3, 1, 9}}); + auto three = make_graph(6, {{0, 2, 1, 1}, {2, 3, 1, 1}, {3, 5, 1, 1}, {0, 1, 1, 1}, {1, 4, 1, 1}, {4, 5, 1, 1}}); + auto nodes_of = [](const StrictMultiDiGraph& g, const std::vector& walk) { + std::vector out = {g.edge_src()[static_cast(walk.front())]}; + for (EdgeId e : walk) out.push_back(g.edge_dst()[static_cast(e)]); + return out; + }; + for (auto tie : {EdgeTieBreak::Deterministic, EdgeTieBreak::PreferHigherPriority}) { + for (auto prio : {EdgePriority::Residual, EdgePriority::Capacity, EdgePriority::Headroom}) { + const auto s = sel(false, true, tie, prio); + auto a = run(c2, {0}, Direction::Forward, std::vector {3}, s); + EXPECT_EQ(nodes_of(c2, a.path->walk), (std::vector{0, 2, 3})); + auto ar = run(c2, {3}, Direction::Reverse, std::vector {0}, s); + EXPECT_EQ(ar.path->walk, a.path->walk); + auto b = run(three, {0}, Direction::Forward, std::vector {5}, s); + EXPECT_EQ(nodes_of(three, b.path->walk), (std::vector{0, 1, 4, 5})); + auto br = run(three, {5}, Direction::Reverse, std::vector {0}, s); + EXPECT_EQ(br.path->walk, b.path->walk); + } + } +} diff --git a/tests/py/test_imports.py b/tests/py/test_imports.py index 7f536a1..f60c017 100644 --- a/tests/py/test_imports.py +++ b/tests/py/test_imports.py @@ -1,32 +1,46 @@ -"""The public surface of the 1a build: the graph on units, Eligibility and the conversions.""" +"""The public surface of the 1b build: the graph on units, Eligibility, the conversions, the +route structures, the selection and placement axes and the ledger.""" import _netgraph_core import netgraph_core as ngc +PUBLIC_1B = { + "StrictMultiDiGraph", + "Eligibility", + "to_units", + "as_float", + "PredDAG", + "Path", + "EdgeTieBreak", + "EdgePriority", + "PathSelection", + "FlowPlacement", + "Admission", + "FlowGraph", + "FlowRecord", +} + +# Private recorder entries: the flow core (1a), the single-bundle Pinned transaction, the +# internal search and the apportionment (1b). Never re-exported by netgraph_core. +PRIVATE_1B = { + "_place_global_units", + "_PinnedFlow", + "_spf", + "_round_robin", + "_highest_averages", +} + def test_public_surface(): - assert set(ngc.__all__) == { - "__version__", - "StrictMultiDiGraph", - "Eligibility", - "to_units", - "as_float", - } + assert set(ngc.__all__) == PUBLIC_1B | {"__version__"} for name in ngc.__all__: assert hasattr(ngc, name) assert isinstance(ngc.__version__, str) -def test_extension_surface_is_exactly_the_1a_set(): - # The old library is deleted in 1a: the extension exports the graph on units, the - # conversions and the private flow-core entry, nothing else. - public = {n for n in dir(_netgraph_core) if not n.startswith("__")} - assert public == { - "StrictMultiDiGraph", - "Eligibility", - "to_units", - "as_float", - "_place_global_units", - } - assert not hasattr(ngc, "_place_global_units") +def test_extension_surface_is_exactly_the_1b_set(): + names = {n for n in dir(_netgraph_core) if not n.startswith("__")} + assert names == PUBLIC_1B | PRIVATE_1B + for name in PRIVATE_1B: + assert not hasattr(ngc, name) diff --git a/tests/py/test_internal_search.py b/tests/py/test_internal_search.py new file mode 100644 index 0000000..ded1a67 --- /dev/null +++ b/tests/py/test_internal_search.py @@ -0,0 +1,97 @@ +"""The private entries of sub-PR 1b: the internal search (``_spf``, APPROACH 6) and the +apportionment (``_round_robin``, ``_highest_averages``, APPROACH 2.1).""" + +from __future__ import annotations + +import _netgraph_core as ext +import pytest + +import netgraph_core as ngc + +DET = ngc.EdgeTieBreak.DETERMINISTIC +PREFER = ngc.EdgeTieBreak.PREFER_HIGHER_PRIORITY +CAP = ngc.EdgePriority.CAPACITY + + +def build(n, edges, q=1.0): + return ngc.StrictMultiDiGraph( + n, + [e[0] for e in edges], + [e[1] for e in edges], + [e[2] for e in edges], + [e[3] for e in edges], + q, + ) + + +def ids(g, positions): + return sorted(int(e) for e, p in enumerate(g.input_index()) if p in positions) + + +def test_full_search_and_anycast(): + g = build(5, [(0, 1, 1, 1), (1, 2, 1, 1), (0, 3, 1, 2), (3, 2, 1, 5), (1, 4, 1, 5)]) + full = ext._spf(g, 0, "forward", None, ngc.PathSelection(DET, CAP)) + assert full["costs"] == [0, 1, 2, 2, 6] + assert "target_cost" not in full + any_ = ext._spf(g, [0], "forward", [2, 4], ngc.PathSelection(DET, CAP)) + assert any_["target_cost"] == 2 + assert sorted(int(e) for e in any_["dag"].via_edges()) == ids(g, {0, 1}) + rev = ext._spf(g, [2, 4], "reverse", [0], ngc.PathSelection(DET, CAP)) + assert rev["dag"] == any_["dag"] + none = ext._spf(g, 2, "forward", 0, ngc.PathSelection(DET, CAP)) + assert none["target_cost"] is None + assert list(none["dag"].via_edges()) == [] + unreachable = ext._spf(g, 2, "forward", None, ngc.PathSelection(DET, CAP)) + assert unreachable["costs"] == [None, None, 0, None, None] + + +def test_staged_single_path_review13_d3(): + # s -> v (1, prio 1), v -> t (3, prio 1), v -> a -> b -> t (1 + 1 + 1, prio 2): the + # bottleneck of every walk is 1, so the two-hop walk wins under both tie modes. + g = build(5, [(0, 1, 1, 1), (1, 4, 1, 3), (1, 2, 2, 1), (2, 3, 2, 1), (3, 4, 2, 1)]) + for tie in (DET, PREFER): + r = ext._spf(g, 0, "forward", 4, ngc.PathSelection(tie, CAP, multipath=False)) + assert r["path"] == ids(g, {0, 1}) + assert r["target_cost"] == 4 + + +def test_search_validation(): + g = build(3, [(0, 1, 2, 1), (1, 2, 2, 1)]) + sel = ngc.PathSelection(DET, CAP) + with pytest.raises(ValueError, match="requires targets"): + ext._spf(g, 0, "forward", None, ngc.PathSelection(DET, CAP, multipath=False)) + with pytest.raises(ValueError, match="both endpoint sets"): + ext._spf(g, [0, 1], "forward", [1], sel) + with pytest.raises(ValueError, match="empty"): + ext._spf(g, [], "forward", None, sel) + with pytest.raises(ValueError, match="residual has length"): + ext._spf(g, 0, "forward", None, sel, [1]) + with pytest.raises(ValueError, match=r"residual\[1\] = 3"): + ext._spf(g, 0, "forward", None, sel, [1, 3]) + with pytest.raises(ValueError, match="min_residual"): + ext._spf(g, 0, "forward", None, sel, None, 0) + with pytest.raises(ValueError, match="direction"): + ext._spf(g, 0, "sideways", None, sel) + gated = ext._spf(g, 0, "forward", None, sel, [2, 1], 2) + assert gated["costs"] == [0, 1, None] + # Section 6's order (review-1b R15): the endpoint checks precede the residual's. + with pytest.raises(ValueError, match="both endpoint sets"): + ext._spf(g, 0, "forward", 0, sel, []) + with pytest.raises(ValueError, match="requires targets"): + ext._spf( + g, 0, "forward", None, ngc.PathSelection(DET, CAP, multipath=False), [] + ) + + +def test_apportionment_entries(): + assert ext._round_robin(7, 3) == [3, 2, 2] + assert ext._highest_averages(10, [3, 9, 9]) == [1, 5, 4] + assert ext._highest_averages(11, [3, 9, 9]) == [1, 5, 5] + assert ext._highest_averages(4, [1, 3, 3]) == [0, 2, 2] + big = (1 << 62) - 2 + a = ext._highest_averages(big, [(1 << 60) + 1, (1 << 60) - 1, 7]) + assert sum(a) == big + with pytest.raises(ValueError, match="outside"): + ext._highest_averages(1, [0, 1]) + with pytest.raises(ValueError, match="2\\^62"): + ext._highest_averages(1, [1 << 61, 1 << 61]) diff --git a/tests/py/test_ledger.py b/tests/py/test_ledger.py new file mode 100644 index 0000000..90c2da3 --- /dev/null +++ b/tests/py/test_ledger.py @@ -0,0 +1,288 @@ +"""The integer ledger FlowGraph through the bindings (APPROACH 8): baselines on unnarrowed +values, snapshots and records, FlowIds, bottleneck, and the lifetime table driven by the +private single-bundle Pinned entry of sub-PR 1b (``_netgraph_core._PinnedFlow``).""" + +from __future__ import annotations + +import copy +import gc +import math + +import _netgraph_core as ext +import numpy as np +import pytest + +import netgraph_core as ngc + +L62 = 1 << 62 +INF = math.inf + + +def build(n, edges, q=1.0): + return ngc.StrictMultiDiGraph( + n, + [e[0] for e in edges], + [e[1] for e in edges], + [e[2] for e in edges], + [e[3] for e in edges], + q, + ) + + +def dag_of(g, edges): + """The PredDAG of an edge set (ascending via per node).""" + per = [[] for _ in range(g.num_nodes())] + for e in sorted(edges): + per[int(g.edge_dst()[e])].append(e) + offsets, parents, via = [0], [], [] + for v in range(g.num_nodes()): + for e in per[v]: + parents.append(int(g.edge_src()[e])) + via.append(e) + offsets.append(len(via)) + return ngc.PredDAG.from_csr(g, offsets, parents, via) + + +def pinned(fg, src, dst, edges, placement, admission=None, priority=None, elig=None): + g = fg.graph() + return ext._PinnedFlow( + fg, + src, + dst, + dag_of(g, edges), + placement, + admission if admission is not None else ngc.Admission.LOSSLESS, + priority if priority is not None else ngc.EdgePriority.RESIDUAL, + elig, + ) + + +EB = ngc.FlowPlacement.EQUAL_BALANCED +MF = ngc.FlowPlacement.MAX_FLOW +W = ngc.FlowPlacement.WEIGHTED +LOSSY = ngc.Admission.LOSSY + + +def test_baseline_validation_order_and_snapshots(): + g = build(2, [(0, 1, 5, 1), (1, 0, 3, 1)]) + fg = ngc.FlowGraph(g) + assert list(fg.residual()) == [5, 3] + assert fg.residual().dtype == np.int64 + assert list(fg.allocated()) == [0, 0] + assert list(fg.capacity_units()) == [5, 3] + assert fg.records() == [] + assert fg.graph() is g + for bad, msg in ( + ([6, 0], r"residual\[0\] = 6 is outside \[0, capacity_units\[0\] = 5\]"), + ([1], "residual has length 1, expected 2"), + ([-1, 0], r"residual\[0\] = -1"), + ([L62 * 4, 0], r"residual\[0\] = 18446744073709551616"), + ([2.5, 0], "integer"), + ([True, 0], "integer"), + (np.zeros((1, 2), dtype=np.int64), "1-D"), + ): + with pytest.raises(ValueError, match=msg): + ngc.FlowGraph(g, bad) + fg2 = ngc.FlowGraph(graph=g, residual=np.array([1, 2], dtype=np.uint64)) + assert list(fg2.residual()) == [1, 2] + assert fg2.uid() != fg.uid() + + +def test_lifetime_bound_rows_and_epochs(): + g = build(2, [(0, 1, 5, 1)]) + fg = ngc.FlowGraph(g) + p = pinned(fg, 0, 1, [0], EB) + r = p.place(0) + assert (r["termination"], r["routes_changed"], fg.records()) == ( + "complete", + False, + [], + ) + assert fg._state()["next_flow_id"] == 0 + with pytest.raises(ValueError, match="bound"): + fg.reset() + with pytest.raises(ValueError, match="bound"): + fg.copy() + with pytest.raises(ValueError, match="bound"): + copy.copy(fg) + r = p.place(2) + assert (r["placed"], r["routes_changed"], p.flow_id()[1]) == (2, True, 0) + r = p.place(INF) + assert (r["placed"], r["termination"], r["remaining"]) == (3, "exhausted", INF) + with pytest.raises(ValueError, match=r"after \+inf"): + p.place(1) + with pytest.raises(ValueError, match=r"after \+inf"): + p.place(0) + old = p.flow_id() + p.remove() + assert list(fg.allocated()) == [0] + with pytest.raises(ValueError, match="retired or unknown"): + fg.record(old) + r = p.place(1) + assert (r["placed"], p.flow_id()[1]) == (1, 1) + p.remove() + p.remove() + assert (p.requested(), p.infinite()) == (0, False) + assert fg._state() == {"next_flow_id": 2, "bound": [0], "orphans": []} + + +def test_orphans_from_destruction_reset_and_copy(): + g = build(2, [(0, 1, 5, 1)]) + fg = ngc.FlowGraph(g) + q = pinned(fg, 0, 1, [0], EB) + q.place(2) + del q + gc.collect() # destroyed without remove(): an orphan + assert fg._state()["orphans"] == [0] + assert fg.records()[0].delivered == 2 + p = pinned(fg, 0, 1, [0], EB) + assert p.place(5)["placed"] == 3 + p.release() + with pytest.raises(Exception, match="released"): + p.place(1) + c = fg.copy() + assert c.uid() != fg.uid() + assert [r.flow_id for r in c.records()] == [(c.uid(), 0), (c.uid(), 1)] + assert list(c.allocated()) == [5] + d = copy.deepcopy(fg) + assert list(d.allocated()) == [5] + fg.reset([4]) + assert (fg.records(), list(fg.residual())) == ([], [4]) + assert fg._state() == {"next_flow_id": 2, "bound": [], "orphans": []} + assert len(c.records()) == 2 # the copy is independent + + +def test_policy_keeps_the_ledger_alive(): + g = build(2, [(0, 1, 5, 1)]) + fg = ngc.FlowGraph(g) + p = pinned(fg, 0, 1, [0], MF) + del fg + gc.collect() + assert p.place(2)["placed"] == 2 + assert p.placed_demand() == 2 + + +def test_records_fields_and_counters_beyond_64_bits(): + g = build(4, [(0, 1, 10, 1), (0, 2, 10, 1), (1, 3, 1, 1), (2, 3, 10, 1)]) + fg = ngc.FlowGraph(g) + p = pinned(fg, 0, 3, range(4), EB, LOSSY) + r = p.place(10) + assert (r["placed"], r["termination"], r["drops"]) == (6, "complete", [(2, 4)]) + rec = fg.records()[0] + assert rec.flow_id == (fg.uid(), 0) + assert (rec.owner, rec.sources, rec.destinations) == (0, [0], [3]) + assert (rec.placement, rec.admission) == (EB, LOSSY) + assert list(rec.bundle.via_edges()) == [0, 1, 2, 3] + assert (rec.route_cost, rec.offered, rec.delivered) == (2, 10, 6) + assert rec.edges == [(0, 5), (1, 5), (2, 1), (3, 5)] + assert rec.drops == [(2, 4)] + assert fg.record(rec.flow_id).delivered == 6 + # Lossless records: no offered; a route without a walk at creation: route_cost None. + g2 = build(2, [(0, 1, 1, 1)]) + fg2 = ngc.FlowGraph(g2) + pinned(fg2, 0, 1, [0], MF).place(1) + assert fg2.records()[0].offered is None + # Every unit dropped on a saturated link, nine times: the counters pass 2^64. + sat = pinned(fg2, 0, 1, [0], EB, LOSSY) + for _ in range(9): + sat.place(L62 - 1) + lossy = fg2.records()[1] + assert lossy.offered == 9 * (L62 - 1) + assert lossy.drops == [(0, 9 * (L62 - 1))] + assert sat.requested() == 9 * (L62 - 1) + assert lossy.offered > 1 << 64 + + +def test_empty_split_graph_keeps_the_route_cost(): + # Weighted Residual with every route edge at residual 0 under another owner's flow: the + # record exists at zero delivery with an empty split graph; route_cost is the bundle's + # minimum walk cost (APPROACH 8), not a split-graph value. + g = build(2, [(0, 1, 2, 3)]) + fg = ngc.FlowGraph(g) + pinned(fg, 0, 1, [0], MF).place(2) + w = pinned(fg, 0, 1, [0], W) + r = w.place(1) + assert (r["placed"], r["termination"], r["routes_changed"]) == ( + 0, + "exhausted", + True, + ) + rec = fg.record(w.flow_id()) + assert (rec.route_cost, rec.delivered, rec.edges) == (3, 0, []) + assert w.plan()["members"] == [] + + +def test_flow_ids_of_other_ledgers_and_bad_shapes(): + g = build(2, [(0, 1, 5, 1)]) + a, b = ngc.FlowGraph(g), ngc.FlowGraph(g) + p = pinned(a, 0, 1, [0], MF) + p.place(1) + with pytest.raises(ValueError, match="another ledger"): + b.record(p.flow_id()) + with pytest.raises(ValueError, match="tuple"): + a.record(0) + with pytest.raises(ValueError, match="retired or unknown"): + a.record((a.uid(), L62 * 8)) + + +def test_requests_are_integer_units_or_inf(): + g = build(2, [(0, 1, 5, 1)]) + fg = ngc.FlowGraph(g) + p = pinned(fg, 0, 1, [0], EB) + for bad, msg in ( + (1.5, "integer units or"), + (-1, "out of range"), + (L62, "out of range"), + (True, "integer"), + (-INF, "integer units or"), + (float("nan"), "integer units or"), + ): + with pytest.raises(ValueError, match=msg): + p.place(bad) + assert fg.records() == [] + assert p.place(np.int64(2))["placed"] == 2 + + +def test_bottleneck_versus_min_cut(): + g = build(4, [(0, 1, 1, 1), (0, 2, 2, 3), (1, 2, 1, 1), (2, 3, 2, 1)]) + fg = ngc.FlowGraph(g) + p = pinned(fg, 0, 3, range(4), MF) + assert p.place(INF)["placed"] == 2 + inv = [0] * 4 + for e, pos in enumerate(g.input_index()): + inv[pos] = e + assert fg.bottleneck(0) == sorted([inv[0], inv[3]]) + assert fg.bottleneck([0, 0]) == sorted([inv[0], inv[3]]) + assert fg.bottleneck([0], g.eligibility(excluded_nodes=[0])) == [] + with pytest.raises(ValueError, match="empty"): + fg.bottleneck([]) + with pytest.raises(ValueError, match="out of node range"): + fg.bottleneck([L62 * 8]) + with pytest.raises(ValueError, match="foreign"): + fg.bottleneck([0], build(4, [(0, 1, 1, 1)]).eligibility(excluded_edges=[0])) + + +def test_pinned_construction_errors(): + g = build(3, [(0, 1, 5, 1), (2, 1, 5, 1)]) + fg = ngc.FlowGraph(g) + with pytest.raises(ValueError, match="malformed bundle"): + pinned(fg, 0, 2, [0, 1], MF) + with pytest.raises(ValueError, match="Lossy"): + pinned(fg, 0, 1, [0], MF, LOSSY) + with pytest.raises(ValueError, match="Headroom"): + pinned(fg, 0, 1, [0], W, priority=ngc.EdgePriority.HEADROOM) + with pytest.raises(ValueError, match="both endpoint sets"): + pinned(fg, 1, 1, [0], MF) + other = build(3, [(0, 1, 5, 1), (2, 1, 5, 1)]) + with pytest.raises(ValueError, match="foreign PredDAG"): + ext._PinnedFlow( + fg, + 0, + 1, + dag_of(other, [0]), + MF, + ngc.Admission.LOSSLESS, + ngc.EdgePriority.RESIDUAL, + None, + ) + assert fg._state()["bound"] == [] diff --git a/tests/py/test_route_structures.py b/tests/py/test_route_structures.py new file mode 100644 index 0000000..81530e3 --- /dev/null +++ b/tests/py/test_route_structures.py @@ -0,0 +1,191 @@ +"""PredDAG, Path, PathSelection, the enums and Eligibility.intersect through the bindings +(APPROACH 4, 5, 6): validation in the documented order on unnarrowed values, read-only views, +structural equality.""" + +from __future__ import annotations + +import gc + +import numpy as np +import pytest + +import netgraph_core as ngc + +L64 = 1 << 64 + + +def build(n, edges, q=1.0): + return ngc.StrictMultiDiGraph( + n, + [e[0] for e in edges], + [e[1] for e in edges], + [e[2] for e in edges], + [e[3] for e in edges], + q, + ) + + +# ids by (src, cost, dst, input): 0: 0->1, 1: 0->1, 2: 0->2 (cost 3), 3: 1->2, 4: 2->0 +G_EDGES = [(0, 1, 5, 1), (0, 1, 5, 1), (1, 2, 5, 1), (0, 2, 5, 3), (2, 0, 5, 1)] + + +def test_from_csr_round_trip_and_views(): + g = build(3, G_EDGES) + dag = ngc.PredDAG.from_csr(g, [0, 0, 2, 4], [0, 0, 0, 1], [0, 1, 2, 3]) + assert dag.graph_uid() == g.uid() + assert list(dag.parent_offsets()) == [0, 0, 2, 4] + assert list(dag.parents()) == [0, 0, 0, 1] + assert list(dag.via_edges()) == [0, 1, 2, 3] + view = dag.via_edges() + assert view.dtype == np.int32 + with pytest.raises(ValueError): + view[0] = 9 + del dag + gc.collect() + assert list(view) == [0, 1, 2, 3] # the view keeps the DAG alive + same = ngc.PredDAG.from_csr( + g, np.array([0, 0, 2, 4]), np.array([0, 0, 0, 1]), np.array([0, 1, 2, 3]) + ) + again = ngc.PredDAG.from_csr(g, [0, 0, 2, 4], [0, 0, 0, 1], [0, 1, 2, 3]) + assert same == again and hash(same) == hash(again) + assert same != ngc.PredDAG.from_csr(g, [0, 0, 0, 0], [], []) + + +@pytest.mark.parametrize( + ("offsets", "parents", "via", "message"), + [ + ([0, 0, 0], [], [], "offsets has length 3"), + ([1, 0, 2, 1], [], [], r"offsets\[0\] = 1"), + ([L64, 0, 0, 0], [], [], r"offsets\[0\] = 18446744073709551616"), + ([0, 2, 1, 2], [0, 0], [0, 1], "not monotone at 2"), + ([0, L64, 0, 0], [], [], "not monotone at 2"), + ([0, 0, 2, 2], [0], [0, 1], "parents has 1"), + ([0, 0, 2, 3], [9, 0], [0, 1], r"offsets\[N\] = 3"), + ([0, 0, 2, 2], [3, 0], [0, 1], r"parents\[0\] = 3"), + ([0, 0, 2, 2], [-L64, 0], [0, 1], r"parents\[0\] = -18446744073709551616"), + ([0, 0, 2, 2], [0, 0], [0, L64], r"via\[1\] = 18446744073709551616"), + ([0, 0, 2, 2], [1, 0], [0, 1], r"joins \(0, 1\), not \(1, 1\)"), + ([0, 0, 2, 2], [0, 0], [1, 1], "duplicate entry"), + ([0, 1, 2, 3], [2, 0, 1], [4, 0, 3], "cycle"), + ([0, 0, 2, 2], [0, 0], [1, 0], "not in ascending via EdgeId"), + ], +) +def test_from_csr_validation_order(offsets, parents, via, message): + g = build(3, G_EDGES) + with pytest.raises(ValueError, match=message): + ngc.PredDAG.from_csr(g, offsets, parents, via) + + +@pytest.mark.parametrize( + "offsets", + [ + [0, 2**100, 2**99], + (0, 2**100, 2**99), + [0, 2**64 - 1, 2**64 - 2], + np.array([0, 2**64 - 1, 2**64 - 2], dtype=np.uint64), + [0, 100, 99], + ], +) +def test_from_csr_compares_offsets_beyond_int64_exactly(offsets): + # review-1b D2: two offsets above int64 compare by value, so a decreasing pair is the + # monotonicity error at its index, before the offsets[N] count check. + g = build(2, [(0, 1, 1, 1)]) + with pytest.raises(ValueError, match="not monotone at 2"): + ngc.PredDAG.from_csr(g, offsets, [], []) + increasing = [0, 2**99, 2**100] + with pytest.raises( + ValueError, match=r"offsets\[N\] = 1267650600228229401496703205376" + ): + ngc.PredDAG.from_csr(g, increasing, [], []) + + +def test_from_csr_type_and_shape_defects(): + g = build(3, G_EDGES) + with pytest.raises(ValueError, match="1-D"): + ngc.PredDAG.from_csr(g, np.zeros((2, 2), dtype=np.int64), [], []) + with pytest.raises(ValueError, match="integer"): + ngc.PredDAG.from_csr(g, [0, 0, 0, 0.0], [], []) + with pytest.raises(ValueError, match="integer"): + ngc.PredDAG.from_csr(g, [0, 0, 0, True], [], []) + + +def test_path_cost_hops_and_to_dag(): + g = build(3, G_EDGES) + p = ngc.Path(g, [[1, 0], [3]]) + assert (p.src(), p.dst(), p.cost()) == (0, 2, 2) + assert p.hops() == [[1, 0], [3]] # the caller's member order is kept + assert p.nodes() == [0, 1, 2] + dag = p.to_dag() + assert list(dag.parent_offsets()) == [0, 0, 2, 3] + assert list(dag.via_edges()) == [0, 1, 3] + assert ngc.Path(g, [[1, 0], [3]]) == p + assert ngc.Path(g, [[0, 1], [3]]) != p + assert hash(ngc.Path(g, [[1, 0], [3]])) == hash(p) + + +@pytest.mark.parametrize( + ("hops", "message"), + [ + ([[], [7]], "member 7 out of edge range"), + ([[L64]], "member 18446744073709551616 out of edge range"), + ([[0, 0], []], "duplicate member 0"), + ([], "hop list is empty"), + ([[0], []], "hop 1 is empty"), + ([[0, 2]], r"do not share \(u, v\)"), + ([[0], [2]], "not contiguous"), + ([[2], [4]], "node 0 is repeated"), + ], +) +def test_path_validation_order(hops, message): + g = build(3, G_EDGES) + with pytest.raises(ValueError, match=message): + ngc.Path(g, hops) + + +def test_path_selection_and_enums(): + sel = ngc.PathSelection( + ngc.EdgeTieBreak.PREFER_HIGHER_PRIORITY, ngc.EdgePriority.HEADROOM + ) + assert (sel.multipath, sel.multi_edge) == (True, True) + assert sel.tie_break == ngc.EdgeTieBreak.PREFER_HIGHER_PRIORITY + assert sel == ngc.PathSelection( + ngc.EdgeTieBreak.PREFER_HIGHER_PRIORITY, ngc.EdgePriority.HEADROOM, True, True + ) + assert sel != ngc.PathSelection( + ngc.EdgeTieBreak.PREFER_HIGHER_PRIORITY, + ngc.EdgePriority.HEADROOM, + multipath=False, + ) + assert "PREFER_HIGHER_PRIORITY" in repr(sel) + with pytest.raises(TypeError): + ngc.PathSelection() # type: ignore[call-arg] # tie_break and priority are explicit + assert {m.name for m in ngc.FlowPlacement.__members__.values()} == { + "MAX_FLOW", + "WEIGHTED", + "EQUAL_BALANCED", + } + assert set(ngc.Admission.__members__) == {"LOSSLESS", "LOSSY"} + assert set(ngc.EdgePriority.__members__) == {"RESIDUAL", "CAPACITY", "HEADROOM"} + assert set(ngc.EdgeTieBreak.__members__) == { + "DETERMINISTIC", + "PREFER_HIGHER_PRIORITY", + } + + +def test_eligibility_intersect_returns_the_operand_object(): + g = build(3, G_EDGES) + everything = g.eligibility() + a = g.eligibility(excluded_edges=[0]) + b = g.eligibility(excluded_edges=[1]) + ab = g.eligibility(excluded_edges=[0, 1]) + assert ngc.Eligibility.intersect(everything, a) is a + assert ngc.Eligibility.intersect(a, everything) is a + assert ngc.Eligibility.intersect(a, a) is a + assert ngc.Eligibility.intersect(a, ab) is ab + x = ngc.Eligibility.intersect(a, b) + y = ngc.Eligibility.intersect(a, b) + assert x.uid() not in (0, a.uid(), b.uid(), y.uid()) + assert list(x.edge_allowed()) == list(ab.edge_allowed()) + other = build(3, G_EDGES) + with pytest.raises(ValueError, match="different graphs"): + ngc.Eligibility.intersect(a, other.eligibility()) From 2088f3b24bfca2c971217c855057b18d59a9e5b7 Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Tue, 6 Oct 2026 10:17:59 -0700 Subject: [PATCH 05/12] Phase 1c: the policy engine, the evaluators and the DagCache; the 1a to 1c tree coherent and groomed Build the third piece of the streamlined Core on the 1a graph, the 1b ledger and kernels, against APPROACH revision 42, then make the whole 1a to 1c tree coherent and groom its public interface before 1d: - FlowPolicy with the four routing modes (CostOnly, CapacityAware, Global, Pinned), DiminishingReturns, the cost ceiling (Absolute, Stretch), explicit placement, admission and selection; place(volume, outputs) -> PlacementResult (placed, remaining, termination, local outcomes per roster source, the two event-accumulating flags, drops, the cumulative cost curve, edge flows, residual, bottleneck, min cut); remove(), records(), record(id), placed_demand(), usable_bundles(); the roster fixed at construction (9.1); the per-source transaction of 9.2 (cumulative round-robin offers, stages 1 to 4 on a per-thread working overlay, growth never repeating a route on a multipath DAG, T_max probes within the 62-probe bound); the termination reduction and the flag table of 9.4; the epoch law and lifetimes of section 8; Global as the cumulative minimum-cost flow on V = W + own with the canonical decomposition and per-source records; every output prepared before one atomic commit, including the Python result objects (all-or-nothing across worker threads and the binding, APPROACH 13, with its two stated limits: the pybind11 object machinery, and a supplied building DagCache as a memo). - Sparse record bundles over member nodes (R16): the public CSR shape on demand, unchanged supplied bundles shared, the builder's grouping by a stable counting sort. - DagCache in building mode: key (root set, direction, Eligibility uid), value the full static support, statistics (hits, misses, entries, bytes, live values), clear(). - Evaluators: capacity, batch_capacity (arguments first, then each pair completely in pair order; the lowest failing index; explicit per-pair sequences length-checked; Pinned rejected), sensitivity (Global positive-flow candidates, the other models every allowed edge of at least one unit, signed losses, unevaluable with a reason); workers with private caches allocated before any thread starts, noexcept worker bodies, every thread joined before the lowest-index failure is rethrown; the exact shortcuts (Global loss from the baseline's maximum flow, CostOnly off-route edges skipped). - One min-cost flow core with the deletion re-solve as a mode (APPROACH 2.2): the deletion state as a value, working potentials as published potentials plus the running sum of the deletion deltas, 128-bit working arithmetic within the stated bounds, the three fixed-origin repairs with the failed-search reuse; it replaces the separate warm solver and the 1b single-bundle driver (_PinnedFlow, src/pinned_flow.cpp); a one-bundle Pinned FlowPolicy stands in for the latter. - The coherence pass of the whole tree: no dead code, no wrong comment, no leaked abstraction, one implementation per rule; profiling removed; the test probes and trace behind the CMake option NETGRAPH_CORE_PRIVATE_HOOKS (OFF for wheels, ON for the development build and the tests; the OFF build exports no hook, probe or trace symbol); transactional copy assignment of the deletion state; validating constructors for every enumeration-typed option (std::invalid_argument naming the field). - The public interface groomed to one scheme: Graph (was StrictMultiDiGraph), FlowLedger (was FlowGraph) taken by FlowPolicy as ledger=, OutputOptions (was Outputs), DiminishingReturns (was Diminishing), MAX_FLOWS_PER_CALL in Python (kMaxFlowsPerCall in C++); the public headers split so each says what it holds (graph.hpp, eligibility.hpp, flow_record.hpp, flow_ledger.hpp, flow_policy.hpp, evaluators.hpp, dag_cache.hpp, options.hpp with the two placement enums, pred_dag.hpp, types.hpp); Counter128 self-contained, so no public header includes a detail/ header; the private headers and the test files on one naming scheme; the policy fixture data in tests/data/ with a maintained exporter; no alias, no re-export, no adapter of a retired spelling. - The kernel's round robin as one inline callback template (one implementation of the split; the span form a wrapper), so allocate and the Lossy step run in one pass: the coherence pass's two-pass form had cost kernel.eb.grid 11 percent against df3b894; results unchanged. - The kernel plan builder fills its arrays by index into pre-sized vectors instead of push_back: under Xcode 27's libc++ 22 the push_back form cost the kernel rows about ten percent (emplace_back's new fast-path and slow-path lambdas); results unchanged. - Bindings, _docs.py stubs and the Python package for every class and function (keyword-only configuration objects, keep-alive of ledger, Eligibility and cache, the GIL released in the evaluators); pytest collects tests/ only; 157 C++ tests (134 ordinary, 23 in the separate fault executable) and 266 Python tests, with fault-injection sweeps of every growth, publication and deletion path at 1, 2 and 4 workers and at every Python allocation position. Gates (dev/design/2026-10-streamline/gates/1c/, uncommitted design tree), on the final groomed build with the private hooks on and off: workspace check and sanitizers green; the fault executable under the sanitizers; the warning-set builds with 0 diagnostics; the literal equivalence run 0 failures (31,788 unchanged, 28,224 new-api, 14,778 rule, 13,701 new-only; the rest invalid-input, deferred to 1d or absent by rule); oracle population 13,901 pass / 0 fail (ksp and spf queries wait for 1d); every returned value checked and proven by mutation (114,222 corruptions of the accepted results, 0 undocumented survivors); inventory CHECK OK on 1,249 rows, 0 Core callers of any deleted symbol; prototype parity identical on the 1b and 1c drivers (45 and 59 fixtures, 8,412 random cases); every recorded result of the groomed build identical to the pre-grooming build's (20,960 of 20,960) and to the frozen 1c baseline; A/B/A against df3b894 on the final tree (both sides Apple clang 21 build 2100.3.34.2, Xcode 27), 15 of 15 rows: construction -43 and -52 percent, the kernels -3 to -28 (kernel.eb.grid -1.8 within tolerance), capacity -28 and -24, max-flow -13 and -14, sensitivity -8 (the deletion re-solve replacing the separate warm solver) and -92, the CapacityAware cohort rows -61 and -99.6; the new-model rows as target-versus-target A/A, 3 of 3. Reviewed adversarially (review-1c, review-1c-fixes, review-1c-r29, review-resolve 1 to 3, review-audit-1abc, review-coherence, review-coherence-rc, review-naming 1 to 4, review-grooming, review-timing-fixes): F1 to F5 fixed (a worker-thread allocation that escaped to std::terminate, Python publication after the commit, batch validation order, the explicit empty eligibility list, the Global zero call's cumulative curve); the coherence audits' findings fixed and RC1 to RC3 closed; the tooling's twelve gate holes (W11) closed by W12 before these gates ran. NetGraph stays red until its migration converges (Phase 2). Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 140 +- CMakeLists.txt | 39 +- Makefile | 2 +- README.md | 112 +- bindings/python/module.cpp | 1083 ++++++++-- dev/cpp-tests.sh | 2 +- include/netgraph/core/dag_cache.hpp | 67 + include/netgraph/core/detail/endpoints.hpp | 55 + include/netgraph/core/detail/enum_domain.hpp | 66 + .../netgraph/core/detail/evaluator_checks.hpp | 28 + include/netgraph/core/detail/flow_core.hpp | 225 ++- include/netgraph/core/detail/int128.hpp | 76 +- include/netgraph/core/detail/kernels.hpp | 115 +- .../netgraph/core/detail/ledger_access.hpp | 50 +- include/netgraph/core/detail/node_map.hpp | 2 - include/netgraph/core/detail/pinned_flow.hpp | 104 - .../netgraph/core/detail/policy_engine.hpp | 80 + .../netgraph/core/detail/pred_dag_builder.hpp | 21 +- .../netgraph/core/detail/private_hooks.hpp | 51 + include/netgraph/core/detail/search.hpp | 85 +- include/netgraph/core/detail/trace.hpp | 97 + include/netgraph/core/eligibility.hpp | 46 + include/netgraph/core/evaluators.hpp | 89 + .../core/{flow_graph.hpp => flow_ledger.hpp} | 26 +- include/netgraph/core/flow_policy.hpp | 160 ++ .../core/{flow.hpp => flow_record.hpp} | 19 +- .../{strict_multidigraph.hpp => graph.hpp} | 60 +- include/netgraph/core/max_flow.hpp | 61 - include/netgraph/core/options.hpp | 43 +- include/netgraph/core/pred_dag.hpp | 27 +- include/netgraph/core/profiling.hpp | 116 -- include/netgraph/core/types.hpp | 7 +- pyproject.toml | 9 +- python/netgraph_core/__init__.py | 100 +- python/netgraph_core/_docs.py | 509 ++++- src/dag_cache.cpp | 76 + src/eligibility.cpp | 81 + src/evaluators.cpp | 315 +++ src/flow_core.cpp | 880 +++++++++ src/{flow_graph.cpp => flow_ledger.cpp} | 101 +- src/flow_policy.cpp | 1741 +++++++++++++++++ src/{strict_multidigraph.cpp => graph.cpp} | 76 +- src/kernels.cpp | 448 ++--- src/max_flow.cpp | 581 ------ src/pinned_flow.cpp | 252 --- src/pred_dag.cpp | 105 +- src/profiling.cpp | 131 -- src/search.cpp | 100 +- ...asking_tests.cpp => eligibility_tests.cpp} | 8 +- tests/cpp/fault_injection.hpp | 101 + tests/cpp/flow_core_deletion_fault_tests.cpp | 453 +++++ tests/cpp/flow_core_deletion_tests.cpp | 856 ++++++++ ...max_flow_tests.cpp => flow_core_tests.cpp} | 25 +- ..._tests.cpp => flow_ledger_fault_tests.cpp} | 192 +- ..._graph_tests.cpp => flow_ledger_tests.cpp} | 250 ++- tests/cpp/flow_policy_fault_tests.cpp | 462 +++++ tests/cpp/flow_policy_fixture_tests.cpp | 891 +++++++++ tests/cpp/flow_policy_tests.cpp | 704 +++++++ ...multidigraph_tests.cpp => graph_tests.cpp} | 10 +- tests/cpp/kernels_tests.cpp | 185 +- tests/cpp/pinned_one.hpp | 54 + tests/cpp/pred_dag_tests.cpp | 64 +- tests/cpp/search_tests.cpp | 13 +- tests/cpp/smoke_test.cpp | 30 - tests/cpp/test_utils.hpp | 31 +- tests/data/policy_fixtures.json | 1 + tests/py/private_hooks.py | 12 + ...lace_global_units.py => test_flow_core.py} | 6 +- .../{test_ledger.py => test_flow_ledger.py} | 172 +- tests/py/test_flow_policy.py | 514 +++++ tests/py/test_flow_policy_fixtures.py | 342 ++++ tests/py/test_graph.py | 56 +- tests/py/test_imports.py | 46 - tests/py/test_internal_search.py | 8 +- tests/py/test_public_surface.py | 140 ++ tests/py/test_route_structures.py | 3 +- 76 files changed, 11473 insertions(+), 2815 deletions(-) create mode 100644 include/netgraph/core/dag_cache.hpp create mode 100644 include/netgraph/core/detail/endpoints.hpp create mode 100644 include/netgraph/core/detail/enum_domain.hpp create mode 100644 include/netgraph/core/detail/evaluator_checks.hpp delete mode 100644 include/netgraph/core/detail/pinned_flow.hpp create mode 100644 include/netgraph/core/detail/policy_engine.hpp create mode 100644 include/netgraph/core/detail/private_hooks.hpp create mode 100644 include/netgraph/core/detail/trace.hpp create mode 100644 include/netgraph/core/eligibility.hpp create mode 100644 include/netgraph/core/evaluators.hpp rename include/netgraph/core/{flow_graph.hpp => flow_ledger.hpp} (83%) create mode 100644 include/netgraph/core/flow_policy.hpp rename include/netgraph/core/{flow.hpp => flow_record.hpp} (69%) rename include/netgraph/core/{strict_multidigraph.hpp => graph.hpp} (66%) delete mode 100644 include/netgraph/core/max_flow.hpp delete mode 100644 include/netgraph/core/profiling.hpp create mode 100644 src/dag_cache.cpp create mode 100644 src/eligibility.cpp create mode 100644 src/evaluators.cpp create mode 100644 src/flow_core.cpp rename src/{flow_graph.cpp => flow_ledger.cpp} (82%) create mode 100644 src/flow_policy.cpp rename src/{strict_multidigraph.cpp => graph.cpp} (74%) delete mode 100644 src/max_flow.cpp delete mode 100644 src/pinned_flow.cpp delete mode 100644 src/profiling.cpp rename tests/cpp/{masking_tests.cpp => eligibility_tests.cpp} (93%) create mode 100644 tests/cpp/fault_injection.hpp create mode 100644 tests/cpp/flow_core_deletion_fault_tests.cpp create mode 100644 tests/cpp/flow_core_deletion_tests.cpp rename tests/cpp/{max_flow_tests.cpp => flow_core_tests.cpp} (96%) rename tests/cpp/{ledger_fault_tests.cpp => flow_ledger_fault_tests.cpp} (63%) rename tests/cpp/{flow_graph_tests.cpp => flow_ledger_tests.cpp} (75%) create mode 100644 tests/cpp/flow_policy_fault_tests.cpp create mode 100644 tests/cpp/flow_policy_fixture_tests.cpp create mode 100644 tests/cpp/flow_policy_tests.cpp rename tests/cpp/{strict_multidigraph_tests.cpp => graph_tests.cpp} (97%) create mode 100644 tests/cpp/pinned_one.hpp delete mode 100644 tests/cpp/smoke_test.cpp create mode 100644 tests/data/policy_fixtures.json create mode 100644 tests/py/private_hooks.py rename tests/py/{test_place_global_units.py => test_flow_core.py} (98%) rename tests/py/{test_ledger.py => test_flow_ledger.py} (66%) create mode 100644 tests/py/test_flow_policy.py create mode 100644 tests/py/test_flow_policy_fixtures.py delete mode 100644 tests/py/test_imports.py create mode 100644 tests/py/test_public_surface.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e22dae..285898c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,69 +7,91 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Streamline sub-PR 1b (breaking; integration branch `streamline`) - -- **Ledger**: `FlowGraph(graph, residual=None)` is the integer ledger: a baseline in units - (validated within `[0, capacity_units]`), records as snapshots in FlowId order - (`records()`, `record(flow_id)` with `FlowId = (ledger uid, id)`, ids never reused), - `capacity_units()`, `residual()`, `allocated()`, `bottleneck(sources, elig)`, `reset()` and - `copy()` with the lifetime table (bound, orphaned records; reset and copy rejected while a - policy is bound). `FlowRecord` carries the flow's endpoints as sets, placement, admission, - bundle (`PredDAG`), route cost, `offered` (Lossy only), delivery and per-edge allocations and - drops; counters are 128-bit (exact Python ints). Every policy call reaches the ledger as one - atomic exchange: a failed call, an allocation failure included, changes nothing. -- **Kernels**: `FlowPlacement` (`MAX_FLOW`, `WEIGHTED`, `EQUAL_BALANCED`) and `Admission` - (`LOSSLESS`, `LOSSY`). MaxFlow solves the flow core on the flow's split graph over - `residual + own allocation` (its own allocation cancellable, other owners never); Weighted - apportions by highest averages (D'Hondt, EdgeId ties) over weights frozen at creation from - `priority`; EqualBalanced by round robin from the lowest EdgeId; the cumulative amount grows - to `max(T, min(T + v, T_max))` with `T_max` found exactly on the monotone allocation; Lossy - splits each offer per call and records transit drops. Exercised through the private - `_netgraph_core._PinnedFlow` (a Pinned placement with one bundle) until the policy engine. -- **Route structures**: `PredDAG.from_csr(graph, offsets, parents, via)` with the complete - validation (shape, ranges, endpoints, duplicates, cycles, ascending via within a node), on - the exact integers also beyond int64; - `Path(graph, hops)` with `to_dag()`; `PathSelection`, `EdgeTieBreak` - (`DETERMINISTIC`, `PREFER_HIGHER_PRIORITY`), `EdgePriority`. -- **Eligibility**: `Eligibility.intersect(a, b)`. -- **Search** (internal until sub-PR 1d): the unique shortest-path DAG from a root set in both - directions, anycast pruning to the nearest targets, and the staged canonical single path - (tight DAG, best attainable bottleneck under `PREFER_HIGHER_PRIORITY`, hops, edge ids). - -### Streamline sub-PR 1a (breaking; integration branch `streamline`) - -The release rebuilds Core in sub-PRs (`dev/design/2026-10-streamline/`). Sub-PR 1a: - -- **Removed**: the previous library and its bindings: `FlowState`, `FlowGraph`, `FlowPolicy` - and their configuration, the backend and algorithms facade, the search entry points - (`shortest_paths`, `shortest_paths_to`, `spf_dag`, `k_shortest_paths`), max-flow, batch - max-flow and sensitivity, the SPF memo and `StateStamp`, `kMinFlow`, `kEpsilon`, - `constants.hpp`, the Python profiling accessors, and their tests. The ledger, kernels, - policies, searches and evaluators return in sub-PRs 1b to 1d; the entries below the 1a block - describe the removed API. -- **Graph**: `StrictMultiDiGraph(num_nodes, src, dst, capacity_units, cost, resolution)` is the - one constructor: capacities are `int64` units of an explicit resolution (a power of two in - `[2^-32, 2^32]`), costs are positive integers, and the public `EdgeId` is the rank of an edge in - `(src, cost, dst, input)` with `input_index()` mapping ids to input positions. Validation - reports the first defect in a fixed order (unit values, shapes, costs, remaining bounds, - including the cost-sum guard). Accessors return read-only NumPy views. `Eligibility` is built - from excluded node ids and EdgeIds. `to_units` (round half up) and `as_float` are the only - conversions between physical values and units. -- **Flow core**: one integer minimum-cost flow (successive shortest paths with potentials, Dinic - phases per cost tier, every edge its own arc, arcs at a node in ascending EdgeId) behind the - private entry `_netgraph_core._place_global_units`, returning the value, the marginal cost - curve, edge flows, the residual minimum cut, the potentials and six operation counters. +The streamlined Core (breaking; integration branch `streamline`): one numeric domain (integer +units), one flow core, one placement engine. It replaces the 0.11 API. -### Added +### Changed -- **Shortest Paths**: `shortest_paths_dag(g, src, dst, ...)` (C++) and `Backend::spf_dag` / `Algorithms::spf_dag` return the destination's cost and the predecessor DAG without materialising the N-sized distance vector; `calc_max_flow`, `FlowState::place_max_flow`, `FlowPolicy` and the KSP spur searches now use it (outputs unchanged). `Backend::spf_dag` has a default implementation derived from `spf`, so other backends need no change. Measured old/new/old against 0.11.0: about 1.25x on first-neighbour searches at 16k–262k nodes, unchanged on full searches. -- **Graph**: `StrictMultiDiGraph.max_cost()` / `cost_gcd()` in Python, the inputs of the bucket-queue dispatch rule (`max_cost // cost_gcd + 1 <= 65536`). -- **Explicit Paths**: `PredDAG.from_hops(graph, hops)` (C++ `make_hops_dag`) builds a route whose hops may each use several parallel edges, so a pinned bundle carries all of them (a 4 x 10 bundle carries 40 where `from_edges` with one edge carries 10). `from_edges` is the one-edge-per-hop case and shares the validation. -- **Shortest Paths**: `EdgeSelection.priority` (`EdgePriority.RESIDUAL` | `CAPACITY` | `HEADROOM`) selects the per-edge value that the `PREFER_HIGHER_RESIDUAL` tie-break and the node-level bottleneck ordering rank by: the residual (the default and the previous behaviour), the static capacity even when a residual is supplied, or the headroom `residual / capacity`. Capacity gating is unchanged and always uses the residual. The default route is unaffected (same code, bit-identical outputs); the other priorities cost one extra capacity load (and a division for headroom) per examined edge. +- **Graph**: `Graph(num_nodes, src, dst, capacity_units, cost, resolution)` (replacing 0.11's + `StrictMultiDiGraph`) is the one constructor: capacities are `int64` units of an explicit + resolution (a power of two in `[2^-32, 2^32]`), costs are positive integers, and the public + `EdgeId` is the rank of an edge in `(src, cost, dst, input)` with `input_index()` mapping ids + to input positions. Validation reports the first defect in a fixed order (the resolution, the + unit values, the shapes, zero or negative costs, the remaining bounds including the cost-sum + guard), on the exact integers also beyond int64. Accessors return read-only NumPy views. + `Eligibility` is built from excluded node ids and EdgeIds, with `Eligibility.intersect(a, b)`. + `to_units` (round half up) and `as_float` are the only conversions between physical values and + units. +- **Route structures**: `PredDAG.from_csr(graph, offsets, parents, via)` with the complete + validation (shape, ranges, endpoints, duplicates, cycles, ascending via within a node); a DAG + is stored over its member nodes and presents the CSR shape on demand. `Path(graph, hops)` + with `to_dag()`. `PathSelection`, `EdgeTieBreak` (`DETERMINISTIC`, `PREFER_HIGHER_PRIORITY`), + `EdgePriority` (`RESIDUAL`, `CAPACITY`, `HEADROOM`). +- **Ledger**: `FlowLedger(graph, residual=None)` (replacing 0.11's `FlowGraph`) is the integer + ledger: a baseline in units (validated within `[0, capacity_units]`), records as snapshots in + FlowId order (`records()`, `record(flow_id)` with `FlowId = (ledger uid, id)`, ids never + reused), `capacity_units()`, `residual()`, `allocated()`, `bottleneck(sources, elig)`, + `reset()` and `copy()` with the lifetime table (bound and orphaned records; reset and copy + rejected while a policy is bound). `FlowRecord` carries the flow's endpoints as sets, + placement, admission, bundle (`PredDAG`), route cost, `offered` (Lossy only), delivery and + per-edge allocations and drops; counters are 128-bit (exact Python ints). The ledger is + mutated only through policies. +- **Kernels**: `FlowPlacement` (`MAX_FLOW`, `WEIGHTED`, `EQUAL_BALANCED`) and `Admission` + (`LOSSLESS`, `LOSSY`). MaxFlow solves the flow core on the flow's split graph over the residual + plus its own allocation; Weighted apportions by highest averages (D'Hondt, EdgeId ties) over + weights frozen at creation from `priority`; EqualBalanced by round robin from the lowest + EdgeId; Lossy splits each offer per call and records transit drops. +- **Policies**: `FlowPolicy(ledger, sources, destinations, config, elig=None, cache=None)` + is the one placement engine. `FlowPolicyConfig(routing=..., placement=..., admission=..., + selection=...)` has four explicit axes; the routing is `CostOnly()`, `CapacityAware(...)` + (flow count, an optional maximum, an `Absolute` or `Stretch` cost ceiling, a minimum flow + bandwidth in units, reoptimisation, `DiminishingReturns(window, fraction)`), `Global()` or + `Pinned(bundles)`; values outside an enumeration raise `ValueError`. `place(volume, + outputs=None)` runs the per-source transaction (creation, reoptimisation, amounts, growth; at + most `MAX_FLOWS_PER_CALL` creations per call) or, on Global, the cumulative minimum-cost flow on + the residual plus the policy's own allocation, and returns `PlacementResult` (`placed`, + `remaining`, `termination`, `local_outcomes`, `routes_changed`, `weights_changed`, `drops`, + Global's `cost_distribution`, and `edge_flows`, `residual`, `bottleneck` and Global's + `min_cut` when requested through `OutputOptions`). Validation precedes the zero-volume shortcut. + `remove()`, `records()`, `record(flow_id)` (the policy's own records), `placed_demand()` and + `usable_bundles()` complete the policy. +- **Failure guarantee**: every public call is all-or-nothing up to its caller, an allocation + failure included, across worker threads and the Python result. Two limits: the pybind11 object + machinery (a construction or conversion outside the staged calls allocates through + `make_new_instance`, which does not check `tp_alloc`: a Python allocation failure there + crashes the process, Core state unchanged), and a supplied building `DagCache`, a memo that + keeps the complete entries and statistics of the lookups that succeeded before a failure. +- **DagCache**: `DagCache(graph)` in building mode: keyed by the root set, the direction and + the Eligibility, its values the full static supports (immutable, shared, valid after + `clear()`), with `stats()` (`hits`, `misses`, `entries`, `bytes`, `live_values`). +- **Evaluators**: `capacity(graph, sources, destinations, CapacityOptions(...))` (with Global + and MaxFlow the exact maximum flow, its marginal cost curve and its minimum cut), + `batch_capacity(graph, pairs, opts, per_pair_elig=None, workers=None)` (pair order; the lowest + failing index raises; a supplied cache needs `workers == 1`) and `sensitivity(graph, sources, + destinations, SensitivityOptions(...), workers=None)` (every candidate edge, the signed + unthresholded loss in units, unevaluable edges with their reason). The default worker counts + read `NGRAPH_CORE_BATCH_THREADS` and `NGRAPH_CORE_SENSITIVITY_THREADS` (1 when unset). +- **Flow core**: one integer minimum-cost flow (successive shortest paths with potentials, + Dinic phases per cost tier, every edge its own arc, arcs at a node in ascending EdgeId) serves + Global routing, the MaxFlow kernel and the evaluators (the exact sensitivity model re-solves + each deletion from the baseline solve's final state); the private entry + `_netgraph_core._place_global_units` returns the value, the marginal cost curve, edge flows, + the residual minimum cut, the potentials and six operation counters. +- **C++ headers** (under `netgraph/core/`): `types.hpp`, `graph.hpp` (`Graph`, `to_units`, + `as_float`; it includes `eligibility.hpp`), `eligibility.hpp`, `options.hpp` (the selection + axes, `FlowPlacement`, `Admission`), `pred_dag.hpp`, `flow_record.hpp` (`FlowId`, + `Counter128`, `FlowRecord`), `flow_ledger.hpp`, `flow_policy.hpp`, `dag_cache.hpp` and + `evaluators.hpp`; each compiles alone and includes no `detail/` header. Python spells the + creation cap `MAX_FLOWS_PER_CALL` (C++ `kMaxFlowsPerCall`). -### Fixed +### Removed -- **Flow Policy**: two policies managing the same `(src, dst, flowClass)` on one `FlowGraph` assign the same flow ids, so they previously merged their allocations under identical ledger keys and `remove_demand` on one zeroed the other's flow. Creating a flow whose index the ledger already holds now raises `ValueError` instead. +- The 0.11 library and its bindings: `FlowState`, the previous `FlowGraph` and `FlowPolicy` and + their configuration, the backend and algorithms facade (with its `Graph` handle), the search + entry points (`shortest_paths`, `shortest_paths_to`, `spf_dag`, `k_shortest_paths`), max-flow, + batch max-flow and the previous sensitivity, the SPF memo and `StateStamp`, `kMinFlow`, + `kEpsilon`, `constants.hpp`, and the profiling facility (the Python accessors, the C++ scopes + and `NGRAPH_CORE_PROFILE`). ## [0.11.0] - 2026-10-02 diff --git a/CMakeLists.txt b/CMakeLists.txt index 86c7559..bb219ef 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,6 +4,10 @@ project(netgraph_core LANGUAGES CXX) set(PYBIND11_FINDPYTHON NEW CACHE STRING "Use modern FindPython in pybind11") # Optional C++ tests option(NETGRAPH_CORE_BUILD_TESTS "Build C++ unit tests" OFF) +# The private hooks (test controls and prototype-parity diagnostics the contract does not name): +# ON for the editable development build (pyproject.toml) and the test suites (dev/cpp-tests.sh), +# OFF for an installed wheel. +option(NETGRAPH_CORE_PRIVATE_HOOKS "Build the private test hooks into the library and the extension" OFF) if(NETGRAPH_CORE_BUILD_TESTS) include(FetchContent) FetchContent_Declare( @@ -14,17 +18,20 @@ if(NETGRAPH_CORE_BUILD_TESTS) FetchContent_MakeAvailable(googletest) enable_testing() add_executable(netgraph_core_tests - tests/cpp/smoke_test.cpp - tests/cpp/strict_multidigraph_tests.cpp - tests/cpp/max_flow_tests.cpp - tests/cpp/masking_tests.cpp + tests/cpp/graph_tests.cpp + tests/cpp/flow_core_tests.cpp + tests/cpp/flow_core_deletion_tests.cpp + tests/cpp/eligibility_tests.cpp tests/cpp/pred_dag_tests.cpp tests/cpp/search_tests.cpp tests/cpp/kernels_tests.cpp - tests/cpp/flow_graph_tests.cpp + tests/cpp/flow_ledger_tests.cpp + tests/cpp/flow_policy_fixture_tests.cpp + tests/cpp/flow_policy_tests.cpp ) target_link_libraries(netgraph_core_tests PRIVATE netgraph_core GTest::gtest_main) target_include_directories(netgraph_core_tests PRIVATE tests/cpp) + target_compile_definitions(netgraph_core_tests PRIVATE NETGRAPH_CORE_TEST_DATA="${CMAKE_CURRENT_SOURCE_DIR}/tests/data") if(NETGRAPH_CORE_COVERAGE AND (CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")) # Atomic counters: see the NETGRAPH_CORE_COVERAGE block below. target_compile_options(netgraph_core_tests PRIVATE -O0 -g --coverage) @@ -34,7 +41,8 @@ add_executable(netgraph_core_tests target_link_options(netgraph_core_tests PRIVATE --coverage) endif() # Allocation-failure tests replace the global operator new: a separate executable. - add_executable(netgraph_core_fault_tests tests/cpp/ledger_fault_tests.cpp) + add_executable(netgraph_core_fault_tests tests/cpp/flow_ledger_fault_tests.cpp tests/cpp/flow_policy_fault_tests.cpp + tests/cpp/flow_core_deletion_fault_tests.cpp) target_link_libraries(netgraph_core_fault_tests PRIVATE netgraph_core GTest::gtest_main) target_include_directories(netgraph_core_fault_tests PRIVATE tests/cpp) if(NETGRAPH_CORE_COVERAGE AND (CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")) @@ -86,19 +94,23 @@ endif() add_library(netgraph_core STATIC - src/strict_multidigraph.cpp - src/max_flow.cpp + src/graph.cpp + src/eligibility.cpp + src/flow_core.cpp src/pred_dag.cpp src/search.cpp src/kernels.cpp - src/flow_graph.cpp - src/pinned_flow.cpp - src/profiling.cpp + src/flow_ledger.cpp + src/dag_cache.cpp + src/flow_policy.cpp + src/evaluators.cpp ) target_include_directories(netgraph_core PUBLIC $ $ ) +# PUBLIC: the library, the extension and the tests see one value (0 or 1). +target_compile_definitions(netgraph_core PUBLIC NETGRAPH_CORE_PRIVATE_HOOKS=$) ## C++ standard is set globally above. target_compile_features(netgraph_core PUBLIC cxx_std_20) @@ -219,9 +231,8 @@ endif() if(NETGRAPH_CORE_COVERAGE) message(STATUS "Enabling coverage instrumentation (disables optimizations)") if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") - # Worker threads (batch and sensitivity evaluators from 1c on) corrupt the default - # non-atomic profile counters under concurrent updates (gcov then emits impossible - # values such as "branch 4 taken -1"). + # The evaluators' worker threads corrupt the default non-atomic profile counters under + # concurrent updates (gcov then emits impossible values such as "branch 4 taken -1"). set(COV_FLAGS -O0 -g --coverage) if(CMAKE_CXX_COMPILER_ID MATCHES "GNU") list(APPEND COV_FLAGS -fprofile-update=atomic) diff --git a/Makefile b/Makefile index 0e41d81..cbcd14f 100644 --- a/Makefile +++ b/Makefile @@ -258,7 +258,7 @@ cov: mkdir -p "$$BUILD_DIR"; \ GEN_ARGS=""; \ if command -v ninja >/dev/null 2>&1; then GEN_ARGS="-G Ninja"; fi; \ - cmake -S . -B "$$BUILD_DIR" -DNETGRAPH_CORE_BUILD_TESTS=ON -DNETGRAPH_CORE_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug $$GEN_ARGS; \ + cmake -S . -B "$$BUILD_DIR" -DNETGRAPH_CORE_BUILD_TESTS=ON -DNETGRAPH_CORE_PRIVATE_HOOKS=ON -DNETGRAPH_CORE_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug $$GEN_ARGS; \ cmake --build "$$BUILD_DIR" --config Debug -j; \ ctest --test-dir "$$BUILD_DIR" --output-on-failure || echo "⚠️ Some C++ tests failed (continuing for coverage)" @echo "📈 Generating C++ coverage XML (gcovr)..." diff --git a/README.md b/README.md index 7d4e4fd..f2aebf7 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,12 @@ C++ graph engine for network flow analysis, traffic engineering simulation, and capacity planning. -> **Streamline in progress.** The `streamline` branch rebuilds Core in sub-PRs -> (`dev/design/2026-10-streamline/`). This build (sub-PR 1b) carries the graph on integer -> units, the minimum-cost flow core behind a private entry point, the route structures -> (`PredDAG`, `Path`), the integer ledger `FlowGraph` and the placement kernels (exercised -> through private entries until the policy engine arrives); policies, the public searches and -> the evaluators return in the following sub-PRs. The previous API (0.12 and earlier) is in git -> history and on PyPI. +> **Streamline in progress.** The `streamline` branch rebuilds Core. This build carries the +> graph on integer units, the minimum-cost flow core, the route structures (`PredDAG`, `Path`), +> the integer ledger `FlowLedger` with the placement kernels, the policy engine `FlowPolicy`, the +> `DagCache` (building mode) and the evaluators `capacity`, `batch_capacity` and `sensitivity`. +> The public search API (shortest paths, k shortest paths, path resolution) and the frozen cache +> are not part of it yet. The previous API (0.11 and earlier) is in git history and on PyPI. ## Overview @@ -26,10 +25,10 @@ C++ graph engine for network flow analysis, traffic engineering simulation, and ### Graph -- **`StrictMultiDiGraph(num_nodes, src, dst, capacity_units, cost, resolution)`**: immutable +- **`Graph(num_nodes, src, dst, capacity_units, cost, resolution)`**: immutable directed multigraph with forward and reverse CSR adjacency; parallel edges and self-loops are - allowed. Validation reports the first defect in a fixed order (unit values, shapes, costs, - remaining bounds). Accessors (`capacity_units()`, `cost()`, `edge_src()`, `edge_dst()`, + allowed. Validation reports the first defect in a fixed order (the resolution, the unit + values, the shapes, zero or negative costs, the remaining bounds). Accessors (`capacity_units()`, `cost()`, `edge_src()`, `edge_dst()`, `input_index()`, `row_offsets()`, `in_row_offsets()`, `in_adj_edge_index()`) return read-only NumPy views that keep the graph alive. - **`Eligibility`**: immutable, graph-bound static eligibility built by @@ -38,8 +37,9 @@ C++ graph engine for network flow analysis, traffic engineering simulation, and ### Route structures -- **`PredDAG`**: immutable, graph-bound predecessor DAG in CSR form (`parent_offsets()`, - `parents()`, `via_edges()`), entries in ascending via EdgeId within a node. +- **`PredDAG`**: immutable, graph-bound predecessor DAG, stored over its member nodes and + presented in CSR form (`parent_offsets()` built on demand, `parents()`, `via_edges()`), + entries in ascending via EdgeId within a node. `PredDAG.from_csr(graph, offsets, parents, via)` validates the shape, the ranges and endpoints, duplicates, acyclicity and the canonical order before accepting a DAG. - **`Path(graph, hops)`**: an explicit walk whose hops may hold several parallel members; @@ -50,7 +50,7 @@ C++ graph engine for network flow analysis, traffic engineering simulation, and ### Ledger and kernels -- **`FlowGraph(graph, residual=None)`**: the integer ledger of one graph: an immutable baseline +- **`FlowLedger(graph, residual=None)`**: the integer ledger of one graph: an immutable baseline in units and the records (`FlowRecord`: flow id, owner, endpoints, placement, admission, bundle, route cost, offered, delivered, per-edge allocation and drops) of the flows placed on it. `allocated()` is the exact sum of the records and `residual()` the baseline minus it; @@ -59,35 +59,84 @@ C++ graph engine for network flow analysis, traffic engineering simulation, and - **Kernels** (`FlowPlacement`: `MAX_FLOW`, `WEIGHTED`, `EQUAL_BALANCED`; `Admission`: `LOSSLESS`, `LOSSY`): MaxFlow is the flow core on the flow's DAG (a replacement on its own allocation plus the residual), Weighted splits by highest averages over weights frozen at - creation, EqualBalanced by round robin; Lossy forwards hop by hop and records drops. Until the - policy engine lands, a placement on one bundle runs through the private - `_netgraph_core._PinnedFlow`. + creation, EqualBalanced by round robin; Lossy forwards hop by hop and records drops. + +### Policies + +- **`FlowPolicy(ledger, sources, destinations, config, elig=None, cache=None)`**: the one + placement engine, bound to its ledger. `FlowPolicyConfig(routing=..., placement=..., + admission=..., selection=...)` has four explicit axes; the routing is `CostOnly()` (static + shortest paths), `CapacityAware(flow_count=1, max_flow_count=None, max_path_cost=None, + min_flow_bandwidth=0, reoptimize=False, diminishing=None)` (residual-aware searches with an + `Absolute` or `Stretch` cost ceiling, a `DiminishingReturns(window, fraction)` guard, and at + most `MAX_FLOWS_PER_CALL` (10,000) creations per call), `Global()` (the minimum-cost flow of the policy's + sources to its destinations on the residual plus its own allocation) or `Pinned(bundles)` + (fixed `PredDAG`s). +- **`place(volume, outputs=None)`** runs one transaction per call (per source for CostOnly and + CapacityAware, cumulative on Global) and returns a `PlacementResult` (`placed`, `remaining`, + `termination`, `local_outcomes`, `routes_changed`, `weights_changed`, `drops` and the + optional `cost_distribution` (Global) and, when requested through `OutputOptions`, `edge_flows`, + `residual`, `bottleneck` and, on Global with `OutputOptions(bottleneck=True)`, `min_cut`). + `remove()` reverts the policy's flows; `records()`, `record(flow_id)`, `placed_demand()` and + `usable_bundles()` read its state. +- **Failure guarantee**: every public call is all-or-nothing up to its caller: a failure + anywhere (validation, allocation, a worker thread, the construction of the Python result) + leaves every ledger, policy, cache and scratch buffer as it was and raises one exception. + Two limits: the pybind11 object machinery is outside the guarantee (constructing a bound + object from Python, or a conversion into a new bound object or container outside the staged + calls, allocates through pybind11's `make_new_instance`, which does not check `tp_alloc`, so a + Python allocation failure there crashes the process, with Core state unchanged); and a + supplied building `DagCache` is a memo that keeps the complete entries and statistics of the + lookups that succeeded before a later failure of the call. + +### DagCache + +`DagCache(graph)` keeps the static shortest-path supports policies build, keyed by the root +set, the direction and the Eligibility; values are immutable and shared, and `stats()` reports +hits, misses, entries, retained bytes and live values. This build has the building mode (one +writer); the frozen mode follows. + +### Evaluators + +`capacity(graph, sources, destinations, CapacityOptions(config, elig=None, cache=None, +outputs=None))` is `place(+inf)` of a policy on a fresh ledger; Global with MaxFlow gives the +exact maximum flow with its marginal cost curve, and its minimum cut with +`outputs=OutputOptions(bottleneck=True)`. `batch_capacity(graph, pairs, +opts, per_pair_elig=None, workers=None)` evaluates pairs in pair order (the lowest failing +index raises). `sensitivity(graph, sources, destinations, SensitivityOptions(...), +workers=None)` reports, for every candidate edge, the signed loss in placed units when the +edge is removed. The default worker counts come from `NGRAPH_CORE_BATCH_THREADS` and +`NGRAPH_CORE_SENSITIVITY_THREADS`. ### Flow core -`_netgraph_core._place_global_units(graph, view, sources, destinations, elig, cap)` computes -the minimum-cost flow of the maximum value attainable under `cap` (`None`: uncapped) from a -source set to a destination set over the eligible edges, on the capacities `view` (units, id -order), starting from zero flow. It returns `{placed, cost_distribution, edge_flows, min_cut, -potentials, counters}`. It is private: the recorder and the gates use it; the public evaluators -built on it arrive with the policy engine. +One native integer minimum-cost flow core serves Global routing, the MaxFlow kernel and the +evaluators: the minimum-cost flow of the maximum value attainable under a cap from a source set +to a destination set over the eligible edges, starting from zero flow; the exact sensitivity +model re-solves each deletion from the baseline solve's final state. Its private Python entry +`_netgraph_core._place_global_units(graph, view, sources, destinations, elig, cap)` returns the +payload `{placed, cost_distribution, edge_flows, min_cut, potentials, counters}` for the +equivalence recorder and the oracle checks. ## Quick Start ```python import netgraph_core as ngc -import _netgraph_core q = 2.0**-12 # Two parallel paths from node 0 to node 3 (physical capacities 10 and 5). caps = ngc.to_units([10.0, 10.0, 5.0, 5.0], q) -graph = ngc.StrictMultiDiGraph(4, [0, 1, 0, 2], [1, 3, 2, 3], caps, [1, 1, 2, 2], q) +graph = ngc.Graph(4, [0, 1, 0, 2], [1, 3, 2, 3], caps, [1, 1, 2, 2], q) -payload = _netgraph_core._place_global_units( - graph, graph.capacity_units(), [0], [3], None, None +config = ngc.FlowPolicyConfig( + routing=ngc.Global(), + placement=ngc.FlowPlacement.MAX_FLOW, + admission=ngc.Admission.LOSSLESS, + selection=ngc.PathSelection(ngc.EdgeTieBreak.DETERMINISTIC, ngc.EdgePriority.CAPACITY), ) -print(ngc.as_float(payload["placed"], q)) # 15.0 -print(payload["cost_distribution"]) # [[2, 40960], [4, 20480]] +result = ngc.capacity(graph, 0, 3, ngc.CapacityOptions(config)) +print(ngc.as_float(result.placed, q)) # 15.0 +print(result.cost_distribution) # [(2, 40960), (4, 20480)] ``` ## Installation @@ -138,11 +187,16 @@ make cov # Combined coverage report (C++ + Python) Performance work (harnesses, old/new/old protocol, recorded measurements) is described in [dev/perf/README.md](dev/perf/README.md). +The editable development build (`make install`, `make dev`) carries the private test hooks +(CMake option `NETGRAPH_CORE_PRIVATE_HOOKS`; a wheel build does not); the test suites skip the +tests that need them when they are absent. + ## Environment Variables | Variable | Effect | | --- | --- | -| `NGRAPH_CORE_PROFILE=1` | Enable the C++ profiling scopes (`profiling.hpp`); the Python accessors return with `netgraph_core.diagnostics`. | +| `NGRAPH_CORE_BATCH_THREADS` | Default worker count of `batch_capacity` (an integer in [1, 4096]; 1 when unset or invalid; an explicit `workers` wins). | +| `NGRAPH_CORE_SENSITIVITY_THREADS` | Default worker count of `sensitivity` (as above). | ## Requirements diff --git a/bindings/python/module.cpp b/bindings/python/module.cpp index d61ce6a..e9faa8d 100644 --- a/bindings/python/module.cpp +++ b/bindings/python/module.cpp @@ -1,8 +1,11 @@ /* - Pybind11 module `_netgraph_core`: the graph on integer units, the flow core's private entry, - the ledger with its records, the route structures (PredDAG, Path), Eligibility with - intersect, the selection and placement enums, and the private entries of the 1b recorder - population (a Pinned placement with one bundle, the internal search, the apportionment). + Pybind11 module `_netgraph_core`: the graph on integer units with Eligibility and the unit + conversions, the route structures (PredDAG, Path), the selection and placement axes, the + ledger with its records, the policy engine with its configuration and results, the DagCache + and the evaluators (capacity, batch_capacity, sensitivity); and two private entries: the flow + core's _place_global_units (the recorder's entry, APPROACH 2.2) and the internal search _spf. + A build with the private hooks (CMake NETGRAPH_CORE_PRIVATE_HOOKS: the editable development + build) adds the test controls and the parity diagnostics. - Graph accessors return read-only NumPy views that keep the graph alive (no per-call copy). - Integer inputs (capacities, costs, ids, views, caps) must be integers: Python ints, NumPy @@ -10,11 +13,13 @@ truncated. Values are read without narrowing (WideInts) and validated in the contract's order before any conversion to the C++ types (APPROACH 3, revision 25). Physical values enter only through to_units; the resolution is validated before anything else. - - std::invalid_argument from Core maps to ValueError. + - std::invalid_argument from Core maps to ValueError; enumeration values outside their + enumeration are rejected at construction (rule 13). - _place_global_units is the recorder's private entry point (APPROACH 2.2): it returns the payload as a dict of Python ints and lists. - Amounts and counters cross as Python ints (exact, also beyond 64 bits); per-edge arrays as int64 NumPy arrays in EdgeId order; +inf is accepted only as a request (float('inf')). + - Python spells constants in UPPER_SNAKE_CASE: MAX_FLOWS_PER_CALL is the C++ kMaxFlowsPerCall. */ #include #include @@ -23,24 +28,39 @@ #include #include #include +#include #include #include #include #include +#include +#include #include -#include "netgraph/core/detail/kernels.hpp" -#include "netgraph/core/detail/ledger_access.hpp" -#include "netgraph/core/detail/pinned_flow.hpp" +#include "netgraph/core/dag_cache.hpp" +#include "netgraph/core/detail/endpoints.hpp" +#include "netgraph/core/detail/evaluator_checks.hpp" +#include "netgraph/core/detail/flow_core.hpp" #include "netgraph/core/detail/pred_dag_builder.hpp" +#include "netgraph/core/detail/private_hooks.hpp" #include "netgraph/core/detail/search.hpp" -#include "netgraph/core/flow.hpp" -#include "netgraph/core/flow_graph.hpp" -#include "netgraph/core/max_flow.hpp" +#include "netgraph/core/eligibility.hpp" +#include "netgraph/core/evaluators.hpp" +#include "netgraph/core/flow_ledger.hpp" +#include "netgraph/core/flow_policy.hpp" +#include "netgraph/core/flow_record.hpp" +#include "netgraph/core/graph.hpp" #include "netgraph/core/options.hpp" #include "netgraph/core/pred_dag.hpp" -#include "netgraph/core/strict_multidigraph.hpp" #include "netgraph/core/types.hpp" +#if NETGRAPH_CORE_PRIVATE_HOOKS +#include + +#include "netgraph/core/detail/kernels.hpp" +#include "netgraph/core/detail/ledger_access.hpp" +#include "netgraph/core/detail/policy_engine.hpp" +#include "netgraph/core/detail/trace.hpp" +#endif namespace py = pybind11; using namespace netgraph::core; @@ -296,9 +316,9 @@ double amount_as_float(const py::handle& item, int k, double resolution) { return scaled_python_int(idx, k); } -std::shared_ptr make_graph(const py::object& num_nodes, const py::object& src, - const py::object& dst, const py::object& capacity_units, - const py::object& cost, double resolution) { +std::shared_ptr make_graph(const py::object& num_nodes, const py::object& src, + const py::object& dst, const py::object& capacity_units, + const py::object& cost, double resolution) { // APPROACH 3 (revision 25): the resolution, the unit values, the shapes, zero or negative // costs, the remaining bounds (ids in range, then the sums); every value is read without // narrowing, and the messages are the constructor's, which re-checks the same order. @@ -352,11 +372,11 @@ std::shared_ptr make_graph(const py::object& num_nodes, cons auto src_v = s.narrow(); auto dst_v = d.narrow(); py::gil_scoped_release release; - return std::make_shared(static_cast(n), src_v, dst_v, cap_v, cost_v, resolution); + return std::make_shared(static_cast(n), src_v, dst_v, cap_v, cost_v, resolution); } -// ---------------------------------------------------------------- 1b helpers +// ---------------------------------------------------------------- ledger, records, requests // The exact order of wide integers: a positive overflow above every int64, a negative one // below, and two overflows on the same side by their exact values (review-1b D2). @@ -370,7 +390,7 @@ bool wide_less(const WideInts& w, std::size_t i, std::size_t j) { // A per-edge integer array in [0, capacity_units[e]] (a ledger baseline, a search residual), // validated on the unnarrowed values: the length (shape step), then every entry. -std::vector edge_units(const py::object& obj, const StrictMultiDiGraph& g, const char* name) { +std::vector edge_units(const py::object& obj, const Graph& g, const char* name) { const auto num_edges = static_cast(g.num_edges()); const std::size_t len = sequence_length(obj, name); if (len != num_edges) { @@ -388,26 +408,27 @@ std::vector edge_units(const py::object& obj, const StrictMultiDiGraph& return v.narrow(); } -// A node set: one NodeId or a sequence of them, ids range-checked before narrowing. -std::vector node_ids(const py::object& obj, std::int32_t num_nodes, const char* name) { - WideInts ids; - if (!is_bool(obj) && PyIndex_Check(obj.ptr()) && !py::isinstance(obj)) { - py::list one; - one.append(obj); - ids = wide_integers(one, name); - } else { - ids = wide_integers(obj, name); - } - if (ids.size() == 0) throw py::value_error(std::string(name) + ": the node set is empty"); +// A node set's ids, non-empty and range-checked before narrowing (detail::node_set's messages). +std::vector checked_ids(const WideInts& ids, std::int32_t num_nodes, const char* name) { + if (ids.size() == 0) throw py::value_error(detail::empty_set_error(name)); for (std::size_t i = 0; i < ids.size(); ++i) { if (!ids.in(i, 0, static_cast(num_nodes) - 1)) { - throw py::value_error(std::string(name) + ": node " + ids.text(i) + " out of node range [0, " + - std::to_string(num_nodes) + ")"); + throw py::value_error(detail::node_range_error(name, ids.text(i), num_nodes)); } } return ids.narrow(); } +// A node set: one NodeId or a sequence of them. +std::vector node_ids(const py::object& obj, std::int32_t num_nodes, const char* name) { + if (!is_bool(obj) && PyIndex_Check(obj.ptr()) && !py::isinstance(obj)) { + py::list one; + one.append(obj); + return checked_ids(wide_integers(one, name), num_nodes, name); + } + return checked_ids(wide_integers(obj, name), num_nodes, name); +} + // A request: an integer number of units in [0, 2^62) or +inf (float('inf')); nullopt is +inf. std::optional request_units(const py::object& volume) { if (py::isinstance(volume) || py::isinstance(volume, py::module_::import("numpy").attr("floating"))) { @@ -434,17 +455,6 @@ py::array_t int64_array(const std::vector& v) { return out; } -const char* termination_name(detail::Termination t) { - switch (t) { - case detail::Termination::Complete: return "complete"; - case detail::Termination::LimitFlows: return "limit_flows"; - case detail::Termination::LimitDiminishing: return "limit_diminishing"; - case detail::Termination::NoRoute: return "no_route"; - case detail::Termination::Exhausted: return "exhausted"; - } - return "?"; -} - FlowId flow_id_of(const py::object& obj) { if (!py::isinstance(obj) || py::len(obj) != 2) { throw py::value_error("a FlowId is a tuple (ledger uid, id)"); @@ -477,10 +487,244 @@ py::list int_list(const std::vector& v) { return out; } + +// The Python form of CapacityOptions and SensitivityOptions: the Eligibility and the cache are +// held by reference count for the call (the C++ structs borrow raw pointers). +struct PyCapacityOptions { + FlowPolicyConfig config; + std::shared_ptr elig; + std::shared_ptr cache; + OutputOptions outputs; +}; +struct PySensitivityOptions { + FlowPolicyConfig config; + std::shared_ptr elig; + std::shared_ptr cache; + OutputOptions outputs; +}; + +// ---------------------------------------------------------------- policies and evaluators + +// An optional int field of [lo, hi] (None allowed when `optional`); bools and floats rejected. +std::optional int_field(const py::object& obj, std::int64_t lo, std::int64_t hi, const std::string& what, + bool optional) { + if (obj.is_none()) { + if (optional) return std::nullopt; + throw py::value_error(what + " must be an integer (got None)"); + } + return integer_value(obj, lo, hi, what); +} + +double float_field(const py::object& obj, const std::string& what) { + if (is_bool(obj) || !(py::isinstance(obj) || PyIndex_Check(obj.ptr()) || + py::isinstance(obj, py::module_::import("numpy").attr("floating")))) { + throw py::value_error(what + " must be a number (got " + std::string(py::str(py::type::of(obj).attr("__name__"))) + + ")"); + } + return obj.cast(); +} + +py::object remaining_obj(const std::optional& r) { + return r.has_value() ? py::object(py::int_(*r)) : py::object(py::float_(std::numeric_limits::infinity())); +} + +Routing routing_of(const py::object& obj) { + if (py::isinstance(obj)) return obj.cast(); + if (py::isinstance(obj)) return obj.cast(); + if (py::isinstance(obj)) return obj.cast(); + if (py::isinstance(obj)) return obj.cast(); + throw py::type_error("routing must be CostOnly, CapacityAware, Global or Pinned (got " + + std::string(py::str(py::type::of(obj).attr("__name__"))) + ")"); +} + +py::object routing_obj(const Routing& r) { + return std::visit([](const auto& x) { return py::cast(x); }, r); +} + +// workers: an explicit integer is passed through (validated by the evaluator); None takes the +// environment default (detail::default_workers), and 1 whenever a cache is supplied (the +// building cache's one writer; APPROACH 10, 11). +std::int32_t workers_of(const py::object& workers, bool sensitivity, bool cache_given) { + if (!workers.is_none()) { + return static_cast(integer_value(workers, std::numeric_limits::min(), + std::numeric_limits::max(), "workers")); + } + if (cache_given) return 1; + return detail::default_workers(sensitivity); +} + +OutputOptions outputs_of(const py::object& obj) { + if (obj.is_none()) return OutputOptions {}; + if (!py::isinstance(obj)) { + throw py::type_error("outputs must be an OutputOptions (got " + std::string(py::str(py::type::of(obj).attr("__name__"))) + + ")"); + } + return obj.cast(); +} + +template +Options options_of(const py::object& obj, const char* name) { + if (!py::isinstance(obj)) { + throw py::type_error(std::string(name) + " expected (got " + std::string(py::str(py::type::of(obj).attr("__name__"))) + + ")"); + } + return obj.cast(); +} + +// A new Python object of the bound class T owning a default-constructed T, made as pybind11's +// generic move-cast makes one (type_caster_generic::cast) but with the object allocation +// checked: pybind11's make_new_instance uses the result of tp_alloc unchecked, so a failed +// Python allocation there is a crash rather than a MemoryError. Every failure here raises, and +// the half-built object is released on unwinding (its value is deleted, nothing registered). +// The policy calls make their result object with this before anything is published. +template +std::pair new_object() { + const py::detail::type_info* tinfo = py::detail::get_type_info(typeid(T), /*throw_if_missing=*/true); + PyTypeObject* type = tinfo->type; + PyObject* raw = type->tp_alloc(type, 0); + if (raw == nullptr) throw py::error_already_set(); + auto obj = py::reinterpret_steal(raw); + auto* inst = reinterpret_cast(raw); + inst->allocate_layout(); + inst->owned = false; + auto* value = new T(); + py::detail::values_and_holders(inst).begin()->value_ptr() = value; + inst->owned = true; + tinfo->init_instance(inst, nullptr); + return {std::move(obj), value}; +} + +// The value moved into its result object after the commit: nothing may allocate there. +static_assert(std::is_nothrow_move_assignable_v); +static_assert(std::is_nothrow_move_assignable_v); +static_assert(std::is_nothrow_move_assignable_v); + +template +py::object object_of(T value) { + auto [obj, slot] = new_object(); + *slot = std::move(value); + return std::move(obj); +} + + +// ---------------------------------------------------------------- private hooks + +#if NETGRAPH_CORE_PRIVATE_HOOKS + +// A trace value as Python objects (map keys that are decimal integers become ints: the maps +// keyed by source NodeId). +py::object trace_obj(const detail::Trace& t) { + using K = detail::Trace::Kind; + switch (t.kind) { + case K::Null: return py::none(); + case K::Bool: return py::bool_(t.b); + case K::Int: return py::int_(t.i); + case K::Str: return py::str(t.s); + case K::List: { + py::list out; + for (const auto& x : t.items) out.append(trace_obj(x)); + return out; + } + case K::Map: { + py::dict out; + for (const auto& [k, x] : t.fields) { + const bool digits = !k.empty() && std::all_of(k.begin(), k.end(), [](char ch) { return ch >= '0' && ch <= '9'; }); + if (digits) { + out[py::int_(std::stoll(k))] = trace_obj(x); + } else { + out[py::str(k)] = trace_obj(x); + } + } + return out; + } + } + return py::none(); +} + +// The epoch state of a policy (roster, requested, +inf, per roster source the flows). +py::dict state_obj(const FlowPolicy& policy) { + const auto& pe = detail::PolicyAccess::engine(policy); + py::dict out; + const auto& r = pe.config.routing; + out["mode"] = std::holds_alternative(r) ? "CostOnly" + : std::holds_alternative(r) ? "CapacityAware" + : std::holds_alternative(r) ? "Global" + : "Pinned"; + out["roster"] = pe.roster; + out["requested"] = counter_int(pe.requested); + out["infinite"] = pe.infinite; + out["placed_demand"] = policy.placed_demand(); + py::dict cohorts; + for (std::size_t i = 0; i < pe.roster.size(); ++i) { + py::list flows; + for (const auto& s : pe.cohorts[i]) { + const FlowRecord& rec = detail::LedgerAccess::live(pe.fg, s.id); + py::dict f; + f["flow_id"] = py::make_tuple(rec.flow_id.first, rec.flow_id.second); + f["src"] = s.src; + if (s.plan) { + f["route"] = s.plan->route; + } else { + const auto edges = detail::PredDAGBuilder::ascending(*s.bundle); + f["route"] = std::vector(edges.begin(), edges.end()); + } + f["members"] = s.plan ? py::cast(s.plan->members) : py::object(py::none()); + f["weights"] = s.plan && s.plan->placement == FlowPlacement::Weighted ? py::cast(s.plan->weights) + : py::object(py::none()); + f["T"] = rec.delivered; + f["delivered"] = rec.delivered; + f["alloc"] = pairs_list(rec.edges); + f["bundle_index"] = s.bundle_index < 0 ? py::object(py::none()) : py::object(py::int_(s.bundle_index)); + flows.append(f); + } + cohorts[py::int_(pe.roster[i])] = flows; + } + out["cohorts"] = cohorts; + return out; +} + +// The allocation-failure hook: the k-th object-domain allocation fails (Python's allocator +// wrapped for one call, then restored). The allocator is process-wide while the call runs, so +// the hook serves one caller at a time (the tests); its counters are atomics because the module +// does not hold the GIL for every thread (mod_gil_not_used). +PyMemAllocatorEx g_saved_allocator {}; +std::atomic g_fail_countdown {-1}; +std::atomic g_fail_seen {0}; +std::atomic g_fail_hit {false}; + +bool fail_now() { + g_fail_seen.fetch_add(1); + if (g_fail_countdown.load() < 0) return false; + if (g_fail_countdown.fetch_sub(1) == 0) { + g_fail_hit.store(true); + return true; + } + return false; +} +void* failing_malloc(void*, std::size_t n) { return fail_now() ? nullptr : g_saved_allocator.malloc(g_saved_allocator.ctx, n); } +void* failing_calloc(void*, std::size_t n, std::size_t size) { + return fail_now() ? nullptr : g_saved_allocator.calloc(g_saved_allocator.ctx, n, size); +} +void* failing_realloc(void*, void* ptr, std::size_t n) { + return fail_now() ? nullptr : g_saved_allocator.realloc(g_saved_allocator.ctx, ptr, n); +} +void forwarding_free(void*, void* ptr) { g_saved_allocator.free(g_saved_allocator.ctx, ptr); } + +#endif + } // namespace PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { - m.doc() = "NetGraph-Core: the graph on integer units, the minimum-cost flow core, the ledger and the kernels"; + m.doc() = + "NetGraph-Core: the graph on integer units, the route structures, the integer ledger, the policy\n" + "engine FlowPolicy, the DagCache and the evaluators (capacity, batch_capacity, sensitivity).\n" + "Every public call is all-or-nothing up to its caller (APPROACH 13), with two limits: (1) the pybind11\n" + "object machinery is outside the guarantee: constructing a bound object from Python, or reading an\n" + "attribute or method value converted into a new bound object or container outside the staged calls,\n" + "allocates through pybind11's make_new_instance, which does not check the result of tp_alloc, so a\n" + "Python allocation failure at that point crashes the process, with Core state unchanged; (2) a supplied\n" + "building DagCache is a memo: it keeps the complete entries and statistics of the lookups that\n" + "succeeded before a later failure of the call."; py::enum_(m, "FlowPlacement", "The kernel of one flow: MAX_FLOW (the minimum-cost flow core on the flow's split graph,\n" @@ -514,7 +758,9 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { py::class_(m, "PathSelection", "The selection of a search: tie_break and priority are explicit; multipath = True keeps\n" "every equal-cost walk (the DAG), False the one canonical single path; multi_edge = True\n" - "keeps every eligible parallel member, False one per (u, v) pair.") + "keeps every eligible parallel member, False one per (u, v) pair. A tie_break or priority\n" + "outside its enumeration raises ValueError.") + // The constructor checks both enumerations (ValueError naming the field). .def(py::init(), py::arg("tie_break"), py::arg("priority"), py::arg("multipath") = true, py::arg("multi_edge") = true) .def_readonly("tie_break", &PathSelection::tie_break) @@ -581,7 +827,7 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { py::class_>( m, "Eligibility", "Immutable, graph-bound static eligibility: dense node and edge flags. Built by\n" - "StrictMultiDiGraph.eligibility(excluded_nodes, excluded_edges).") + "Graph.eligibility(excluded_nodes, excluded_edges).") .def("graph_uid", &Eligibility::graph_uid, "uid of the graph this value belongs to") .def("uid", &Eligibility::uid, "0 for the all-allowed value, a fresh uid otherwise") .def( @@ -612,8 +858,8 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { "an operand itself when the result equals it (a first, then b), otherwise a new value with a\n" "fresh uid; equal intersections built separately get distinct uids."); - py::class_>( - m, "StrictMultiDiGraph", + py::class_>( + m, "Graph", "Immutable directed multigraph on integer units. Edges are given in input order; the\n" "public EdgeId is the rank in the stable order (src, cost, dst, input), input_index()[e]\n" "is the input position of e, and every other per-edge array is in id order. Costs are\n" @@ -626,63 +872,63 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { "zero or negative costs (rule 28), the remaining bounds (endpoints in range, then the\n" "capacity and cost sums below 2^62). Integers are read without narrowing, so the error\n" "does not depend on how the input is represented (APPROACH 3).") - .def("uid", &StrictMultiDiGraph::uid, "process-unique id (never reused)") - .def("resolution", &StrictMultiDiGraph::resolution, "the resolution q (units of capacity)") - .def("num_nodes", &StrictMultiDiGraph::num_nodes) - .def("num_edges", &StrictMultiDiGraph::num_edges) - .def("max_cost", &StrictMultiDiGraph::max_cost, "largest edge cost (0 without edges)") - .def("cost_gcd", &StrictMultiDiGraph::cost_gcd, "gcd of the edge costs (0 without edges)") + .def("uid", &Graph::uid, "process-unique id (never reused)") + .def("resolution", &Graph::resolution, "the resolution q (units of capacity)") + .def("num_nodes", &Graph::num_nodes) + .def("num_edges", &Graph::num_edges) + .def("max_cost", &Graph::max_cost, "largest edge cost (0 without edges)") + .def("cost_gcd", &Graph::cost_gcd, "gcd of the edge costs (0 without edges)") .def( "capacity_units", [](const py::object& self) { - const auto& g = self.cast(); + const auto& g = self.cast(); return readonly_view(g.capacity_units(), self); }, "int64[E] capacities in units, id order (read-only view)") .def( "cost", [](const py::object& self) { - const auto& g = self.cast(); + const auto& g = self.cast(); return readonly_view(g.cost(), self); }, "int64[E] costs, id order (read-only view)") .def( "edge_src", [](const py::object& self) { - const auto& g = self.cast(); + const auto& g = self.cast(); return readonly_view(g.edge_src(), self); }, "int32[E] source node per edge (read-only view)") .def( "edge_dst", [](const py::object& self) { - const auto& g = self.cast(); + const auto& g = self.cast(); return readonly_view(g.edge_dst(), self); }, "int32[E] destination node per edge (read-only view)") .def( "input_index", [](const py::object& self) { - const auto& g = self.cast(); + const auto& g = self.cast(); return readonly_view(g.input_index(), self); }, "int32[E]: id -> input position (read-only view); the inverse is the caller's") .def( "row_offsets", [](const py::object& self) { - const auto& g = self.cast(); + const auto& g = self.cast(); return readonly_view(g.row_offsets(), self); }, "int32[N + 1]: the out-edges of u are the ids [row_offsets[u], row_offsets[u + 1])") .def( "in_row_offsets", [](const py::object& self) { - const auto& g = self.cast(); + const auto& g = self.cast(); return readonly_view(g.in_row_offsets(), self); }, "int32[N + 1] reverse CSR offsets") .def( "in_adj_edge_index", [](const py::object& self) { - const auto& g = self.cast(); + const auto& g = self.cast(); return readonly_view(g.in_adj_edge_index(), self); }, "int32[E]: the in-edges of v in (cost, src, input) order, as EdgeIds") .def( "eligibility", - [](const StrictMultiDiGraph& g, const py::object& excluded_nodes, const py::object& excluded_edges) { + [](const Graph& g, const py::object& excluded_nodes, const py::object& excluded_edges) { // Nodes, then edges, each range-checked before narrowing (the constructor's order). const auto xn = wide_integers(excluded_nodes, "excluded_nodes"); for (std::size_t i = 0; i < xn.size(); ++i) { @@ -707,10 +953,10 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { m.def( "_place_global_units", - [](const std::shared_ptr& graph, const py::object& view, const py::object& sources, + [](const std::shared_ptr& graph, const py::object& view, const py::object& sources, const py::object& destinations, const std::shared_ptr& elig, const py::object& cap) -> py::dict { - if (!graph) throw py::value_error("graph must be a StrictMultiDiGraph"); + if (!graph) throw py::value_error("graph must be a Graph"); // The core's order (view length, view entries, sources, destinations, overlap, cap, // Eligibility), every value checked before narrowing; the core re-checks the same order. const auto num_edges = static_cast(graph->num_edges()); @@ -727,30 +973,10 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { std::to_string(e) + "] = " + std::to_string(capacity[e]) + "]"); } } - const auto endpoints = [&](const py::object& obj, const char* name) { - const auto ids = wide_integers(obj, (std::string(name) + " node ids").c_str()); - if (ids.size() == 0) throw py::value_error(std::string(name) + ": the endpoint set is empty"); - for (std::size_t i = 0; i < ids.size(); ++i) { - if (!ids.in(i, 0, static_cast(graph->num_nodes()) - 1)) { - throw py::value_error(std::string(name) + ": node " + ids.text(i) + " out of node range [0, " + - std::to_string(graph->num_nodes()) + ")"); - } - } - return ids.narrow(); - }; - const auto s = endpoints(sources, "sources"); - const auto d = endpoints(destinations, "destinations"); - std::vector ss(s); - std::vector dd(d); - std::sort(ss.begin(), ss.end()); - std::sort(dd.begin(), dd.end()); - for (std::size_t i = 0, k = 0; i < ss.size() && k < dd.size();) { - if (ss[i] == dd[k]) { - throw py::value_error("node " + std::to_string(ss[i]) + - " is in both endpoint sets: sources and destinations must not overlap"); - } - if (ss[i] < dd[k]) ++i; else ++k; - } + const auto s = checked_ids(wide_integers(sources, "sources node ids"), graph->num_nodes(), "sources"); + const auto d = checked_ids(wide_integers(destinations, "destinations node ids"), graph->num_nodes(), "destinations"); + detail::check_disjoint(detail::node_set(s, graph->num_nodes(), "sources"), + detail::node_set(d, graph->num_nodes(), "destinations"), "sources", "destinations"); std::optional c; if (!cap.is_none()) { if (is_bool(cap) || !PyIndex_Check(cap.ptr())) { @@ -759,16 +985,16 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { py::object idx = python_int(cap, "cap"); int overflow = 0; const long long value = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); - if (overflow != 0 || value < 0 || value > kMaxFlowCap) { + if (overflow != 0 || value < 0 || value > detail::kMaxFlowCap) { throw py::value_error("cap " + std::string(py::str(idx)) + " is outside [0, 2^63 - 2]"); } c = static_cast(value); } - FlowCoreResult r; + detail::FlowCoreResult r; { py::gil_scoped_release release; const auto view_units = v.narrow(); - r = place_global_units(*graph, view_units, s, d, elig.get(), c); + r = detail::place_global_units(*graph, view_units, s, d, elig.get(), c); } py::dict out; out["placed"] = py::int_(r.placed); @@ -814,13 +1040,15 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { m, "PredDAG", "Immutable, graph-bound predecessor DAG: the entries of node v are parents()[k], via_edges()[k]\n" "for k in [parent_offsets()[v], parent_offsets()[v + 1]), within a node in strictly ascending\n" - "via EdgeId (one canonical form). Equality is structural. Built by PredDAG.from_csr,\n" - "Path.to_dag() and the searches.") + "via EdgeId (one canonical form). Equality is structural. parents() and via_edges() are\n" + "read-only views that keep the DAG alive; parent_offsets() is a fresh read-only array built on\n" + "demand (the DAG is stored over its member nodes). Built by PredDAG.from_csr, Path.to_dag()\n" + "and the searches.") .def_static( "from_csr", - [](const std::shared_ptr& graph, const py::object& offsets, const py::object& parents, + [](const std::shared_ptr& graph, const py::object& offsets, const py::object& parents, const py::object& via) { - if (!graph) throw py::value_error("graph must be a StrictMultiDiGraph"); + if (!graph) throw py::value_error("graph must be a Graph"); // APPROACH 5's order on unnarrowed values; the C++ constructor re-checks it and adds the // structural checks (endpoints, duplicates, acyclic, ascending). const auto n = static_cast(graph->num_nodes()); @@ -869,8 +1097,14 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { .def("graph_uid", &PredDAG::graph_uid, "uid of the graph this DAG belongs to") .def( "parent_offsets", - [](const py::object& self) { return readonly_view(self.cast().parent_offsets(), self); }, - "int32[N + 1] CSR offsets (read-only view)") + [](const PredDAG& d) { + const auto off = d.parent_offsets(); + py::array_t arr(static_cast(off.size())); + std::copy(off.begin(), off.end(), arr.mutable_data()); + py::detail::array_proxy(arr.ptr())->flags &= ~py::detail::npy_api::NPY_ARRAY_WRITEABLE_; + return arr; + }, + "int32[N + 1] CSR offsets, presented on demand from the sparse storage (a read-only array)") .def( "parents", [](const py::object& self) { return readonly_view(self.cast().parents(), self); }, "int32 parent NodeId per entry (read-only view)") @@ -894,8 +1128,8 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { m, "Path", "Immutable, graph-bound explicit walk of hops; a hop lists one or more parallel members of one\n" "(u, v) pair in the caller's order. cost = sum over hops of the minimum member cost.") - .def(py::init([](const std::shared_ptr& graph, const py::object& hops) { - if (!graph) throw py::value_error("graph must be a StrictMultiDiGraph"); + .def(py::init([](const std::shared_ptr& graph, const py::object& hops) { + if (!graph) throw py::value_error("graph must be a Graph"); if (not_a_sequence(hops)) throw py::value_error("Path: hops must be a sequence of hops (sequences of EdgeIds)"); auto seq = py::reinterpret_borrow(hops); std::vector raw; @@ -926,7 +1160,6 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { .def("hops", [](const Path& p) { return p.hops(); }, "the hops as lists of EdgeIds (the caller's member order)") .def("src", &Path::src) .def("dst", &Path::dst) - .def("nodes", [](const Path& p) { return p.nodes(); }, "the visited nodes, src first") .def("cost", &Path::cost, "sum over hops of the minimum member cost") .def( "to_dag", [](const Path& p) { return std::make_shared(p.to_dag()); }, @@ -977,43 +1210,43 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { "), owner=" + std::to_string(r.owner) + ", delivered=" + std::to_string(r.delivered) + ")"; }); - py::class_>( - m, "FlowGraph", + auto ledger_class = py::class_>( + m, "FlowLedger", "The integer ledger of one graph: an immutable baseline residual in units and the records of\n" "the flows placed on it by policies (the ledger has no public placement or removal).\n" "allocated() is the sum of every record's edges and residual() = baseline - allocated(), both\n" "int64[E] snapshots. reset() and copy() are rejected while a policy is bound.") - .def(py::init([](const std::shared_ptr& graph, const py::object& residual) { - if (!graph) throw py::value_error("graph must be a StrictMultiDiGraph"); - if (residual.is_none()) return std::make_shared(graph); + .def(py::init([](const std::shared_ptr& graph, const py::object& residual) { + if (!graph) throw py::value_error("graph must be a Graph"); + if (residual.is_none()) return std::make_shared(graph); const auto r = edge_units(residual, *graph, "residual"); - return std::make_shared(graph, r); + return std::make_shared(graph, r); }), py::arg("graph"), py::arg("residual") = py::none(), "residual: the baseline as integer units, one entry per edge in [0, capacity_units[e]] (a\n" "physical baseline goes through to_units first); None: the capacities. ValueError otherwise.") - .def("uid", &FlowGraph::uid, "process-unique ledger uid (the first half of every FlowId)") + .def("uid", &FlowLedger::uid, "process-unique ledger uid (the first half of every FlowId)") .def( - "graph", [](const FlowGraph& fg) { return std::const_pointer_cast(fg.graph()); }, + "graph", [](const FlowLedger& fg) { return std::const_pointer_cast(fg.graph()); }, "the ledger's graph") .def( "capacity_units", - [](const py::object& self) { return readonly_view(self.cast().capacity_units(), self); }, + [](const py::object& self) { return readonly_view(self.cast().capacity_units(), self); }, "int64[E] the graph's capacities in units (read-only view)") .def( - "residual", [](const FlowGraph& fg) { return int64_array(fg.residual()); }, + "residual", [](const FlowLedger& fg) { return int64_array(fg.residual()); }, "int64[E] snapshot: baseline - allocated") .def( - "allocated", [](const FlowGraph& fg) { return int64_array(fg.allocated()); }, + "allocated", [](const FlowLedger& fg) { return int64_array(fg.allocated()); }, "int64[E] snapshot: the sum of every record's edges") - .def("records", &FlowGraph::records, "live records (bound and orphaned) as snapshots in FlowId order") + .def("records", &FlowLedger::records, "live records (bound and orphaned) as snapshots in FlowId order") .def( - "record", [](const FlowGraph& fg, const py::object& flow_id) { return fg.record(flow_id_of(flow_id)); }, + "record", [](const FlowLedger& fg, const py::object& flow_id) { return fg.record(flow_id_of(flow_id)); }, py::arg("flow_id"), "one record by FlowId (ValueError for a FlowId of another ledger, a retired or an unknown id)") .def( "bottleneck", - [](const FlowGraph& fg, const py::object& sources, const std::shared_ptr& elig) { + [](const FlowLedger& fg, const py::object& sources, const std::shared_ptr& elig) { const auto s = node_ids(sources, fg.graph()->num_nodes(), "sources"); return fg.bottleneck(s, elig.get()); }, @@ -1023,7 +1256,7 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { "ascending EdgeId (not a cut).") .def( "reset", - [](FlowGraph& fg, const py::object& residual) { + [](FlowLedger& fg, const py::object& residual) { if (residual.is_none()) { fg.reset(); return; @@ -1035,95 +1268,510 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { "A new baseline (None: the capacities); orphaned records and their allocations are cleared\n" "and their ids retired (ids are never reused). ValueError while a policy is bound.") .def( - "copy", [](const FlowGraph& fg) { return std::make_shared(fg); }, + "copy", [](const FlowLedger& fg) { return std::make_shared(fg); }, "a new ledger (new uid) with the same baseline and records, ids kept under the new uid;\n" "ValueError while a policy is bound") - .def("__copy__", [](const FlowGraph& fg) { return std::make_shared(fg); }) - .def("__deepcopy__", [](const FlowGraph& fg, const py::object&) { return std::make_shared(fg); }, - py::arg("memo")) + .def("__copy__", [](const FlowLedger& fg) { return std::make_shared(fg); }) + .def("__deepcopy__", [](const FlowLedger& fg, const py::object&) { return std::make_shared(fg); }, + py::arg("memo")); +#if NETGRAPH_CORE_PRIVATE_HOOKS + ledger_class .def( "_state", - [](const FlowGraph& fg) { + [](const FlowLedger& fg) { py::dict out; out["next_flow_id"] = detail::LedgerAccess::next_flow_id(fg); out["bound"] = detail::LedgerAccess::bound_owners(fg); out["orphans"] = detail::LedgerAccess::orphan_owners(fg); return out; }, - "private diagnostics: {next_flow_id, bound, orphans} (owner ids)"); - - // ------------------------------------------------------------------ private 1b recorder entries - - py::class_( - m, "_PinnedFlow", - "Private (sub-PR 1b): a Pinned placement with one bundle, the single-flow transaction of the\n" - "1b recorder population (APPROACH 8: a manual flow is a Pinned policy with one bundle). Sub-PR\n" - "1c's FlowPolicy with Pinned routing replaces it.") - .def(py::init([](FlowGraph& fg, const py::object& src, const py::object& dst, const std::shared_ptr& bundle, - FlowPlacement placement, Admission admission, EdgePriority priority, - const std::shared_ptr& elig) { - const auto n = fg.graph()->num_nodes(); - const auto s = integer_value(src, 0, static_cast(n) - 1, "src"); - const auto d = integer_value(dst, 0, static_cast(n) - 1, "dst"); - if (!bundle) throw py::value_error("bundle must be a PredDAG"); - return std::make_unique(fg, static_cast(s), static_cast(d), bundle, - placement, admission, priority, elig); + "private: {next_flow_id, bound, orphans} (owner ids)"); +#else + (void)ledger_class; +#endif + + // ------------------------------------------------------------------ the policy engine + + py::class_(m, "Absolute", "CostCeiling: an absolute, inclusive path-cost budget (cost >= 0).") + .def(py::init([](const py::object& cost) { + return Absolute {*int_field(cost, std::numeric_limits::min(), std::numeric_limits::max(), + "Absolute cost", false)}; }), - py::keep_alive<1, 2>(), py::arg("flow_graph"), py::arg("src"), py::arg("dst"), py::arg("bundle"), - py::arg("placement"), py::arg("admission"), py::arg("priority"), py::arg("elig") = nullptr) + py::arg("cost")) + .def_readonly("cost", &Absolute::cost) + .def("__eq__", [](const Absolute& a, const py::object& b) { return py::isinstance(b) && a == b.cast(); }) + .def("__hash__", [](const Absolute& a) { return py::hash(py::make_tuple("Absolute", a.cost)); }) + .def("__repr__", [](const Absolute& a) { return "Absolute(" + std::to_string(a.cost) + ")"; }); + + py::class_(m, "Stretch", + "CostCeiling: a factor in [1, inf) of the static shortest cost under the policy's\n" + "Eligibility, read as the exact dyadic rational it is (compared without rounding).") + .def(py::init([](const py::object& factor) { return Stretch {float_field(factor, "Stretch factor")}; }), + py::arg("factor")) + .def_readonly("factor", &Stretch::factor) + .def("__eq__", [](const Stretch& a, const py::object& b) { return py::isinstance(b) && a == b.cast(); }) + .def("__hash__", [](const Stretch& a) { return py::hash(py::make_tuple("Stretch", a.factor)); }) + .def("__repr__", [](const Stretch& a) { return "Stretch(" + std::string(py::str(py::float_(a.factor))) + ")"; }); + + py::class_(m, "DiminishingReturns", + "The diminishing-returns guard of CapacityAware growth: stop once at least `window`\n" + "growth steps were accepted in the call and the last `window` gains sum below\n" + "`fraction` times the source's (finite) offer. window >= 1, fraction in (0, 1).") + .def(py::init([](const py::object& window, const py::object& fraction) { + DiminishingReturns d; + d.window = static_cast(*int_field(window, std::numeric_limits::min(), + std::numeric_limits::max(), + "diminishing window", false)); + d.fraction = float_field(fraction, "diminishing fraction"); + return d; + }), + py::arg("window"), py::arg("fraction")) + .def_readonly("window", &DiminishingReturns::window) + .def_readonly("fraction", &DiminishingReturns::fraction) + .def("__repr__", [](const DiminishingReturns& d) { + return "DiminishingReturns(window=" + std::to_string(d.window) + ", fraction=" + std::string(py::str(py::float_(d.fraction))) + ")"; + }); + + py::class_(m, "CostOnly", + "Routing: IGP forwarding. One flow per roster source on the static shortest-path DAG\n" + "(the DagCache value; selection projected at creation), never refreshed, grown or\n" + "reoptimised.") + .def(py::init<>()) + .def("__repr__", [](const CostOnly&) { return std::string("CostOnly()"); }); + + py::class_( + m, "CapacityAware", + "Routing: distributed CSPF tunnels. flow_count flows created by the canonical search on the\n" + "selection view (residual >= max(1, min_flow_bandwidth) units, the max_path_cost ceiling),\n" + "grown up to max_flow_count (None: no ceiling), optionally reoptimised (reroute with retention\n" + "fallback) and guarded by `diminishing` (None: off).") + .def(py::init([](const py::object& flow_count, const py::object& max_flow_count, const py::object& max_path_cost, + const py::object& min_flow_bandwidth, bool reoptimize, const py::object& diminishing) { + CapacityAware ca; + constexpr auto lo32 = std::numeric_limits::min(); + constexpr auto hi32 = std::numeric_limits::max(); + ca.flow_count = static_cast(*int_field(flow_count, lo32, hi32, "flow_count", false)); + if (auto mx = int_field(max_flow_count, lo32, hi32, "max_flow_count", true)) { + ca.max_flow_count = static_cast(*mx); + } + if (!max_path_cost.is_none()) { + if (py::isinstance(max_path_cost)) { + ca.max_path_cost = max_path_cost.cast(); + } else if (py::isinstance(max_path_cost)) { + ca.max_path_cost = max_path_cost.cast(); + } else { + throw py::type_error("max_path_cost must be Absolute, Stretch or None"); + } + } + ca.min_flow_bandwidth = *int_field(min_flow_bandwidth, std::numeric_limits::min(), + std::numeric_limits::max(), "min_flow_bandwidth (units)", false); + ca.reoptimize = reoptimize; + if (!diminishing.is_none()) { + if (!py::isinstance(diminishing)) throw py::type_error("diminishing must be DiminishingReturns or None"); + ca.diminishing = diminishing.cast(); + } + return ca; + }), + py::kw_only(), py::arg("flow_count") = 1, py::arg("max_flow_count") = py::none(), + py::arg("max_path_cost") = py::none(), py::arg("min_flow_bandwidth") = 0, py::arg("reoptimize") = false, + py::arg("diminishing") = py::none()) + .def_readonly("flow_count", &CapacityAware::flow_count) + .def_readonly("max_flow_count", &CapacityAware::max_flow_count) + .def_property_readonly("max_path_cost", + [](const CapacityAware& ca) -> py::object { + if (!ca.max_path_cost.has_value()) return py::none(); + return std::visit([](const auto& x) { return py::cast(x); }, *ca.max_path_cost); + }) + .def_readonly("min_flow_bandwidth", &CapacityAware::min_flow_bandwidth) + .def_readonly("reoptimize", &CapacityAware::reoptimize) + .def_readonly("diminishing", &CapacityAware::diminishing) + .def("__repr__", [](const CapacityAware& ca) { + return "CapacityAware(flow_count=" + std::to_string(ca.flow_count) + ", max_flow_count=" + + (ca.max_flow_count ? std::to_string(*ca.max_flow_count) : std::string("None")) + ")"; + }); + + py::class_(m, "Global", + "Routing: the omniscient controller. Every call re-solves the minimum-cost flow of the\n" + "cumulative value over the whole eligible graph (placement MaxFlow only).") + .def(py::init<>()) + .def("__repr__", [](const Global&) { return std::string("Global()"); }); + + py::class_(m, "Pinned", + "Routing: explicit route structures, one flow per usable bundle in supply order (single\n" + "endpoints; a Path converts with to_dag()). The list is copied at construction.") + .def(py::init([](const py::object& bundles) { + if (not_a_sequence(bundles)) throw py::type_error("Pinned bundles must be a sequence of PredDAG"); + Pinned p; + for (const auto& b : py::reinterpret_borrow(bundles)) { + if (!py::isinstance(b)) { + throw py::type_error("Pinned bundles must be PredDAG values (a Path converts with to_dag())"); + } + p.bundles.push_back(b.cast>()); + } + return p; + }), + py::arg("bundles")) + .def_property_readonly("bundles", + [](const Pinned& p) { + py::list out; + for (const auto& b : p.bundles) out.append(std::const_pointer_cast(b)); + return out; + }) + .def("__repr__", [](const Pinned& p) { return "Pinned(bundles=" + std::to_string(p.bundles.size()) + ")"; }); + + py::class_( + m, "FlowPolicyConfig", + "The four explicit axes of a policy: routing (CostOnly, CapacityAware, Global or Pinned), placement\n" + "(FlowPlacement), admission (Admission) and selection (PathSelection). A placement or admission\n" + "outside its enumeration raises ValueError.") + .def(py::init([](const py::object& routing, FlowPlacement placement, Admission admission, + const PathSelection& selection) { + // The routing's structure first (TypeError), then the constructor's enumeration checks. + auto r = routing_of(routing); + return FlowPolicyConfig {std::move(r), placement, admission, selection}; + }), + py::kw_only(), py::arg("routing"), py::arg("placement"), py::arg("admission"), py::arg("selection")) + .def_property_readonly("routing", [](const FlowPolicyConfig& c) { return routing_obj(c.routing); }) + .def_readonly("placement", &FlowPolicyConfig::placement) + .def_readonly("admission", &FlowPolicyConfig::admission) + .def_readonly("selection", &FlowPolicyConfig::selection) + .def("__repr__", [](const FlowPolicyConfig& c) { + return "FlowPolicyConfig(routing=" + std::string(py::repr(routing_obj(c.routing))) + ")"; + }); + + py::class_(m, "OutputOptions", + "Optional outputs of a placement (all false by default; absent outputs are None):\n" + "edge_flows (the policy's own allocation per edge after the call), residual and\n" + "bottleneck (the ledger's after the call); on Global, bottleneck adds min_cut.") + .def(py::init(), py::kw_only(), py::arg("edge_flows") = false, py::arg("residual") = false, + py::arg("bottleneck") = false) + .def_readonly("edge_flows", &OutputOptions::edge_flows) + .def_readonly("residual", &OutputOptions::residual) + .def_readonly("bottleneck", &OutputOptions::bottleneck); + + py::enum_(m, "Termination", + "The local outcome of a source and a call's termination (APPROACH 9.4): COMPLETE (the\n" + "offer is served), LIMIT_FLOWS, LIMIT_DIMINISHING, NO_ROUTE, EXHAUSTED.") + .value("COMPLETE", Termination::Complete) + .value("LIMIT_FLOWS", Termination::LimitFlows) + .value("LIMIT_DIMINISHING", Termination::LimitDiminishing) + .value("NO_ROUTE", Termination::NoRoute) + .value("EXHAUSTED", Termination::Exhausted); + + py::class_( + m, "PlacementResult", + "One placement call: placed (units), remaining (request - placed; float('inf') for +inf),\n" + "termination, local_outcomes [(source, Termination)] per roster source, routes_changed,\n" + "weights_changed, drops [(EdgeId, units)] of this call; cost_distribution [(cost, units)] on\n" + "Global (the marginal curve of the policy's cumulative flow; a zero call reports it unchanged), else\n" + "None; the optional outputs\n" + "edge_flows, residual (int64[E]), bottleneck, min_cut (Global with bottleneck; None when the\n" + "solve was not maximal), None unless requested.") + .def_readonly("placed", &PlacementResult::placed) + .def_property_readonly("remaining", [](const PlacementResult& r) { return remaining_obj(r.remaining); }) + .def_readonly("termination", &PlacementResult::termination) + .def_readonly("local_outcomes", &PlacementResult::local_outcomes) + .def_readonly("routes_changed", &PlacementResult::routes_changed) + .def_readonly("weights_changed", &PlacementResult::weights_changed) + .def_property_readonly("drops", [](const PlacementResult& r) { return pairs_list(r.drops); }) + .def_readonly("cost_distribution", &PlacementResult::cost_distribution) + .def_property_readonly("edge_flows", + [](const PlacementResult& r) -> py::object { + return r.edge_flows ? py::object(int64_array(*r.edge_flows)) : py::object(py::none()); + }) + .def_property_readonly("residual", + [](const PlacementResult& r) -> py::object { + return r.residual ? py::object(int64_array(*r.residual)) : py::object(py::none()); + }) + .def_readonly("bottleneck", &PlacementResult::bottleneck) + .def_readonly("min_cut", &PlacementResult::min_cut) + .def("__repr__", [](const PlacementResult& r) { + return "PlacementResult(placed=" + std::to_string(r.placed) + ", termination=" + + std::string(py::str(py::cast(r.termination).attr("name"))) + ")"; + }); + + py::class_(m, "DagCacheStats", + "DagCache statistics (atomic per field, not a consistent snapshot): hits, misses,\n" + "entries, bytes (the entries' retained payload), live_values (values alive anywhere).") + .def_readonly("hits", &DagCache::Stats::hits) + .def_readonly("misses", &DagCache::Stats::misses) + .def_readonly("entries", &DagCache::Stats::entries) + .def_readonly("bytes", &DagCache::Stats::bytes) + .def_readonly("live_values", &DagCache::Stats::live_values); + + py::class_>( + m, "DagCache", + "A cache of static shortest-path supports bound to one graph, keyed (roots as a set, direction,\n" + "Eligibility uid); building mode (one writer, no concurrent readers). CostOnly policies and the\n" + "evaluators read it; selection is projected at creation, never cached. clear() empties it (the\n" + "hit and miss counters persist); held values stay valid. A supplied building cache is a memo\n" + "(APPROACH 13, limit 2): a public call that fails keeps the complete entries and statistics of the\n" + "lookups that succeeded before; never a partial entry, and a retained entry never changes a result.") + .def(py::init([](const std::shared_ptr& graph) { + if (!graph) throw py::value_error("graph must be a Graph"); + return std::make_shared(*graph); + }), + py::arg("graph")) + .def("graph_uid", &DagCache::graph_uid, "uid of the graph this cache is bound to") + .def("clear", &DagCache::clear) + .def("stats", &DagCache::stats); + + auto flow_policy = py::class_( + m, "FlowPolicy", + "The one placement engine (APPROACH 9), bound to its ledger at construction: place(volume,\n" + "outputs) runs the transaction and commits one atomic exchange (a failed call raises with the ledger\n" + "and the policy as they were, its Python result included; limits in APPROACH 13: pybind11's\n" + "unchecked tp_alloc in object construction and conversions outside the staged calls, and a supplied\n" + "building DagCache, a memo that keeps its successful lookups); remove() reverts every flow and\n" + "opens a new epoch; dropping the policy without remove() leaves its records as orphans.\n" + "sources and destinations are one NodeId or a sequence (disjoint sets). ValueError for invalid\n" + "endpoints, configurations, foreign objects and requests; TypeError for wrong argument types.") + .def(py::init([](const std::shared_ptr& ledger, const py::object& sources, + const py::object& destinations, const FlowPolicyConfig& config, + const std::shared_ptr& elig, const std::shared_ptr& cache) { + if (!ledger) throw py::value_error("ledger must be a FlowLedger"); + const auto n = ledger->graph()->num_nodes(); + const auto s = node_ids(sources, n, "sources"); + const auto d = node_ids(destinations, n, "destinations"); + return std::make_unique(*ledger, s, d, config, elig.get(), cache.get()); + }), + py::keep_alive<1, 2>(), py::keep_alive<1, 6>(), py::keep_alive<1, 7>(), py::arg("ledger"), + py::arg("sources"), py::arg("destinations"), py::arg("config"), py::arg("elig") = nullptr, + py::arg("cache") = nullptr) .def( "place", - [](detail::PinnedFlow& pf, const py::object& volume) { + [](FlowPolicy& p, const py::object& volume, const py::object& outputs) { const auto v = request_units(volume); - // The GIL stays held: the call mutates the ledger, which other Python threads may - // read (one writer per ledger, APPROACH 13). - const detail::PinnedResult r = pf.place(v); - py::dict out; - out["placed"] = py::int_(r.placed); - out["remaining"] = r.remaining.has_value() ? py::object(py::int_(*r.remaining)) - : py::object(py::float_(std::numeric_limits::infinity())); - out["termination"] = termination_name(r.termination); - out["routes_changed"] = r.routes_changed; - out["weights_changed"] = r.weights_changed; - out["drops"] = pairs_list(r.drops); - out["iterations"] = r.iterations; - return out; + const auto o = outputs_of(outputs); + // All or nothing across the boundary: the result's Python object exists before the + // call, and after the commit nothing allocates (the value moves into it). The GIL + // stays held: the call mutates the ledger (one writer per ledger). + auto [obj, slot] = new_object(); + *slot = p.place(v, o); + return std::move(obj); }, - py::arg("volume"), - "place(volume: int units or float('inf')) -> {placed, remaining, termination, routes_changed,\n" - "weights_changed, drops, iterations (diagnostic: bisection steps of the T_max search)}") - .def("remove", &detail::PinnedFlow::remove) - .def("release", &detail::PinnedFlow::release, "destroy without remove(): the record stays as an orphan") - .def("flow_id", - [](const detail::PinnedFlow& pf) -> py::object { - const auto id = pf.flow_id(); - return id.has_value() ? py::object(py::make_tuple(id->first, id->second)) : py::object(py::none()); - }) - .def("owner", &detail::PinnedFlow::owner) - .def("requested", [](const detail::PinnedFlow& pf) { return counter_int(pf.requested()); }, - "the epoch's cumulative finite request (units)") - .def("infinite", &detail::PinnedFlow::infinite, "whether +inf was requested in this epoch") - .def("placed_demand", &detail::PinnedFlow::placed_demand) - .def("usable_bundles", &detail::PinnedFlow::usable_bundles) - .def("plan", [](const detail::PinnedFlow& pf) -> py::object { - const auto* plan = pf.plan(); - if (plan == nullptr) return py::none(); - py::dict out; - out["placement"] = plan->placement; - out["admission"] = plan->admission; - out["route"] = plan->route; - out["members"] = plan->members; - out["weights"] = plan->placement == FlowPlacement::Weighted ? py::object(py::cast(plan->weights)) - : py::object(py::none()); - return out; + py::arg("volume"), py::arg("outputs") = py::none(), + "place(volume: int units or float('inf'), outputs: OutputOptions | None) -> PlacementResult") + .def("remove", &FlowPolicy::remove, "revert every flow of the policy, retire the ids, open a new epoch") + .def("placed_demand", &FlowPolicy::placed_demand, "the sum of delivered over the policy's live records") + .def("records", &FlowPolicy::records, "the policy's live records as snapshots in FlowId order") + .def( + "record", [](const FlowPolicy& p, const py::object& flow_id) { return p.record(flow_id_of(flow_id)); }, + py::arg("flow_id"), + "one of the policy's live records by FlowId (ValueError for a FlowId of another ledger or policy, a\n" + "retired or an unknown id)") + .def("usable_bundles", &FlowPolicy::usable_bundles, + "Pinned: the bundles with a src -> dst walk after static pruning (0 for the other modes)") + .def_property_readonly("config", &FlowPolicy::config); +#if NETGRAPH_CORE_PRIVATE_HOOKS + flow_policy + .def( + "_place_traced", + [](FlowPolicy& p, const py::object& volume, const py::object& outputs) { + const auto v = request_units(volume); + const auto o = outputs_of(outputs); + // As place(): every Python object exists before anything is published; the trace is + // converted at the engine's staged point, after the call is complete and before the + // commit, so a failed conversion changes nothing. + auto [obj, slot] = new_object(); + py::tuple pair(2); + py::object trace_py; + detail::Trace trace; + const std::function staged = [&] { trace_py = trace_obj(trace); }; + *slot = detail::place_traced(p, v, o, trace, &staged); + PyTuple_SET_ITEM(pair.ptr(), 0, obj.release().ptr()); + PyTuple_SET_ITEM(pair.ptr(), 1, trace_py.release().ptr()); + return pair; + }, + py::arg("volume"), py::arg("outputs") = py::none(), + "private: place() with the transaction's trace (prototype 2's keys)") + .def("_state", &state_obj, "private: the epoch state (roster, requested, +inf, cohorts)") + .def( + "_owner", [](const FlowPolicy& p) { return detail::PolicyAccess::engine(p).owner; }, + "private: the ledger-local owner id (FlowRecord.owner)"); +#else + (void)flow_policy; +#endif + + + py::class_(m, "CapacityOptions", + "The configuration, the static Eligibility (None: all allowed), the DagCache (None:\n" + "private per worker) and the optional outputs of capacity() and batch_capacity().") + .def(py::init([](const FlowPolicyConfig& config, const std::shared_ptr& elig, + const std::shared_ptr& cache, const py::object& outputs) { + return PyCapacityOptions {config, elig, cache, outputs_of(outputs)}; + }), + py::arg("config"), py::kw_only(), py::arg("elig") = nullptr, py::arg("cache") = nullptr, + py::arg("outputs") = py::none()) + .def_readonly("config", &PyCapacityOptions::config) + .def_readonly("elig", &PyCapacityOptions::elig) + .def_readonly("cache", &PyCapacityOptions::cache) + .def_readonly("outputs", &PyCapacityOptions::outputs); + + py::class_(m, "SensitivityOptions", + "The configuration, Eligibility, DagCache and baseline outputs of sensitivity().") + .def(py::init([](const FlowPolicyConfig& config, const std::shared_ptr& elig, + const std::shared_ptr& cache, const py::object& outputs) { + return PySensitivityOptions {config, elig, cache, outputs_of(outputs)}; + }), + py::arg("config"), py::kw_only(), py::arg("elig") = nullptr, py::arg("cache") = nullptr, + py::arg("outputs") = py::none()) + .def_readonly("config", &PySensitivityOptions::config) + .def_readonly("elig", &PySensitivityOptions::elig) + .def_readonly("cache", &PySensitivityOptions::cache) + .def_readonly("outputs", &PySensitivityOptions::outputs); + + py::enum_(m, "SensitivityStatus", + "EVALUATED (the loss is the baseline minus the capacity without the edge) or\n" + "UNEVALUABLE (the deletion raised a model exception: loss None, reason the message).") + .value("EVALUATED", SensitivityStatus::Evaluated) + .value("UNEVALUABLE", SensitivityStatus::Unevaluable); + + py::class_(m, "SensitivityEntry", "One candidate edge: edge, loss (int units or None), status, reason.") + .def_readonly("edge", &SensitivityEntry::edge) + .def_readonly("loss", &SensitivityEntry::loss) + .def_readonly("status", &SensitivityEntry::status) + .def_property_readonly("reason", [](const SensitivityEntry& e) -> py::object { + return e.status == SensitivityStatus::Unevaluable ? py::object(py::str(e.reason)) : py::object(py::none()); }); + py::class_(m, "SensitivityResult", + "baseline (the PlacementResult of capacity()) and entries (ascending EdgeId; an\n" + "absent edge has loss 0).") + .def_readonly("baseline", &SensitivityResult::baseline) + .def_readonly("entries", &SensitivityResult::entries); + + m.attr("MAX_FLOWS_PER_CALL") = py::int_(kMaxFlowsPerCall); + + m.def( + "capacity", + [](const std::shared_ptr& graph, const py::object& sources, const py::object& destinations, + const py::object& opts) { + if (!graph) throw py::value_error("graph must be a Graph"); + const auto o = options_of(opts, "CapacityOptions"); + const auto s = node_ids(sources, graph->num_nodes(), "sources"); + const auto d = node_ids(destinations, graph->num_nodes(), "destinations"); + const CapacityOptions c {o.config, o.elig.get(), o.cache.get(), o.outputs}; + PlacementResult r; + { + py::gil_scoped_release release; + r = capacity(*graph, s, d, c); + } + return object_of(std::move(r)); + }, + py::arg("graph"), py::arg("sources"), py::arg("destinations"), py::arg("opts"), + "place(+inf) of a FlowPolicy with opts.config on a fresh ledger of the graph -> PlacementResult.\n" + "The exact maximum flow is the Global + MaxFlow configuration.\n" + "A failed call raises; a supplied building cache keeps the entries and statistics of the lookups\n" + "that succeeded before the failure (a memo: APPROACH 13, limit 2)."); + + m.def( + "batch_capacity", + [](const std::shared_ptr& graph, const py::object& pairs, const py::object& opts, + const py::object& per_pair_elig, const py::object& workers) { + if (!graph) throw py::value_error("graph must be a Graph"); + // The arguments first, then each pair completely and in pair order (the same two parts + // as the C++ validation), so the lowest failing pair index is reported. + const auto o = options_of(opts, "CapacityOptions"); + const auto w = workers_of(workers, false, static_cast(o.cache)); + const CapacityOptions c {o.config, o.elig.get(), o.cache.get(), o.outputs}; + detail::check_batch_arguments(*graph, c, w); + if (not_a_sequence(pairs)) throw py::value_error("pairs must be a sequence of (sources, destinations)"); + const auto items = py::reinterpret_borrow(pairs); + const auto num_pairs = py::len(items); + std::optional eligs; + if (!per_pair_elig.is_none()) { + // An explicit sequence has one entry per pair (None entries allowed), also when empty. + if (not_a_sequence(per_pair_elig)) throw py::value_error("per_pair_elig must be a sequence of Eligibility or None"); + eligs = py::reinterpret_borrow(per_pair_elig); + if (py::len(*eligs) != num_pairs) { + throw py::value_error("per_pair_elig has " + std::to_string(py::len(*eligs)) + " entries for " + + std::to_string(num_pairs) + " pairs"); + } + } + std::vector, std::vector>> ps; + std::vector> keep; + std::vector per; + const auto n = graph->num_nodes(); + for (std::size_t i = 0; i < num_pairs; ++i) { + const py::object item = items[i]; + if (not_a_sequence(item) || py::len(item) != 2) { + throw py::value_error("each pair must be (sources, destinations)"); + } + auto t = py::reinterpret_borrow(item); + auto src = node_ids(t[0], n, "sources"); + auto dst = node_ids(t[1], n, "destinations"); + const Eligibility* e = nullptr; + if (eligs) { + const py::object entry = (*eligs)[i]; + if (!entry.is_none()) { + if (!py::isinstance(entry)) throw py::type_error("per_pair_elig entries must be Eligibility or None"); + keep.push_back(entry.cast>()); + e = keep.back().get(); + } + per.push_back(e); + } + detail::check_batch_pair(*graph, src, dst, e, i); + ps.emplace_back(std::move(src), std::move(dst)); + } + std::vector rs; + { + py::gil_scoped_release release; + rs = batch_capacity(*graph, ps, c, per, w); + } + py::list out(rs.size()); + for (std::size_t i = 0; i < rs.size(); ++i) { + PyList_SET_ITEM(out.ptr(), static_cast(i), object_of(std::move(rs[i])).release().ptr()); + } + return out; + }, + py::arg("graph"), py::arg("pairs"), py::arg("opts"), py::arg("per_pair_elig") = py::none(), + py::arg("workers") = py::none(), + "capacity() per (sources, destinations) pair in pair order, opts.elig intersected with\n" + "per_pair_elig[i] (None entries allowed; a given sequence has one entry per pair, also when\n" + "empty); workers >= 1 (None: NGRAPH_CORE_BATCH_THREADS or 1; 1 with a supplied cache).\n" + "Everything is validated before a worker starts, the arguments first, then each pair\n" + "completely in pair order, so the error of the lowest failing pair index is raised; Pinned is\n" + "rejected; an empty batch returns [].\n" + "A failed call raises; a supplied building cache keeps the entries and statistics of the lookups\n" + "that succeeded before the failure (a memo: APPROACH 13, limit 2)."); + + m.def( + "sensitivity", + [](const std::shared_ptr& graph, const py::object& sources, const py::object& destinations, + const py::object& opts, const py::object& workers) { + if (!graph) throw py::value_error("graph must be a Graph"); + const auto o = options_of(opts, "SensitivityOptions"); + const auto s = node_ids(sources, graph->num_nodes(), "sources"); + const auto d = node_ids(destinations, graph->num_nodes(), "destinations"); + const auto w = workers_of(workers, true, static_cast(o.cache)); + const SensitivityOptions c {o.config, o.elig.get(), o.cache.get(), o.outputs}; + SensitivityResult r; + { + py::gil_scoped_release release; + r = sensitivity(*graph, s, d, c, w); + } + return object_of(std::move(r)); + }, + py::arg("graph"), py::arg("sources"), py::arg("destinations"), py::arg("opts"), py::arg("workers") = py::none(), + "The baseline capacity() and one entry per candidate edge (Global: the baseline's positive-flow\n" + "edges; other models: every allowed edge of at least 1 unit): the loss is the baseline's placed\n" + "minus capacity() without the edge, in signed units, never thresholded (Global evaluates it by the\n" + "flow core's deletion re-solve from the baseline's final state, and CostOnly gives edges no route\n" + "uses loss 0 without a placement, result-equivalent forms of that definition); a placed deletion\n" + "raising a model exception is UNEVALUABLE with the message as reason.\n" + "workers as batch_capacity (NGRAPH_CORE_SENSITIVITY_THREADS).\n" + "A failed call raises; a supplied building cache keeps the entries and statistics of the lookups\n" + "that succeeded before the failure (a memo: APPROACH 13, limit 2)."); + + // ------------------------------------------------------------------ private recorder entries + m.def( "_spf", - [](const std::shared_ptr& graph, const py::object& roots, const std::string& direction, + [](const std::shared_ptr& graph, const py::object& roots, const std::string& direction, const py::object& targets, const PathSelection& selection, const py::object& residual, const py::object& min_residual, const std::shared_ptr& elig) { - if (!graph) throw py::value_error("graph must be a StrictMultiDiGraph"); + if (!graph) throw py::value_error("graph must be a Graph"); if (direction != "forward" && direction != "reverse") { throw py::value_error("direction must be 'forward' or 'reverse'"); } @@ -1134,7 +1782,7 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { // before the residual is read (review-1b R15); detail::spf repeats them. if (t.has_value()) { detail::check_disjoint(detail::node_set(r, graph->num_nodes(), "roots"), - detail::node_set(*t, graph->num_nodes(), "targets")); + detail::node_set(*t, graph->num_nodes(), "targets"), "roots", "targets"); } else if (!selection.multipath) { throw py::value_error("multipath = false requires targets (the single path joins a root to a target)"); } @@ -1148,8 +1796,8 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { if (t.has_value()) ts = std::span(*t); std::optional> rs; if (res.has_value()) rs = std::span(*res); - o = detail::spf(*graph, r, direction == "forward" ? detail::Direction::Forward : detail::Direction::Reverse, ts, - selection, rs, minr, elig.get()); + o = detail::spf(*graph, r, direction == "forward" ? Direction::Forward : Direction::Reverse, ts, selection, rs, + minr, elig.get()); } py::dict out; if (o.costs.has_value()) { @@ -1157,7 +1805,7 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { for (Cost c : *o.costs) costs.append(cost_or_none(c)); out["costs"] = costs; } - out["dag"] = std::make_shared(detail::PredDAGBuilder::from_edges(*graph, o.edges)); + out["dag"] = std::make_shared(detail::PredDAGBuilder::of_edges(*graph, o.edges)); if (t.has_value()) { out["target_cost"] = o.target_cost.has_value() ? py::object(py::int_(*o.target_cost)) : py::object(py::none()); } @@ -1166,8 +1814,58 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { }, py::arg("graph"), py::arg("roots"), py::arg("direction"), py::arg("targets"), py::arg("selection"), py::arg("residual") = py::none(), py::arg("min_residual") = 1, py::arg("elig") = nullptr, - "Private (sub-PR 1b; public spf in 1d): the search of APPROACH 6. Returns {costs (full search),\n" - "dag, target_cost (with targets; None when unreachable), path (the single path's walk)}."); + "private: the internal search of APPROACH 6 (the recorder's entry; the public spf replaces it).\n" + "Returns {costs (full search), dag, target_cost (with targets; None when unreachable), path (the\n" + "single path's walk)}."); + + // ------------------------------------------------------------------ private hooks + +#if NETGRAPH_CORE_PRIVATE_HOOKS + + m.def( + "_max_flows_per_call", + [](const py::object& value) { + auto& cap = detail::max_flows_per_call(); + const auto old = cap.load(); + if (!value.is_none()) cap.store(integer_value(value, 0, kMaxFlowsPerCall, "max_flows_per_call")); + return old; + }, + py::arg("value") = py::none(), + "private test hook: the call-wide creation cap (MAX_FLOWS_PER_CALL by default); returns the old value"); + + m.def( + "_sensitivity_fail_edge", + [](const py::object& edge) { + auto& hook = detail::sensitivity_fail_edge(); + const auto old = hook.load(); + hook.store(edge.is_none() ? -1 : integer_value(edge, -1, std::numeric_limits::max(), "edge")); + return old; + }, + py::arg("edge") = py::none(), + "private test hook: the candidate whose deletion raises a model exception (None: off)"); + + m.def( + "_fail_python_allocation", + [](const py::object& fn, const py::tuple& args, long k) { + // Calls fn(*args) with the k-th Python object allocation (from 0) failing, then + // restores the allocator. Returns (hit, allocations seen, result or None, exception or + // None). One caller at a time: the allocator is process-wide while the call runs. + PyMem_GetAllocator(PYMEM_DOMAIN_OBJ, &g_saved_allocator); + PyMemAllocatorEx hook {nullptr, failing_malloc, failing_calloc, failing_realloc, forwarding_free}; + g_fail_countdown.store(k); + g_fail_seen.store(0); + g_fail_hit.store(false); + PyMem_SetAllocator(PYMEM_DOMAIN_OBJ, &hook); + PyObject* r = PyObject_CallObject(fn.ptr(), args.ptr()); + PyMem_SetAllocator(PYMEM_DOMAIN_OBJ, &g_saved_allocator); + g_fail_countdown.store(-1); + py::object result = r != nullptr ? py::reinterpret_steal(r) : py::object(py::none()); + py::object error = py::none(); + if (r == nullptr) error = py::reinterpret_steal(PyErr_GetRaisedException()); + return py::make_tuple(g_fail_hit.load(), g_fail_seen.load(), result, error); + }, + py::arg("fn"), py::arg("args"), py::arg("k"), + "private test hook: fn(*args) with the k-th Python object allocation failing -> (hit, seen, result, error)"); m.def( "_round_robin", @@ -1178,7 +1876,7 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { detail::round_robin(t, out); return out; }, - py::arg("total"), py::arg("k"), "Private: round robin of total units over k members (APPROACH 2.1)."); + py::arg("total"), py::arg("k"), "private: round robin of total units over k members (APPROACH 2.1)"); m.def( "_highest_averages", @@ -1200,6 +1898,7 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { return out; }, py::arg("total"), py::arg("weights"), - "Private: highest-averages (D'Hondt) apportionment of total units over positive weights, ties by\n" - "the lower index (APPROACH 2.1)."); + "private: highest-averages (D'Hondt) apportionment of total units over positive weights, ties by\n" + "the lower index (APPROACH 2.1)"); +#endif } diff --git a/dev/cpp-tests.sh b/dev/cpp-tests.sh index aab39be..ff28da7 100644 --- a/dev/cpp-tests.sh +++ b/dev/cpp-tests.sh @@ -17,7 +17,7 @@ if [[ "$(uname -s)" == Darwin ]]; then else leak_detection=1 fi -cmake -S . -B "$build_dir" -DNETGRAPH_CORE_BUILD_TESTS=ON \ +cmake -S . -B "$build_dir" -DNETGRAPH_CORE_BUILD_TESTS=ON -DNETGRAPH_CORE_PRIVATE_HOOKS=ON \ -DNETGRAPH_CORE_SANITIZE="$sanitize" -DCMAKE_BUILD_TYPE="$build_type" cmake --build "$build_dir" --config "$build_type" --parallel "$jobs" if [[ "$sanitize" == ON ]]; then diff --git a/include/netgraph/core/dag_cache.hpp b/include/netgraph/core/dag_cache.hpp new file mode 100644 index 0000000..f021220 --- /dev/null +++ b/include/netgraph/core/dag_cache.hpp @@ -0,0 +1,67 @@ +/* The DagCache of APPROACH 10 in building mode. */ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/graph.hpp" +#include "netgraph/core/options.hpp" +#include "netgraph/core/pred_dag.hpp" +#include "netgraph/core/types.hpp" + +namespace netgraph::core { + +// A cache of shortest-path supports bound to one graph (by uid). The key is (the roots as a +// sorted set, the direction, the Eligibility uid; no Eligibility is uid 0); the value is the full +// static support of that search (multipath = true, multi_edge = true, no residual: every tight +// edge of the statically eligible member graph over the nodes it reaches, forward-oriented for a +// reverse search) as an immutable shared PredDAG, valid after clear() and after the cache is +// destroyed. Selection is never cached: a policy projects it at creation (APPROACH 9.1, 10). +// +// Building mode: one writer, no concurrent readers. A hit returns the value; a miss runs the +// search, inserts and returns (a failed search counts one miss and inserts nothing); an invalid +// query (a graph or Eligibility of another graph, an empty root set, a root out of range) throws +// std::invalid_argument and counts nothing. clear() empties the cache; the hit and miss counters +// persist. Statistics are atomic per field, not a consistent snapshot: `bytes` is the retained +// payload of the entries (the DAGs' stored arrays), `live_values` the values alive anywhere, +// entries included. A supplied cache whose public call fails later keeps the complete entries +// and statistics of its successful lookups (APPROACH 13, limit 2). +class DagCache { +public: + struct Stats { + std::int64_t hits {0}; + std::int64_t misses {0}; + std::int64_t entries {0}; + std::int64_t bytes {0}; + std::int64_t live_values {0}; + }; + + explicit DagCache(const Graph& graph); + DagCache(const DagCache&) = delete; + DagCache& operator=(const DagCache&) = delete; + ~DagCache() = default; + + [[nodiscard]] std::uint64_t graph_uid() const noexcept { return graph_uid_; } + [[nodiscard]] std::shared_ptr lookup(const Graph& g, std::span roots, + Direction direction, const Eligibility* elig); + void clear() noexcept; + [[nodiscard]] Stats stats() const noexcept; + +private: + using Key = std::tuple, Direction, std::uint64_t>; + std::uint64_t graph_uid_ {0}; + std::map> entries_ {}; + std::atomic hits_ {0}; + std::atomic misses_ {0}; + std::atomic num_entries_ {0}; + std::atomic bytes_ {0}; + // Shared with every value's deleter, so it outlives the cache while values live. + std::shared_ptr> live_ {std::make_shared>(0)}; +}; + +} // namespace netgraph::core diff --git a/include/netgraph/core/detail/endpoints.hpp b/include/netgraph/core/detail/endpoints.hpp new file mode 100644 index 0000000..1db481c --- /dev/null +++ b/include/netgraph/core/detail/endpoints.hpp @@ -0,0 +1,55 @@ +/* Endpoint and root sets (internal; APPROACH 4, 6 and 9.3; rule 13): one normalisation, one + overlap check and one message for each defect, shared by the flow core, the searches, the + ledger's bottleneck, the policies, the evaluators and the bindings (which check unnarrowed + Python integers and report the same texts). */ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/types.hpp" + +namespace netgraph::core::detail { + +inline std::string empty_set_error(const char* name) { return std::string(name) + ": the node set is empty"; } + +inline std::string node_range_error(const char* name, const std::string& node, std::int32_t num_nodes) { + return std::string(name) + ": node " + node + " out of node range [0, " + std::to_string(num_nodes) + ")"; +} + +inline std::string overlap_error(NodeId v, const char* a, const char* b) { + return "node " + std::to_string(v) + " is in both endpoint sets: " + a + " and " + b + " must not overlap"; +} + +// The sorted, duplicate-free node set; std::invalid_argument for an empty set or an id out of +// range (`name` labels the message). +[[nodiscard]] inline std::vector node_set(std::span nodes, std::int32_t num_nodes, + const char* name) { + if (nodes.empty()) throw std::invalid_argument(empty_set_error(name)); + for (NodeId v : nodes) { + if (v < 0 || v >= num_nodes) throw std::invalid_argument(node_range_error(name, std::to_string(v), num_nodes)); + } + std::vector out(nodes.begin(), nodes.end()); + std::sort(out.begin(), out.end()); + out.erase(std::unique(out.begin(), out.end()), out.end()); + return out; +} + +// std::invalid_argument when the two sorted sets share a vertex (the first shared one; rule 13). +inline void check_disjoint(std::span a, std::span b, const char* a_name, + const char* b_name) { + for (std::size_t i = 0, k = 0; i < a.size() && k < b.size();) { + if (a[i] == b[k]) throw std::invalid_argument(overlap_error(a[i], a_name, b_name)); + if (a[i] < b[k]) { + ++i; + } else { + ++k; + } + } +} + +} // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/enum_domain.hpp b/include/netgraph/core/detail/enum_domain.hpp new file mode 100644 index 0000000..0fa3c5e --- /dev/null +++ b/include/netgraph/core/detail/enum_domain.hpp @@ -0,0 +1,66 @@ +/* Membership of enumeration values (internal; rule 13). An enum-typed field can hold a value + outside its enumeration (a C++ cast, a Python enum built from an integer); such a value is + outside the domain and is rejected (std::invalid_argument naming the field) before it + reaches any rule or record. */ +#pragma once + +#include +#include + +#include "netgraph/core/options.hpp" + +namespace netgraph::core::detail { + +[[noreturn]] inline void not_a_member(const char* field, int value, const char* enumeration) { + throw std::invalid_argument(std::string(field) + ": " + std::to_string(value) + " is not a value of " + enumeration); +} + +inline void check_member(FlowPlacement v, const char* field) { + switch (v) { + case FlowPlacement::MaxFlow: + case FlowPlacement::Weighted: + case FlowPlacement::EqualBalanced: return; + } + not_a_member(field, static_cast(v), "FlowPlacement"); +} + +inline void check_member(Admission v, const char* field) { + switch (v) { + case Admission::Lossless: + case Admission::Lossy: return; + } + not_a_member(field, static_cast(v), "Admission"); +} + +inline void check_member(EdgeTieBreak v, const char* field) { + switch (v) { + case EdgeTieBreak::Deterministic: + case EdgeTieBreak::PreferHigherPriority: return; + } + not_a_member(field, static_cast(v), "EdgeTieBreak"); +} + +inline void check_member(EdgePriority v, const char* field) { + switch (v) { + case EdgePriority::Residual: + case EdgePriority::Capacity: + case EdgePriority::Headroom: return; + } + not_a_member(field, static_cast(v), "EdgePriority"); +} + +inline void check_member(Direction v, const char* field) { + switch (v) { + case Direction::Forward: + case Direction::Reverse: return; + } + not_a_member(field, static_cast(v), "Direction"); +} + +// The two enum-typed fields of a selection. +inline void check_selection(const PathSelection& selection) { + check_member(selection.tie_break, "selection.tie_break"); + check_member(selection.priority, "selection.priority"); +} + +} // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/evaluator_checks.hpp b/include/netgraph/core/detail/evaluator_checks.hpp new file mode 100644 index 0000000..6d8b361 --- /dev/null +++ b/include/netgraph/core/detail/evaluator_checks.hpp @@ -0,0 +1,28 @@ +/* The evaluators' argument checks and worker default (internal), shared with the Python + binding so that both report the same first error (APPROACH 4 and 11). */ +#pragma once + +#include +#include +#include + +#include "netgraph/core/evaluators.hpp" +#include "netgraph/core/graph.hpp" +#include "netgraph/core/types.hpp" + +namespace netgraph::core::detail { + +// batch_capacity's validation in its two parts: the arguments (workers, foreign objects, the +// cache rule, Pinned, the configuration), then one pair (its endpoint sets, their overlap, its +// Eligibility's graph), the pairs in index order. +void check_batch_arguments(const Graph& g, const CapacityOptions& opts, std::int32_t workers); +void check_batch_pair(const Graph& g, std::span sources, + std::span destinations, const Eligibility* elig, std::size_t index); + +// The evaluators' default worker count from the environment (NGRAPH_CORE_BATCH_THREADS for +// batch_capacity, NGRAPH_CORE_SENSITIVITY_THREADS for sensitivity): an integer in [1, 4096], +// else 1. An explicit workers >= 1 is always honoured; the environment supplies defaults only +// (APPROACH 11). +std::int32_t default_workers(bool sensitivity) noexcept; + +} // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/flow_core.hpp b/include/netgraph/core/detail/flow_core.hpp index 586c794..07a414f 100644 --- a/include/netgraph/core/detail/flow_core.hpp +++ b/include/netgraph/core/detail/flow_core.hpp @@ -1,31 +1,236 @@ -/* The flow core restricted to an edge subset (internal; APPROACH 2.2 and 7). */ +/* The one integer minimum-cost flow core (internal; APPROACH 2.2): the solve on a graph's + eligible edges behind the recorder's private entry and Global routing, the solve on an edge + subset behind the MaxFlow kernel, and the deletion re-solve behind the exact sensitivity + model. */ #pragma once +#include +#include +#include +#include #include #include +#include #include #include -#include "netgraph/core/strict_multidigraph.hpp" +#include "netgraph/core/detail/int128.hpp" +#include "netgraph/core/detail/private_hooks.hpp" +#include "netgraph/core/graph.hpp" #include "netgraph/core/types.hpp" namespace netgraph::core::detail { +// Potential of a vertex that no Dijkstra run of the call settled. Potentials are otherwise +// non-negative path costs, so the sentinel never collides. +inline constexpr Cost kNeverReached = -1; +// Upper bound of the internal value cap; also the capacity of the virtual source and sink arcs, +// strictly above every attainable value (below 2^62), so a virtual arc never saturates. +inline constexpr Amount kMaxFlowCap = INT64_MAX - 1; + +// Operation counters of one call: diagnostics for the performance gate's drift check, identical +// across identical calls, never an oracle. Virtual vertices and arcs count like real ones. +struct FlowCoreCounters { + std::int64_t dijkstra_runs {0}; // every run, the final failed one included + std::int64_t dijkstra_pops {0}; // heap pops, stale ones included + std::int64_t settled {0}; // vertices finalised, summed over runs + std::int64_t arc_scans {0}; // arcs examined by Dijkstra, Dinic BFS and DFS advance + std::int64_t dinic_phases {0}; // BFS level builds, the last unsuccessful one of a tier included + std::int64_t augmentations {0}; // DFS paths pushed +}; + +// The payload of a solve on a graph (the recorder's _place_global_units, Global routing). +struct FlowCoreResult { + Amount placed {0}; + // The marginal cost curve: (cost, units) with costs strictly increasing and units positive. + std::vector> cost_distribution {}; + std::vector edge_flows {}; // per edge, id order + // Edges with positive view from the full residual closure of the real sources to its + // complement, ascending id; nullopt while the destinations stay connected (a cap stopped the + // solve) and on an input cap of 0. + std::optional> min_cut {}; + std::vector potentials {}; // per node; kNeverReached when never settled + FlowCoreCounters counters {}; +}; + +// A solve's network in local coordinates: real vertices 0..num_nodes-1, s* = num_nodes, +// t* = num_nodes + 1. The input is the view's edges in ascending EdgeId (tail, head, cost and +// view capacity per edge) and the endpoint sets in ascending NodeId; the arcs are slots in +// adjacency order (the slots of x are [off[x], off[x + 1])), each with its head, its cost and +// the slot of its partner, in the arc order of APPROACH 2.2. +struct Network { + std::int32_t num_nodes {0}; + std::vector edges {}; + std::vector tail {}; + std::vector head {}; + std::vector cost {}; + std::vector cap {}; + std::vector sources {}; + std::vector destinations {}; + std::vector off {}; + std::vector arc_head {}; + std::vector rev {}; + std::vector arc_cost {}; + std::vector edge_slot {}; // per edge: its forward slot (at the tail) + std::vector src_rev {}; // per source: the reverse slot of its virtual arc (at the source) + std::vector dst_fwd {}; // per destination: the forward slot of its virtual arc +}; + +// The final state of a solve, captured for the deletion re-solve (APPROACH 2.2): bound to the +// solve's graph (whose owner it holds), effective view and endpoint sets (copied into its +// network, shared immutable between copies); the residual capacities, the attained value, the +// working potentials of all N + 2 vertices, the maximum certificate and the solve's counters. +// A copyable value: copies share the network only. Every field belongs to the one solve, so an +// assignment is all or nothing (APPROACH 13): the copy is built aside, then exchanged by moves, +// which never throw; a failed copy assignment leaves the target as it was. +class DeletionState { +public: + DeletionState() = default; + DeletionState(const DeletionState&) = default; + DeletionState(DeletionState&&) = default; + DeletionState& operator=(const DeletionState& other); + DeletionState& operator=(DeletionState&&) = default; + ~DeletionState() = default; + + [[nodiscard]] const Graph& graph() const noexcept { return *graph_; } + [[nodiscard]] const Network& network() const noexcept { return *net_; } + [[nodiscard]] std::span residual() const noexcept { return res_; } // per slot + [[nodiscard]] Amount value() const noexcept { return value_; } + // The working potentials per local vertex: the published potential plus the tier maxima of + // the later tiers; numeric for every vertex (never the published sentinel). + [[nodiscard]] std::span potentials() const noexcept { return pi_; } + // The maximum certificate: the destinations are outside the full residual closure of the + // real sources. + [[nodiscard]] bool maximum() const noexcept { return maximum_; } + [[nodiscard]] const FlowCoreCounters& counters() const noexcept { return counters_; } + [[nodiscard]] std::vector edge_flows() const; // per EdgeId of the graph + +private: + friend class FlowSolver; + friend class DeletionResolver; + std::shared_ptr graph_ {}; + std::shared_ptr net_ {}; + std::vector res_ {}; + std::vector pi_ {}; + Amount value_ {0}; + bool maximum_ {false}; + FlowCoreCounters counters_ {}; +}; + +// The exchange of the copy assignment, and every move, must not throw. +static_assert(std::is_nothrow_move_constructible_v && std::is_nothrow_move_assignable_v); + +// The internal capture request of a solve: place_global_units stores its final state in +// `state`, bound to `graph`, the shared owner of the solve's graph. The ordinary payload does +// not change. A request, not a value: movable, never copied. +struct DeletionCapture { + DeletionCapture() = default; + DeletionCapture(const DeletionCapture&) = delete; + DeletionCapture(DeletionCapture&&) = default; + DeletionCapture& operator=(const DeletionCapture&) = delete; + DeletionCapture& operator=(DeletionCapture&&) = default; + ~DeletionCapture() = default; + + std::shared_ptr graph {}; + std::optional state {}; +#if NETGRAPH_CORE_PRIVATE_HOOKS + // The tests' view of the solve: every Dijkstra's distances per local vertex (INT64_MAX when + // not reached), before its potential update. + std::function)> on_search {}; +#endif +}; +static_assert(std::is_nothrow_move_constructible_v && + std::is_nothrow_move_assignable_v); + +// The result of one deletion re-solve: the repaired value and its certificates (APPROACH 2.2). +struct DeletionResult { + Amount value {0}; + // The working potentials of all real and virtual vertices (local coordinates, s* = N and + // t* = N + 1), valid on every positive residual arc. + std::vector potentials {}; + // The full residual closure of the real sources to its complement on the deleted view, edges + // of positive view capacity, ascending EdgeId: value == sum(view capacity[cut]). + std::vector cut {}; + std::vector edge_flows {}; // the final flow per EdgeId (the deleted edge 0) + FlowCoreCounters counters {}; // this call's, from zero +}; + +#if NETGRAPH_CORE_PRIVATE_HOOKS +// The tests' observation of a re-solve: the state right after the deletion (repair 0), after +// every potential update and every push of repairs 1 to 3, and at completion (repair 4). +struct DeletionEvent { + int repair {0}; + bool push {false}; + Amount moved {0}; // by the current repair so far + std::span residual {}; + std::span potentials {}; + const FlowCoreCounters* counters {nullptr}; +}; +struct DeletionProbe { + std::function observe {}; + // The control of the search reuse: repair 2 reruns repair 1's failed search. + bool rerun_failed_search {false}; +}; +#endif + +class FlowSolver; + +// The deletion re-solve on one worker's copy of a captured state: each call resets the +// residuals and potentials from that copy, deletes a real arc e = (u, v) with flow f_e > 0 and +// repairs the state by three fixed-origin repairs (u -> v for f_e; u -> s*, then t* -> v, for +// the remainder). The working storage is the resolver's own, allocated at construction. +class DeletionResolver { +public: + explicit DeletionResolver(const DeletionState& state); + ~DeletionResolver(); + DeletionResolver(DeletionResolver&&) noexcept; + DeletionResolver& operator=(DeletionResolver&&) noexcept; + DeletionResolver(const DeletionResolver&) = delete; + DeletionResolver& operator=(const DeletionResolver&) = delete; + + // Throws std::invalid_argument before any change when the state is not certified maximum or + // e is not an edge of the view with positive flow. + [[nodiscard]] DeletionResult resolve(EdgeId e); +#if NETGRAPH_CORE_PRIVATE_HOOKS + [[nodiscard]] DeletionResult resolve(EdgeId e, const DeletionProbe& probe); +#endif + +private: + // The input index of e after the precondition checks above. + [[nodiscard]] std::size_t index_of(EdgeId e) const; + DeletionState state_; + std::unique_ptr solver_; +}; + +// Minimum-cost flow of the maximum value attainable under `cap` from the source set to the +// destination set over the eligible edges, on the capacities `view` (units, id order). Every +// solve starts from zero flow. `cap` nullopt is uncapped; an input cap of 0 returns the zero +// payload without running a search. Throws std::invalid_argument before any work, in this +// order, for a view of the wrong length or with an entry outside [0, capacity_units[e]], an +// empty or out-of-range endpoint set, a vertex in both sets, a cap outside [0, 2^63 - 2], or a +// foreign Eligibility. Excluded endpoints are skipped. With `capture`, the final state is +// stored there as well. +[[nodiscard]] FlowCoreResult place_global_units(const Graph& g, std::span view, + std::span sources, + std::span destinations, const Eligibility* elig, + std::optional cap, DeletionCapture* capture = nullptr); + +// The solve on an edge subset (the MaxFlow kernel's replacement on V_i = W + own_i over a +// flow's split graph): flows aligned with the input edges, the value and the marginal curve. struct SubsetFlow { Amount placed {0}; - std::vector flows {}; // aligned with the input edges + std::vector flows {}; std::vector> cost_distribution {}; }; // The minimum-cost flow of the maximum value under `cap` (nullopt: uncapped) from `sources` to // `destinations` over the edges `edges` (ascending EdgeId, distinct) with capacities `caps` -// (aligned with `edges`, each in [0, 2^62)). The one flow core of APPROACH 2.2 with the same -// arc order (ascending EdgeId at every node, the virtual arc first, virtual arcs by ascending -// endpoint NodeId), from zero flow; the vertices are compressed to the ones the edges and -// endpoints touch, which changes no flow, curve or augmenting path. The endpoint sets are -// ascending, distinct and disjoint; the caller validated every input (the MaxFlow kernel's -// replacement solve on V_i = W + own_i over the flow's split graph). -[[nodiscard]] SubsetFlow solve_subset(const StrictMultiDiGraph& g, std::span edges, +// (aligned with `edges`, each in [0, 2^62)). The same core and arc order as +// place_global_units (ascending EdgeId at every node, the virtual arc first, virtual arcs by +// ascending endpoint NodeId), from zero flow; the vertices are compressed to the ones the edges +// and endpoints touch, which changes no flow, curve or augmenting path. The endpoint sets are +// ascending, distinct and disjoint; the caller validated every input. +[[nodiscard]] SubsetFlow solve_subset(const Graph& g, std::span edges, std::span caps, std::span sources, std::span destinations, std::optional cap); diff --git a/include/netgraph/core/detail/int128.hpp b/include/netgraph/core/detail/int128.hpp index 32cb664..9e92826 100644 --- a/include/netgraph/core/detail/int128.hpp +++ b/include/netgraph/core/detail/int128.hpp @@ -1,12 +1,22 @@ -/* Portable unsigned 128-bit products and comparisons for exact rational arithmetic (internal). +/* Portable 128-bit values (internal). - Used by the highest-averages apportionment (quotients w / d compared by cross products, lower - quotas floor(I * w / W)) and by the Headroom priority (residual / capacity compared by cross - products). Every operand is below 2^63, so every product is below 2^126 and every quotient - that is formed fits 64 bits. Compilers with __int128 use it; others use 32-bit limbs. + U128, unsigned, for exact rational arithmetic: the highest-averages apportionment (quotients + w / d compared by cross products, lower quotas floor(I * w / W)), the Headroom priority + (residual / capacity compared by cross products), the comparisons with a double factor read + as the exact dyadic rational it is (the Stretch ceiling, the diminishing guard) and the + cumulative counters (Counter128). Every product operand is below 2^63, so every product is + below 2^126 and every quotient that is formed fits 64 bits. Compilers with __int128 use it; + others use 32-bit limbs. + + I128, signed, for the deletion re-solve of the flow core (APPROACH 2.2): working potentials, + distance keys and sums of tier maxima, which stay within (22 n + 15) C for n vertices and the + cost sum C < 2^62. Only addition, subtraction and comparison, on two limbs everywhere. */ #pragma once +#include +#include +#include #include namespace netgraph::core::detail { @@ -64,4 +74,60 @@ inline std::uint64_t div_u128(U128 n, std::uint64_t d) noexcept { #endif +// The number of significant bits of v (0 for 0). +inline int bits(U128 v) noexcept { return v.hi != 0 ? 64 + std::bit_width(v.hi) : std::bit_width(v.lo); } + +// v * 2^k for 0 <= k < 128; the caller keeps the result below 2^128 (bits(v) + k <= 128). +inline U128 shl(U128 v, int k) noexcept { + if (k == 0) return v; + if (k >= 64) return {v.lo << (k - 64), 0}; + return {(v.hi << k) | (v.lo >> (64 - k)), v.lo << k}; +} + +// A signed 128-bit integer in two's complement, value = hi * 2^64 + lo. The member order makes +// the defaulted comparison (hi signed, then lo unsigned) the numeric order. +struct I128 { + std::int64_t hi {0}; + std::uint64_t lo {0}; + + constexpr I128() noexcept = default; + // Implicit, so int64 costs and amounts enter the arithmetic directly. + constexpr I128(std::int64_t v) noexcept : hi(v < 0 ? -1 : 0), lo(static_cast(v)) {} + + friend constexpr bool operator==(const I128&, const I128&) noexcept = default; + friend constexpr std::strong_ordering operator<=>(const I128&, const I128&) noexcept = default; + + friend constexpr I128 operator+(I128 a, I128 b) noexcept { + I128 r; + r.lo = a.lo + b.lo; + const std::uint64_t carry = r.lo < a.lo ? 1U : 0U; + r.hi = static_cast(static_cast(a.hi) + static_cast(b.hi) + carry); + return r; + } + friend constexpr I128 operator-(I128 a, I128 b) noexcept { + I128 r; + r.lo = a.lo - b.lo; + const std::uint64_t borrow = a.lo < b.lo ? 1U : 0U; + r.hi = static_cast(static_cast(a.hi) - static_cast(b.hi) - borrow); + return r; + } + constexpr I128& operator+=(I128 b) noexcept { return *this = *this + b; } +}; + +// A positive finite double as the exact dyadic rational it is: f = m * 2^x with m odd and +// below 2^53. +struct Dyadic { + std::uint64_t m {0}; + int x {0}; +}; +inline Dyadic dyadic(double f) noexcept { + int e = 0; + const double frac = std::frexp(f, &e); // f = frac * 2^e, frac in [0.5, 1) + Dyadic d {static_cast(std::ldexp(frac, 53)), e - 53}; + const int tz = std::countr_zero(d.m); + d.m >>= tz; + d.x += tz; + return d; +} + } // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/kernels.hpp b/include/netgraph/core/detail/kernels.hpp index bbbed3f..25e4c7a 100644 --- a/include/netgraph/core/detail/kernels.hpp +++ b/include/netgraph/core/detail/kernels.hpp @@ -4,29 +4,51 @@ creation: the route, its split graph (the route's edges that pass the kernel's membership rule, pruned to the walks from the source to the destination set, destinations terminal) and, for Weighted, the weights. These are the per-flow routines of the placement transaction - (APPROACH 9.2 stage 3); the policies of sub-PR 1c compose them, the ledger commits their - output. + (APPROACH 9.2 stage 3); the policy engine composes them and the ledger commits their output. */ #pragma once #include #include #include +#include #include -#include "netgraph/core/flow.hpp" +#include "netgraph/core/graph.hpp" #include "netgraph/core/options.hpp" #include "netgraph/core/pred_dag.hpp" -#include "netgraph/core/strict_multidigraph.hpp" #include "netgraph/core/types.hpp" namespace netgraph::core::detail { // ---------------------------------------------------------------- apportionment (2.1) -// Round robin (EqualBalanced and Lossy EqualBalanced): floor(total / k) to each of the k = -// out.size() members, one more unit to the first total mod k members (ascending EdgeId). -void round_robin(Amount total, std::span out) noexcept; +// Round robin (EqualBalanced and Lossy EqualBalanced at every node; the cohort's split over its +// flows and a source's offer over its flows): floor(total / k) to each of the k members, one +// more unit to the first total mod k members (ascending EdgeId). Only the n leading members with +// a positive share (n = total when total < k, else k) are visited: the members from n on carry +// 0, which a caller with a zeroed output need not write or visit. The callback form computes the +// split once and calls share(i, x) for i in [0, n), in index order; share returns false to stop +// early. It returns the number of shares accepted: n, or the index i whose call returned false. +// The span form writes out[0 .. n) of the k = out.size() members and returns n. +template +inline std::size_t round_robin(Amount total, std::size_t k, Share&& share) { + const auto kk = static_cast(k); + const Amount base = total / kk; + const auto extra = static_cast(total % kk); + const std::size_t n = base == 0 ? extra : k; + for (std::size_t i = 0; i < n; ++i) { + if (!share(i, base + (i < extra ? 1 : 0))) return i; + } + return n; +} + +inline std::size_t round_robin(Amount total, std::span out) noexcept { + return round_robin(total, out.size(), [&](std::size_t i, Amount x) noexcept { + out[i] = x; + return true; + }); +} // Highest averages (Jefferson, D'Hondt; Weighted and Lossy Weighted): the units go one at a // time to the member maximising w / (a + 1), ties by the lower member index (ascending @@ -37,14 +59,13 @@ void highest_averages(Amount total, std::span weights, std::span dsts {}; // ascending std::vector route {}; // ascending: the flow's route (its record's bundle) @@ -63,41 +84,42 @@ struct KernelPlan { std::int32_t src_out_end {0}; }; -// The edges of `edges` that lie on some walk from `src` to the destination set (edges leaving -// a destination removed: destinations are terminal), ascending. -[[nodiscard]] std::vector prune(const StrictMultiDiGraph& g, std::span edges, NodeId src, +// The edges of `dag` that lie on some walk from `src` to the destination set (edges leaving a +// destination removed: destinations are terminal), ascending. +[[nodiscard]] std::vector prune(const Graph& g, const PredDAG& dag, NodeId src, std::span dsts); -// The plan of a new flow on `route` (ascending, acyclic; the edges of the flow's DAG or -// bundle), its membership captured on `creation_view` (route-aligned: one entry per route -// edge, the view of the search that created the route; read only by a Weighted Residual -// membership, may be empty otherwise): MaxFlow every route edge (its view decides), Weighted by -// `priority` (Residual: creation view >= 1 unit; Capacity: static capacity >= 1 unit) with the -// weights read from the same values, EqualBalanced static capacity >= 1 unit; Lossy keeps its -// kernel's membership. The members are then pruned to the walks from src to the destination -// set (none leaving a destination) and given their topological structure, in one pass. `dsts` -// ascending and disjoint from src. -[[nodiscard]] KernelPlan make_plan(const StrictMultiDiGraph& g, FlowPlacement placement, Admission admission, +// The minimum walk cost from `src` to the destination set over `route` (a record's bundle; +// APPROACH 8, rule 12); kUnreachable when the route has no such walk. The split-graph builder's +// cost pass alone: no plan is built. +[[nodiscard]] Cost route_cost(const Graph& g, const PredDAG& route, NodeId src, + std::span dsts); + +// The plan of a new flow on `route` (acyclic; the flow's DAG or path, its record's bundle), its +// membership captured on `creation_view` (aligned with the route's edges in ascending EdgeId: +// the view of the search that created the route; read only by a Weighted Residual membership, +// may be empty otherwise): MaxFlow every route edge (its view decides), Weighted by `priority` +// (Residual: creation view >= 1 unit; Capacity: static capacity >= 1 unit) with the weights read +// from the same values, EqualBalanced static capacity >= 1 unit; Lossy keeps its kernel's +// membership. The members are pruned to the walks from src to the destination set (none +// leaving a destination) and given their topological structure, in one pass. With `cost`, the +// route's minimum walk cost (route_cost) is computed in the same pass when every route edge is a +// member. `dsts` ascending and disjoint from src. +[[nodiscard]] KernelPlan make_plan(const Graph& g, FlowPlacement placement, Admission admission, EdgePriority priority, NodeId src, std::span dsts, - std::span route, std::span creation_view); - -// The usable route of a bundle as a plan: of the bundle's edges the ones with static capacity -// >= 1 unit that `elig` allows (every one when null), pruned to the walks from src to the -// destination set; route == members, with the topological structure (built on the bundle's -// own predecessor lists). It is -// the plan of every kernel whose membership is static (MaxFlow, EqualBalanced, Weighted with -// Capacity: weights the capacities); a Weighted Residual plan is made from its route at -// creation (make_plan). -// With `cost`, the route's minimum walk cost from src to the destination set is computed in -// the same pass (route_cost of the plan; kUnreachable when the route is empty). -[[nodiscard]] KernelPlan make_route_plan(const StrictMultiDiGraph& g, FlowPlacement placement, Admission admission, + const PredDAG& route, std::span creation_view, Cost* cost = nullptr); + +// The usable route of a Pinned bundle as a plan: of the bundle's edges the ones with static +// capacity >= 1 unit that `elig` allows (every one when null), pruned to the walks from src to +// the destination set; route == members, with the topological structure. It is the plan of +// every kernel whose membership is static (MaxFlow, EqualBalanced, Weighted with Capacity: +// weights the capacities); a Weighted Residual plan is made from its route at creation +// (make_plan). With `cost`, the usable route's minimum walk cost (kUnreachable when it is +// empty), computed in the same pass. +[[nodiscard]] KernelPlan make_route_plan(const Graph& g, FlowPlacement placement, Admission admission, EdgePriority priority, NodeId src, std::span dsts, const PredDAG& bundle, const Eligibility* elig, Cost* cost = nullptr); -// The minimum walk cost from the source to the destination set over the route (the -// record's bundle); kUnreachable when the route has no such walk. -[[nodiscard]] Cost route_cost(const StrictMultiDiGraph& g, const KernelPlan& plan); - // ---------------------------------------------------------------- allocation (16.2, 16.3) // The cumulative allocation alloc(T) of a Weighted or EqualBalanced plan, member-aligned: @@ -123,17 +145,18 @@ struct TMax { // One Lossless step of one flow (APPROACH 9.2 stage 3, 16.3): T_new = max(T, min(T + request, // T_max(V))) on the replacement view V = W + own (member-aligned `view_m`), with `own_m` the // flow's committed allocation alloc(T) (member-aligned). MaxFlow solves the flow core on the -// split graph with cap T + request (uncapped for +inf); Weighted and EqualBalanced probe -// T + request first and search T_max (as t_max does) only when it does not fit: the result is -// the same T_new. `request` nullopt is -// +inf. `alloc` is the new member-aligned allocation (own_m itself when T is unchanged on a -// Weighted or EqualBalanced plan). +// split graph with cap T + request (uncapped for +inf) and reports the solve's marginal curve; +// Weighted and EqualBalanced probe T + request first and search T_max (as t_max does) only when +// it does not fit: the result is the same T_new. `request` nullopt is +inf. `alloc` is the new +// member-aligned allocation (own_m itself when T is unchanged on a Weighted or EqualBalanced +// plan). struct LosslessStep { Amount total {0}; std::vector alloc {}; - std::int32_t iterations {0}; // probes of this step's T_max search (at most 62) + std::int32_t iterations {0}; // probes of this step's T_max search (at most 62) + std::vector> cost_distribution {}; // MaxFlow: the solve's marginal curve }; -[[nodiscard]] LosslessStep lossless_step(const StrictMultiDiGraph& g, const KernelPlan& plan, Amount total, +[[nodiscard]] LosslessStep lossless_step(const Graph& g, const KernelPlan& plan, Amount total, std::span view_m, std::span own_m, std::optional request); diff --git a/include/netgraph/core/detail/ledger_access.hpp b/include/netgraph/core/detail/ledger_access.hpp index aa174ca..9496627 100644 --- a/include/netgraph/core/detail/ledger_access.hpp +++ b/include/netgraph/core/detail/ledger_access.hpp @@ -8,58 +8,54 @@ #pragma once #include -#include #include #include -#include #include -#include "netgraph/core/flow.hpp" -#include "netgraph/core/flow_graph.hpp" +#include "netgraph/core/flow_ledger.hpp" +#include "netgraph/core/flow_record.hpp" namespace netgraph::core::detail { // A record proposed by a policy for the commit: `id` nullopt is a new flow (its id is assigned -// at the commit, in draft order). `edges` and `drops` ascending EdgeId; zero entries dropped. +// at the commit, in draft order); the commit sets the record's flow_id and owner. The record's +// `edges` and `drops` ascend by EdgeId; zero entries are dropped. struct Draft { std::optional id {}; - std::vector sources {}; - std::vector destinations {}; - FlowPlacement placement {FlowPlacement::MaxFlow}; - Admission admission {Admission::Lossless}; - std::shared_ptr bundle {}; - Cost route_cost {kUnreachable}; - std::optional offered {}; - Amount delivered {0}; - std::vector> edges {}; - std::vector> drops {}; + FlowRecord record {}; }; +// The saturation diagnostic of FlowLedger::bottleneck on a given residual (the sources already a +// validated set): the edges (statically eligible, capacity >= 1 unit) leaving the set reachable +// from the allowed sources over eligible forward arcs with residual >= 1 unit, ascending EdgeId. +[[nodiscard]] std::vector bottleneck_of(const Graph& g, std::span residual, + std::span sources, const Eligibility* elig); + struct LedgerAccess { // A new owner id (0, 1, ... per ledger). Throws on a moved-from ledger. - [[nodiscard]] static std::int64_t bind(FlowGraph& fg); + [[nodiscard]] static std::int64_t bind(FlowLedger& fg); // The owner's policy is destroyed without remove(): its records stay as orphans. - static void release(FlowGraph& fg, std::int64_t owner) noexcept; + static void release(FlowLedger& fg, std::int64_t owner) noexcept; // remove(): the owner's records are deleted (allocations reverted), their ids retired. // Throws only before any change (a moved-from ledger, an unbound owner). - static std::vector detach(FlowGraph& fg, std::int64_t owner); + static std::vector detach(FlowLedger& fg, std::int64_t owner); // One atomic exchange: `drafts` must carry every live record of the owner (by id) plus the // new ones; the new aggregate must stay within [0, baseline] on every edge. Throws (and // changes nothing) otherwise, an allocation failure included: every allocation happens // before a publication step that cannot throw. Returns the FlowIds of the new records in // draft order. The drafts' vectors are moved from. - static std::vector commit(FlowGraph& fg, std::int64_t owner, std::span drafts); + static std::vector commit(FlowLedger& fg, std::int64_t owner, std::span drafts); // A live record by id (throws std::invalid_argument for an unknown or retired id). - [[nodiscard]] static const FlowRecord& live(const FlowGraph& fg, std::int64_t id); + [[nodiscard]] static const FlowRecord& live(const FlowLedger& fg, std::int64_t id); // The working residual baseline - allocated into `out` (resized to E). - static void residual_into(const FlowGraph& fg, std::vector& out); - [[nodiscard]] static std::span baseline(const FlowGraph& fg); + static void residual_into(const FlowLedger& fg, std::vector& out); + [[nodiscard]] static std::span baseline(const FlowLedger& fg); // The aggregate allocation (borrowed; valid until the next commit, detach or reset). - [[nodiscard]] static std::span allocated(const FlowGraph& fg); - // Diagnostics (tests, the private recorder binding). - [[nodiscard]] static std::int64_t next_flow_id(const FlowGraph& fg); - [[nodiscard]] static std::vector bound_owners(const FlowGraph& fg); - [[nodiscard]] static std::vector orphan_owners(const FlowGraph& fg); + [[nodiscard]] static std::span allocated(const FlowLedger& fg); + // Diagnostics (the tests and the private bindings). + [[nodiscard]] static std::int64_t next_flow_id(const FlowLedger& fg); + [[nodiscard]] static std::vector bound_owners(const FlowLedger& fg); + [[nodiscard]] static std::vector orphan_owners(const FlowLedger& fg); }; } // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/node_map.hpp b/include/netgraph/core/detail/node_map.hpp index 5a1d9af..6af17a5 100644 --- a/include/netgraph/core/detail/node_map.hpp +++ b/include/netgraph/core/detail/node_map.hpp @@ -3,7 +3,6 @@ #include #include -#include #include #include "netgraph/core/types.hpp" @@ -47,7 +46,6 @@ class NodeMap { } [[nodiscard]] std::int32_t of(NodeId v) const noexcept { return slot_[static_cast(v)]; } [[nodiscard]] std::size_t size() const noexcept { return count_; } - [[nodiscard]] std::span nodes() const noexcept { return {nodes_.data(), count_}; } static NodeMap& local() { thread_local NodeMap map; diff --git a/include/netgraph/core/detail/pinned_flow.hpp b/include/netgraph/core/detail/pinned_flow.hpp deleted file mode 100644 index f109145..0000000 --- a/include/netgraph/core/detail/pinned_flow.hpp +++ /dev/null @@ -1,104 +0,0 @@ -/* A Pinned placement with one bundle: the single-flow transaction of sub-PR 1b (internal). - - APPROACH 8: a fixed-structure ("manual") placement is a Pinned policy with one bundle. Until - the policy engine of sub-PR 1c exists, this driver runs that transaction (APPROACH 9.2 - restricted to one flow on one bundle) for the tests, the A/B/A kernel rows and the private - recorder entry, composing the per-flow kernel steps with the ledger's internal entry - points; 1c's FlowPolicy with a one-bundle Pinned routing runs the same steps and replaces it. - - * place(0) is a no-op that creates nothing; a positive request creates the flow when the - bundle is usable (a src -> dst walk survives static eligibility and the capacity >= 1 - unit rule), else nothing is created and the call ends no_route; - * MaxFlow (replacement): a fresh flow-core solve on V = W + own over the split graph with - cap T + v (+inf: uncapped); - * Weighted and EqualBalanced (incremental): T_new = max(T, min(T + v, T_max(V))); - * Lossy: the offer split by the kernel's rule on W, per call; - * a finite request after +inf is rejected within an epoch (also 0); remove() reverts the - allocation, retires the id and opens a new epoch; destruction without remove() leaves the - record as an orphan. -*/ -#pragma once - -#include -#include -#include -#include -#include - -#include "netgraph/core/detail/kernels.hpp" -#include "netgraph/core/flow.hpp" -#include "netgraph/core/flow_graph.hpp" -#include "netgraph/core/options.hpp" -#include "netgraph/core/pred_dag.hpp" - -namespace netgraph::core::detail { - -// APPROACH 9.4 local outcomes (the public PlacementResult arrives with the policies in 1c). -enum class Termination { Complete, LimitFlows, LimitDiminishing, NoRoute, Exhausted }; - -struct PinnedResult { - Amount placed {0}; - std::optional remaining {}; // nullopt: +inf - Termination termination {Termination::Complete}; - bool routes_changed {false}; - bool weights_changed {false}; - std::vector> drops {}; // this call's transit drops - std::int32_t iterations {0}; // diagnostic: bisection steps of this call's T_max search -}; - -class PinnedFlow { -public: - // Validates (std::invalid_argument): the kernel combination (Lossy with MaxFlow, Weighted - // with Headroom), the endpoints (in range, distinct), a bundle or Eligibility of another - // graph, a bundle without any src -> dst walk before pruning (malformed). - PinnedFlow(FlowGraph& fg, NodeId src, NodeId dst, std::shared_ptr bundle, FlowPlacement placement, - Admission admission, EdgePriority priority, std::shared_ptr elig = nullptr); - PinnedFlow(const PinnedFlow&) = delete; - PinnedFlow& operator=(const PinnedFlow&) = delete; - ~PinnedFlow(); - - // `volume` nullopt is +inf; a finite request is an integer in [0, 2^62). - PinnedResult place(std::optional volume); - void remove(); - // The policy is destroyed without remove(): the record stays as an orphan; later calls throw. - void release() noexcept; - - [[nodiscard]] std::optional flow_id() const noexcept { return flow_id_; } - [[nodiscard]] Amount placed_demand() const; - [[nodiscard]] int usable_bundles() const noexcept { return route_plan_.route.empty() ? 0 : 1; } - // The live flow's plan, frozen at its creation (null without a flow). - [[nodiscard]] const KernelPlan* plan() const noexcept { return plan_; } - [[nodiscard]] std::int64_t owner() const noexcept { return owner_; } - // The epoch's call state: the cumulative finite request (positive calls that reached the - // ledger) and whether +inf was requested. - [[nodiscard]] const Counter128& requested() const noexcept { return requested_; } - [[nodiscard]] bool infinite() const noexcept { return infinite_; } - -private: - void check_bound() const; - - FlowGraph& fg_; - NodeId src_; - NodeId dst_; - std::shared_ptr bundle_; - FlowPlacement placement_; - Admission admission_; - EdgePriority priority_; - std::shared_ptr elig_; - // The usable route (pruned) as a plan with members == route: the plan of every kernel with a - // static membership. Its cost and its DAG (the record's bundle) do not change either. - KernelPlan route_plan_ {}; - Cost route_cost_ {kUnreachable}; - std::shared_ptr route_dag_ {}; // made at the first creation when pruned - std::int64_t owner_ {0}; - bool bound_ {false}; - // Epoch state. - std::optional flow_id_ {}; - std::optional captured_ {}; // a Weighted Residual plan, captured at creation - const KernelPlan* plan_ {nullptr}; // &route_plan_ or &*captured_ while a flow lives - Amount total_ {0}; - Counter128 requested_ {}; - bool infinite_ {false}; -}; - -} // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/policy_engine.hpp b/include/netgraph/core/detail/policy_engine.hpp new file mode 100644 index 0000000..9991cd9 --- /dev/null +++ b/include/netgraph/core/detail/policy_engine.hpp @@ -0,0 +1,80 @@ +/* The state of a FlowPolicy (internal; APPROACH 8 and 9). The transaction lives in + src/flow_policy.cpp; the evaluators, the private bindings and the tests read this state + through PolicyAccess. */ +#pragma once + +#include +#include +#include +#include +#include + +#include "netgraph/core/detail/kernels.hpp" +#include "netgraph/core/flow_ledger.hpp" +#include "netgraph/core/flow_policy.hpp" +#include "netgraph/core/flow_record.hpp" +#include "netgraph/core/pred_dag.hpp" + +namespace netgraph::core::detail { + +struct DeletionCapture; + +// A live flow of the policy between calls. Its amounts are its ledger record (delivered = the +// cumulative T of a Lossless flow; edges; Lossy offered and drops); the plan is frozen at the +// flow's creation (and replaced by an adopted reroute). Global flows have no plan. +struct LiveFlow { + std::int64_t id {-1}; + NodeId src {0}; + std::shared_ptr plan {}; + std::shared_ptr bundle {}; + Cost cost {kUnreachable}; + std::int32_t bundle_index {-1}; // Pinned: the supplied bundle +}; + +// One supplied Pinned bundle prepared at construction: its usable route (static eligibility, +// capacity >= 1 unit, pruned to the src -> dst walks) as the plan of every static-membership +// kernel, its cost, and the record bundle (the supplied DAG itself when nothing was pruned). +struct PreparedPinnedBundle { + std::shared_ptr route_plan {}; + Cost cost {kUnreachable}; + std::shared_ptr route_dag {}; +}; + +struct PolicyEngine { + PolicyEngine(FlowLedger& ledger, FlowPolicyConfig cfg) : fg(ledger), config(std::move(cfg)) {} + + FlowLedger& fg; + std::shared_ptr graph {}; + FlowPolicyConfig config; + const Eligibility* elig {nullptr}; + std::vector sources {}; // the normalised sets + std::vector destinations {}; + std::vector dests_ok {}; // the allowed destinations (the records' destination set) + std::vector roster {}; // fixed at construction, ascending + std::vector baseline {}; // CapacityAware: the static shortest cost per roster source + std::shared_ptr support {}; // CostOnly: the full static support + std::vector bundles {}; // Pinned + std::vector usable {}; // Pinned: indices of usable bundles + std::int64_t owner {0}; + // Epoch state. + std::vector> cohorts {}; // per roster index, in FlowId order + std::vector> curve {}; // Global: the cumulative marginal curve (9.1) + Counter128 requested {}; + bool infinite {false}; + // Global: a capture request for the next solve's final state (the exact sensitivity model's + // baseline, APPROACH 11); set and cleared by the evaluator. + DeletionCapture* capture {nullptr}; +}; + +// The internal access to a policy's engine (the pattern of LedgerAccess): FlowPolicy's public +// interface is the contract's; everything else reaches the state here. +struct PolicyAccess { + [[nodiscard]] static const PolicyEngine& engine(const FlowPolicy& policy) noexcept { return *policy.engine_; } + [[nodiscard]] static PolicyEngine& engine(FlowPolicy& policy) noexcept { return *policy.engine_; } +}; + +// The configuration checks of FlowPolicy (APPROACH 9; enumeration values first, rule 13), +// shared with the evaluators' argument checks. +void validate_config(const FlowPolicyConfig& cfg, std::size_t num_sources, std::size_t num_destinations); + +} // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/pred_dag_builder.hpp b/include/netgraph/core/detail/pred_dag_builder.hpp index 2b6cafe..648b9fa 100644 --- a/include/netgraph/core/detail/pred_dag_builder.hpp +++ b/include/netgraph/core/detail/pred_dag_builder.hpp @@ -1,6 +1,8 @@ -/* Internal construction of PredDAG values (search output, kernel routes, Path::to_dag). */ +/* Internal construction of PredDAG values from trusted inputs (search output, flow routes, + Path::to_dag) and read access to their sparse storage. */ #pragma once +#include #include #include #include @@ -12,12 +14,21 @@ namespace netgraph::core::detail { struct PredDAGBuilder { // The DAG of an edge set known to be acyclic and duplicate-free: per node its predecessor // entries (edge_src, e) in ascending EdgeId. Not validated beyond what the callers guarantee. - [[nodiscard]] static PredDAG from_edges(const StrictMultiDiGraph& g, std::span edges); - // Per child its ascending via members, all with the one parent parent_of[child]. - [[nodiscard]] static PredDAG from_children(std::uint64_t graph_uid, const std::vector>& per_child, - const std::vector& parent_of); + // O(|edges| + N) for ascending input (every caller's), O(|edges| log |edges| + N) otherwise + // (a sort first); the grouping by child is a stable counting sort over a temporary N + 1 + // array, and nothing retained is proportional to the graph's size. + [[nodiscard]] static PredDAG of_edges(const Graph& g, std::span edges); + // A Path's DAG: hop h's members (ascending) are the entries of child nodes[h + 1], all with + // the parent nodes[h]; the nodes are distinct. + [[nodiscard]] static PredDAG of_path(std::uint64_t graph_uid, std::int32_t num_nodes, + const std::vector>& hops, const std::vector& nodes); // The DAG's edges in ascending EdgeId, without a copy (kept by the DAG since construction). [[nodiscard]] static std::span ascending(const PredDAG& dag) noexcept; + // The sparse storage: the member nodes (ascending) and their entry offsets (size + 1). + [[nodiscard]] static std::span member_nodes(const PredDAG& dag) noexcept; + [[nodiscard]] static std::span member_offsets(const PredDAG& dag) noexcept; + // The retained payload in bytes (every stored array; the DagCache's `bytes` statistic). + [[nodiscard]] static std::size_t payload_bytes(const PredDAG& dag) noexcept; }; } // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/private_hooks.hpp b/include/netgraph/core/detail/private_hooks.hpp new file mode 100644 index 0000000..6f09fb4 --- /dev/null +++ b/include/netgraph/core/detail/private_hooks.hpp @@ -0,0 +1,51 @@ +/* The private hooks of the development build (internal). CMake option NETGRAPH_CORE_PRIVATE_HOOKS: + ON for the editable development build and the test suites, OFF for an installed wheel. The + hooks are test controls and prototype-parity diagnostics that the contract does not name: + a lowered creation cap, an injected sensitivity model failure and the transaction trace. + Without the option none of them exists: the engine reads the constant kMaxFlowsPerCall, + sensitivity has no injection branch, and every trace block is a compile-time-dead branch + (kTraced), so no trace code is emitted. */ +#pragma once + +#ifndef NETGRAPH_CORE_PRIVATE_HOOKS +#define NETGRAPH_CORE_PRIVATE_HOOKS 0 +#endif + +namespace netgraph::core::detail { + +// Whether this build traces placement calls (the private hooks). +inline constexpr bool kTraced = NETGRAPH_CORE_PRIVATE_HOOKS != 0; + +} // namespace netgraph::core::detail + +#if NETGRAPH_CORE_PRIVATE_HOOKS + +#include +#include +#include +#include + +#include "netgraph/core/detail/trace.hpp" +#include "netgraph/core/flow_policy.hpp" + +namespace netgraph::core::detail { + +// The call-wide creation cap every CapacityAware call reads (kMaxFlowsPerCall unless a test +// lowers it to reach the cap on small graphs). +std::atomic& max_flows_per_call() noexcept; + +// The sensitivity candidate whose deletion raises a model exception (-1: none). No valid +// configuration raises under an edge deletion (every construction check is +// deletion-independent), so this is how the tests reach `unevaluable` (APPROACH 11). +std::atomic& sensitivity_fail_edge() noexcept; + +// FlowPolicy::place with the transaction's trace (prototype 2's keys). `staged`, when given, runs +// once the result and the trace are final and before anything is published (the commit, the +// epoch state): an exception from it aborts the call with nothing changed (the binding builds +// its Python objects there). +PlacementResult place_traced(FlowPolicy& policy, std::optional volume, const OutputOptions& outputs, Trace& trace, + const std::function* staged = nullptr); + +} // namespace netgraph::core::detail + +#endif diff --git a/include/netgraph/core/detail/search.hpp b/include/netgraph/core/detail/search.hpp index 2818890..f0a4eb0 100644 --- a/include/netgraph/core/detail/search.hpp +++ b/include/netgraph/core/detail/search.hpp @@ -1,13 +1,14 @@ -/* The search semantics of APPROACH 6 as internal routines (the public spf and KSP are 1d). +/* The search semantics of APPROACH 6 as internal routines (the DagCache's supports, the + policies' routes and the private _spf entry of the bindings). Positive costs make the shortest-path DAG from a root set unique (2.4): the tight edges d(u) + cost(e) = d(v) of the member graph. With targets the DAG is pruned to the walks that end in a nearest target (anycast), the same edge set for a forward search from the roots and a reverse search rooted at the targets. The single path is the staged canonical walk: (1) the tight DAG of the query, (2) under PreferHigherPriority the best attainable - bottleneck B* (a max-min DP on the DAG, capped by a Yen prefix's bottleneck) and the DAG - restricted to priority >= B*, (3) one reverse hop count h from the targets and a greedy - walk taking the smallest tight edge whose head has h(v) = h(u) - 1. + bottleneck B* (a max-min DP on the DAG) and the DAG restricted to priority >= B*, (3) one + reverse hop count h from the targets and a greedy walk taking the smallest tight edge whose + head has h(v) = h(u) - 1. */ #pragma once @@ -16,15 +17,13 @@ #include #include +#include "netgraph/core/graph.hpp" #include "netgraph/core/options.hpp" #include "netgraph/core/pred_dag.hpp" -#include "netgraph/core/strict_multidigraph.hpp" #include "netgraph/core/types.hpp" namespace netgraph::core::detail { -enum class Direction { Forward, Reverse }; - // An exact priority value: num / den with den >= 1 (den == 1 for Residual and Capacity, the // capacity for Headroom); `infinite` above every finite value (the bottleneck of an empty walk). struct Priority { @@ -34,31 +33,46 @@ struct Priority { }; [[nodiscard]] int compare(const Priority& a, const Priority& b) noexcept; // -1, 0, 1 +// The priority value of an edge with `residual` units left of `capacity`: the residual units +// (Residual), the capacity units (Capacity) or the rational residual / capacity (Headroom). +[[nodiscard]] Priority priority_value(EdgePriority priority, Amount residual, Amount capacity) noexcept; + // The per-edge priority values a search reads: `residual` empty (no residual) reads the static -// capacity for every priority; else Residual = residual units, Capacity = capacity units, -// Headroom = residual / capacity. +// capacity for every priority; else priority_value on the residual. struct PriorityValues { - const StrictMultiDiGraph* g {nullptr}; + const Graph* g {nullptr}; EdgePriority priority {EdgePriority::Capacity}; std::span residual {}; [[nodiscard]] Priority of(EdgeId e) const noexcept; }; +// select_adjacency (APPROACH 6): whether member `e` replaces `cur`, the choice so far among the +// eligible members of one (u, v) pair seen in ascending EdgeId (cur < e): the lower cost, then +// under PreferHigherPriority (`prefer`) the higher priority value; on a tie the lower EdgeId +// (cur) stays. `value(e)` reads the priority value of an edge on the view of the selection. +template +[[nodiscard]] bool replaces(const Graph& g, EdgeId e, EdgeId cur, bool prefer, const Value& value) { + const auto cost = g.cost(); + const Cost ce = cost[static_cast(e)]; + const Cost cc = cost[static_cast(cur)]; + if (ce != cc) return ce < cc; + return prefer && compare(value(e), value(cur)) > 0; +} + // The member graph (flags per edge): statically eligible, capacity >= 1 unit, residual >= -// min_residual when a residual is given; with multi_edge = false one member per (u, v): the -// minimum cost, then under PreferHigherPriority the highest priority value, then the lowest -// EdgeId (select_adjacency). -[[nodiscard]] std::vector member_mask(const StrictMultiDiGraph& g, const Eligibility* elig, +// min_residual when a residual is given; with multi_edge = false one member per (u, v) by +// select_adjacency. +[[nodiscard]] std::vector member_mask(const Graph& g, const Eligibility* elig, const PathSelection& selection, std::span residual, Amount min_residual); // Dijkstra over the members from (Forward) or toward (Reverse) the root set; kUnreachable for // the nodes it does not reach. -[[nodiscard]] std::vector distances(const StrictMultiDiGraph& g, std::span member, +[[nodiscard]] std::vector distances(const Graph& g, std::span member, std::span roots, Direction direction); // The tight edges among the reached nodes of a full search (forward-oriented), ascending. -[[nodiscard]] std::vector full_dag(const StrictMultiDiGraph& g, std::span member, +[[nodiscard]] std::vector full_dag(const Graph& g, std::span member, std::span dist, Direction direction); // The edges on the minimum-cost walks from the source set to the destination set (the walks @@ -68,30 +82,27 @@ struct TightDag { Cost cost {0}; std::vector edges {}; }; -[[nodiscard]] TightDag tight_dag(const StrictMultiDiGraph& g, std::span member, +[[nodiscard]] TightDag tight_dag(const Graph& g, std::span member, std::span sources, std::span destinations); // The staged canonical single path from the source set to the destination set over the -// members; `priority` null for Deterministic; `prefix_cap` caps B* (a Yen root prefix). +// members; `priority` null for Deterministic. struct SinglePath { std::vector walk {}; // in walk order Cost cost {0}; - std::optional bottleneck {}; // under PreferHigherPriority - std::optional b_star {}; }; -[[nodiscard]] std::optional canonical_path(const StrictMultiDiGraph& g, +[[nodiscard]] std::optional canonical_path(const Graph& g, std::span member, std::span sources, std::span destinations, - const PriorityValues* priority, - std::optional prefix_cap = std::nullopt); - -// The search of APPROACH 6 (sub-PR 1d exposes it as spf()): validates (std::invalid_argument) -// the root set (non-empty, ids in range; duplicates removed), the targets when given -// (non-empty, ids in range, disjoint from the roots), multipath = false without targets, the -// residual (length E, entries in [0, capacity_units]), min_residual >= 1 and a foreign -// Eligibility, before any work. Excluded roots and targets are skipped (all excluded: an -// empty result). Without targets: `costs` for every node and the full DAG; with targets: the + const PriorityValues* priority); + +// The search of APPROACH 6: validates (std::invalid_argument) the direction and the selection +// (enumeration values), the root set (non-empty, ids in range; duplicates removed), the targets +// when given (non-empty, ids in range, disjoint from the roots), multipath = false without +// targets, the residual (length E, entries in [0, capacity_units]), min_residual >= 1 and a +// foreign Eligibility, before any work. Excluded roots and targets are skipped (all excluded: +// an empty result). Without targets: `costs` for every node and the full DAG; with targets: the // anycast-pruned DAG (or the canonical single path for multipath = false) and `target_cost` // (nullopt when no target is reachable). struct SpfOut { @@ -100,16 +111,16 @@ struct SpfOut { std::optional target_cost {}; std::optional path {}; }; -[[nodiscard]] SpfOut spf(const StrictMultiDiGraph& g, std::span roots, Direction direction, +[[nodiscard]] SpfOut spf(const Graph& g, std::span roots, Direction direction, std::optional> targets, const PathSelection& selection, std::optional> residual, Amount min_residual, const Eligibility* elig); -// The sorted, duplicate-free node set; throws std::invalid_argument for an empty set or an id -// out of range (`name` labels the message). -[[nodiscard]] std::vector node_set(std::span nodes, std::int32_t num_nodes, const char* name); - -// Throws std::invalid_argument when the two sorted sets share a vertex (rule 13). -void check_disjoint(std::span a, std::span b); +// The full static support of a search from (Forward) or toward (Reverse) the root set: every +// tight edge of the statically eligible member graph (multipath, multi_edge, no residual) over +// the nodes it reaches, forward-oriented (APPROACH 10: the DagCache's value, and a CostOnly +// policy's DAG when it has no cache). +[[nodiscard]] PredDAG static_support(const Graph& g, std::span roots, Direction direction, + const Eligibility* elig); } // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/trace.hpp b/include/netgraph/core/detail/trace.hpp new file mode 100644 index 0000000..ecf3d64 --- /dev/null +++ b/include/netgraph/core/detail/trace.hpp @@ -0,0 +1,97 @@ +/* The transaction trace of a placement call (internal diagnostics for the tests and the + prototype parity check): a small JSON-like value whose keys follow prototype 2's trace. Built + only in a build with the private hooks, when a call asks for it (detail::place_traced). */ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace netgraph::core::detail { + +struct Trace { + enum class Kind : std::uint8_t { Null, Bool, Int, Str, List, Map }; + Kind kind {Kind::Null}; + bool b {false}; + std::int64_t i {0}; + std::string s {}; + std::vector items {}; // List + std::vector> fields {}; // Map, in insertion order + + static Trace null() { return {}; } + static Trace boolean(bool v) { + Trace t; + t.kind = Kind::Bool; + t.b = v; + return t; + } + static Trace integer(std::int64_t v) { + Trace t; + t.kind = Kind::Int; + t.i = v; + return t; + } + static Trace str(std::string v) { + Trace t; + t.kind = Kind::Str; + t.s = std::move(v); + return t; + } + static Trace list() { + Trace t; + t.kind = Kind::List; + return t; + } + static Trace map() { + Trace t; + t.kind = Kind::Map; + return t; + } + template + static Trace ints(std::span v) { + Trace t = list(); + t.items.reserve(v.size()); + for (const auto& x : v) t.items.push_back(integer(static_cast(x))); + return t; + } + template + static Trace ints(const std::vector& v) { + return ints(std::span(v)); + } + // [[a, b], ...] from pairs. + template + static Trace pairs(const std::vector>& v) { + Trace t = list(); + for (const auto& [a, x] : v) { + Trace p = list(); + p.items.push_back(integer(static_cast(a))); + p.items.push_back(integer(static_cast(x))); + t.items.push_back(std::move(p)); + } + return t; + } + + // Map: set (replacing an existing key). + Trace& set(std::string_view key, Trace v) { + kind = Kind::Map; + for (auto& [k, x] : fields) { + if (k == key) { + x = std::move(v); + return x; + } + } + fields.emplace_back(std::string(key), std::move(v)); + return fields.back().second; + } + // List: append. + Trace& push(Trace v) { + kind = Kind::List; + items.push_back(std::move(v)); + return items.back(); + } +}; + +} // namespace netgraph::core::detail diff --git a/include/netgraph/core/eligibility.hpp b/include/netgraph/core/eligibility.hpp new file mode 100644 index 0000000..c9ca76a --- /dev/null +++ b/include/netgraph/core/eligibility.hpp @@ -0,0 +1,46 @@ +/* Eligibility: which nodes and edges of one graph may be used (APPROACH 4). Constructed only by + Graph::eligibility(excluded_nodes, excluded_edges) (graph.hpp, which includes this header). */ +#pragma once + +#include +#include +#include +#include + +#include "netgraph/core/types.hpp" + +namespace netgraph::core { + +class Graph; + +// Immutable, graph-bound static eligibility: dense node and edge flags. An edge is allowed when +// it is not excluded and both of its endpoints are allowed. +class Eligibility { +public: + [[nodiscard]] std::uint64_t graph_uid() const noexcept { return graph_uid_; } + // 0 for the all-allowed value; a fresh uid otherwise. + [[nodiscard]] std::uint64_t uid() const noexcept { return uid_; } + [[nodiscard]] std::span node_allowed() const noexcept { return node_allowed_; } + [[nodiscard]] std::span edge_allowed() const noexcept { return edge_allowed_; } + [[nodiscard]] bool node_ok(NodeId v) const noexcept { + return node_allowed_[static_cast(v)] != 0; + } + [[nodiscard]] bool edge_ok(EdgeId e) const noexcept { + return edge_allowed_[static_cast(e)] != 0; + } + // The intersection of two values of one graph (std::invalid_argument for values of different + // graphs): an operand when the result equals it (a first, then b), keeping that operand's + // uid, so a consumer builds each state once and reuses it; otherwise a new value with a fresh + // uid (equal intersections built separately get distinct uids). + [[nodiscard]] static Eligibility intersect(const Eligibility& a, const Eligibility& b); + +private: + friend class Graph; + Eligibility() = default; + std::uint64_t graph_uid_ {0}; + std::uint64_t uid_ {0}; + std::vector node_allowed_ {}; + std::vector edge_allowed_ {}; +}; + +} // namespace netgraph::core diff --git a/include/netgraph/core/evaluators.hpp b/include/netgraph/core/evaluators.hpp new file mode 100644 index 0000000..dca860f --- /dev/null +++ b/include/netgraph/core/evaluators.hpp @@ -0,0 +1,89 @@ +/* The evaluators of APPROACH 11: capacity (a FlowPolicy placement of +inf on a fresh ledger; the + exact maximum flow is capacity() with routing Global and placement MaxFlow), batch_capacity + (capacity per pair) and sensitivity (the loss of deleting each candidate edge). */ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/dag_cache.hpp" +#include "netgraph/core/flow_policy.hpp" +#include "netgraph/core/graph.hpp" +#include "netgraph/core/types.hpp" + +namespace netgraph::core { + +// The configuration, the static Eligibility, the DagCache and the optional outputs of one +// evaluation (the Eligibility and the cache outlive the call). +struct CapacityOptions { + FlowPolicyConfig cfg; + const Eligibility* elig {nullptr}; + DagCache* cache {nullptr}; + OutputOptions outputs {}; +}; +// The same fields; `outputs` apply to the baseline. +struct SensitivityOptions { + FlowPolicyConfig cfg; + const Eligibility* elig {nullptr}; + DagCache* cache {nullptr}; + OutputOptions outputs {}; +}; + +enum class SensitivityStatus { Evaluated, Unevaluable }; + +// One candidate edge: loss = baseline placed - placed without the edge (signed, in units, never +// thresholded); an unevaluable deletion has no loss and the model exception's message in reason. +struct SensitivityEntry { + EdgeId edge {0}; + std::optional loss {}; + SensitivityStatus status {SensitivityStatus::Evaluated}; + std::string reason {}; +}; + +struct SensitivityResult { + PlacementResult baseline {}; + std::vector entries {}; // ascending EdgeId; an absent edge has loss 0 +}; + +// place(+inf) of a FlowPolicy with opts.cfg on a fresh ledger of g (its capacities). +[[nodiscard]] PlacementResult capacity(const Graph& g, std::span sources, + std::span destinations, const CapacityOptions& opts); + +// capacity() per pair, in pair order, with opts.elig intersected with per_pair_elig[i] (null +// entries allowed; an empty span means none: the Python binding checks the length of every +// sequence it is given). Validated before any worker starts, the arguments first (workers >= 1, +// opts.elig's and the cache's graph, a supplied cache only with workers == 1: a building cache +// has one writer; Pinned rejected: bundles bind endpoints; the per_pair_elig length; the +// configuration), then each pair completely in pair order (its endpoint sets, their overlap, +// its Eligibility's graph), so the lowest failing pair index is reported. Without a supplied +// cache each worker owns a private building cache, allocated before any thread starts. A worker +// exception aborts the batch and the exception of the lowest pair index is raised; an empty +// batch returns an empty list. +[[nodiscard]] std::vector batch_capacity( + const Graph& g, const std::vector, std::vector>>& pairs, + const CapacityOptions& opts, std::span per_pair_elig, std::int32_t workers); + +// The baseline capacity() and one entry per candidate edge: under Global the edges with +// positive flow in the baseline; under every other model every edge opts.elig allows with at +// least 1 unit of static capacity. A loss is the baseline's placed minus the placed amount of +// capacity() with the edge deleted (opts.elig without it; the same configuration and cache). +// Global evaluates a deletion by the flow core's deletion re-solve from the baseline solve's +// final state (APPROACH 2.2: the minimum-cost flow of the maximum value without the edge, that +// value exactly), on one copy of the state per worker; an exact deletion on valid inputs always +// has an optimum, so any failure there propagates. CostOnly gives an edge outside every roster +// source's reachable part of the static support loss 0 without a placement, a result-equivalent +// predicate (no route uses the edge, so the deletion changes no route and no amount); every +// other deletion is placed on a fresh ledger, where a model exception (std::runtime_error) makes +// the entry unevaluable with its message as the reason. Every other exception propagates (the +// lowest candidate's). Workers and the cache +// rule as batch_capacity; a worker's private cache is cleared per candidate (fresh Eligibility +// uids never hit). +[[nodiscard]] SensitivityResult sensitivity(const Graph& g, std::span sources, + std::span destinations, const SensitivityOptions& opts, + std::int32_t workers); + +} // namespace netgraph::core diff --git a/include/netgraph/core/flow_graph.hpp b/include/netgraph/core/flow_ledger.hpp similarity index 83% rename from include/netgraph/core/flow_graph.hpp rename to include/netgraph/core/flow_ledger.hpp index a02adfc..cdd421f 100644 --- a/include/netgraph/core/flow_graph.hpp +++ b/include/netgraph/core/flow_ledger.hpp @@ -1,4 +1,4 @@ -/* The integer ledger FlowGraph (APPROACH 8). */ +/* The integer ledger FlowLedger (APPROACH 8). */ #pragma once #include @@ -9,8 +9,8 @@ #include #include -#include "netgraph/core/flow.hpp" -#include "netgraph/core/strict_multidigraph.hpp" +#include "netgraph/core/flow_record.hpp" +#include "netgraph/core/graph.hpp" #include "netgraph/core/types.hpp" namespace netgraph::core { @@ -31,20 +31,20 @@ struct LedgerAccess; // ledger is empty and unusable (every call throws std::logic_error). A policy destroyed // without remove() leaves its records and allocations as orphans (readable, never changed // again) until reset(). -class FlowGraph { +class FlowLedger { public: // `residual`: the baseline in units, one entry per edge in [0, capacity_units[e]] // (std::invalid_argument otherwise); absent: the capacities. - explicit FlowGraph(std::shared_ptr graph); - FlowGraph(std::shared_ptr graph, std::span residual); - FlowGraph(const FlowGraph& other); - FlowGraph(FlowGraph&& other); - FlowGraph& operator=(const FlowGraph&) = delete; - FlowGraph& operator=(FlowGraph&&) = delete; - ~FlowGraph() = default; + explicit FlowLedger(std::shared_ptr graph); + FlowLedger(std::shared_ptr graph, std::span residual); + FlowLedger(const FlowLedger& other); + FlowLedger(FlowLedger&& other); + FlowLedger& operator=(const FlowLedger&) = delete; + FlowLedger& operator=(FlowLedger&&) = delete; + ~FlowLedger() = default; [[nodiscard]] std::uint64_t uid() const; - [[nodiscard]] const std::shared_ptr& graph() const; + [[nodiscard]] const std::shared_ptr& graph() const; // The graph's static capacities (the ledger's view of them). [[nodiscard]] std::span capacity_units() const; [[nodiscard]] std::vector residual() const; @@ -68,7 +68,7 @@ class FlowGraph { void check_usable() const; std::vector checked_baseline(std::span residual) const; - std::shared_ptr graph_ {}; + std::shared_ptr graph_ {}; std::uint64_t uid_ {0}; bool usable_ {true}; std::vector baseline_ {}; diff --git a/include/netgraph/core/flow_policy.hpp b/include/netgraph/core/flow_policy.hpp new file mode 100644 index 0000000..588bba2 --- /dev/null +++ b/include/netgraph/core/flow_policy.hpp @@ -0,0 +1,160 @@ +/* The policy engine FlowPolicy (APPROACH 8 and 9). */ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/dag_cache.hpp" +#include "netgraph/core/flow_ledger.hpp" +#include "netgraph/core/flow_record.hpp" +#include "netgraph/core/graph.hpp" +#include "netgraph/core/options.hpp" +#include "netgraph/core/pred_dag.hpp" +#include "netgraph/core/types.hpp" + +namespace netgraph::core { + +namespace detail { +struct PolicyEngine; +struct PolicyAccess; +} // namespace detail + +// CapacityAware creations per call (stage 1 and accepted growth, across sources) before the +// call stops with limit_flows (APPROACH 9.2): an engine constant. +inline constexpr std::int64_t kMaxFlowsPerCall = 10000; + +// The diminishing-returns guard of CapacityAware growth: stop when at least `window` growth +// steps were accepted in the call and the last `window` gains sum below `fraction` times the +// source's offer (finite offers only). window >= 1, fraction in (0, 1). +struct DiminishingReturns { + std::int32_t window {1}; + double fraction {0.5}; +}; + +// The routing modes (APPROACH 9.1): a field exists only in the variant it applies to. +// CostOnly: IGP forwarding, one flow per source on the static shortest-path DAG (or its +// canonical single path), never refreshed, grown or reoptimised. +// CapacityAware: distributed CSPF tunnels: flow_count flows created by the canonical search on +// the selection view, grown up to max_flow_count (absent: no ceiling), gated by +// min_flow_bandwidth (units) and the max_path_cost ceiling, optionally reoptimised and guarded +// by `diminishing` (absent: off). +// Global: the omniscient controller, the minimum-cost flow of the cumulative value over the +// whole eligible graph (a replacement kernel; placement MaxFlow only). +// Pinned: explicit route structures, one flow per usable bundle in supply order (single +// endpoints; a Path converts with to_dag()). +struct CostOnly {}; +struct CapacityAware { + std::int32_t flow_count {1}; + std::optional max_flow_count {}; + std::optional max_path_cost {}; + Amount min_flow_bandwidth {0}; + bool reoptimize {false}; + std::optional diminishing {}; +}; +struct Global {}; +struct Pinned { + std::vector> bundles {}; +}; +using Routing = std::variant; + +// The four outer axes are explicit (no defaults). Global and Pinned read selection.priority only +// for the Weighted kernel (membership and weights) and ignore the rest; CostOnly and +// CapacityAware read all four fields (APPROACH 9). The constructor rejects a placement, +// admission or selection field outside its enumeration (std::invalid_argument naming the field; +// rule 13); the fields stay assignable, so the policy and the evaluators check them again with +// the combination rules. +struct FlowPolicyConfig { + FlowPolicyConfig(Routing routing_, FlowPlacement placement_, Admission admission_, PathSelection selection_); + Routing routing; + FlowPlacement placement; + Admission admission; + PathSelection selection; +}; + +// Optional outputs of a placement (absent unless requested): edge_flows (this policy's own +// allocation per edge after the call), residual and bottleneck (the ledger's after the call), +// and on Global the min_cut of the solve when bottleneck is requested. +struct OutputOptions { + bool edge_flows {false}; + bool residual {false}; + bool bottleneck {false}; +}; + +// The local outcome of a source and the call's termination (APPROACH 9.4). +enum class Termination { Complete, LimitFlows, LimitDiminishing, NoRoute, Exhausted }; + +// The result of one placement call: the deltas of this call (placed, remaining = request - +// placed, nullopt for +inf; drops = this call's transit drops), the termination and one local +// outcome per roster source (ascending NodeId), the two flags of the 9.4 table; Global adds the +// marginal cost curve of its cumulative solve. The optional outputs are present when requested. +struct PlacementResult { + Amount placed {0}; + std::optional remaining {}; + Termination termination {Termination::Complete}; + std::vector> local_outcomes {}; + bool routes_changed {false}; + bool weights_changed {false}; + std::vector> drops {}; + std::optional>> cost_distribution {}; + std::optional> edge_flows {}; + std::optional> residual {}; + std::optional> bottleneck {}; + // Global with bottleneck requested: the full-residual cut when the solve was maximal on its + // view (an empty roster's maximal flow 0 has the empty cut); nullopt otherwise: not requested, + // not Global, a solve stopped by its cap, or a zero call (nothing is solved). + std::optional> min_cut {}; +}; + +// The one placement engine (APPROACH 9). A policy binds to its ledger at construction and is the +// only way flows enter it: place() runs the transaction of 9.2 and commits one atomic exchange; +// remove() reverts every flow of the policy, retires the ids and opens a new epoch; destroying a +// policy without remove() leaves its records in the ledger as orphans. +// +// Validation (std::invalid_argument, before anything binds): the endpoint sets (non-empty, ids +// in range, disjoint), a foreign Eligibility or DagCache, the configuration (enumeration values; +// Lossy with MaxFlow, Global or CapacityAware; Global without MaxFlow; Weighted with Headroom; +// flow_count >= 1; max_flow_count >= flow_count; min_flow_bandwidth >= 0; the ceiling; the +// diminishing guard; Pinned: single endpoints, a non-empty bundle list of this graph without +// duplicates, every bundle with a src -> dst walk before pruning). place() validates the +// request ([0, 2^62) units or +inf; a finite request after +inf in the epoch is rejected, also +// 0) before any change. +// +// Lifetime: the ledger, the Eligibility and the DagCache outlive the policy (the policy holds +// pointers to them). The roster (the eligible sources that statically reach an allowed +// destination) and, for Pinned, the usable bundles are fixed at construction. +class FlowPolicy { +public: + FlowPolicy(FlowLedger& ledger, std::span sources, std::span destinations, + FlowPolicyConfig config, const Eligibility* elig = nullptr, DagCache* cache = nullptr); + FlowPolicy(const FlowPolicy&) = delete; + FlowPolicy& operator=(const FlowPolicy&) = delete; + FlowPolicy(FlowPolicy&&) = delete; + FlowPolicy& operator=(FlowPolicy&&) = delete; + ~FlowPolicy(); + + // volume: units, nullopt is +inf. + PlacementResult place(std::optional volume, const OutputOptions& outputs = {}); + void remove(); + // The sum of delivered over this policy's live records. + [[nodiscard]] Amount placed_demand() const; + // This policy's live records as snapshots in FlowId order. + [[nodiscard]] std::vector records() const; + // One of this policy's live records; a FlowId of another ledger or policy, a retired or an + // unknown id throws std::invalid_argument. + [[nodiscard]] FlowRecord record(const FlowId& flow_id) const; + // Pinned: the number of bundles with a src -> dst walk after static pruning; 0 otherwise. + [[nodiscard]] std::int32_t usable_bundles() const noexcept; + // The configuration (a copy taken at construction). + [[nodiscard]] const FlowPolicyConfig& config() const noexcept; + +private: + friend struct detail::PolicyAccess; + std::unique_ptr engine_; +}; + +} // namespace netgraph::core diff --git a/include/netgraph/core/flow.hpp b/include/netgraph/core/flow_record.hpp similarity index 69% rename from include/netgraph/core/flow.hpp rename to include/netgraph/core/flow_record.hpp index 9c62689..b3b8c3f 100644 --- a/include/netgraph/core/flow.hpp +++ b/include/netgraph/core/flow_record.hpp @@ -1,35 +1,22 @@ -/* Placement kinds, flow identities and ledger records (APPROACH 7 and 8). */ +/* Flow identities, the 128-bit counter and the ledger records (APPROACH 8). */ #pragma once #include -#include #include #include #include #include +#include "netgraph/core/options.hpp" #include "netgraph/core/pred_dag.hpp" #include "netgraph/core/types.hpp" namespace netgraph::core { -// The kernel that computes one flow's pushes on its DAG: MaxFlow (the flow core of the -// minimum-cost flow restricted to the flow's split graph, a replacement on V = W + own), -// Weighted (hop-by-hop WCMP: highest-averages apportionment over frozen weights at every node) -// and EqualBalanced (ECMP: round robin at every node). -enum class FlowPlacement { MaxFlow, Weighted, EqualBalanced }; - -// Lossless admits only what fits; Lossy forwards hop by hop without admission control and -// drops what does not fit on each edge (EqualBalanced and Weighted only). -enum class Admission { Lossless, Lossy }; - -// The path-cost sentinel of an unreachable node or an empty route (None in Python). -inline constexpr Cost kUnreachable = std::numeric_limits::max(); - // (ledger uid, id): ids are assigned by the ledger in creation order and never reused. using FlowId = std::pair; -// A cumulative counter (offered, per-edge drops): a sum over calls, kept in 128 bits as +// A cumulative counter (offered, per-edge drops): a sum over calls, the 128-bit value // hi * 2^64 + lo, always below 2^127 (APPROACH 2.1, 2.3). struct Counter128 { std::uint64_t hi {0}; diff --git a/include/netgraph/core/strict_multidigraph.hpp b/include/netgraph/core/graph.hpp similarity index 66% rename from include/netgraph/core/strict_multidigraph.hpp rename to include/netgraph/core/graph.hpp index 0a43345..f657d56 100644 --- a/include/netgraph/core/strict_multidigraph.hpp +++ b/include/netgraph/core/graph.hpp @@ -5,12 +5,11 @@ #include #include +#include "netgraph/core/eligibility.hpp" #include "netgraph/core/types.hpp" namespace netgraph::core { -class Eligibility; - // Converts a physical value to units of the resolution q, rounding half up. The only way a // physical number enters Core. Throws std::invalid_argument for NaN, infinity, a negative value, // a result at or above the domain bound 2^62, or a resolution that is not a power of two in @@ -36,19 +35,19 @@ class Eligibility; // // A copy is a new graph: same ids and input map, a new uid. Uids come from one process-wide // 64-bit counter and are never reused. -class StrictMultiDiGraph { +class Graph { public: - StrictMultiDiGraph(std::int32_t num_nodes, - std::span src, - std::span dst, - std::span capacity_units, - std::span cost, - double resolution); - StrictMultiDiGraph(const StrictMultiDiGraph& other); - StrictMultiDiGraph(StrictMultiDiGraph&& other) noexcept; - StrictMultiDiGraph& operator=(const StrictMultiDiGraph&) = delete; - StrictMultiDiGraph& operator=(StrictMultiDiGraph&&) = delete; - ~StrictMultiDiGraph() noexcept = default; + Graph(std::int32_t num_nodes, + std::span src, + std::span dst, + std::span capacity_units, + std::span cost, + double resolution); + Graph(const Graph& other); + Graph(Graph&& other) noexcept; + Graph& operator=(const Graph&) = delete; + Graph& operator=(Graph&&) = delete; + ~Graph() noexcept = default; [[nodiscard]] std::uint64_t uid() const noexcept { return uid_; } [[nodiscard]] double resolution() const noexcept { return resolution_; } @@ -74,7 +73,8 @@ class StrictMultiDiGraph { [[nodiscard]] std::span in_adj_edge_index() const noexcept { return in_adj_edge_index_; } // Static eligibility from excluded ids (duplicates and unsorted lists accepted, out-of-range - // ids rejected). Empty lists mean all allowed (uid 0). + // ids rejected). Empty lists mean all allowed (uid 0). The one public construction of an + // Eligibility (eligibility.hpp). [[nodiscard]] Eligibility eligibility(std::span excluded_nodes = {}, std::span excluded_edges = {}) const; @@ -94,34 +94,4 @@ class StrictMultiDiGraph { std::vector in_adj_edge_index_ {}; }; -// Immutable, graph-bound static eligibility: dense node and edge flags. An edge is allowed when -// it is not excluded and both of its endpoints are allowed. -class Eligibility { -public: - [[nodiscard]] std::uint64_t graph_uid() const noexcept { return graph_uid_; } - // 0 for the all-allowed value; a fresh uid otherwise. - [[nodiscard]] std::uint64_t uid() const noexcept { return uid_; } - [[nodiscard]] std::span node_allowed() const noexcept { return node_allowed_; } - [[nodiscard]] std::span edge_allowed() const noexcept { return edge_allowed_; } - [[nodiscard]] bool node_ok(NodeId v) const noexcept { - return node_allowed_[static_cast(v)] != 0; - } - [[nodiscard]] bool edge_ok(EdgeId e) const noexcept { - return edge_allowed_[static_cast(e)] != 0; - } - // The intersection of two values of one graph (std::invalid_argument for values of different - // graphs): an operand when the result equals it (a first, then b), keeping that operand's - // uid, so a consumer builds each state once and reuses it; otherwise a new value with a fresh - // uid (equal intersections built separately get distinct uids). - [[nodiscard]] static Eligibility intersect(const Eligibility& a, const Eligibility& b); - -private: - friend class StrictMultiDiGraph; - Eligibility() = default; - std::uint64_t graph_uid_ {0}; - std::uint64_t uid_ {0}; - std::vector node_allowed_ {}; - std::vector edge_allowed_ {}; -}; - } // namespace netgraph::core diff --git a/include/netgraph/core/max_flow.hpp b/include/netgraph/core/max_flow.hpp deleted file mode 100644 index 2fc7052..0000000 --- a/include/netgraph/core/max_flow.hpp +++ /dev/null @@ -1,61 +0,0 @@ -/* The one integer minimum-cost flow core (APPROACH 2.2). */ -#pragma once - -#include -#include -#include -#include -#include - -#include "netgraph/core/strict_multidigraph.hpp" -#include "netgraph/core/types.hpp" - -namespace netgraph::core { - -// Potential of a vertex that no Dijkstra run of the call settled. Potentials are otherwise -// non-negative path costs, so the sentinel never collides. -inline constexpr Cost kNeverReached = -1; -// Upper bound of the internal value cap; also the capacity of the virtual source and sink arcs, -// strictly above every attainable value (below 2^62), so a virtual arc never saturates. -inline constexpr Amount kMaxFlowCap = INT64_MAX - 1; - -// Operation counters of one call: diagnostics for the performance gate's drift check, identical -// across identical calls, never an oracle. Virtual vertices and arcs count like real ones. -struct FlowCoreCounters { - std::int64_t dijkstra_runs {0}; // every run, the final failed one included - std::int64_t dijkstra_pops {0}; // heap pops, stale ones included - std::int64_t settled {0}; // vertices finalised, summed over runs - std::int64_t arc_scans {0}; // arcs examined by Dijkstra, Dinic BFS and DFS advance - std::int64_t dinic_phases {0}; // BFS level builds, the last unsuccessful one of a tier included - std::int64_t augmentations {0}; // DFS paths pushed -}; - -// The private payload of _place_global_units. -struct FlowCoreResult { - Amount placed {0}; - // The marginal cost curve: (cost, units) with costs strictly increasing and units positive. - std::vector> cost_distribution {}; - std::vector edge_flows {}; // per edge, id order - // Edges with positive view from the full residual closure of the real sources to its - // complement, ascending id; nullopt while the destinations stay connected (a cap stopped the - // solve) and on an input cap of 0. - std::optional> min_cut {}; - std::vector potentials {}; // per node; kNeverReached when never settled - FlowCoreCounters counters {}; -}; - -// Minimum-cost flow of the maximum value attainable under `cap` from the source set to the -// destination set over the eligible edges, on the capacities `view` (units, id order). Every -// solve starts from zero flow. `cap` nullopt is uncapped; an input cap of 0 returns the zero -// payload without running a search. Throws std::invalid_argument before any work for a view of -// the wrong length or with an entry outside [0, capacity_units[e]], an empty or out-of-range -// endpoint set, a vertex in both sets, a cap outside [0, 2^63 - 2], or a foreign Eligibility. -// Excluded endpoints are skipped. -[[nodiscard]] FlowCoreResult place_global_units(const StrictMultiDiGraph& g, - std::span view, - std::span sources, - std::span destinations, - const Eligibility* elig, - std::optional cap); - -} // namespace netgraph::core diff --git a/include/netgraph/core/options.hpp b/include/netgraph/core/options.hpp index 96cbf27..7fae6bc 100644 --- a/include/netgraph/core/options.hpp +++ b/include/netgraph/core/options.hpp @@ -1,8 +1,31 @@ -/* Route selection axes shared by the searches and the policies (APPROACH 6). */ +/* The shared small option types: the selection axes of the searches and the policies (APPROACH 6) + and the placement kinds of the kernels (APPROACH 7). Component configurations and result + statuses stay with their component. */ #pragma once +#include + +#include "netgraph/core/types.hpp" + namespace netgraph::core { +// The orientation of a search: Forward from the roots, or Reverse (rooted at the roots over +// reversed arcs; the DAG it returns is forward-oriented, toward the roots). +enum class Direction { Forward, Reverse }; + +// One cost ceiling knob (APPROACH 6): an Absolute cost budget (inclusive, >= 0), or a Stretch +// factor (in [1, inf)) of the static shortest cost under the given Eligibility, read as the exact +// dyadic rational it is and compared without rounding. +struct Absolute { + Cost cost {0}; + friend bool operator==(const Absolute&, const Absolute&) = default; +}; +struct Stretch { + double factor {1.0}; + friend bool operator==(const Stretch&, const Stretch&) = default; +}; +using CostCeiling = std::variant; + // How ties between equal-cost candidates are broken, at both tie levels: the member chosen // among parallel edges of one (u, v) pair and the single path chosen among equal-cost walks. // Deterministic ignores capacities entirely (the lowest EdgeId wins); PreferHigherPriority @@ -19,10 +42,12 @@ enum class EdgePriority { Residual, Capacity, Headroom }; // instead of the one canonical single path; `multi_edge` keeps every eligible parallel member // of a (u, v) pair instead of the one chosen by (minimum cost, priority under // PreferHigherPriority, lowest EdgeId). tie_break and priority have no default (APPROACH 9: an -// outer axis is explicit); the two flags default to the plain form of their mechanism. +// outer axis is explicit); the two flags default to the plain form of their mechanism. The +// constructor rejects a tie_break or priority outside its enumeration (std::invalid_argument +// naming the field; rule 13); the fields stay assignable, so every entry that reads a selection +// checks it again. struct PathSelection { - PathSelection(EdgeTieBreak tie_break_, EdgePriority priority_, bool multipath_ = true, bool multi_edge_ = true) - : tie_break(tie_break_), priority(priority_), multipath(multipath_), multi_edge(multi_edge_) {} + PathSelection(EdgeTieBreak tie_break_, EdgePriority priority_, bool multipath_ = true, bool multi_edge_ = true); EdgeTieBreak tie_break; EdgePriority priority; bool multipath; @@ -30,4 +55,14 @@ struct PathSelection { friend bool operator==(const PathSelection&, const PathSelection&) = default; }; +// The kernel that computes one flow's pushes on its DAG: MaxFlow (the flow core of the +// minimum-cost flow restricted to the flow's split graph, a replacement on V = W + own), +// Weighted (hop-by-hop WCMP: highest-averages apportionment over frozen weights at every node) +// and EqualBalanced (ECMP: round robin at every node). +enum class FlowPlacement { MaxFlow, Weighted, EqualBalanced }; + +// Lossless admits only what fits; Lossy forwards hop by hop without admission control and +// drops what does not fit on each edge (EqualBalanced and Weighted only). +enum class Admission { Lossless, Lossy }; + } // namespace netgraph::core diff --git a/include/netgraph/core/pred_dag.hpp b/include/netgraph/core/pred_dag.hpp index 3c7442f..2660201 100644 --- a/include/netgraph/core/pred_dag.hpp +++ b/include/netgraph/core/pred_dag.hpp @@ -5,7 +5,7 @@ #include #include -#include "netgraph/core/strict_multidigraph.hpp" +#include "netgraph/core/graph.hpp" #include "netgraph/core/types.hpp" namespace netgraph::core { @@ -20,32 +20,39 @@ struct PredDAGBuilder; // strictly ascending via EdgeId, so a DAG value has one canonical form. Equality is structural // (same graph, same CSR). Every API that takes a route structure takes a PredDAG; a DAG of // another graph is rejected wherever it is used. +// +// Storage is sparse (APPROACH 8, revision 27): the entries are kept per member node (the nodes +// with at least one entry, ascending, and their offsets), never as an N + 1 offsets array, so a +// flow's bundle costs O(its entries) whatever the graph's size. parent_offsets() presents the +// from_csr shape on demand (a fresh N + 1 array); parents() and via_edges() are the stored +// entries (grouped by node in ascending NodeId), which are the same arrays in both forms. class PredDAG { public: // Validates, in this order, before any traversal (std::invalid_argument on the first defect): // offsets of length N + 1, offsets[0] == 0, monotone, offsets[N] == len(parents) == // len(via); then every parent and via in range, every via joining (parent, child), no // duplicate (child, via), acyclic; within a node the entries in ascending via EdgeId. - [[nodiscard]] static PredDAG from_csr(const StrictMultiDiGraph& g, std::span offsets, + [[nodiscard]] static PredDAG from_csr(const Graph& g, std::span offsets, std::span parents, std::span via); [[nodiscard]] std::uint64_t graph_uid() const noexcept { return graph_uid_; } - [[nodiscard]] std::int32_t num_nodes() const noexcept { return static_cast(offsets_.size()) - 1; } - [[nodiscard]] std::span parent_offsets() const noexcept { return offsets_; } + // The from_csr offsets (N + 1 entries), built on demand from the sparse storage. + [[nodiscard]] std::vector parent_offsets() const; [[nodiscard]] std::span parents() const noexcept { return parents_; } [[nodiscard]] std::span via_edges() const noexcept { return via_; } - // The member edges in ascending EdgeId. - [[nodiscard]] std::vector edges() const { return edges_; } friend bool operator==(const PredDAG& a, const PredDAG& b) noexcept { - return a.graph_uid_ == b.graph_uid_ && a.offsets_ == b.offsets_ && a.parents_ == b.parents_ && a.via_ == b.via_; + return a.graph_uid_ == b.graph_uid_ && a.num_nodes_ == b.num_nodes_ && a.nodes_ == b.nodes_ && + a.node_offsets_ == b.node_offsets_ && a.parents_ == b.parents_ && a.via_ == b.via_; } private: friend struct detail::PredDAGBuilder; PredDAG() = default; std::uint64_t graph_uid_ {0}; - std::vector offsets_ {0}; + std::int32_t num_nodes_ {0}; + std::vector nodes_ {}; // nodes with entries, ascending + std::vector node_offsets_ {0}; // nodes_.size() + 1: the entries of nodes_[i] std::vector parents_ {}; std::vector via_ {}; std::vector edges_ {}; // via_ in ascending EdgeId (derived once, at construction) @@ -59,14 +66,12 @@ class Path { // Validates, in this order (std::invalid_argument on the first defect): every member id in // range (before any endpoint is read), no duplicate member, a non-empty hop list, every hop // non-empty, the members of a hop sharing (u, v), contiguous hops, no repeated node. - Path(const StrictMultiDiGraph& g, std::vector> hops); + Path(const Graph& g, std::vector> hops); [[nodiscard]] std::uint64_t graph_uid() const noexcept { return graph_uid_; } [[nodiscard]] const std::vector>& hops() const noexcept { return hops_; } [[nodiscard]] NodeId src() const noexcept { return nodes_.front(); } [[nodiscard]] NodeId dst() const noexcept { return nodes_.back(); } - // The visited nodes, src first (hops + 1 entries). - [[nodiscard]] const std::vector& nodes() const noexcept { return nodes_; } [[nodiscard]] Cost cost() const noexcept { return cost_; } // The equivalent PredDAG (every member of every hop; entries in ascending via EdgeId). [[nodiscard]] PredDAG to_dag() const; diff --git a/include/netgraph/core/profiling.hpp b/include/netgraph/core/profiling.hpp deleted file mode 100644 index 19b9287..0000000 --- a/include/netgraph/core/profiling.hpp +++ /dev/null @@ -1,116 +0,0 @@ -/* Simple profiling utilities for performance analysis. - -Enable via environment variable: NGRAPH_CORE_PROFILE=1 - -When disabled (default), each instrumented scope costs one cached-bool check. - -Usage: - #include "netgraph/core/profiling.hpp" - - void my_function() { - NGRAPH_PROFILE_SCOPE("my_function"); - // ... function body ... - } - -From Python: the accessors return with the diagnostics module of the streamlined API -(sub-PR 1d); until then the statistics are reachable from C++ only. -*/ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace netgraph::core { - -// Check once at startup, cache result. Returns true if NGRAPH_CORE_PROFILE=1. -// Defined in profiling.cpp to avoid ODR violations with static library linking. -bool profiling_enabled() noexcept; - -// Singleton collecting profiling statistics. -class ProfilingStats { -public: - // Defined in profiling.cpp to avoid ODR violations with static library linking. - static ProfilingStats& instance(); - - void record(const char* name, double micros); - void dump() const { dump(std::cerr); } - void dump(std::ostream& out) const; - void reset(); - -private: - ProfilingStats() = default; - struct Entry { - double total_us = 0; - int64_t count = 0; - double min_us = 1e18; - double max_us = 0; - }; - - class LocalStats { - public: - explicit LocalStats(ProfilingStats& owner); - ~LocalStats(); - - void record(const char* name, double micros); - void append_to(std::unordered_map& out) const; - void clear(); - - private: - ProfilingStats& owner_; - mutable std::mutex mutex_; - std::unordered_map stats_; - }; - - LocalStats& local_stats(); - void register_local(LocalStats* local); - void finalize_local(LocalStats* local); - static void merge_entry(Entry& dst, const Entry& src); - std::unordered_map snapshot_unlocked() const; - - mutable std::mutex mutex_; - std::vector active_locals_; - std::unordered_map finished_stats_; -}; - -// RAII timer. Does nothing if name is nullptr. -class ScopedTimer { -public: - explicit ScopedTimer(const char* name) noexcept : name_(name) { - if (name_) { - start_ = std::chrono::high_resolution_clock::now(); - } - } - - ~ScopedTimer() { - if (name_) { - auto end = std::chrono::high_resolution_clock::now(); - double us = std::chrono::duration(end - start_).count(); - ProfilingStats::instance().record(name_, us); - } - } - - ScopedTimer(const ScopedTimer&) = delete; - ScopedTimer& operator=(const ScopedTimer&) = delete; - -private: - const char* name_; - std::chrono::high_resolution_clock::time_point start_; -}; - -// Concatenate tokens for unique variable names -#define NGRAPH_CONCAT_IMPL(a, b) a##b -#define NGRAPH_CONCAT(a, b) NGRAPH_CONCAT_IMPL(a, b) - -// Main profiling macro. Expands to a ScopedTimer only if profiling is enabled; -// when disabled the timer is constructed with a null name and does no work. -#define NGRAPH_PROFILE_SCOPE(name) \ - ::netgraph::core::ScopedTimer NGRAPH_CONCAT(_ngraph_timer_, __LINE__)( \ - ::netgraph::core::profiling_enabled() ? (name) : nullptr) - -} // namespace netgraph::core diff --git a/include/netgraph/core/types.hpp b/include/netgraph/core/types.hpp index af7efd5..916adef 100644 --- a/include/netgraph/core/types.hpp +++ b/include/netgraph/core/types.hpp @@ -2,12 +2,13 @@ #pragma once #include +#include namespace netgraph::core { // Node and edge identifiers are signed 32-bit integers. An EdgeId is the rank of the edge in // the stable order (src, cost, dst, input) of its graph (layout B'); the input position of an -// edge is StrictMultiDiGraph::input_index()[e]. +// edge is Graph::input_index()[e]. using NodeId = std::int32_t; using EdgeId = std::int32_t; // Edge and path costs: positive integers, never converted. @@ -20,4 +21,8 @@ using Amount = std::int64_t; // all edge costs stay below 2^62. inline constexpr std::int64_t kDomainLimit = std::int64_t{1} << 62; +// The path-cost sentinel of an unreachable node or an empty route (None in Python). Costs are +// positive and their sum is below 2^62, so no path cost reaches it. +inline constexpr Cost kUnreachable = std::numeric_limits::max(); + } // namespace netgraph::core diff --git a/pyproject.toml b/pyproject.toml index 7f9a41a..472a411 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,11 +55,16 @@ sdist.exclude = ["dev/"] # Include Python sources from the "python/" directory wheel.packages = ["python/netgraph_core"] -[tool.scikit-build.cmake.define] -# No optional backends yet; defaults are built-in CPU-only +[[tool.scikit-build.overrides]] +# The editable development build carries the private test hooks (CMake option +# NETGRAPH_CORE_PRIVATE_HOOKS); a wheel or sdist build does not. +if.state = "^editable$" +cmake.define.NETGRAPH_CORE_PRIVATE_HOOKS = "ON" [tool.pytest.ini_options] addopts = "-q" +# pytest without arguments collects the repository tests only, not saved evidence under dev/. +testpaths = ["tests"] markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"] [tool.ruff] diff --git a/python/netgraph_core/__init__.py b/python/netgraph_core/__init__.py index d3a7b89..2b9ee44 100644 --- a/python/netgraph_core/__init__.py +++ b/python/netgraph_core/__init__.py @@ -4,14 +4,17 @@ downstream users import everything from a single place. Typing shims supplied in ``_docs.py`` keep editors happy without affecting runtime behavior. -The streamlined Core is being rebuilt in sub-PRs (``dev/design/2026-10-streamline``): -this build carries the graph on integer units (``StrictMultiDiGraph``, -``Eligibility`` with ``intersect``, ``to_units``, ``as_float``), the route structures -(``PredDAG``, ``Path``), the selection and placement axes (``PathSelection``, -``EdgeTieBreak``, ``EdgePriority``, ``FlowPlacement``, ``Admission``) and the integer -ledger (``FlowGraph`` with its ``FlowRecord`` snapshots), plus the private entries -of the flow core, the kernels and the internal search in ``_netgraph_core``; the -policies, the public searches and the evaluators return in the following sub-PRs. +The package carries the graph on integer units (``Graph``, ``Eligibility`` +with ``intersect``, ``to_units``, ``as_float``), the route structures (``PredDAG``, +``Path``), the selection and placement axes (``PathSelection``, ``EdgeTieBreak``, +``EdgePriority``, ``FlowPlacement``, ``Admission``), the integer ledger (``FlowLedger`` +with its ``FlowRecord`` snapshots), the policy engine (``FlowPolicy`` with +``FlowPolicyConfig`` over the routing variants ``CostOnly``, ``CapacityAware``, +``Global``, ``Pinned``; ``PlacementResult``, ``OutputOptions``, ``Termination``), the +``DagCache`` (building mode) and the evaluators (``capacity``, ``batch_capacity``, +``sensitivity``). The public search API (shortest paths, k shortest paths, path +resolution) and the frozen cache are not part of this build; ``_netgraph_core`` also holds the private entries +of the flow core and the internal search, which are never re-exported here. """ # pyright: reportMissingImports=false @@ -20,18 +23,41 @@ from typing import TYPE_CHECKING from _netgraph_core import ( + MAX_FLOWS_PER_CALL, + Absolute, Admission, + CapacityAware, + CapacityOptions, + CostOnly, + DagCache, + DagCacheStats, + DiminishingReturns, EdgePriority, EdgeTieBreak, Eligibility, - FlowGraph, + FlowLedger, FlowPlacement, + FlowPolicy, + FlowPolicyConfig, FlowRecord, + Global, + Graph, + OutputOptions, Path, PathSelection, + Pinned, + PlacementResult, PredDAG, - StrictMultiDiGraph, + SensitivityEntry, + SensitivityOptions, + SensitivityResult, + SensitivityStatus, + Stretch, + Termination, as_float, + batch_capacity, + capacity, + sensitivity, to_units, ) @@ -44,24 +70,47 @@ # because it turns "unchecked" into "confidently wrong". if TYPE_CHECKING: # pragma: no cover - typing-only from ._docs import ( # noqa: I001 + MAX_FLOWS_PER_CALL as MAX_FLOWS_PER_CALL, + Absolute as Absolute, Admission as Admission, + CapacityAware as CapacityAware, + CapacityOptions as CapacityOptions, + CostOnly as CostOnly, + DagCache as DagCache, + DagCacheStats as DagCacheStats, + DiminishingReturns as DiminishingReturns, EdgePriority as EdgePriority, EdgeTieBreak as EdgeTieBreak, Eligibility as Eligibility, - FlowGraph as FlowGraph, + FlowLedger as FlowLedger, FlowPlacement as FlowPlacement, + FlowPolicy as FlowPolicy, + FlowPolicyConfig as FlowPolicyConfig, FlowRecord as FlowRecord, + Global as Global, + Graph as Graph, + OutputOptions as OutputOptions, Path as Path, PathSelection as PathSelection, + Pinned as Pinned, + PlacementResult as PlacementResult, PredDAG as PredDAG, - StrictMultiDiGraph as StrictMultiDiGraph, + SensitivityEntry as SensitivityEntry, + SensitivityOptions as SensitivityOptions, + SensitivityResult as SensitivityResult, + SensitivityStatus as SensitivityStatus, + Stretch as Stretch, + Termination as Termination, as_float as as_float, + batch_capacity as batch_capacity, + capacity as capacity, + sensitivity as sensitivity, to_units as to_units, ) __all__ = [ "__version__", - "StrictMultiDiGraph", + "Graph", "Eligibility", "to_units", "as_float", @@ -72,6 +121,29 @@ "PathSelection", "FlowPlacement", "Admission", - "FlowGraph", + "FlowLedger", "FlowRecord", + "Absolute", + "Stretch", + "DiminishingReturns", + "CostOnly", + "CapacityAware", + "Global", + "Pinned", + "FlowPolicyConfig", + "OutputOptions", + "Termination", + "PlacementResult", + "FlowPolicy", + "MAX_FLOWS_PER_CALL", + "DagCache", + "DagCacheStats", + "CapacityOptions", + "SensitivityOptions", + "SensitivityStatus", + "SensitivityEntry", + "SensitivityResult", + "capacity", + "batch_capacity", + "sensitivity", ] diff --git a/python/netgraph_core/_docs.py b/python/netgraph_core/_docs.py index 785ef07..1ca4f57 100644 --- a/python/netgraph_core/_docs.py +++ b/python/netgraph_core/_docs.py @@ -3,8 +3,16 @@ This module is intentionally light; runtime implementations live in the compiled extension. The goal is to provide type hints and help text. -TODO: replace this hand-written stub with generated `.pyi` (e.g. via -pybind11-stubgen) to prevent drift from the runtime bindings (Phase 5). +Every public call is all-or-nothing up to its caller (APPROACH 13), with two limits: (1) the +pybind11 object machinery is outside the guarantee: constructing a bound object from Python, +or reading an attribute or method value converted into a new bound object or container outside +the staged calls, allocates through pybind11's ``make_new_instance``, which does not check the +result of ``tp_alloc``, so a Python allocation failure at that point crashes the process, with +Core state unchanged; (2) a supplied building DagCache is a memo: it keeps the complete entries +and statistics of the lookups that succeeded before a later failure of the call. + +Generated ``.pyi`` stubs (pybind11-stubgen, APPROACH 13) arrive with the public search (PLAN, +sub-PR 1d); this hand-written shim is deleted in Phase 5. """ from __future__ import annotations @@ -58,7 +66,7 @@ def as_float(x, resolution): # type: ignore[no-untyped-def] class Eligibility: """Immutable, graph-bound static eligibility: dense node and edge flags. - Built by ``StrictMultiDiGraph.eligibility(excluded_nodes, excluded_edges)``. An + Built by ``Graph.eligibility(excluded_nodes, excluded_edges)``. An edge is allowed when it is not excluded and both of its endpoints are allowed. Passing it to a call of another graph raises ValueError. """ @@ -90,7 +98,7 @@ def intersect(a: "Eligibility", b: "Eligibility") -> "Eligibility": ... -class StrictMultiDiGraph: +class Graph: """Immutable directed multigraph on integer units (layout B'). Edges are given in input order. The public EdgeId of an edge is its rank in the @@ -277,7 +285,8 @@ class PathSelection: ``tie_break`` and ``priority`` are explicit; ``multipath = True`` keeps every equal-cost walk (the DAG), ``False`` the one canonical single path; ``multi_edge = True`` keeps every eligible parallel member, ``False`` one per ``(u, v)`` pair (minimum cost, then the - priority under PREFER_HIGHER_PRIORITY, then the lowest EdgeId). Immutable value. + priority under PREFER_HIGHER_PRIORITY, then the lowest EdgeId). Immutable value; a + ``tie_break`` or ``priority`` outside its enumeration raises ValueError. """ def __init__( @@ -303,13 +312,15 @@ class PredDAG: The entries of node ``v`` are ``parents()[k]``, ``via_edges()[k]`` for ``k`` in ``[parent_offsets()[v], parent_offsets()[v + 1])``: the edge ``via`` joins ``parent -> v``. Within a node the entries are in strictly ascending via EdgeId (one - canonical form). Equality is structural. Accessors return read-only NumPy views that - keep the DAG alive. A DAG of another graph is rejected wherever it is used. + canonical form). Equality is structural. ``parents()`` and ``via_edges()`` are read-only + NumPy views that keep the DAG alive; ``parent_offsets()`` is a fresh read-only array that + owns its data, built on demand (the DAG is stored over its member nodes only). A DAG of + another graph is rejected wherever it is used. """ @staticmethod def from_csr( - graph: StrictMultiDiGraph, + graph: Graph, offsets: "Sequence[int] | np.ndarray", parents: "Sequence[int] | np.ndarray", via: "Sequence[int] | np.ndarray", @@ -330,7 +341,7 @@ def graph_uid(self) -> int: ... def parent_offsets(self) -> "np.ndarray": - """int32[N + 1] CSR offsets.""" + """int32[N + 1] CSR offsets, a fresh read-only array built on demand.""" ... def parents(self) -> "np.ndarray": @@ -350,9 +361,7 @@ class Path: cost. ``to_dag()`` gives the equivalent ``PredDAG``. """ - def __init__( - self, graph: StrictMultiDiGraph, hops: "Sequence[Sequence[int]]" - ) -> None: + def __init__(self, graph: Graph, hops: "Sequence[Sequence[int]]") -> None: """Validate and build the walk. Raises: @@ -366,10 +375,6 @@ def graph_uid(self) -> int: ... def hops(self) -> list[list[int]]: ... def src(self) -> int: ... def dst(self) -> int: ... - def nodes(self) -> list[int]: - """The visited nodes, ``src`` first.""" - ... - def cost(self) -> int: ... def to_dag(self) -> PredDAG: ... @@ -410,7 +415,7 @@ def edges(self) -> list[tuple[int, int]]: ... def drops(self) -> list[tuple[int, int]]: ... -class FlowGraph: +class FlowLedger: """The integer ledger of one graph (APPROACH 8). An immutable baseline residual in units and the records of the flows placed on it by @@ -423,7 +428,7 @@ class FlowGraph: def __init__( self, - graph: StrictMultiDiGraph, + graph: Graph, residual: "Sequence[int] | np.ndarray | None" = None, ) -> None: """Bind a ledger to ``graph``. @@ -444,7 +449,7 @@ def uid(self) -> int: """Process-unique ledger uid (the first half of every FlowId).""" ... - def graph(self) -> StrictMultiDiGraph: ... + def graph(self) -> Graph: ... def capacity_units(self) -> "np.ndarray": """int64[E] the graph's capacities in units (read-only view).""" ... @@ -483,7 +488,471 @@ def reset(self, residual: "Sequence[int] | np.ndarray | None" = None) -> None: """ ... - def copy(self) -> "FlowGraph": + def copy(self) -> "FlowLedger": """A new ledger (new uid) with the same baseline and records (ids kept under the new uid); ValueError while a policy is bound.""" ... + + +# ---------------------------------------------------------------------------- policies + +MAX_FLOWS_PER_CALL: int +"""The call-wide CapacityAware creation cap, 10,000: the flows one ``place`` call may create +(creation and accepted growth, across sources) before it stops with ``LIMIT_FLOWS``. An engine +constant (C++ ``kMaxFlowsPerCall``); not a limit on any mode's existing flow count.""" + + +class Absolute: + """CostCeiling: an absolute, inclusive path-cost budget (``cost >= 0``).""" + + def __init__(self, cost: int) -> None: ... + @property + def cost(self) -> int: ... + + +class Stretch: + """CostCeiling: a factor in ``[1, inf)`` of the static shortest cost under the policy's + Eligibility, read as the exact dyadic rational it is (compared without rounding).""" + + def __init__(self, factor: float) -> None: ... + @property + def factor(self) -> float: ... + + +class DiminishingReturns: + """The diminishing-returns guard of CapacityAware growth. + + Growth stops once at least ``window`` steps were accepted in the call and the last + ``window`` gains sum below ``fraction`` times the source's (finite) offer + (``limit_diminishing``). ``window >= 1``, ``fraction`` in ``(0, 1)``. + """ + + def __init__(self, window: int, fraction: float) -> None: ... + @property + def window(self) -> int: ... + @property + def fraction(self) -> float: ... + + +class CostOnly: + """Routing: IGP forwarding. One flow per roster source on the static shortest-path DAG + (the DagCache value of the destination set; selection projected at creation on the + working residual), never refreshed, grown or reoptimised.""" + + def __init__(self) -> None: ... + + +class CapacityAware: + """Routing: distributed CSPF tunnels (APPROACH 9.1, 9.2). + + ``flow_count`` flows are created by the canonical search of section 6 on the selection + view (residual >= ``max(1, min_flow_bandwidth)`` units, the ``max_path_cost`` ceiling), + then grown up to ``max_flow_count`` (``None``: no ceiling); ``reoptimize`` reroutes + retained flows (adopted only when the candidate delivers the flow's amount); + ``diminishing`` guards growth (``None``: off). Fields are keyword-only. + """ + + def __init__( + self, + *, + flow_count: int = 1, + max_flow_count: int | None = None, + max_path_cost: "Absolute | Stretch | None" = None, + min_flow_bandwidth: int = 0, + reoptimize: bool = False, + diminishing: DiminishingReturns | None = None, + ) -> None: ... + @property + def flow_count(self) -> int: ... + @property + def max_flow_count(self) -> int | None: ... + @property + def max_path_cost(self) -> "Absolute | Stretch | None": ... + @property + def min_flow_bandwidth(self) -> int: ... + @property + def reoptimize(self) -> bool: ... + @property + def diminishing(self) -> DiminishingReturns | None: ... + + +class Global: + """Routing: the omniscient controller. Every call re-solves the minimum-cost flow of the + cumulative value over the whole eligible graph on ``V = W + own`` (placement MaxFlow + only); records are the canonical per-source decomposition.""" + + def __init__(self) -> None: ... + + +class Pinned: + """Routing: explicit route structures, one flow per usable bundle in supply order + (single endpoints). A ``Path`` converts with ``to_dag()``.""" + + def __init__(self, bundles: "Sequence[PredDAG]") -> None: ... + @property + def bundles(self) -> list[PredDAG]: ... + + +class FlowPolicyConfig: + """The four explicit axes of a policy (keyword-only, no defaults). + + ``Global`` and ``Pinned`` read ``selection.priority`` only for the Weighted kernel and + ignore the rest; ``CostOnly`` and ``CapacityAware`` read every field. A ``placement`` or + ``admission`` outside its enumeration raises ValueError; a routing of another type, + TypeError. + """ + + def __init__( + self, + *, + routing: "CostOnly | CapacityAware | Global | Pinned", + placement: FlowPlacement, + admission: Admission, + selection: PathSelection, + ) -> None: ... + @property + def routing(self) -> "CostOnly | CapacityAware | Global | Pinned": ... + @property + def placement(self) -> FlowPlacement: ... + @property + def admission(self) -> Admission: ... + @property + def selection(self) -> PathSelection: ... + + +class OutputOptions: + """Optional outputs of a placement (absent outputs are ``None``): ``edge_flows`` (the + policy's own allocation per edge after the call), ``residual`` and ``bottleneck`` (the + ledger's after the call); on Global, ``bottleneck`` adds ``min_cut``.""" + + def __init__( + self, + *, + edge_flows: bool = False, + residual: bool = False, + bottleneck: bool = False, + ) -> None: ... + @property + def edge_flows(self) -> bool: ... + @property + def residual(self) -> bool: ... + @property + def bottleneck(self) -> bool: ... + + +class Termination: + """A source's local outcome and a call's termination (APPROACH 9.4). + + The reduction over the sources whose offer is unserved: none ``COMPLETE``, else the + highest of ``LIMIT_FLOWS > LIMIT_DIMINISHING > EXHAUSTED > NO_ROUTE``. + """ + + COMPLETE: ClassVar["Termination"] + LIMIT_FLOWS: ClassVar["Termination"] + LIMIT_DIMINISHING: ClassVar["Termination"] + NO_ROUTE: ClassVar["Termination"] + EXHAUSTED: ClassVar["Termination"] + __members__: ClassVar[dict[str, "Termination"]] + + def __init__(self, value: int) -> None: ... + def __int__(self) -> int: ... + def __index__(self) -> int: ... + @property + def name(self) -> str: ... + @property + def value(self) -> int: ... + + +class PlacementResult: + """One placement call (the deltas of this call). + + ``placed`` and ``remaining = request - placed`` (``float('inf')`` for ``+inf``) in + units; ``termination`` and ``local_outcomes`` (one ``(source, Termination)`` per roster + source, ascending); ``routes_changed`` and ``weights_changed`` (committed events of the + call, the flag table of 9.4); ``drops`` (this call's transit drops); Global adds + ``cost_distribution``, the marginal curve of the policy's cumulative flow (its amounts sum + to ``placed_demand()``; a zero call reports it unchanged, ``[]`` before the first + placement and after ``remove()``). ``edge_flows``, ``residual``, ``bottleneck`` and + (Global) ``min_cut`` exist when requested through ``OutputOptions``; ``min_cut`` is ``None`` + when the solve was not maximal on its view, and on a zero call (nothing is solved). + """ + + @property + def placed(self) -> int: ... + @property + def remaining(self) -> "int | float": ... + @property + def termination(self) -> Termination: ... + @property + def local_outcomes(self) -> list[tuple[int, Termination]]: ... + @property + def routes_changed(self) -> bool: ... + @property + def weights_changed(self) -> bool: ... + @property + def drops(self) -> list[tuple[int, int]]: ... + @property + def cost_distribution(self) -> list[tuple[int, int]] | None: ... + @property + def edge_flows(self) -> "np.ndarray | None": ... + @property + def residual(self) -> "np.ndarray | None": ... + @property + def bottleneck(self) -> list[int] | None: ... + @property + def min_cut(self) -> list[int] | None: ... + + +class DagCacheStats: + """DagCache statistics, atomic per field (not a consistent snapshot): ``hits``, + ``misses``, ``entries``, ``bytes`` (the entries' retained payload) and + ``live_values`` (values alive anywhere, entries included).""" + + @property + def hits(self) -> int: ... + @property + def misses(self) -> int: ... + @property + def entries(self) -> int: ... + @property + def bytes(self) -> int: ... + @property + def live_values(self) -> int: ... + + +class DagCache: + """A cache of static shortest-path supports bound to one graph (APPROACH 10). + + Keyed ``(roots as a set, direction, Eligibility uid)``; the value is the full static + support (``multipath``, ``multi_edge``, no residual) as an immutable shared value, valid + after ``clear()``. Selection is projected at creation, never cached. Building mode: one + writer, no concurrent readers (a supplied cache needs ``workers == 1``). A supplied + building cache is a memo (APPROACH 13, limit 2): a public call that fails keeps the + complete entries and statistics of the lookups that succeeded before; it never holds a + partial entry, held values stay valid, and a retained entry never changes a result. + """ + + def __init__(self, graph: Graph) -> None: ... + def graph_uid(self) -> int: ... + def clear(self) -> None: + """Empties the cache; the hit and miss counters persist; held values stay valid.""" + ... + + def stats(self) -> DagCacheStats: ... + + +class FlowPolicy: + """The one placement engine (APPROACH 9), bound to its ledger at construction. + + ``place(volume, outputs)`` runs the transaction of 9.2 (per source for CostOnly and + CapacityAware: creation, reoptimisation, amounts, growth on the shared working + residual; Global: the cumulative minimum-cost flow on ``V = W + own``) and commits one + atomic exchange: a failed call raises and leaves the ledger and the policy as they were, + a failed allocation of its Python result included (the result object exists before + anything is published). Two limits (APPROACH 13): (1) the pybind11 object machinery is + outside the guarantee: constructing a bound object from Python, or reading an attribute + or method value converted into a new bound object or container outside the staged calls, + allocates through pybind11's ``make_new_instance``, which does not check ``tp_alloc``, so a + Python allocation failure there crashes the process, with Core state unchanged; (2) a + supplied building DagCache is a memo: it keeps the complete entries and statistics of the + lookups that succeeded before a later failure (of this call or of the construction); + never a partial entry, and a retained entry never changes a result. ``remove()`` reverts every + flow, retires the ids and opens a new epoch. Dropping the policy without ``remove()`` + leaves its records as orphans. Keeps the ledger, the Eligibility and the cache alive. + + Raises: + ValueError: invalid endpoints (empty, out of range, overlapping sets), configurations + (Lossy with MaxFlow, Global or CapacityAware; Global without MaxFlow; Weighted + with Headroom; counts, ceilings, guards; Pinned bundles: empty, duplicate, + foreign, without a src -> dst walk; Pinned with endpoint sets), foreign objects, + and requests (outside ``[0, 2**62)`` units, a finite request after ``+inf``). + TypeError: arguments of the wrong type. + """ + + def __init__( + self, + ledger: FlowLedger, + sources: "int | Sequence[int]", + destinations: "int | Sequence[int]", + config: FlowPolicyConfig, + elig: Eligibility | None = None, + cache: DagCache | None = None, + ) -> None: ... + def place( + self, volume: "int | float", outputs: OutputOptions | None = None + ) -> PlacementResult: + """Place ``volume`` units (an int in ``[0, 2**62)``) or ``float('inf')``.""" + ... + + def remove(self) -> None: ... + def placed_demand(self) -> int: + """The sum of delivered over the policy's live records.""" + ... + + def records(self) -> list[FlowRecord]: + """The policy's live records as snapshots in FlowId order.""" + ... + + def record(self, flow_id: tuple[int, int]) -> FlowRecord: + """One of the policy's live records; ValueError for a FlowId of another ledger or + policy, a retired or an unknown id.""" + ... + + def usable_bundles(self) -> int: + """Pinned: the bundles with a src -> dst walk after static pruning (else 0).""" + ... + + @property + def config(self) -> FlowPolicyConfig: + """The configuration (a copy taken at construction).""" + ... + + +class CapacityOptions: + """The configuration, the static Eligibility (``None``: all allowed), the DagCache + (``None``: a private cache per worker) and the outputs of ``capacity()`` and + ``batch_capacity()``.""" + + def __init__( + self, + config: FlowPolicyConfig, + *, + elig: Eligibility | None = None, + cache: DagCache | None = None, + outputs: OutputOptions | None = None, + ) -> None: ... + @property + def config(self) -> FlowPolicyConfig: ... + @property + def elig(self) -> Eligibility | None: ... + @property + def cache(self) -> DagCache | None: ... + @property + def outputs(self) -> OutputOptions: ... + + +class SensitivityOptions: + """The configuration, Eligibility, DagCache and baseline outputs of ``sensitivity()``.""" + + def __init__( + self, + config: FlowPolicyConfig, + *, + elig: Eligibility | None = None, + cache: DagCache | None = None, + outputs: OutputOptions | None = None, + ) -> None: ... + @property + def config(self) -> FlowPolicyConfig: ... + @property + def elig(self) -> Eligibility | None: ... + @property + def cache(self) -> DagCache | None: ... + @property + def outputs(self) -> OutputOptions: ... + + +class SensitivityStatus: + """``EVALUATED`` (loss = baseline minus the capacity without the edge) or + ``UNEVALUABLE`` (the deletion raised a model exception: ``loss`` is ``None`` and + ``reason`` the message).""" + + EVALUATED: ClassVar["SensitivityStatus"] + UNEVALUABLE: ClassVar["SensitivityStatus"] + __members__: ClassVar[dict[str, "SensitivityStatus"]] + + def __init__(self, value: int) -> None: ... + def __int__(self) -> int: ... + def __index__(self) -> int: ... + @property + def name(self) -> str: ... + @property + def value(self) -> int: ... + + +class SensitivityEntry: + """One candidate edge: ``edge``, ``loss`` (signed units, never thresholded; ``None`` + when unevaluable), ``status``, ``reason`` (``None`` unless unevaluable).""" + + @property + def edge(self) -> int: ... + @property + def loss(self) -> int | None: ... + @property + def status(self) -> SensitivityStatus: ... + @property + def reason(self) -> str | None: ... + + +class SensitivityResult: + """``baseline`` (the PlacementResult of ``capacity()``) and ``entries`` (ascending + EdgeId; an absent edge has loss 0).""" + + @property + def baseline(self) -> PlacementResult: ... + @property + def entries(self) -> list[SensitivityEntry]: ... + + +def capacity( + graph: Graph, + sources: "int | Sequence[int]", + destinations: "int | Sequence[int]", + opts: CapacityOptions, +) -> PlacementResult: + """``place(+inf)`` of a FlowPolicy with ``opts.config`` on a fresh ledger of the graph. + + The exact maximum flow is the Global + MaxFlow configuration (the minimum-cost maximum + flow; ``cost_distribution`` its marginal curve). A failed call raises and returns no + result; a supplied building cache keeps the entries and statistics of the lookups that + succeeded before the failure (a memo: APPROACH 13, limit 2). + """ + ... + + +def batch_capacity( + graph: Graph, + pairs: "Sequence[tuple[int | Sequence[int], int | Sequence[int]]]", + opts: CapacityOptions, + per_pair_elig: "Sequence[Eligibility | None] | None" = None, + workers: int | None = None, +) -> list[PlacementResult]: + """``capacity()`` per ``(sources, destinations)`` pair, in pair order. + + ``opts.elig`` is intersected with ``per_pair_elig[i]`` (``None`` entries allowed; a given + sequence has one entry per pair, also when it is empty). Everything is validated before a + worker starts: the arguments first (workers, options, the configuration: Pinned is + rejected; the ``per_pair_elig`` length), then each pair completely in pair order (its + endpoints, their overlap, its Eligibility), so the error of the lowest failing pair index + is raised; an empty batch returns ``[]``. ``workers``: an explicit int >= 1 is honoured; ``None`` takes + ``NGRAPH_CORE_BATCH_THREADS`` (or 1), and 1 when a cache is supplied (a supplied + building cache is rejected unless ``workers == 1``). A failed call raises and returns + no result; a supplied building cache keeps the entries and statistics of the lookups + that succeeded before the failure (a memo: APPROACH 13, limit 2). + """ + ... + + +def sensitivity( + graph: Graph, + sources: "int | Sequence[int]", + destinations: "int | Sequence[int]", + opts: SensitivityOptions, + workers: int | None = None, +) -> SensitivityResult: + """The baseline ``capacity()`` and one deletion per candidate edge. + + Candidates: under Global the baseline's positive-flow edges; under every other model + every edge ``opts.elig`` allows with at least 1 unit of static capacity. A loss is the + baseline's placed minus ``capacity()`` with the edge deleted (``opts.elig`` minus the + edge, the same configuration and cache), in signed units, never thresholded. Global + evaluates it by the flow core's deletion re-solve from the baseline solve's final state + (that value exactly; a failure there raises); CostOnly gives an edge outside every roster + source's reachable part of the static support loss 0 without a placement, a + result-equivalent predicate; every other deletion is placed on a fresh ledger, where a + model exception makes the entry ``UNEVALUABLE`` with the message as reason. ``workers`` as ``batch_capacity`` (``NGRAPH_CORE_SENSITIVITY_THREADS``). + A failed call raises and returns no result; a supplied building cache keeps the + entries and statistics of the lookups that succeeded before the failure (a memo: + APPROACH 13, limit 2). + """ + ... diff --git a/src/dag_cache.cpp b/src/dag_cache.cpp new file mode 100644 index 0000000..aa62cc6 --- /dev/null +++ b/src/dag_cache.cpp @@ -0,0 +1,76 @@ +/* + The DagCache of APPROACH 10 in building mode: a map from (roots, direction, Eligibility uid) + to the full static support of that search, with atomic statistics. +*/ +#include "netgraph/core/dag_cache.hpp" + +#include +#include +#include + +#include "netgraph/core/detail/endpoints.hpp" +#include "netgraph/core/detail/enum_domain.hpp" +#include "netgraph/core/detail/pred_dag_builder.hpp" +#include "netgraph/core/detail/search.hpp" + +namespace netgraph::core { + +namespace { + +// Decrements the live-value counter when the last holder of a value lets it go. +struct LiveDeleter { + std::shared_ptr> live; + void operator()(const PredDAG* dag) const noexcept { + live->fetch_sub(1, std::memory_order_relaxed); + delete dag; + } +}; + +} // namespace + +DagCache::DagCache(const Graph& graph) : graph_uid_(graph.uid()) {} + +std::shared_ptr DagCache::lookup(const Graph& g, std::span roots, + Direction direction, const Eligibility* elig) { + // Invalid queries throw before anything is counted. + if (g.uid() != graph_uid_) throw std::invalid_argument("DagCache: the graph is not the cache's graph (foreign graph)"); + if (elig != nullptr && elig->graph_uid() != g.uid()) { + throw std::invalid_argument("foreign Eligibility: built for another graph"); + } + auto key_roots = detail::node_set(roots, g.num_nodes(), "roots"); + detail::check_member(direction, "direction"); + Key key {std::move(key_roots), direction, elig != nullptr ? elig->uid() : 0}; + const auto it = entries_.find(key); + if (it != entries_.end()) { + hits_.fetch_add(1, std::memory_order_relaxed); + return it->second; + } + misses_.fetch_add(1, std::memory_order_relaxed); + // A failed search (or a failed insertion) inserts nothing; the miss stays counted. + auto value = std::make_unique(detail::static_support(g, std::get<0>(key), direction, elig)); + const auto bytes = static_cast(detail::PredDAGBuilder::payload_bytes(*value)); + live_->fetch_add(1, std::memory_order_relaxed); // the deleter runs even if the control block fails + std::shared_ptr shared(value.release(), LiveDeleter {live_}); + entries_.emplace(std::move(key), shared); // strong guarantee: a throw leaves the map unchanged + num_entries_.fetch_add(1, std::memory_order_relaxed); + bytes_.fetch_add(bytes, std::memory_order_relaxed); + return shared; +} + +void DagCache::clear() noexcept { + entries_.clear(); + num_entries_.store(0, std::memory_order_relaxed); + bytes_.store(0, std::memory_order_relaxed); +} + +DagCache::Stats DagCache::stats() const noexcept { + Stats s; + s.hits = hits_.load(std::memory_order_relaxed); + s.misses = misses_.load(std::memory_order_relaxed); + s.entries = num_entries_.load(std::memory_order_relaxed); + s.bytes = bytes_.load(std::memory_order_relaxed); + s.live_values = live_->load(std::memory_order_relaxed); + return s; +} + +} // namespace netgraph::core diff --git a/src/eligibility.cpp b/src/eligibility.cpp new file mode 100644 index 0000000..25e1765 --- /dev/null +++ b/src/eligibility.cpp @@ -0,0 +1,81 @@ +/* + Eligibility: the static node and edge flags of one graph (APPROACH 4), and its one public + construction Graph::eligibility(excluded_nodes, excluded_edges). An excluded node excludes its + incident edges (its out-edge id range and its reverse-CSR in-edges); intersect keeps an + operand, and its uid, when the result equals it. +*/ +#include "netgraph/core/eligibility.hpp" + +#include +#include +#include +#include + +#include "netgraph/core/detail/uid.hpp" +#include "netgraph/core/graph.hpp" + +namespace netgraph::core { + +namespace { + +using detail::next_uid; + +} // namespace + +Eligibility Graph::eligibility(std::span excluded_nodes, + std::span excluded_edges) const { + const auto n = static_cast(num_nodes_); + const auto m = capacity_.size(); + for (NodeId v : excluded_nodes) { + if (v < 0 || static_cast(v) >= n) { + throw std::invalid_argument("excluded node " + std::to_string(v) + " out of node range [0, " + + std::to_string(n) + ")"); + } + } + for (EdgeId e : excluded_edges) { + if (e < 0 || static_cast(e) >= m) { + throw std::invalid_argument("excluded edge " + std::to_string(e) + " out of edge range [0, " + + std::to_string(m) + ")"); + } + } + Eligibility el; + el.graph_uid_ = uid_; + el.node_allowed_.assign(n, 1); + el.edge_allowed_.assign(m, 1); + for (EdgeId e : excluded_edges) el.edge_allowed_[static_cast(e)] = 0; + for (NodeId v : excluded_nodes) { + const auto vi = static_cast(v); + if (el.node_allowed_[vi] == 0) continue; // duplicate + el.node_allowed_[vi] = 0; + for (auto e = row_offsets_[vi]; e < row_offsets_[vi + 1]; ++e) { + el.edge_allowed_[static_cast(e)] = 0; + } + for (auto s = in_row_offsets_[vi]; s < in_row_offsets_[vi + 1]; ++s) { + el.edge_allowed_[static_cast(in_adj_edge_index_[static_cast(s)])] = 0; + } + } + el.uid_ = (excluded_nodes.empty() && excluded_edges.empty()) ? 0 : next_uid(); + return el; +} + +Eligibility Eligibility::intersect(const Eligibility& a, const Eligibility& b) { + if (a.graph_uid_ != b.graph_uid_) { + throw std::invalid_argument("Eligibility.intersect: the two values belong to different graphs"); + } + Eligibility out; + out.graph_uid_ = a.graph_uid_; + out.node_allowed_.resize(a.node_allowed_.size()); + out.edge_allowed_.resize(a.edge_allowed_.size()); + for (std::size_t v = 0; v < out.node_allowed_.size(); ++v) { + out.node_allowed_[v] = static_cast(a.node_allowed_[v] & b.node_allowed_[v]); + } + for (std::size_t e = 0; e < out.edge_allowed_.size(); ++e) { + out.edge_allowed_[e] = static_cast(a.edge_allowed_[e] & b.edge_allowed_[e]); + } + if (out.node_allowed_ == a.node_allowed_ && out.edge_allowed_ == a.edge_allowed_) return a; + if (out.node_allowed_ == b.node_allowed_ && out.edge_allowed_ == b.edge_allowed_) return b; + out.uid_ = next_uid(); + return out; +} + +} // namespace netgraph::core diff --git a/src/evaluators.cpp b/src/evaluators.cpp new file mode 100644 index 0000000..f424f28 --- /dev/null +++ b/src/evaluators.cpp @@ -0,0 +1,315 @@ +/* + The evaluators of APPROACH 11: capacity() is place(+inf) of a FlowPolicy on a fresh ledger; + batch_capacity() runs it per pair and sensitivity() per deleted candidate edge, on `workers` + threads (results in input order; the lowest index's exception wins). Sensitivity evaluates a + Global deletion by the flow core's deletion re-solve from the baseline solve's final state + (APPROACH 2.2) and gives a CostOnly edge outside the baseline's routes loss 0 without a + placement (a result-equivalent predicate); every other deletion is placed on a fresh ledger. +*/ +#include "netgraph/core/evaluators.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/detail/endpoints.hpp" +#include "netgraph/core/detail/evaluator_checks.hpp" +#include "netgraph/core/detail/flow_core.hpp" +#include "netgraph/core/detail/policy_engine.hpp" +#include "netgraph/core/detail/pred_dag_builder.hpp" +#include "netgraph/core/detail/private_hooks.hpp" + +namespace netgraph::core { + +namespace { + +// A ledger of g for one evaluation: the graph is borrowed (the ledger dies within the call). +std::shared_ptr borrowed(const Graph& g) { + return std::shared_ptr(std::shared_ptr {}, &g); +} + +PlacementResult evaluate(const Graph& g, std::span sources, + std::span destinations, const FlowPolicyConfig& cfg, const Eligibility* elig, + DagCache* cache, const OutputOptions& outputs) { + FlowLedger fg(borrowed(g)); + FlowPolicy policy(fg, sources, destinations, cfg, elig, cache); + return policy.place(std::nullopt, outputs); +} + +void check_common(const Graph& g, const Eligibility* elig, const DagCache* cache, std::int32_t workers) { + if (workers < 1) throw std::invalid_argument("workers must be >= 1 (got " + std::to_string(workers) + ")"); + if (elig != nullptr && elig->graph_uid() != g.uid()) { + throw std::invalid_argument("foreign Eligibility: built for another graph"); + } + if (cache != nullptr && cache->graph_uid() != g.uid()) { + throw std::invalid_argument("foreign DagCache: bound to another graph"); + } + if (cache != nullptr && workers != 1) { + throw std::invalid_argument( + "a supplied building DagCache has one writer: it is rejected unless workers == 1 (got workers = " + + std::to_string(workers) + ")"); + } +} + +// The number of threads run_indexed uses for n tasks. +std::size_t worker_threads(std::size_t n, std::int32_t workers) { + return std::max(1, static_cast(std::min(workers, static_cast(n)))); +} + +// Runs task(i, cache, t) for i in [0, n) on worker_threads(n, workers) threads (t the worker's +// index), each with its own private building cache unless `shared` is given (workers == 1 +// then). Indices are taken in increasing +// order and a taken index always runs, so after an exception every lower index has run: the +// lowest failing index is the one a sequential run would report, and its exception is rethrown. +// Everything a worker needs (the result slots, the private caches) is allocated here, before +// any thread starts, so a failure throws in the caller; the worker body cannot throw (an +// exception escaping a std::thread terminates the process): a task's exception is caught into +// its slot, and every launched thread is joined before anything is rethrown. +template +void run_indexed(std::size_t n, std::int32_t workers, const Graph& g, DagCache* shared, Task task) { + if (n == 0) return; + const auto threads = worker_threads(n, workers); + std::vector errors(n); + std::vector> own; + if (shared == nullptr) { + own.reserve(threads); + for (std::size_t t = 0; t < threads; ++t) own.push_back(std::make_unique(g)); + } + std::atomic next {0}; + std::atomic abort {false}; + auto worker = [&](std::size_t t) noexcept { + DagCache& cache = shared != nullptr ? *shared : *own[t]; + while (!abort.load(std::memory_order_relaxed)) { + const std::size_t i = next.fetch_add(1, std::memory_order_relaxed); + if (i >= n) break; + try { + task(i, cache, t); + } catch (...) { + errors[i] = std::current_exception(); + abort.store(true, std::memory_order_relaxed); + } + } + }; + if (threads == 1) { + worker(0); + } else { + std::vector pool; + pool.reserve(threads); + try { + for (std::size_t t = 0; t < threads; ++t) pool.emplace_back(worker, t); + } catch (...) { + abort.store(true); + for (auto& th : pool) th.join(); + throw; + } + for (auto& th : pool) th.join(); + } + for (const auto& e : errors) { + if (e) std::rethrow_exception(e); + } +} + +// CostOnly sensitivity: the edges of the baseline's routes. A source's route is read from the +// part of the full static support reachable from it (projected by the selection on the values +// of those edges; on a fresh ledger the creation view is the capacity). Deleting an edge +// outside every such part leaves every roster source's distance and reachable support as they +// were (distances only grow, and no shortest path of a reachable node used the edge), so every +// route, and the placement over the same capacities, is unchanged: the loss is exactly 0 and +// the deletion is not re-evaluated. +std::vector reachable_support(const Graph& g, const detail::PolicyEngine& pe) { + const auto num_edges = static_cast(g.num_edges()); + std::vector in_support(num_edges, 0); + for (EdgeId e : detail::PredDAGBuilder::ascending(*pe.support)) in_support[static_cast(e)] = 1; + std::vector reached(num_edges, 0); + std::vector seen(static_cast(g.num_nodes()), 0); + const auto row = g.row_offsets(); + const auto edge_dst = g.edge_dst(); + std::vector stack(pe.roster.begin(), pe.roster.end()); + for (NodeId s : stack) seen[static_cast(s)] = 1; + while (!stack.empty()) { + const auto u = static_cast(stack.back()); + stack.pop_back(); + for (auto e = static_cast(row[u]); e < static_cast(row[u + 1]); ++e) { + if (in_support[e] == 0) continue; + reached[e] = 1; + const auto v = static_cast(edge_dst[e]); + if (seen[v] == 0) { + seen[v] = 1; + stack.push_back(static_cast(v)); + } + } + } + return reached; +} + +} // namespace + +namespace detail { + +void check_batch_arguments(const Graph& g, const CapacityOptions& opts, std::int32_t workers) { + check_common(g, opts.elig, opts.cache, workers); + if (std::holds_alternative(opts.cfg.routing)) { + throw std::invalid_argument("Pinned routing is rejected in batch_capacity (bundles bind their endpoints)"); + } + validate_config(opts.cfg, 2, 2); // Pinned (the endpoint-count rule) is rejected above +} + +void check_batch_pair(const Graph& g, std::span sources, + std::span destinations, const Eligibility* elig, std::size_t index) { + const auto s = node_set(sources, g.num_nodes(), "sources"); + const auto t = node_set(destinations, g.num_nodes(), "destinations"); + check_disjoint(s, t, "sources", "destinations"); + if (elig != nullptr && elig->graph_uid() != g.uid()) { + throw std::invalid_argument("foreign Eligibility: per_pair_elig[" + std::to_string(index) + + "] built for another graph"); + } +} + +std::int32_t default_workers(bool sensitivity) noexcept { + const char* v = sensitivity ? std::getenv("NGRAPH_CORE_SENSITIVITY_THREADS") : std::getenv("NGRAPH_CORE_BATCH_THREADS"); + if (v == nullptr) return 1; + char* end = nullptr; + const long n = std::strtol(v, &end, 10); + if (end == v || *end != '\0' || n < 1 || n > 4096) return 1; + return static_cast(n); +} + +} // namespace detail + +PlacementResult capacity(const Graph& g, std::span sources, + std::span destinations, const CapacityOptions& opts) { + return evaluate(g, sources, destinations, opts.cfg, opts.elig, opts.cache, opts.outputs); +} + +std::vector batch_capacity( + const Graph& g, const std::vector, std::vector>>& pairs, + const CapacityOptions& opts, std::span per_pair_elig, std::int32_t workers) { + detail::check_batch_arguments(g, opts, workers); + if (!per_pair_elig.empty() && per_pair_elig.size() != pairs.size()) { + throw std::invalid_argument("per_pair_elig has " + std::to_string(per_pair_elig.size()) + " entries for " + + std::to_string(pairs.size()) + " pairs"); + } + for (std::size_t i = 0; i < pairs.size(); ++i) { + detail::check_batch_pair(g, pairs[i].first, pairs[i].second, per_pair_elig.empty() ? nullptr : per_pair_elig[i], i); + } + std::vector> results(pairs.size()); + run_indexed(pairs.size(), workers, g, opts.cache, [&](std::size_t i, DagCache& cache, std::size_t /*t*/) { + const Eligibility* extra = per_pair_elig.empty() ? nullptr : per_pair_elig[i]; + std::optional both; + const Eligibility* elig = opts.elig; + if (extra != nullptr) { + if (elig == nullptr) { + elig = extra; + } else { + both = Eligibility::intersect(*opts.elig, *extra); + elig = &*both; + } + } + results[i] = evaluate(g, pairs[i].first, pairs[i].second, opts.cfg, elig, &cache, opts.outputs); + }); + std::vector out; + out.reserve(results.size()); + for (auto& r : results) out.push_back(std::move(*r)); + return out; +} + +SensitivityResult sensitivity(const Graph& g, std::span sources, + std::span destinations, const SensitivityOptions& opts, + std::int32_t workers) { + check_common(g, opts.elig, opts.cache, workers); + const bool global = std::holds_alternative(opts.cfg.routing); + const bool cost_only = std::holds_alternative(opts.cfg.routing); + SensitivityResult out; + std::vector on_route; // CostOnly: the baseline's reachable support + detail::DeletionCapture capture; // Global: the baseline solve's final state + { + std::unique_ptr own; + DagCache* cache = opts.cache; + if (cache == nullptr) { + own = std::make_unique(g); + cache = own.get(); + } + FlowLedger fg(borrowed(g)); + FlowPolicy policy(fg, sources, destinations, opts.cfg, opts.elig, cache); + auto& pe = detail::PolicyAccess::engine(policy); + if (global) pe.capture = &capture; + out.baseline = policy.place(std::nullopt, opts.outputs); + pe.capture = nullptr; + if (cost_only) on_route = reachable_support(g, pe); + } + std::vector candidates; + if (global) { + // The baseline's positive-flow edges; no state, and no candidate, when no roster source + // reaches an allowed destination (nothing is placed). + if (capture.state.has_value()) { + if (capture.state->value() != out.baseline.placed) { + throw std::logic_error("sensitivity: the captured state differs from the baseline"); + } + const auto flows = capture.state->edge_flows(); + for (EdgeId e = 0; e < g.num_edges(); ++e) { + if (flows[static_cast(e)] > 0) candidates.push_back(e); + } + } + } else { + const auto capacity_units = g.capacity_units(); + for (EdgeId e = 0; e < g.num_edges(); ++e) { + if ((opts.elig == nullptr || opts.elig->edge_ok(e)) && capacity_units[static_cast(e)] >= 1) { + candidates.push_back(e); + } + } + } + const Amount base = out.baseline.placed; + out.entries.resize(candidates.size()); + // Global: one resolver per worker (its copy of the state and its working storage), made here. + std::vector resolvers; + if (global && !candidates.empty()) { + const auto threads = worker_threads(candidates.size(), workers); + resolvers.reserve(threads); + for (std::size_t t = 0; t < threads; ++t) resolvers.emplace_back(*capture.state); + } + run_indexed(candidates.size(), workers, g, opts.cache, [&](std::size_t i, DagCache& cache, std::size_t t) { + const EdgeId e = candidates[i]; + auto& entry = out.entries[i]; + entry.edge = e; + if (global) { + // An exact deletion on valid inputs always has a feasible optimum: a failure propagates. + entry.loss = base - resolvers[t].resolve(e).value; + entry.status = SensitivityStatus::Evaluated; + return; + } + try { +#if NETGRAPH_CORE_PRIVATE_HOOKS + if (detail::sensitivity_fail_edge().load(std::memory_order_relaxed) == e) { + throw std::runtime_error("injected model exception for the deletion of edge " + std::to_string(e) + + " (test hook)"); + } +#endif + if (cost_only && on_route[static_cast(e)] == 0) { + entry.loss = 0; + } else { + const EdgeId one[1] = {e}; + Eligibility deleted = g.eligibility({}, one); + if (opts.elig != nullptr) deleted = Eligibility::intersect(*opts.elig, deleted); + const auto r = evaluate(g, sources, destinations, opts.cfg, &deleted, &cache, OutputOptions {}); + entry.loss = base - r.placed; + } + entry.status = SensitivityStatus::Evaluated; + } catch (const std::runtime_error& ex) { + entry.loss = std::nullopt; + entry.status = SensitivityStatus::Unevaluable; + entry.reason = ex.what(); + } + if (&cache != opts.cache) cache.clear(); + }); + return out; +} + +} // namespace netgraph::core diff --git a/src/flow_core.cpp b/src/flow_core.cpp new file mode 100644 index 0000000..462f4b5 --- /dev/null +++ b/src/flow_core.cpp @@ -0,0 +1,880 @@ +/* + The one integer minimum-cost flow core (APPROACH 2.2). + + One objective on every view: minimum cost for the maximum value attainable under the value + cap. Successive shortest paths with potentials and blocking flows. Each tier runs Dijkstra on + reduced costs cost(e) + pi(u) - pi(v) to completion over the residual network reachable from + the virtual source s* (through the virtual sink t* and onward), adds the distances to the + potentials of the vertices it settled, then drains the zero-reduced-cost admissible subgraph + by Dinic phases (BFS levels, DFS with current-arc pointers) until no admissible augmenting + path remains or the cap is reached; then the next tier. Positive original costs do not + exclude zero-reduced-cost residual cycles, hence the repeated phases. The solve ends when a + Dijkstra does not reach t* or the cap is reached; there is no completion phase. + + Arc identity: every eligible edge is its own arc pair (forward with the view capacity, + reverse with the flow). At a node the forward arcs of its out-edges and the reverse arcs of + its in-edges are visited in one sequence by ascending EdgeId (a self-loop's forward arc before + its reverse arc); at a real endpoint its virtual arc comes first; s* visits its arcs by + ascending source NodeId and t* by ascending destination NodeId. Virtual arcs cost 0 and carry + 2^63 - 2 units, above every attainable value. Every solve starts from zero flow, so the zero + potentials are valid (all costs are positive). + + The deletion re-solve (the exact sensitivity model, APPROACH 11) continues from a solve's + final state instead. A solve asked to capture its state also records, as a shadow that + changes none of its searches, the working potentials: the running sum of the tier maxima D_k + and, per settled vertex, that sum at its last settlement; the working potential is the + published one plus the difference (a never-settled vertex: the whole sum, from numeric zero). + They equal the update of every vertex after every Dijkstra, the reached ones by their distance + and the others by D_k, so the reduced cost is non-negative on every positive residual arc, + real or virtual. The re-solve deletes a real arc e = (u, v) with flow f_e > 0 from a copy of + the state and runs three fixed-origin repairs, each a sequence of tiers as above from its + origin to its target, with that all-vertex update before the Dinic phases: u -> v for f_e, + moving m; when r = f_e - m > 0, u -> s* for r, whose first tier reuses repair 1's failed + search (nothing changed since), then t* -> v for r. The result is the minimum-cost flow of the + maximum value placed - r on the view without e, certified by the working potentials and the + real sources' residual closure; at most m + 2 r Dijkstra runs. + + Dijkstra's queue is a radix heap: keys pop in increasing order and entries with equal keys pop + in a fixed order (last pushed first within the lowest bucket), so the settle order, and with it + every counter, is a function of the input alone. Equal-key order affects only the counters, + never a distance, a potential or an augmenting path. The solve runs to the cap or to + disconnection; there is no clamp of the cap (APPROACH 2.2, revision 25). + + Arithmetic. A solve from zero stays in int64: every true residual distance is the cost of a + simple residual path, below the cost sum bound 2^62; potentials are such distances; a path's + bottleneck contains a real arc (sources and destinations are disjoint), so every amount is + below 2^62. The deletion mode's working potentials, distance keys, tentative distances and + sums of tier maxima are signed 128-bit (I128; reachability kept apart from the value by a + stamp): they stay within (n + 1) C at capture and (22 n + 15) C across the repairs for n + vertices and the cost sum C < 2^62, and its heap takes 128-bit keys. Amounts stay int64. +*/ +#include "netgraph/core/detail/flow_core.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/detail/endpoints.hpp" +#include "netgraph/core/detail/node_map.hpp" + +namespace netgraph::core::detail { + +namespace { + +constexpr Cost kInfDist = std::numeric_limits::max(); +constexpr Amount kUncapped = std::numeric_limits::max(); + +// The bucket of a key: the number of the highest bit in which it differs from the last popped +// key (keys are non-negative). +std::size_t bucket_of(Cost key, Cost last) noexcept { + return static_cast(std::bit_width(static_cast(key) ^ static_cast(last))); +} +std::size_t bucket_of(I128 key, I128 last) noexcept { + return static_cast(bits(U128 {static_cast(key.hi ^ last.hi), key.lo ^ last.lo})); +} + +// Monotone priority queue for Dijkstra on non-negative integer keys (a radix heap): a key goes +// to the bucket of the highest bit in which it differs from the last popped key, and a pop +// redistributes the first non-empty bucket below its minimum. Entries are (key, vertex); equal +// keys pop in a fixed order, so every run is a function of its input. Keys are int64 (a solve +// from zero) or I128 (the deletion mode). +template +class RadixHeap { +public: + void clear() { + for (auto& b : buckets_) b.clear(); + last_ = 0; + size_ = 0; + } + [[nodiscard]] bool empty() const noexcept { return size_ == 0; } + void push(Key key, std::int32_t v) { + buckets_[bucket_of(key, last_)].emplace_back(key, v); + ++size_; + } + std::pair pop() { + if (buckets_[0].empty()) { + std::size_t i = 1; + while (buckets_[i].empty()) ++i; + auto& from = buckets_[i]; + Key lowest = from.front().first; + for (const auto& entry : from) lowest = std::min(lowest, entry.first); + last_ = lowest; + for (const auto& entry : from) buckets_[bucket_of(entry.first, last_)].push_back(entry); + from.clear(); + } + const auto top = buckets_[0].back(); + buckets_[0].pop_back(); + --size_; + return top; + } + +private: + std::array>, 8 * sizeof(Key) + 1> buckets_ {}; + Key last_ {0}; + std::size_t size_ {0}; +}; + +template +inline constexpr bool kWide = std::is_same_v; + +} // namespace + +// The residual network of one solve (Network: real vertices 0..N-1, s* = N, t* = N + 1) with +// its flow, potentials and scratch; also the working storage of a deletion re-solve. +class FlowSolver { +public: + // One reusable instance per thread (`local()`) for the solves from zero: build() resizes every + // buffer for the input, keeping capacity across calls, so repeated solves do not allocate + // once warm. Not reentrant (a solve never starts another). A deletion resolver owns its own. + static FlowSolver& local() { + thread_local FlowSolver solver; + return solver; + } + + // The input part of the network (num_nodes, the edges, the endpoint sets) is the caller's to + // fill; build() adds the arcs. + Network& network() noexcept { return net_; } + + // The arcs of the input, zero flow and zero potentials. `shadow`: record the working + // potentials for a capture. + void build(bool shadow) { + auto& in = net_; + size(in.num_nodes); + const auto n = static_cast(n_); + in.off.assign(n + 1, 0); + auto deg = [&](std::int32_t v) { ++in.off[static_cast(v) + 1]; }; + for (std::size_t k = 0; k < in.edges.size(); ++k) { + deg(in.tail[k]); + deg(in.head[k]); + } + for (std::int32_t s : in.sources) { deg(s); deg(s_); } + for (std::int32_t d : in.destinations) { deg(d); deg(t_); } + for (std::size_t v = 0; v < n; ++v) in.off[v + 1] += in.off[v]; + const auto num_slots = static_cast(in.off[n]); + in.arc_head.resize(num_slots); + in.rev.resize(num_slots); + in.arc_cost.resize(num_slots); + res_.resize(num_slots); + pos_.assign(in.off.begin(), in.off.end() - 1); + auto take = [&](std::int32_t v) { return pos_[static_cast(v)]++; }; + auto link = [&](std::int32_t a, std::int32_t to_a, Amount cap, Cost w, std::int32_t b, std::int32_t to_b) { + const auto ai = static_cast(a); + const auto bi = static_cast(b); + in.arc_head[ai] = to_a; + in.rev[ai] = b; + in.arc_cost[ai] = w; + res_[ai] = cap; + in.arc_head[bi] = to_b; + in.rev[bi] = a; + in.arc_cost[bi] = -w; + res_[bi] = 0; + }; + // Virtual arcs first at the real endpoints, then the real arcs by ascending EdgeId (forward + // slot at the tail, reverse slot at the head), then the slots of s* and t*. + in.src_rev.resize(in.sources.size()); + in.dst_fwd.resize(in.destinations.size()); + for (std::size_t i = 0; i < in.sources.size(); ++i) in.src_rev[i] = take(in.sources[i]); + for (std::size_t i = 0; i < in.destinations.size(); ++i) in.dst_fwd[i] = take(in.destinations[i]); + in.edge_slot.resize(in.edges.size()); + for (std::size_t k = 0; k < in.edges.size(); ++k) { + const std::int32_t u = in.tail[k]; + const std::int32_t v = in.head[k]; + const std::int32_t fwd = take(u); + const std::int32_t bwd = take(v); + link(fwd, v, in.cap[k], in.cost[k], bwd, u); + in.edge_slot[k] = fwd; + } + for (std::size_t i = 0; i < in.sources.size(); ++i) { + link(take(s_), in.sources[i], kMaxFlowCap, 0, in.src_rev[i], s_); + } + for (std::size_t i = 0; i < in.destinations.size(); ++i) { + link(in.dst_fwd[i], t_, kMaxFlowCap, 0, take(t_), in.destinations[i]); + } + pi_.assign(n, 0); + ever_.assign(n, 0); + dist_.resize(n); + shadow_ = shadow; + if (shadow) { + tier_sum_ = 0; + checkpoint_.assign(n, I128 {}); + } + } + + // A captured state's network, as the working storage of its deletion re-solves. + void load(const Network& net) { + net_ = net; + size(net_.num_nodes); + res_.resize(net_.arc_head.size()); + wpi_.resize(static_cast(n_)); + wdist_.resize(static_cast(n_)); + wseen_.assign(static_cast(n_), 0); + wepoch_ = 0; + } + + // The solve to the cap or to disconnection. Output in local coordinates: `flows` aligned with + // the input edges, `cut` as input edge indices (positive capacity, from the full residual + // closure of the real sources to its complement, ascending EdgeId) when the destinations are + // outside the closure, potentials per local vertex (kNeverReached when never settled). + struct Output { + Amount placed {0}; + std::vector> cost_distribution {}; + std::vector flows {}; + std::optional> cut {}; + std::vector potentials {}; + FlowCoreCounters counters {}; + }; + + // `closure` adds the cut; `potentials` the per-vertex potentials (the edge-subset entry + // needs neither). + Output run(Amount limit, bool closure, bool potentials = true) { + Output out; + auto& c = out.counters; + Amount total = 0; + origin_ = s_; + target_ = t_; + while (total < limit) { + const bool reached = dijkstra(c); + settle(); + if (!reached) break; + const Cost tier_cost = pi_[static_cast(t_)] - pi_[static_cast(s_)]; + const Amount before = total; + total = drain(c, limit, total, [](Amount) {}); + // A successful Dijkstra reaches t* over arcs with positive residual and zero reduced cost + // along its shortest paths, so the drain moves at least one unit. + if (total == before) { + throw std::logic_error("flow core: a successful Dijkstra drained nothing"); + } + out.cost_distribution.emplace_back(tier_cost, total - before); + } + out.placed = total; + + out.flows.resize(net_.edges.size()); + for (std::size_t k = 0; k < net_.edges.size(); ++k) out.flows[k] = flow_of(k); + if (closure && close_sources()) out.cut = cut_edges(-1); + if (potentials) { + out.potentials.resize(static_cast(net_.num_nodes)); + for (std::size_t v = 0; v < out.potentials.size(); ++v) { + out.potentials[v] = ever_[v] != 0 ? pi_[v] : kNeverReached; + } + } + return out; + } + + // The final state of the last solve (its value and counters given) into `req`. + void capture(DeletionCapture& req, Amount placed, const FlowCoreCounters& counters) { + DeletionState state; + state.graph_ = req.graph; + state.net_ = std::make_shared(net_); + state.res_ = res_; + state.pi_.resize(static_cast(n_)); + for (std::size_t v = 0; v < state.pi_.size(); ++v) state.pi_[v] = I128 {pi_[v]} + (tier_sum_ - checkpoint_[v]); + state.value_ = placed; + state.maximum_ = close_sources(); + state.counters_ = counters; + req.state = std::move(state); + } + +#if NETGRAPH_CORE_PRIVATE_HOOKS + void observe(const std::function)>* on_search) noexcept { on_search_ = on_search; } + // The tests' probe of the re-solves until reset (nullptr: none). + void probe(const DeletionProbe* p) noexcept { probe_ = p; } +#endif + + // The deletion re-solve of input edge k (a real arc with positive flow) from `state`, whose + // network this solver holds (load()). + DeletionResult resolve(const DeletionState& state, std::size_t k) { + std::copy(state.residual().begin(), state.residual().end(), res_.begin()); + std::copy(state.potentials().begin(), state.potentials().end(), wpi_.begin()); + DeletionResult out; + auto& c = out.counters; + const auto fwd = static_cast(net_.edge_slot[k]); + const auto bwd = static_cast(net_.rev[fwd]); + const Amount f = res_[bwd]; + const std::int32_t u = net_.tail[k]; + const std::int32_t v = net_.head[k]; + // The deletion: a pseudo-flow with an excess of f at u and a deficit of f at v, its residual + // the state's without e's two arcs. + res_[fwd] = 0; + res_[bwd] = 0; + emit(0, false, 0, c); + const Amount m = repair(1, u, v, f, false, c); + const Amount r = f - m; + if (r > 0) { + bool reuse = true; +#if NETGRAPH_CORE_PRIVATE_HOOKS + if (probe_ != nullptr && probe_->rerun_failed_search) reuse = false; +#endif + if (repair(2, u, s_, r, reuse, c) != r) { + throw std::logic_error("deletion re-solve: the remainder has no residual path back to the sources"); + } + if (repair(3, t_, v, r, false, c) != r) { + throw std::logic_error("deletion re-solve: the deficit has no residual path from the destinations"); + } + } + emit(4, false, 0, c); + if (!close_sources()) { + throw std::logic_error("deletion re-solve: the destinations stay connected to the sources"); + } + out.value = state.value() - r; + out.potentials = wpi_; + for (std::int32_t j : cut_edges(static_cast(k))) out.cut.push_back(net_.edges[static_cast(j)]); + out.edge_flows.assign(static_cast(state.graph().num_edges()), 0); + for (std::size_t j = 0; j < net_.edges.size(); ++j) { + out.edge_flows[static_cast(net_.edges[j])] = j == k ? 0 : flow_of(j); + } + return out; + } + +private: + void size(std::int32_t num_nodes) { + n_ = num_nodes + 2; + s_ = num_nodes; + t_ = num_nodes + 1; + const auto n = static_cast(n_); + level_.resize(n); + reach_.assign(n, 0); + mark_epoch_ = 0; + cur_.resize(n); + } + + // The flow of input edge k: the residual of its reverse slot. + [[nodiscard]] Amount flow_of(std::size_t k) const { + return res_[static_cast(net_.rev[static_cast(net_.edge_slot[k])])]; + } + + template + std::vector

& potentials() { + if constexpr (kWide

) { + return wpi_; + } else { + return pi_; + } + } + template + std::vector

& distances() { + if constexpr (kWide

) { + return wdist_; + } else { + return dist_; + } + } + template + RadixHeap

& heap() { + if constexpr (kWide

) { + return wheap_; + } else { + return heap_; + } + } + // Whether the last search of the mode reached x (int64: a finite distance; I128: the stamp). + template + [[nodiscard]] bool reached(std::int32_t x) const { + const auto xi = static_cast(x); + if constexpr (kWide

) { + return wseen_[xi] == wepoch_; + } else { + return dist_[xi] != kInfDist; + } + } + + // Dijkstra on reduced costs from origin_ to completion over the residual network. Returns + // whether target_ was reached; settled_ lists the vertices it settled. + template + bool dijkstra(FlowCoreCounters& c) { + auto& pi = potentials

(); + auto& dist = distances

(); + auto& queue = heap

(); + ++c.dijkstra_runs; + if constexpr (kWide

) { + if (++wepoch_ == 0) { + std::fill(wseen_.begin(), wseen_.end(), 0); + wepoch_ = 1; + } + } else { + std::fill(dist.begin(), dist.end(), kInfDist); + } + settled_.clear(); + queue.clear(); + const auto o = static_cast(origin_); + dist[o] = P {0}; + if constexpr (kWide

) wseen_[o] = wepoch_; + queue.push(P {0}, origin_); + while (!queue.empty()) { + const auto [du, ui] = queue.pop(); + ++c.dijkstra_pops; + const auto u = static_cast(ui); + if (du != dist[u]) continue; + settled_.push_back(ui); + ++c.settled; + const P base = du + pi[u]; + const auto end = net_.off[u + 1]; + c.arc_scans += end - net_.off[u]; + for (auto p = net_.off[u]; p < end; ++p) { + const auto a = static_cast(p); + if (res_[a] <= 0) continue; + const auto v = static_cast(net_.arc_head[a]); + const P nd = base + net_.arc_cost[a] - pi[v]; + if constexpr (kWide

) { + if (wseen_[v] != wepoch_ || nd < dist[v]) { + wseen_[v] = wepoch_; + dist[v] = nd; + queue.push(nd, static_cast(v)); + } + } else if (nd < dist[v]) { + dist[v] = nd; + queue.push(nd, static_cast(v)); + } + } + } + return reached

(target_); + } + + // The update of a solve from zero: the settled vertices' potentials by their distances; with + // a capture, the running sum of the tier maxima and the settled vertices' checkpoints. + void settle() { + for (std::int32_t v : settled_) { + const auto vi = static_cast(v); + pi_[vi] += dist_[vi]; + ever_[vi] = 1; + } + if (shadow_) { +#if NETGRAPH_CORE_PRIVATE_HOOKS + if (on_search_ != nullptr && *on_search_) (*on_search_)(dist_); +#endif + Cost top = 0; + for (std::int32_t v : settled_) top = std::max(top, dist_[static_cast(v)]); + tier_sum_ += top; + for (std::int32_t v : settled_) checkpoint_[static_cast(v)] = tier_sum_; + } + } + + // The update of the deletion mode: every vertex, the reached ones by their distance and the + // others by the tier's largest finite distance. + void update_all() { + I128 top = 0; + for (std::int32_t v : settled_) top = std::max(top, wdist_[static_cast(v)]); + for (std::size_t v = 0; v < wpi_.size(); ++v) wpi_[v] += wseen_[v] == wepoch_ ? wdist_[v] : top; + } + + // The Dinic phases from origin_ to target_ over the admissible subgraph (positive residual, + // zero reduced cost) until no admissible path remains or `limit` is reached; on_push(total) + // after every push. + template + Amount drain(FlowCoreCounters& c, Amount limit, Amount total, OnPush on_push) { + while (total < limit) { + if (!build_levels

(c)) break; + std::copy(net_.off.begin(), net_.off.end() - 1, cur_.begin()); + while (total < limit) { + if (!find_path

(c)) break; + Amount amount = limit - total; + for (std::int32_t a : path_) amount = std::min(amount, res_[static_cast(a)]); + for (std::int32_t a : path_) { + res_[static_cast(a)] -= amount; + res_[static_cast(net_.rev[static_cast(a)])] += amount; + } + total += amount; + ++c.augmentations; + on_push(total); + } + } + return total; + } + + // BFS levels over admissible arcs (positive residual, zero reduced cost). Counts one phase. + // Only vertices that can reach target_ over admissible arcs are levelled: every vertex on a + // shortest admissible path from origin_ to such a vertex reaches the target too, so their + // levels equal the levels of a full BFS, and the other vertices are dead ends that the DFS + // would only enter and leave. The forward search also stops once the target's level is + // complete. The augmenting paths are therefore the ones a full BFS gives. + template + bool build_levels(FlowCoreCounters& c) { + const auto& pi = potentials

(); + const auto t = static_cast(target_); + // Reverse search from the target: the arc into v from arc_head[p] is the partner of v's slot p. + ++mark_epoch_; + queue_.clear(); + reach_[t] = mark_epoch_; + queue_.push_back(target_); + for (std::size_t qi = 0; qi < queue_.size(); ++qi) { + const auto v = static_cast(queue_[qi]); + const P pv = pi[v]; + const auto end = net_.off[v + 1]; + c.arc_scans += end - net_.off[v]; + for (auto p = net_.off[v]; p < end; ++p) { + const auto a = static_cast(p); + const auto u = static_cast(net_.arc_head[a]); + if (reach_[u] == mark_epoch_) continue; + const auto in = static_cast(net_.rev[a]); + if (res_[in] > 0 && net_.arc_cost[in] + pi[u] == pv) { + reach_[u] = mark_epoch_; + queue_.push_back(static_cast(u)); + } + } + } + std::fill(level_.begin(), level_.end(), -1); + ++c.dinic_phases; + const auto s = static_cast(origin_); + if (reach_[s] != mark_epoch_) return false; + queue_.clear(); + level_[s] = 0; + queue_.push_back(origin_); + for (std::size_t qi = 0; qi < queue_.size(); ++qi) { + const auto u = static_cast(queue_[qi]); + if (level_[t] >= 0 && level_[u] >= level_[t]) break; + const P pu = pi[u]; + const auto next = level_[u] + 1; + const auto end = net_.off[u + 1]; + c.arc_scans += end - net_.off[u]; + for (auto p = net_.off[u]; p < end; ++p) { + const auto a = static_cast(p); + const auto v = static_cast(net_.arc_head[a]); + if (level_[v] < 0 && reach_[v] == mark_epoch_ && res_[a] > 0 && net_.arc_cost[a] + pu == pi[v]) { + level_[v] = next; + queue_.push_back(static_cast(v)); + } + } + } + return level_[t] >= 0; + } + + // One augmenting path origin_ -> target_ in the level graph into path_ (current-arc pointers + // kept: an arc that is pushed through stays current; a dead end advances its parent's pointer). + template + bool find_path(FlowCoreCounters& c) { + const auto& pi = potentials

(); + stack_.clear(); + path_.clear(); + stack_.push_back(origin_); + while (!stack_.empty()) { + const auto u = static_cast(stack_.back()); + if (static_cast(u) == target_) return true; + bool advanced = false; + const auto end = net_.off[u + 1]; + const auto next = level_[u] + 1; + const P pu = pi[u]; + while (cur_[u] < end) { + ++c.arc_scans; + const auto a = static_cast(cur_[u]); + const auto v = static_cast(net_.arc_head[a]); + if (res_[a] > 0 && level_[v] == next && net_.arc_cost[a] + pu == pi[v]) { + stack_.push_back(static_cast(v)); + path_.push_back(static_cast(a)); + advanced = true; + break; + } + ++cur_[u]; + } + if (!advanced) { + stack_.pop_back(); + if (!path_.empty()) { + path_.pop_back(); + ++cur_[static_cast(stack_.back())]; + } + } + } + return false; + } + + // One fixed-origin repair of the deletion re-solve: tiers from `origin` to `target` until + // `amount` is moved or the target is unreachable; with `reuse`, the first tier reuses the + // last search (from the same origin, nothing changed since). Returns the amount moved. + Amount repair(int index, std::int32_t origin, std::int32_t target, Amount amount, bool reuse, FlowCoreCounters& c) { + origin_ = origin; + target_ = target; + Amount moved = 0; + bool search = !reuse; + while (moved < amount) { + if (search) { + const bool hit = dijkstra(c); + update_all(); + emit(index, false, moved, c); + if (!hit) break; + } else if (!reached(target_)) { + throw std::logic_error("deletion re-solve: the reused search did not reach the repair's target"); + } + search = true; + const Amount before = moved; + moved = drain(c, amount, moved, [&](Amount total) { emit(index, true, total, c); }); + if (moved == before) throw std::logic_error("flow core: a successful Dijkstra drained nothing"); + } + return moved; + } + + // The full residual closure of the real sources (reverse and virtual arcs included) into + // seen_. Returns whether t* is outside it. + bool close_sources() { + seen_.assign(static_cast(n_), 0); + queue_.clear(); + for (std::int32_t s : net_.sources) { + seen_[static_cast(s)] = 1; + queue_.push_back(s); + } + for (std::size_t qi = 0; qi < queue_.size(); ++qi) { + const auto u = static_cast(queue_[qi]); + for (auto p = net_.off[u]; p < net_.off[u + 1]; ++p) { + const auto a = static_cast(p); + const auto v = static_cast(net_.arc_head[a]); + if (res_[a] > 0 && seen_[v] == 0) { + seen_[v] = 1; + queue_.push_back(static_cast(v)); + } + } + } + return seen_[static_cast(t_)] == 0; + } + + // The input edges of positive view capacity from the closure to its complement (ascending), + // the input edge `skip` (a deleted edge) left out. + [[nodiscard]] std::vector cut_edges(std::int64_t skip) const { + std::vector cut; + for (std::size_t k = 0; k < net_.edges.size(); ++k) { + if (static_cast(k) != skip && seen_[static_cast(net_.tail[k])] != 0 && + seen_[static_cast(net_.head[k])] == 0 && net_.cap[k] > 0) { + cut.push_back(static_cast(k)); + } + } + return cut; + } + +#if NETGRAPH_CORE_PRIVATE_HOOKS + void emit(int repair, bool push, Amount moved, const FlowCoreCounters& c) const { + if (probe_ != nullptr && probe_->observe) probe_->observe(DeletionEvent {repair, push, moved, res_, wpi_, &c}); + } +#else + void emit(int /*repair*/, bool /*push*/, Amount /*moved*/, const FlowCoreCounters& /*c*/) const noexcept {} +#endif + + Network net_ {}; + std::int32_t n_ {0}; + std::int32_t s_ {0}; + std::int32_t t_ {0}; + std::int32_t origin_ {0}; + std::int32_t target_ {0}; + std::vector pos_ {}; + std::vector res_ {}; + // A solve from zero. + std::vector pi_ {}; + std::vector ever_ {}; + std::vector dist_ {}; + RadixHeap heap_ {}; + bool shadow_ {false}; + I128 tier_sum_ {}; + std::vector checkpoint_ {}; + // The deletion mode. + std::vector wpi_ {}; + std::vector wdist_ {}; + std::vector wseen_ {}; + std::uint32_t wepoch_ {0}; + RadixHeap wheap_ {}; +#if NETGRAPH_CORE_PRIVATE_HOOKS + const DeletionProbe* probe_ {nullptr}; + const std::function)>* on_search_ {nullptr}; +#endif + // Shared scratch. + std::vector settled_ {}; + std::vector level_ {}; + std::vector reach_ {}; + std::uint32_t mark_epoch_ {0}; + std::vector queue_ {}; + std::vector cur_ {}; + std::vector stack_ {}; + std::vector path_ {}; + std::vector seen_ {}; +}; + +std::vector DeletionState::edge_flows() const { + std::vector flows(static_cast(graph_->num_edges()), 0); + for (std::size_t k = 0; k < net_->edges.size(); ++k) { + const auto fwd = static_cast(net_->edge_slot[k]); + flows[static_cast(net_->edges[k])] = res_[static_cast(net_->rev[fwd])]; + } + return flows; +} + +DeletionState& DeletionState::operator=(const DeletionState& other) { + DeletionState copy(other); // every allocation; *this is untouched until it succeeds + return *this = std::move(copy); +} + +DeletionResolver::DeletionResolver(const DeletionState& state) : state_(state), solver_(std::make_unique()) { + if (state_.net_ == nullptr) throw std::invalid_argument("deletion re-solve: an empty state (no solve captured it)"); + solver_->load(state_.network()); +} + +DeletionResolver::~DeletionResolver() = default; +DeletionResolver::DeletionResolver(DeletionResolver&&) noexcept = default; +DeletionResolver& DeletionResolver::operator=(DeletionResolver&&) noexcept = default; + +DeletionResult DeletionResolver::resolve(EdgeId e) { return solver_->resolve(state_, index_of(e)); } + +#if NETGRAPH_CORE_PRIVATE_HOOKS +DeletionResult DeletionResolver::resolve(EdgeId e, const DeletionProbe& probe) { + const std::size_t k = index_of(e); + struct Reset { + FlowSolver& solver; + ~Reset() { solver.probe(nullptr); } + } reset {*solver_}; + solver_->probe(&probe); + return solver_->resolve(state_, k); +} +#endif + +std::size_t DeletionResolver::index_of(EdgeId e) const { + if (!state_.maximum()) { + throw std::invalid_argument( + "deletion re-solve: the state is not certified maximum (a residual path from the sources to the " + "destinations remains)"); + } + const Network& net = state_.network(); + const auto it = std::lower_bound(net.edges.begin(), net.edges.end(), e); + if (it == net.edges.end() || *it != e) { + throw std::invalid_argument("deletion re-solve: edge " + std::to_string(e) + " is not in the solve's view"); + } + const auto k = static_cast(it - net.edges.begin()); + const auto fwd = static_cast(net.edge_slot[k]); + if (state_.residual()[static_cast(net.rev[fwd])] <= 0) { + throw std::invalid_argument("deletion re-solve: edge " + std::to_string(e) + " carries no flow"); + } + return k; +} + +FlowCoreResult place_global_units(const Graph& g, + std::span view, + std::span sources, + std::span destinations, + const Eligibility* elig, + std::optional cap, + DeletionCapture* capture) { + const auto num_edges = static_cast(g.num_edges()); + if (view.size() != num_edges) { + throw std::invalid_argument("view has length " + std::to_string(view.size()) + ", expected " + + std::to_string(num_edges) + " (one entry per edge)"); + } + const auto capacity = g.capacity_units(); + for (std::size_t e = 0; e < num_edges; ++e) { + if (view[e] < 0 || view[e] > capacity[e]) { + throw std::invalid_argument("view[" + std::to_string(e) + "] = " + std::to_string(view[e]) + + " is outside [0, capacity_units[" + std::to_string(e) + "] = " + + std::to_string(capacity[e]) + "]"); + } + } + const auto srcs = node_set(sources, g.num_nodes(), "sources"); + const auto dsts = node_set(destinations, g.num_nodes(), "destinations"); + check_disjoint(srcs, dsts, "sources", "destinations"); + if (cap.has_value() && (*cap < 0 || *cap > kMaxFlowCap)) { + throw std::invalid_argument("cap " + std::to_string(*cap) + " is outside [0, 2^63 - 2]"); + } + if (elig != nullptr && elig->graph_uid() != g.uid()) { + throw std::invalid_argument("foreign Eligibility: built for another graph"); + } + if (capture != nullptr && capture->graph.get() != &g) { + throw std::logic_error("flow core: a capture request bound to another graph"); + } + FlowCoreResult out; + const bool zero = cap.has_value() && *cap == 0; + if (zero) { + // The input cap 0 runs nothing: the prescribed zero payload. + out.edge_flows.assign(num_edges, 0); + out.potentials.assign(static_cast(g.num_nodes()), kNeverReached); + if (capture == nullptr) return out; + } + // Identity coordinates: every real vertex, the allowed edges and endpoints. + FlowSolver& solver = FlowSolver::local(); + Network& in = solver.network(); + in.num_nodes = g.num_nodes(); + const auto edge_src = g.edge_src(); + const auto edge_dst = g.edge_dst(); + const auto cost = g.cost(); + in.edges.clear(); + in.tail.clear(); + in.head.clear(); + in.cost.clear(); + in.cap.clear(); + in.sources.clear(); + in.destinations.clear(); + in.edges.reserve(num_edges); + for (std::size_t e = 0; e < num_edges; ++e) { + if (elig != nullptr && !elig->edge_ok(static_cast(e))) continue; + in.edges.push_back(static_cast(e)); + in.tail.push_back(edge_src[e]); + in.head.push_back(edge_dst[e]); + in.cost.push_back(cost[e]); + in.cap.push_back(view[e]); + } + for (NodeId s : srcs) { + if (elig == nullptr || elig->node_ok(s)) in.sources.push_back(s); + } + for (NodeId d : dsts) { + if (elig == nullptr || elig->node_ok(d)) in.destinations.push_back(d); + } + solver.build(capture != nullptr); +#if NETGRAPH_CORE_PRIVATE_HOOKS + solver.observe(capture != nullptr ? &capture->on_search : nullptr); +#endif + if (!zero) { + auto r = solver.run(cap.value_or(kUncapped), /*closure=*/true); + out.placed = r.placed; + out.cost_distribution = std::move(r.cost_distribution); + out.edge_flows.assign(num_edges, 0); + for (std::size_t k = 0; k < in.edges.size(); ++k) { + out.edge_flows[static_cast(in.edges[k])] = r.flows[k]; + } + if (r.cut.has_value()) { + std::vector cut; + cut.reserve(r.cut->size()); + for (std::int32_t k : *r.cut) cut.push_back(in.edges[static_cast(k)]); + out.min_cut = std::move(cut); + } + out.potentials = std::move(r.potentials); + out.counters = r.counters; + } + if (capture != nullptr) solver.capture(*capture, out.placed, out.counters); + return out; +} + +SubsetFlow solve_subset(const Graph& g, std::span edges, std::span caps, + std::span sources, std::span destinations, + std::optional cap) { + SubsetFlow out; + out.flows.assign(edges.size(), 0); + if (cap.has_value() && *cap == 0) return out; + const auto edge_src = g.edge_src(); + const auto edge_dst = g.edge_dst(); + const auto cost = g.cost(); + // Compressed vertices in first-touch order. The numbering changes no flow, curve or path: + // the arc order at every vertex is by EdgeId (virtual arc first) and the virtual arcs of s* + // and t* follow the endpoint lists, which stay in ascending NodeId. + NodeMap& map = NodeMap::local(); + map.reset(static_cast(g.num_nodes())); + // The input's storage is the thread's solver's (every field is rewritten here). + FlowSolver& solver = FlowSolver::local(); + Network& in = solver.network(); + in.edges.assign(edges.begin(), edges.end()); + in.tail.clear(); + in.head.clear(); + in.cost.clear(); + in.sources.clear(); + in.destinations.clear(); + for (EdgeId e : edges) { + const auto ei = static_cast(e); + in.tail.push_back(map.add(edge_src[ei])); + in.head.push_back(map.add(edge_dst[ei])); + in.cost.push_back(cost[ei]); + } + for (NodeId v : sources) in.sources.push_back(map.add(v)); + for (NodeId v : destinations) in.destinations.push_back(map.add(v)); + in.num_nodes = static_cast(map.size()); + in.cap.assign(caps.begin(), caps.end()); + solver.build(false); + auto r = solver.run(cap.value_or(kUncapped), /*closure=*/false, /*potentials=*/false); + out.placed = r.placed; + out.flows = std::move(r.flows); + out.cost_distribution = std::move(r.cost_distribution); + return out; +} + +} // namespace netgraph::core::detail diff --git a/src/flow_graph.cpp b/src/flow_ledger.cpp similarity index 82% rename from src/flow_graph.cpp rename to src/flow_ledger.cpp index 8c9e21f..754e0d2 100644 --- a/src/flow_graph.cpp +++ b/src/flow_ledger.cpp @@ -1,43 +1,43 @@ /* - The integer ledger FlowGraph (APPROACH 8). + The integer ledger FlowLedger (APPROACH 8). The aggregate allocation is kept exactly (integers) and changed only by an atomic commit or a detach; residual() is derived from it (baseline - allocated). Records are kept by id (FlowId order); ids come from a per-ledger counter and are never reused, also across reset(). */ -#include "netgraph/core/flow_graph.hpp" +#include "netgraph/core/flow_ledger.hpp" #include #include #include #include +#include "netgraph/core/detail/endpoints.hpp" #include "netgraph/core/detail/ledger_access.hpp" -#include "netgraph/core/detail/search.hpp" #include "netgraph/core/detail/uid.hpp" namespace netgraph::core { -FlowGraph::FlowGraph(std::shared_ptr graph) { - if (!graph) throw std::invalid_argument("FlowGraph: graph is null"); +FlowLedger::FlowLedger(std::shared_ptr graph) { + if (!graph) throw std::invalid_argument("FlowLedger: graph is null"); graph_ = std::move(graph); baseline_.assign(graph_->capacity_units().begin(), graph_->capacity_units().end()); allocated_.assign(baseline_.size(), 0); uid_ = detail::next_uid(); } -FlowGraph::FlowGraph(std::shared_ptr graph, std::span residual) { - if (!graph) throw std::invalid_argument("FlowGraph: graph is null"); +FlowLedger::FlowLedger(std::shared_ptr graph, std::span residual) { + if (!graph) throw std::invalid_argument("FlowLedger: graph is null"); graph_ = std::move(graph); baseline_ = checked_baseline(residual); allocated_.assign(baseline_.size(), 0); uid_ = detail::next_uid(); } -FlowGraph::FlowGraph(const FlowGraph& other) { +FlowLedger::FlowLedger(const FlowLedger& other) { other.check_usable(); if (other.num_bound_ != 0) { - throw std::invalid_argument("FlowGraph: a ledger with bound policies cannot be copied"); + throw std::invalid_argument("FlowLedger: a ledger with bound policies cannot be copied"); } graph_ = other.graph_; uid_ = detail::next_uid(); @@ -51,10 +51,10 @@ FlowGraph::FlowGraph(const FlowGraph& other) { owners_ = other.owners_; // orphans only } -FlowGraph::FlowGraph(FlowGraph&& other) { +FlowLedger::FlowLedger(FlowLedger&& other) { other.check_usable(); if (other.num_bound_ != 0) { - throw std::invalid_argument("FlowGraph: a ledger with bound policies cannot be moved"); + throw std::invalid_argument("FlowLedger: a ledger with bound policies cannot be moved"); } graph_ = std::move(other.graph_); uid_ = other.uid_; @@ -73,11 +73,11 @@ FlowGraph::FlowGraph(FlowGraph&& other) { other.owners_.clear(); } -void FlowGraph::check_usable() const { - if (!usable_) throw std::logic_error("FlowGraph: a moved-from ledger is empty and unusable"); +void FlowLedger::check_usable() const { + if (!usable_) throw std::logic_error("FlowLedger: a moved-from ledger is empty and unusable"); } -std::vector FlowGraph::checked_baseline(std::span residual) const { +std::vector FlowLedger::checked_baseline(std::span residual) const { const auto capacity = graph_->capacity_units(); if (residual.size() != capacity.size()) { throw std::invalid_argument("residual has length " + std::to_string(residual.size()) + ", expected " + @@ -93,34 +93,34 @@ std::vector FlowGraph::checked_baseline(std::span residual return std::vector(residual.begin(), residual.end()); } -std::uint64_t FlowGraph::uid() const { +std::uint64_t FlowLedger::uid() const { check_usable(); return uid_; } -const std::shared_ptr& FlowGraph::graph() const { +const std::shared_ptr& FlowLedger::graph() const { check_usable(); return graph_; } -std::span FlowGraph::capacity_units() const { +std::span FlowLedger::capacity_units() const { check_usable(); return graph_->capacity_units(); } -std::vector FlowGraph::residual() const { +std::vector FlowLedger::residual() const { check_usable(); std::vector out(baseline_.size()); for (std::size_t e = 0; e < out.size(); ++e) out[e] = baseline_[e] - allocated_[e]; return out; } -std::vector FlowGraph::allocated() const { +std::vector FlowLedger::allocated() const { check_usable(); return allocated_; } -std::vector FlowGraph::records() const { +std::vector FlowLedger::records() const { check_usable(); std::vector out; out.reserve(records_.size()); @@ -128,7 +128,7 @@ std::vector FlowGraph::records() const { return out; } -FlowRecord FlowGraph::record(const FlowId& flow_id) const { +FlowRecord FlowLedger::record(const FlowId& flow_id) const { check_usable(); if (flow_id.first != uid_) { throw std::invalid_argument("FlowId (" + std::to_string(flow_id.first) + ", " + std::to_string(flow_id.second) + @@ -142,13 +142,20 @@ FlowRecord FlowGraph::record(const FlowId& flow_id) const { return it->second; } -std::vector FlowGraph::bottleneck(std::span sources, const Eligibility* elig) const { +std::vector FlowLedger::bottleneck(std::span sources, const Eligibility* elig) const { check_usable(); const auto& g = *graph_; const auto srcs = detail::node_set(sources, g.num_nodes(), "sources"); if (elig != nullptr && elig->graph_uid() != g.uid()) { throw std::invalid_argument("foreign Eligibility: built for another graph"); } + return detail::bottleneck_of(g, residual(), srcs, elig); +} + +namespace detail { + +std::vector bottleneck_of(const Graph& g, std::span residual, + std::span sources, const Eligibility* elig) { const auto n = static_cast(g.num_nodes()); const auto m = static_cast(g.num_edges()); const auto edge_src = g.edge_src(); @@ -157,8 +164,9 @@ std::vector FlowGraph::bottleneck(std::span sources, const const auto row = g.row_offsets(); std::vector seen(n, 0); std::vector stack; - for (NodeId s : srcs) { + for (NodeId s : sources) { if (elig != nullptr && !elig->node_ok(s)) continue; + if (seen[static_cast(s)] != 0) continue; seen[static_cast(s)] = 1; stack.push_back(s); } @@ -168,7 +176,7 @@ std::vector FlowGraph::bottleneck(std::span sources, const for (auto e = row[u]; e < row[u + 1]; ++e) { const auto ei = static_cast(e); if (elig != nullptr && !elig->edge_ok(e)) continue; - if (baseline_[ei] - allocated_[ei] < 1) continue; + if (residual[ei] < 1) continue; const auto v = static_cast(edge_dst[ei]); if (seen[v] == 0) { seen[v] = 1; @@ -186,15 +194,17 @@ std::vector FlowGraph::bottleneck(std::span sources, const return out; } -void FlowGraph::reset() { +} // namespace detail + +void FlowLedger::reset() { check_usable(); reset(graph_->capacity_units()); } -void FlowGraph::reset(std::span residual) { +void FlowLedger::reset(std::span residual) { check_usable(); if (num_bound_ != 0) { - throw std::invalid_argument("FlowGraph.reset is rejected while a policy is bound (" + + throw std::invalid_argument("FlowLedger.reset is rejected while a policy is bound (" + std::to_string(num_bound_) + " bound)"); } auto baseline = checked_baseline(residual); @@ -265,20 +275,10 @@ class DeltaScratch { }; FlowRecord record_of(std::uint64_t uid, std::int64_t id, std::int64_t owner, Draft& d) { - FlowRecord rec; + FlowRecord rec = std::move(d.record); rec.flow_id = FlowId {uid, id}; rec.owner = owner; - rec.sources = std::move(d.sources); - rec.destinations = std::move(d.destinations); - rec.placement = d.placement; - rec.admission = d.admission; - rec.bundle = std::move(d.bundle); - rec.route_cost = d.route_cost; - rec.offered = d.offered; - rec.delivered = d.delivered; - rec.edges = std::move(d.edges); std::erase_if(rec.edges, [](const auto& p) { return p.second == 0; }); - rec.drops = std::move(d.drops); std::erase_if(rec.drops, [](const auto& p) { return p.second.is_zero(); }); return rec; } @@ -289,7 +289,7 @@ static_assert(std::is_nothrow_swappable_v>); } // namespace -std::int64_t LedgerAccess::bind(FlowGraph& fg) { +std::int64_t LedgerAccess::bind(FlowLedger& fg) { fg.check_usable(); const std::int64_t owner = fg.next_owner_; fg.owners_.emplace(owner, true); // the only allocation: nothing changes if it throws @@ -298,14 +298,14 @@ std::int64_t LedgerAccess::bind(FlowGraph& fg) { return owner; } -void LedgerAccess::release(FlowGraph& fg, std::int64_t owner) noexcept { +void LedgerAccess::release(FlowLedger& fg, std::int64_t owner) noexcept { const auto it = fg.owners_.find(owner); if (it == fg.owners_.end() || !it->second) return; it->second = false; --fg.num_bound_; } -std::vector LedgerAccess::detach(FlowGraph& fg, std::int64_t owner) { +std::vector LedgerAccess::detach(FlowLedger& fg, std::int64_t owner) { fg.check_usable(); if (!is_bound(fg.owners_, owner)) throw std::logic_error("ledger: detach by an unbound owner"); std::vector ids; @@ -322,7 +322,7 @@ std::vector LedgerAccess::detach(FlowGraph& fg, std::int64_t owner return ids; } -std::vector LedgerAccess::commit(FlowGraph& fg, std::int64_t owner, std::span drafts) { +std::vector LedgerAccess::commit(FlowLedger& fg, std::int64_t owner, std::span drafts) { fg.check_usable(); if (!is_bound(fg.owners_, owner)) throw std::logic_error("ledger: commit by an unbound owner"); // The drafts carry exactly the owner's live records plus new ones. @@ -352,7 +352,8 @@ std::vector LedgerAccess::commit(FlowGraph& fg, std::int64_t owner, std: } bool negative = false; for (const auto& d : drafts) { - for (const auto& [e, x] : d.edges) { + const FlowRecord& r = d.record; + for (const auto& [e, x] : r.edges) { negative = negative || x < 0; delta.add(e, x); } @@ -411,34 +412,34 @@ std::vector LedgerAccess::commit(FlowGraph& fg, std::int64_t owner, std: return created; } -const FlowRecord& LedgerAccess::live(const FlowGraph& fg, std::int64_t id) { +const FlowRecord& LedgerAccess::live(const FlowLedger& fg, std::int64_t id) { const auto it = fg.records_.find(id); if (it == fg.records_.end()) throw std::invalid_argument("ledger: retired or unknown flow id " + std::to_string(id)); return it->second; } -void LedgerAccess::residual_into(const FlowGraph& fg, std::vector& out) { +void LedgerAccess::residual_into(const FlowLedger& fg, std::vector& out) { fg.check_usable(); out.resize(fg.baseline_.size()); for (std::size_t e = 0; e < out.size(); ++e) out[e] = fg.baseline_[e] - fg.allocated_[e]; } -std::span LedgerAccess::baseline(const FlowGraph& fg) { +std::span LedgerAccess::baseline(const FlowLedger& fg) { fg.check_usable(); return fg.baseline_; } -std::span LedgerAccess::allocated(const FlowGraph& fg) { +std::span LedgerAccess::allocated(const FlowLedger& fg) { fg.check_usable(); return fg.allocated_; } -std::int64_t LedgerAccess::next_flow_id(const FlowGraph& fg) { +std::int64_t LedgerAccess::next_flow_id(const FlowLedger& fg) { fg.check_usable(); return fg.next_id_; } -std::vector LedgerAccess::bound_owners(const FlowGraph& fg) { +std::vector LedgerAccess::bound_owners(const FlowLedger& fg) { fg.check_usable(); std::vector out; for (const auto& [owner, bound] : fg.owners_) { @@ -447,7 +448,7 @@ std::vector LedgerAccess::bound_owners(const FlowGraph& fg) { return out; } -std::vector LedgerAccess::orphan_owners(const FlowGraph& fg) { +std::vector LedgerAccess::orphan_owners(const FlowLedger& fg) { fg.check_usable(); std::vector out; for (const auto& [owner, bound] : fg.owners_) { diff --git a/src/flow_policy.cpp b/src/flow_policy.cpp new file mode 100644 index 0000000..7c6a247 --- /dev/null +++ b/src/flow_policy.cpp @@ -0,0 +1,1741 @@ +/* + The policy engine FlowPolicy (APPROACH 8 and 9): one placement transaction for every routing + mode, committed through the ledger as one atomic exchange per call. + + * CostOnly, CapacityAware and Pinned run the per-source transaction of 9.2 and 9.3: the + roster (fixed at construction) in ascending NodeId, each source offered the increment of its + round-robin share of the cumulative request, stages 1 (creation), 2 (reoptimisation), 3 + (amounts) and 4 (growth) on the shared working residual W; one `created` counter per call. + Pinned has one actor, the policy, with its one source. + * Global re-solves the minimum-cost flow of the cumulative value on V = W + own (the flow core + of 2.2 over the whole eligible graph) and decomposes it per source canonically (9.1). + + Every stage works on copies; nothing is published before the commit, which either succeeds or + throws leaving the ledger and the policy unchanged; the epoch state follows the commit with + operations that cannot throw. The working residual is a per-thread overlay over the ledger's + residual (only the edges a call touches cost anything), so a Pinned call, and a CostOnly call + after its cohort's creation, costs O(its flows' members), never O(E); CostOnly creation of a + single path, CapacityAware searches and Global solves read a dense copy. + + The transaction trace (prototype 2's keys) exists in a build with the private hooks only: every + trace block is guarded by kTraced and is compile-time dead without them. +*/ +#include "netgraph/core/flow_policy.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/detail/endpoints.hpp" +#include "netgraph/core/detail/enum_domain.hpp" +#include "netgraph/core/detail/flow_core.hpp" +#include "netgraph/core/detail/int128.hpp" +#include "netgraph/core/detail/kernels.hpp" +#include "netgraph/core/detail/ledger_access.hpp" +#include "netgraph/core/detail/node_map.hpp" +#include "netgraph/core/detail/policy_engine.hpp" +#include "netgraph/core/detail/pred_dag_builder.hpp" +#include "netgraph/core/detail/private_hooks.hpp" +#include "netgraph/core/detail/search.hpp" +#include "netgraph/core/detail/trace.hpp" + +namespace netgraph::core { + +namespace detail { + +#if NETGRAPH_CORE_PRIVATE_HOOKS +std::atomic& max_flows_per_call() noexcept { + static std::atomic cap {kMaxFlowsPerCall}; + return cap; +} + +std::atomic& sensitivity_fail_edge() noexcept { + static std::atomic edge {-1}; + return edge; +} +#endif + +namespace { + +// ================================================================ working residual + +// W = baseline - allocated + delta, the delta being this call's working changes (releases of the +// policy's own allocations and new working allocations). Per-thread slots stamped with the +// call's epoch: a slot is valid only under the current epoch, so nothing is cleared between calls +// and a call that throws leaves stale slots behind an old epoch. One array of slots: growing it +// either succeeds whole or leaves the previous array (strong guarantee). +class Working { +public: + explicit Working(const FlowLedger& fg) + : s_(storage()), base_(LedgerAccess::baseline(fg)), held_(LedgerAccess::allocated(fg)) { + if (s_.slot.size() < base_.size()) s_.slot.resize(base_.size()); + if (++s_.epoch == 0) { + for (auto& x : s_.slot) x.stamp = 0; + s_.epoch = 1; + } + } + Working(const Working&) = delete; + Working& operator=(const Working&) = delete; + + [[nodiscard]] Amount at(EdgeId e) const noexcept { + const auto ei = static_cast(e); + const auto& x = s_.slot[ei]; + return base_[ei] - held_[ei] + (x.stamp == s_.epoch ? x.delta : 0); + } + void add(EdgeId e, Amount v) noexcept { + auto& x = s_.slot[static_cast(e)]; + if (x.stamp != s_.epoch) { + x.stamp = s_.epoch; + x.delta = 0; + } + x.delta += v; + } + // The member-aligned working residual of `members`. + void gather(std::span members, std::vector& out) const { + out.resize(members.size()); + for (std::size_t m = 0; m < members.size(); ++m) out[m] = at(members[m]); + } + // The dense working residual (searches, Global, traces). + void dense(std::vector& out) const { + out.resize(base_.size()); + for (std::size_t e = 0; e < out.size(); ++e) { + const auto& x = s_.slot[e]; + out[e] = base_[e] - held_[e] + (x.stamp == s_.epoch ? x.delta : 0); + } + } + // W += a (release) or W -= a (consume) over member-aligned amounts. + void release(std::span members, std::span a) noexcept { + for (std::size_t m = 0; m < members.size(); ++m) { + if (a[m] != 0) add(members[m], a[m]); + } + } + void consume(std::span members, std::span a) noexcept { + for (std::size_t m = 0; m < members.size(); ++m) { + if (a[m] != 0) add(members[m], -a[m]); + } + } + +private: + struct Slot { + Amount delta {0}; + std::uint32_t stamp {0}; + }; + struct Storage { + std::vector slot; + std::uint32_t epoch {0}; + }; + static Storage& storage() { + thread_local Storage st; + return st; + } + Storage& s_; + std::span base_; + std::span held_; +}; + +// ================================================================ small helpers + +[[noreturn]] void invalid(const std::string& what) { throw std::invalid_argument(what); } + +// The policy's routing mode (the tag of the Routing variant). +template +bool is(const PolicyEngine& pe) noexcept { + return std::holds_alternative(pe.config.routing); +} + +std::string str(std::int64_t v) { return std::to_string(v); } + +const char* outcome_name(Termination t) noexcept { + switch (t) { + case Termination::Complete: return "complete"; + case Termination::LimitFlows: return "limit_flows"; + case Termination::LimitDiminishing: return "limit_diminishing"; + case Termination::NoRoute: return "no_route"; + case Termination::Exhausted: return "exhausted"; + } + return "?"; +} + +// The reduction rank of 9.4 (higher wins among unserved sources). +int rank(Termination t) noexcept { + switch (t) { + case Termination::LimitFlows: return 4; + case Termination::LimitDiminishing: return 3; + case Termination::Exhausted: return 2; + case Termination::NoRoute: return 1; + case Termination::Complete: return 0; + } + return 0; +} + +Trace amount_or_inf(std::optional v) { return v.has_value() ? Trace::integer(*v) : Trace::str("inf"); } + +// cost <= f * base, exactly: f is the dyadic rational it is (f >= 1, finite), base >= 1. +// f = m * 2^x with m odd below 2^53; products stay within 128 bits (APPROACH 6). +bool stretch_le(Cost cost, Cost base, double f) { + const auto [m, x] = dyadic(f); + const U128 rhs = mul_u64(static_cast(base), m); // below 2^115 + if (x >= 0) { + if (bits(rhs) + x > 64) return true; // rhs * 2^x >= 2^64 > cost + return !(shl(rhs, x) < U128 {0, static_cast(cost)}); + } + // f >= 1 with m < 2^53: -x < 53, so cost * 2^-x < 2^116. + const U128 lhs = shl(U128 {0, static_cast(cost)}, -x); + return !(rhs < lhs); +} + +// The member-aligned amounts of a record's (EdgeId, amount) pairs (both ascending). +template +void align(std::span members, const std::vector>& pairs, std::vector& out) { + out.assign(members.size(), T {}); + std::size_t m = 0; + for (const auto& [e, x] : pairs) { + while (m < members.size() && members[m] < e) ++m; + if (m == members.size() || members[m] != e) { + throw std::logic_error("policy: a record's allocation lies outside its flow's split graph"); + } + out[m] = x; + } +} + +// Round robin of a cumulative 128-bit request: the per-source increment of the share when the +// cumulative request goes from R to R + v over k sources (APPROACH 9.3). It depends only on +// r = R mod k: floor((r + v) / k) + [i < (r + v) mod k] - [i < r]. +std::vector cumulative_offers(const Counter128& requested, Amount v, std::size_t k) { + const auto kk = static_cast(k); + const std::uint64_t two64 = ((std::numeric_limits::max() % kk) + 1) % kk; + const std::uint64_t r = ((requested.hi % kk) * two64 % kk + requested.lo % kk) % kk; + const auto total = static_cast(v) + r; + const auto base = static_cast(total / kk); + const std::uint64_t extra = total % kk; + std::vector out(k); + for (std::size_t i = 0; i < k; ++i) { + out[i] = base + (i < extra ? 1 : 0) - (i < r ? 1 : 0); + } + return out; +} + +// ================================================================ working flows + +struct WorkFlow { + std::int64_t id {-1}; // -1: created in this call + NodeId src {0}; + std::shared_ptr plan {}; + std::shared_ptr bundle {}; + Cost cost {kUnreachable}; + std::int32_t bundle_index {-1}; + Amount T {0}; // Lossless: the cumulative amount (= delivered) + std::vector alloc {}; // member-aligned with plan->members (cumulative carried for Lossy) + std::optional offered {}; // Lossy + Amount delivered {0}; // Lossy (Lossless: T) + std::vector drops {}; // Lossy, member-aligned, cumulative + std::int64_t seq {0}; // creation order within the call (new flows) + [[nodiscard]] bool is_new() const noexcept { return id < 0; } + [[nodiscard]] std::span members() const noexcept { return plan->members; } +}; + +struct SourceRun { + std::size_t ri {0}; + NodeId src {0}; + std::optional offer {}; // nullopt: +inf + Amount P {0}; + std::optional B {}; + Amount T_entry {0}; + std::vector flows {}; + bool capped {false}; + std::optional limit {}; + std::vector gains {}; + Amount injected {0}; + Trace* tr {nullptr}; // this source's trace map +}; + +struct Call { + PolicyEngine& pe; + const Graph& g; + Working& W; + std::int64_t cap {kMaxFlowsPerCall}; + std::int64_t created {0}; + bool routes_changed {false}; + bool weights_changed {false}; + std::int64_t seq {0}; + std::vector> drops {}; + Trace* trace {nullptr}; +}; + +// ================================================================ routes and plans + +struct Route { + std::vector edges {}; // ascending + Cost cost {kUnreachable}; +}; + +// A flow's plan on its route (the record's bundle) with its membership captured on the creation +// view (Weighted Residual: `dense_view` when the search read one, else the working residual W); +// with `cost`, the route's minimum walk cost. +std::shared_ptr plan_on(const Call& c, NodeId src, const PredDAG& route, + const std::vector* dense_view, Cost* cost) { + const auto& cfg = c.pe.config; + std::vector creation; + if (cfg.placement == FlowPlacement::Weighted && cfg.selection.priority == EdgePriority::Residual) { + const auto edges = PredDAGBuilder::ascending(route); + creation.resize(edges.size()); + for (std::size_t i = 0; i < edges.size(); ++i) { + creation[i] = dense_view != nullptr ? (*dense_view)[static_cast(edges[i])] : c.W.at(edges[i]); + } + } + return std::make_shared( + make_plan(c.g, cfg.placement, cfg.admission, cfg.selection.priority, src, c.pe.dests_ok, route, creation, cost)); +} + +// The record bundle of a route: `share` itself when it is the whole route, else a new DAG. +std::shared_ptr bundle_of(const Graph& g, std::span route, + const std::shared_ptr& share) { + if (share && PredDAGBuilder::ascending(*share).size() == route.size()) return share; + return std::make_shared(PredDAGBuilder::of_edges(g, route)); +} + +// CostOnly: the static support restricted to the walks from src (a forward reachability over the +// support's edges: B' ids rank edges by source, so a node's support out-edges are one range of +// the ascending edge list), then the selection projected on the creation view W (APPROACH 9.1, +// 10): Collapse by (cost, priority under PreferHigherPriority, lowest EdgeId), and the staged +// canonical single path for multipath = false. +Route cost_only_route(const Call& c, NodeId src) { + const auto& g = c.g; + const auto& pe = c.pe; + const auto& sel = pe.config.selection; + const auto support = PredDAGBuilder::ascending(*pe.support); + const auto row = g.row_offsets(); + const auto edge_dst = g.edge_dst(); + const auto edge_src = g.edge_src(); + std::vector route; + { + // Visited nodes in the per-thread node map (released before any routine that uses it). + NodeMap& map = NodeMap::local(); + map.reset(static_cast(g.num_nodes())); + std::vector stack {src}; + (void)map.add(src); + while (!stack.empty()) { + const NodeId u = stack.back(); + stack.pop_back(); + auto it = std::lower_bound(support.begin(), support.end(), row[static_cast(u)]); + for (; it != support.end() && *it < row[static_cast(u) + 1]; ++it) { + route.push_back(*it); + const NodeId v = edge_dst[static_cast(*it)]; + if (map.of(v) < 0) { + (void)map.add(v); + stack.push_back(v); + } + } + } + std::sort(route.begin(), route.end()); + } + Route out; + if (!sel.multi_edge) { + // One member per (u, v) by select_adjacency on the creation view W (the support has no + // residual gating, so the projection reads W's priority values, never gates on them): the + // members of a pair are consecutive in id order (B' ranks by (src, cost, dst, input)). + const bool prefer = sel.tie_break == EdgeTieBreak::PreferHigherPriority; + const auto capacity = g.capacity_units(); + auto value = [&](EdgeId e) { return priority_value(sel.priority, c.W.at(e), capacity[static_cast(e)]); }; + std::vector kept; + for (std::size_t i = 0; i < route.size();) { + const auto ui = static_cast(route[i]); + EdgeId best = route[i]; + std::size_t j = i + 1; + for (; j < route.size() && edge_src[static_cast(route[j])] == edge_src[ui] && + edge_dst[static_cast(route[j])] == edge_dst[ui]; + ++j) { + if (replaces(g, route[j], best, prefer, value)) best = route[j]; + } + kept.push_back(best); + i = j; + } + route = std::move(kept); + } + if (sel.multipath) { + out.edges = std::move(route); + return out; + } + // The staged canonical single path over the (collapsed) support of the source. + std::vector member(static_cast(g.num_edges()), 0); + for (EdgeId e : route) member[static_cast(e)] = 1; + std::vector wd; + std::optional prio; + if (sel.tie_break == EdgeTieBreak::PreferHigherPriority) { + c.W.dense(wd); + prio = PriorityValues {&g, sel.priority, wd}; + } + const NodeId s[1] = {src}; + auto path = canonical_path(g, member, s, pe.dests_ok, prio ? &*prio : nullptr); + if (path.has_value()) { + out.edges = path->walk; + std::sort(out.edges.begin(), out.edges.end()); + out.cost = path->cost; + } + return out; +} + +// CapacityAware: the canonical route of section 6 on the selection view `view` (dense): static +// eligibility, residual >= max(1, min_flow_bandwidth), Collapse on the view, the tight DAG or the +// staged single path toward the allowed destinations, then the max_path_cost ceiling. +std::optional search(const Call& c, std::size_t ri, std::span view) { + const auto& pe = c.pe; + const auto& g = c.g; + const auto& ca = std::get(pe.config.routing); + const auto& sel = pe.config.selection; + const Amount threshold = std::max(1, ca.min_flow_bandwidth); + const auto member = member_mask(g, pe.elig, sel, view, threshold); + const NodeId s[1] = {pe.roster[ri]}; + Route out; + if (sel.multipath) { + auto tight = tight_dag(g, member, s, pe.dests_ok); + if (tight.cost == kUnreachable) return std::nullopt; + out.edges = std::move(tight.edges); + out.cost = tight.cost; + } else { + const PriorityValues prio {&g, sel.priority, view}; + const bool prefer = sel.tie_break == EdgeTieBreak::PreferHigherPriority; + auto path = canonical_path(g, member, s, pe.dests_ok, prefer ? &prio : nullptr); + if (!path.has_value()) return std::nullopt; + out.edges = std::move(path->walk); + std::sort(out.edges.begin(), out.edges.end()); + out.cost = path->cost; + } + if (ca.max_path_cost.has_value()) { + if (const auto* a = std::get_if(&*ca.max_path_cost)) { + if (out.cost > a->cost) return std::nullopt; + } else { + // The roster holds the sources with a static route, so the baseline is a positive cost. + if (!stretch_le(out.cost, pe.baseline[ri], std::get(*ca.max_path_cost).factor)) return std::nullopt; + } + } + return out; +} + +// ================================================================ amounts of one flow + +// T_max of a flow on a member-aligned view (the trace's bounds): MaxFlow's uncapped step from 0, +// else the exact search. +Amount flow_t_max(const Graph& g, const KernelPlan& plan, std::span view_m) { + if (plan.members.empty()) return 0; + if (plan.placement == FlowPlacement::MaxFlow) return lossless_step(g, plan, 0, view_m, {}, std::nullopt).total; + return t_max(plan, view_m).value; +} + +// ================================================================ the cohort (EqualBalanced) + +// The cohort allocation of an EqualBalanced cohort (APPROACH 16.5): T split by round robin over +// the flows in order, each flow's own plan, summed per edge over the union of members. +class CohortEval { +public: + CohortEval(const std::vector& plans, const Working& W) : plans_(plans) { + for (const auto* p : plans_) uni_.insert(uni_.end(), p->members.begin(), p->members.end()); + std::sort(uni_.begin(), uni_.end()); + uni_.erase(std::unique(uni_.begin(), uni_.end()), uni_.end()); + idx_.resize(plans_.size()); + for (std::size_t i = 0; i < plans_.size(); ++i) { + idx_[i].resize(plans_[i]->members.size()); + for (std::size_t m = 0; m < plans_[i]->members.size(); ++m) { + idx_[i][m] = static_cast(std::lower_bound(uni_.begin(), uni_.end(), plans_[i]->members[m]) - + uni_.begin()); + } + } + W.gather(uni_, view_); + acc_.assign(uni_.size(), 0); + // The search's upper bound: the view summed over the source members of every flow. + std::vector src_member(uni_.size(), 0); + for (std::size_t i = 0; i < plans_.size(); ++i) { + for (auto k = plans_[i]->src_out_begin; k < plans_[i]->src_out_end; ++k) { + src_member[static_cast(idx_[i][static_cast(k)])] = 1; + } + } + for (std::size_t u = 0; u < uni_.size(); ++u) { + if (src_member[u] != 0) hi_ += view_[u]; + } + } + [[nodiscard]] Amount upper() const noexcept { return hi_; } + + // Whether the cohort allocation at total T fits the view. + bool fits(Amount T) { + std::fill(acc_.begin(), acc_.end(), 0); + const auto k = plans_.size(); + std::vector parts(k); + round_robin(T, parts); + for (std::size_t i = 0; i < k; ++i) { + if (parts[i] == 0) continue; + tmp_.resize(plans_[i]->members.size()); + allocate(*plans_[i], parts[i], tmp_); + for (std::size_t m = 0; m < tmp_.size(); ++m) { + const auto u = static_cast(idx_[i][m]); + acc_[u] += tmp_[m]; + if (acc_[u] > view_[u]) return false; + } + } + return true; + } + // The largest T in [lo, hi] that fits, given fits(lo) (bisection, exact; at most 62 probes). + Amount largest(Amount lo, Amount hi) { + while (lo < hi) { + const Amount mid = lo + (hi - lo + 1) / 2; + if (fits(mid)) { + lo = mid; + } else { + hi = mid - 1; + } + } + return lo; + } + // The full T_max (the trace's `bound`). + Amount t_max() { return largest(0, hi_); } + +private: + const std::vector& plans_; + std::vector uni_ {}; + std::vector> idx_ {}; + std::vector view_ {}; + std::vector acc_ {}; + std::vector tmp_ {}; + Amount hi_ {0}; +}; + +// Splits T over the cohort by round robin; a flow whose share changed (or is new) gets the +// allocation of its new share (APPROACH 9.2 stage 3 EB). +void set_cohort(std::vector& flows, Amount T) { + std::vector parts(flows.size()); + round_robin(T, parts); + for (std::size_t i = 0; i < flows.size(); ++i) { + auto& f = flows[i]; + if (parts[i] != f.T || f.is_new()) { + std::vector a(f.plan->members.size()); + allocate(*f.plan, parts[i], a); + f.alloc = std::move(a); + } + f.T = parts[i]; + } +} + +std::vector plans_of(const std::vector& flows) { + std::vector out; + out.reserve(flows.size() + 1); + for (const auto& f : flows) out.push_back(f.plan.get()); + return out; +} + +// ---------------------------------------------------------------- outputs + +// The optional outputs of a call whose policy allocation becomes `after` (the records' edge +// lists), computed before the commit (so nothing allocates once the ledger changed): +// edge_flows = the policy's own allocation after the call, residual = the ledger's after it +// (now + own before - own after), bottleneck on that residual. +void prepare_outputs(const PolicyEngine& pe, const OutputOptions& outputs, + const std::vector>*>& after, PlacementResult& out) { + if (!outputs.edge_flows && !outputs.residual && !outputs.bottleneck) return; + const auto m = static_cast(pe.graph->num_edges()); + std::vector own(m, 0); + for (const auto* edges : after) { + for (const auto& [e, x] : *edges) own[static_cast(e)] += x; + } + if (outputs.residual || outputs.bottleneck) { + std::vector res; + LedgerAccess::residual_into(pe.fg, res); + for (const auto& cohort : pe.cohorts) { + for (const auto& s : cohort) { + for (const auto& [e, x] : LedgerAccess::live(pe.fg, s.id).edges) res[static_cast(e)] += x; + } + } + for (std::size_t e = 0; e < m; ++e) res[e] -= own[e]; + if (outputs.bottleneck) out.bottleneck = bottleneck_of(*pe.graph, res, pe.sources, pe.elig); + if (outputs.residual) out.residual = std::move(res); + } + if (outputs.edge_flows) out.edge_flows = std::move(own); +} + +// The current records' edge lists (a call that commits nothing). +std::vector>*> current_edges(const PolicyEngine& pe) { + std::vector>*> out; + for (const auto& cohort : pe.cohorts) { + for (const auto& s : cohort) out.push_back(&LedgerAccess::live(pe.fg, s.id).edges); + } + return out; +} + +bool any_output(const OutputOptions& outputs) noexcept { + return outputs.edge_flows || outputs.residual || outputs.bottleneck; +} + +std::vector>*> draft_edges(const std::vector& drafts) { + std::vector>*> out; + out.reserve(drafts.size()); + for (const auto& d : drafts) out.push_back(&d.record.edges); + return out; +} + +// ================================================================ the transaction + +Trace route_trace(std::span edges) { return Trace::ints(edges); } + +Trace alloc_trace(const WorkFlow& f) { + Trace t = Trace::list(); + const auto members = f.members(); + for (std::size_t m = 0; m < members.size(); ++m) { + if (f.alloc[m] == 0) continue; + Trace p = Trace::list(); + p.push(Trace::integer(members[m])); + p.push(Trace::integer(f.alloc[m])); + t.push(std::move(p)); + } + return t; +} + +// A new working flow on `bundle` (its record's bundle) with the route's cost (kUnreachable: +// computed with the plan) and its plan captured on the creation view, unless the route's static +// plan is given. +WorkFlow new_flow(Call& c, NodeId src, std::shared_ptr bundle, Cost cost, + const std::vector* dense_view, std::shared_ptr plan = nullptr) { + WorkFlow f; + f.src = src; + f.bundle = std::move(bundle); + f.cost = cost; + f.plan = plan ? std::move(plan) : plan_on(c, src, *f.bundle, dense_view, cost == kUnreachable ? &f.cost : nullptr); + f.alloc.assign(f.plan->members.size(), 0); + if (c.pe.config.admission == Admission::Lossy) { + f.offered = Counter128 {}; + f.drops.assign(f.plan->members.size(), Counter128 {}); + } + return f; +} + +// ---------------------------------------------------------------- stage 1: creation + +void stage1(Call& c, SourceRun& st) { + auto& pe = c.pe; + Trace tr = Trace::map(); + if (is(pe)) { + const Route route = cost_only_route(c, st.src); + if (kTraced && c.trace) tr.set("route", route_trace(route.edges)); + WorkFlow f = new_flow(c, st.src, bundle_of(c.g, route.edges, pe.support), route.cost, nullptr); + f.seq = ++c.seq; + st.flows.push_back(std::move(f)); + } else if (is(pe)) { + const bool captured = pe.config.placement == FlowPlacement::Weighted && + pe.config.selection.priority == EdgePriority::Residual; + Trace usable = Trace::list(); + for (std::int32_t i : pe.usable) { + const auto& b = pe.bundles[static_cast(i)]; + WorkFlow f = new_flow(c, st.src, b.route_dag, b.cost, nullptr, captured ? nullptr : b.route_plan); + f.bundle_index = i; + f.seq = ++c.seq; + st.flows.push_back(std::move(f)); + if (kTraced && c.trace) usable.push(Trace::integer(i)); + } + if (kTraced && c.trace) tr.set("usable", std::move(usable)); + } else { + const auto& ca = std::get(pe.config.routing); + std::vector S; + c.W.dense(S); + std::optional rem = st.offer; + std::optional target; + if (pe.config.placement == FlowPlacement::EqualBalanced && st.offer.has_value()) { + target = (st.P + *st.offer) / ca.flow_count; + } + Trace reservations = Trace::list(); + const std::int64_t budget = std::min(ca.flow_count, std::max(0, c.cap - c.created)); + bool stopped = false; + std::vector view_m; + std::vector zeros; + for (std::int64_t i = 0; i < budget; ++i) { + auto route = search(c, st.ri, S); + if (!route.has_value()) { + stopped = true; + break; + } + WorkFlow f = new_flow(c, st.src, bundle_of(c.g, route->edges, nullptr), route->cost, &S); + const auto members = f.members(); + view_m.resize(members.size()); + for (std::size_t m = 0; m < members.size(); ++m) view_m[m] = S[static_cast(members[m])]; + zeros.assign(members.size(), 0); + std::optional request = pe.config.placement == FlowPlacement::EqualBalanced ? target : rem; + auto step = lossless_step(c.g, *f.plan, 0, view_m, zeros, request); + const Amount t = step.total; + if (pe.config.placement != FlowPlacement::EqualBalanced && rem.has_value()) *rem -= t; + if (kTraced && c.trace) { + Trace row = Trace::map(); + row.set("route", route_trace(route->edges)); + row.set("S", Trace::ints(S)); + row.set("T_bound", Trace::integer(flow_t_max(c.g, *f.plan, view_m))); + row.set("reserved", Trace::integer(t)); + reservations.push(std::move(row)); + } + for (std::size_t m = 0; m < members.size(); ++m) S[static_cast(members[m])] -= step.alloc[m]; + f.seq = ++c.seq; + st.flows.push_back(std::move(f)); + ++c.created; + } + st.capped = budget < ca.flow_count && !stopped; + if (kTraced && c.trace) { + tr.set("eb_target", target.has_value() ? Trace::integer(*target) : Trace::null()); + tr.set("reservations", std::move(reservations)); + tr.set("created_cap_blocked", Trace::boolean(st.capped)); + } + } + if (!st.flows.empty()) c.routes_changed = true; + if (kTraced && st.tr) st.tr->set("stage1", std::move(tr)); +} + +// ---------------------------------------------------------------- stage 2: reoptimisation + +void stage2(Call& c, SourceRun& st) { + Trace rows = Trace::list(); + std::vector Wi; + std::vector view_m; + std::vector zeros; + for (auto& f : st.flows) { + if (f.is_new()) continue; + c.W.dense(Wi); + const auto members = f.members(); + for (std::size_t m = 0; m < members.size(); ++m) Wi[static_cast(members[m])] += f.alloc[m]; + auto cand = search(c, st.ri, Wi); + Trace row = Trace::map(); + if (kTraced && c.trace) { + row.set("flow", Trace::ints(std::vector {static_cast(c.pe.fg.uid()), f.id})); + row.set("T", Trace::integer(f.T)); + row.set("candidate", cand.has_value() ? route_trace(cand->edges) : Trace::null()); + } + if (!cand.has_value() || cand->edges == f.plan->route) { + if (kTraced && c.trace) { + row.set("decision", Trace::str("kept")); + rows.push(std::move(row)); + } + continue; + } + WorkFlow trial = new_flow(c, st.src, bundle_of(c.g, cand->edges, nullptr), cand->cost, &Wi); + const auto tm = trial.members(); + view_m.resize(tm.size()); + for (std::size_t m = 0; m < tm.size(); ++m) view_m[m] = Wi[static_cast(tm[m])]; + zeros.assign(tm.size(), 0); + // The candidate's allocation at T_i (adopted iff it delivers T_i exactly). + auto step = lossless_step(c.g, *trial.plan, 0, view_m, zeros, f.T); + if (step.total < f.T) { + if (kTraced && c.trace) { + row.set("decision", Trace::str("rejected: delivers less than T_i")); + row.set("bound", Trace::integer(flow_t_max(c.g, *trial.plan, view_m))); + rows.push(std::move(row)); + } + continue; + } + // Adopted: a replacement at T_i; W := W_i - alloc_c. + c.W.release(members, f.alloc); + trial.alloc = std::move(step.alloc); + c.W.consume(tm, trial.alloc); + f.plan = std::move(trial.plan); + f.bundle = std::move(trial.bundle); + f.cost = trial.cost; + f.alloc = std::move(trial.alloc); + c.routes_changed = true; + if (c.pe.config.placement == FlowPlacement::Weighted) c.weights_changed = true; + if (kTraced && c.trace) { + row.set("decision", Trace::str("adopted")); + row.set("alloc", alloc_trace(f)); + rows.push(std::move(row)); + } + } + if (kTraced && st.tr) st.tr->set("stage2", std::move(rows)); +} + +// ---------------------------------------------------------------- stage 3: amounts + +// The member-aligned view of stage 3, per thread: every use gathers it whole first, and no use +// nests another, so its contents never outlive one flow's step. +std::vector& view_scratch() { + thread_local std::vector v; + return v; +} + +void stage3_seq(Call& c, SourceRun& st) { + Trace rows = Trace::list(); + std::vector& view_m = view_scratch(); + for (auto& f : st.flows) { + const auto members = f.members(); + c.W.release(members, f.alloc); // V = W + own + c.W.gather(members, view_m); + const Amount T0 = f.T; + auto step = lossless_step(c.g, *f.plan, f.T, view_m, f.alloc, st.B); + f.T = step.total; + f.alloc = std::move(step.alloc); + c.W.consume(members, f.alloc); // W = V - alloc + if (st.B.has_value()) *st.B -= f.T - T0; + if (kTraced && c.trace) { + Trace row = Trace::map(); + row.set("T", Trace::ints(std::vector {T0, f.T})); + row.set("curve", f.plan->placement == FlowPlacement::MaxFlow ? Trace::pairs(step.cost_distribution) : Trace::null()); + row.set("alloc", alloc_trace(f)); + rows.push(std::move(row)); + } + } + if (kTraced && st.tr) st.tr->set("stage3", std::move(rows)); +} + +void stage3_eb(Call& c, SourceRun& st) { + auto& flows = st.flows; + Amount T = 0; + for (const auto& f : flows) T += f.T; + for (const auto& f : flows) c.W.release(f.members(), f.alloc); // V = W + cohort allocation + Amount t_new = T; + std::optional bound; + if (flows.size() == 1) { + auto& f = flows.front(); + std::vector& view_m = view_scratch(); + c.W.gather(f.members(), view_m); + auto step = lossless_step(c.g, *f.plan, f.T, view_m, f.alloc, st.B); + t_new = step.total; + f.alloc = std::move(step.alloc); + f.T = t_new; + if (kTraced && c.trace) bound = flow_t_max(c.g, *f.plan, view_m); + } else { + const auto plans = plans_of(flows); + CohortEval cohort(plans, c.W); + const Amount target = st.B.has_value() ? T + *st.B : cohort.upper(); + if (target > T) { + t_new = cohort.fits(target) ? target : cohort.largest(T, target - 1); + } + if (kTraced && c.trace) bound = cohort.t_max(); + set_cohort(flows, t_new); + } + for (const auto& f : flows) c.W.consume(f.members(), f.alloc); + if (st.B.has_value()) *st.B -= t_new - T; + if (kTraced && st.tr) { + Trace row = Trace::map(); + row.set("T", Trace::ints(std::vector {T, t_new})); + row.set("bound", bound.has_value() ? Trace::integer(*bound) : Trace::null()); + Trace shares = Trace::list(); + for (const auto& f : flows) shares.push(Trace::integer(f.T)); + row.set("shares", std::move(shares)); + st.tr->set("stage3", std::move(row)); + } +} + +void stage3_lossy(Call& c, SourceRun& st) { + auto& flows = st.flows; + std::vector offers(flows.size(), 0); + if (st.offer.has_value()) round_robin(*st.offer, offers); + Trace rows = Trace::list(); + std::vector w_m; + for (std::size_t i = 0; i < flows.size(); ++i) { + auto& f = flows[i]; + const std::optional offer = st.offer.has_value() ? std::optional(offers[i]) : std::nullopt; + const auto members = f.members(); + c.W.gather(members, w_m); + const auto step = lossy_step(*f.plan, offer, w_m); + c.W.consume(members, step.carried); + for (std::size_t m = 0; m < members.size(); ++m) { + f.alloc[m] += step.carried[m]; + if (step.drops[m] != 0) { + if (!f.drops[m].add(step.drops[m])) throw std::invalid_argument("a cumulative drop counter would reach 2^127"); + c.drops.emplace_back(members[m], step.drops[m]); + } + } + if (!f.offered->add(step.offered)) throw std::invalid_argument("the cumulative offered counter would reach 2^127"); + f.delivered += step.delivered; + st.injected += step.injected; + if (kTraced && c.trace) { + Trace row = Trace::map(); + row.set("offer", amount_or_inf(offer)); + row.set("offered", Trace::integer(step.offered)); + row.set("delivered", Trace::integer(step.delivered)); + std::vector> carried, drops; + for (std::size_t m = 0; m < members.size(); ++m) { + if (step.carried[m] != 0) carried.emplace_back(members[m], step.carried[m]); + if (step.drops[m] != 0) drops.emplace_back(members[m], step.drops[m]); + } + row.set("carried", Trace::pairs(carried)); + row.set("drops", Trace::pairs(drops)); + rows.push(std::move(row)); + } + } + if (kTraced && st.tr) { + st.tr->set("stage3", std::move(rows)); + st.tr->set("injected", Trace::integer(st.injected)); + } +} + +// ---------------------------------------------------------------- stage 4: growth + +// fraction * v as the reduced rational "num/den" (or "num"), prototype 2's trace text. +std::string fraction_text(double fraction, Amount v) { + const auto [m, x] = dyadic(fraction); + U128 num = mul_u64(m, static_cast(v)); + int k = -x; // den = 2^k + while (k > 0 && (num.lo & 1U) == 0 && !(num == U128 {0, 0})) { + num = U128 {num.hi >> 1, (num.lo >> 1) | (num.hi << 63)}; + --k; + } + if (num == U128 {0, 0}) return "0"; + auto dec = [](U128 n) { + std::string out; + while (!(n == U128 {0, 0})) { + const std::uint64_t hi_q = n.hi / 10; + const std::uint64_t hi_r = n.hi % 10; + const std::uint64_t lo_q = div_u128(U128 {hi_r, n.lo}, 10); + const std::uint64_t lo_r = n.lo - lo_q * 10; // the remainder of (hi_r * 2^64 + lo) / 10 + out.push_back(static_cast('0' + lo_r)); + n = U128 {hi_q, lo_q}; + } + std::reverse(out.begin(), out.end()); + return out; + }; + if (k == 0) return dec(num); + if (k <= 126) return dec(num) + "/" + dec(k >= 64 ? U128 {std::uint64_t {1} << (k - 64), 0} : U128 {0, std::uint64_t {1} << k}); + return dec(num) + "/2**" + std::to_string(k); +} + +// gains.sum < fraction * v, exactly (fraction the dyadic rational it is, v < 2^62). +bool below_fraction(Amount recent, double fraction, Amount v) { + const auto [m, x] = dyadic(fraction); // x < 0 since fraction < 1 + const U128 rhs = mul_u64(m, static_cast(v)); // below 2^115 + // recent < rhs * 2^x <=> recent * 2^-x < rhs; with -x large the left side exceeds 2^128 + // unless recent is 0. + if (recent == 0) return !(rhs == U128 {0, 0}); + const U128 r {0, static_cast(recent)}; + if (bits(r) - x > 127) return false; + return shl(r, -x) < rhs; +} + +void stage4(Call& c, SourceRun& st) { + auto& pe = c.pe; + const auto& ca = std::get(pe.config.routing); + const auto v = st.offer; + Trace steps = Trace::list(); + std::int32_t accepted = 0; + std::vector Wd; + std::vector view_m; + std::vector zeros; + auto stop = [&](const char* why) { + if (kTraced && c.trace) { + Trace s = Trace::map(); + s.set("stop", Trace::str(why)); + steps.push(std::move(s)); + } + }; + while (!st.B.has_value() || *st.B > 0) { + if (c.created >= c.cap) { + st.limit = Termination::LimitFlows; + stop("kMaxFlowsPerCall"); + break; + } + if (ca.max_flow_count.has_value() && static_cast(st.flows.size()) >= *ca.max_flow_count) { + if (*ca.max_flow_count > ca.flow_count) st.limit = Termination::LimitFlows; + if (kTraced && c.trace) { + Trace s = Trace::map(); + s.set("stop", Trace::str("max_flow_count")); + s.set("k", Trace::integer(static_cast(st.flows.size()))); + steps.push(std::move(s)); + } + break; + } + c.W.dense(Wd); + auto cand = search(c, st.ri, Wd); + if (!cand.has_value()) { + stop("no candidate"); + break; + } + if (pe.config.selection.multipath) { + // Revision 24 (owner): a multipath DAG already spans every equal-cost path, so a second + // flow on a structurally equal DAG gains only round-robin remainders: growth stops. A + // repeated single path is a legitimate tunnel. + bool equal = false; + for (const auto& f : st.flows) equal = equal || f.plan->route == cand->edges; + if (equal) { + if (kTraced && c.trace) { + Trace s = Trace::map(); + s.set("stop", Trace::str("candidate equals a cohort route")); + s.set("route", route_trace(cand->edges)); + steps.push(std::move(s)); + } + break; + } + } + WorkFlow f = new_flow(c, st.src, bundle_of(c.g, cand->edges, nullptr), cand->cost, &Wd); + Trace step = Trace::map(); + if (kTraced && c.trace) { + step.set("route", route_trace(cand->edges)); + step.set("W", Trace::ints(Wd)); + } + Amount gain = 0; + if (pe.config.placement == FlowPlacement::EqualBalanced) { + Amount T = 0; + for (const auto& x : st.flows) T += x.T; + for (const auto& x : st.flows) c.W.release(x.members(), x.alloc); // V = W + cohort allocation + auto plans = plans_of(st.flows); + plans.push_back(f.plan.get()); + CohortEval trial(plans, c.W); + const Amount target = v.has_value() ? st.T_entry + *v : trial.upper(); + Amount t_cand = T; // rejected unless the trial cohort carries more than T + if (target > T && trial.fits(T + 1)) { + t_cand = trial.fits(target) ? target : trial.largest(T + 1, target - 1); + } + if (kTraced && c.trace) { + const Amount bound = trial.t_max(); + step.set("T", Trace::integer(T)); + step.set("T_candidate", Trace::integer(v.has_value() ? std::min(st.T_entry + *v, bound) : bound)); + step.set("bound", Trace::integer(bound)); + } + if (t_cand <= T) { + for (const auto& x : st.flows) c.W.consume(x.members(), x.alloc); // W restored + if (kTraced && c.trace) { + step.set("decision", Trace::str("rejected: cohort total not larger")); + steps.push(std::move(step)); + } + break; + } + f.seq = ++c.seq; + st.flows.push_back(std::move(f)); + set_cohort(st.flows, t_cand); + for (const auto& x : st.flows) c.W.consume(x.members(), x.alloc); + gain = t_cand - T; + if (kTraced && c.trace) { + Trace shares = Trace::list(); + for (const auto& x : st.flows) shares.push(Trace::integer(x.T)); + step.set("shares", std::move(shares)); + } + } else { + const auto members = f.members(); + c.W.gather(members, view_m); + zeros.assign(members.size(), 0); + auto fill = lossless_step(c.g, *f.plan, 0, view_m, zeros, st.B); + if (kTraced && c.trace) { + step.set("bound", Trace::integer(flow_t_max(c.g, *f.plan, view_m))); + step.set("fill", Trace::integer(fill.total)); + } + if (fill.total == 0) { + if (kTraced && c.trace) { + step.set("decision", Trace::str("rejected: zero fill")); + steps.push(std::move(step)); + } + break; + } + f.T = fill.total; + f.alloc = std::move(fill.alloc); + c.W.consume(members, f.alloc); + f.seq = ++c.seq; + gain = f.T; + st.flows.push_back(std::move(f)); + } + ++c.created; + c.routes_changed = true; + ++accepted; + st.gains.push_back(gain); + if (st.B.has_value()) *st.B -= gain; + if (kTraced && c.trace) { + step.set("decision", Trace::str("accepted")); + step.set("gain", Trace::integer(gain)); + steps.push(std::move(step)); + } + if (ca.diminishing.has_value() && v.has_value() && accepted >= ca.diminishing->window) { + Amount recent = 0; + const auto w = static_cast(ca.diminishing->window); + for (std::size_t i = st.gains.size() - w; i < st.gains.size(); ++i) recent += st.gains[i]; + if (below_fraction(recent, ca.diminishing->fraction, *v) && *st.B > 0) { + st.limit = Termination::LimitDiminishing; + if (kTraced && c.trace) { + Trace s = Trace::map(); + s.set("stop", Trace::str("diminishing")); + s.set("recent", Trace::integer(recent)); + s.set("threshold", Trace::str(fraction_text(ca.diminishing->fraction, *v))); + steps.push(std::move(s)); + } + break; + } + } + } + if (kTraced && st.tr) st.tr->set("stage4", std::move(steps)); +} + +// ---------------------------------------------------------------- per source + +void run_source(Call& c, SourceRun& st) { + auto& pe = c.pe; + if (st.flows.empty()) stage1(c, st); + if (st.flows.empty()) return; + if (is(pe) && std::get(pe.config.routing).reoptimize) stage2(c, st); + if (pe.config.admission == Admission::Lossy) { + stage3_lossy(c, st); + } else if (pe.config.placement == FlowPlacement::EqualBalanced) { + stage3_eb(c, st); + } else { + stage3_seq(c, st); + } + if (is(pe)) stage4(c, st); +} + +Termination local_outcome(const PolicyEngine& pe, const SourceRun& st) { + Amount after = 0; + for (const auto& f : st.flows) after += pe.config.admission == Admission::Lossy ? f.delivered : f.T; + const Amount delivered = after - st.P; + if (st.offer.has_value() && *st.offer == 0) return Termination::Complete; + if (pe.config.admission == Admission::Lossy) { + if (st.offer.has_value() && st.injected == *st.offer) return Termination::Complete; + } else if (st.offer.has_value() && delivered == *st.offer) { + return Termination::Complete; + } + if (st.limit == Termination::LimitFlows || st.capped) return Termination::LimitFlows; + if (st.limit == Termination::LimitDiminishing) return Termination::LimitDiminishing; + if (st.flows.empty()) return Termination::NoRoute; + return Termination::Exhausted; +} + +// The working copy of a live flow: its state and its record's amounts, member-aligned. +WorkFlow working_copy(const PolicyEngine& pe, const LiveFlow& s) { + const FlowRecord& rec = LedgerAccess::live(pe.fg, s.id); + WorkFlow f; + f.id = s.id; + f.src = s.src; + f.plan = s.plan; + f.bundle = s.bundle; + f.cost = s.cost; + f.bundle_index = s.bundle_index; + align(f.plan->members, rec.edges, f.alloc); + if (pe.config.admission == Admission::Lossy) { + f.offered = rec.offered.value_or(Counter128 {}); + f.delivered = rec.delivered; + align(f.plan->members, rec.drops, f.drops); + } else { + f.T = rec.delivered; + } + return f; +} + +Draft draft_of(const PolicyEngine& pe, const WorkFlow& f) { + Draft d; + if (!f.is_new()) d.id = f.id; + auto& r = d.record; + r.sources = {f.src}; + r.destinations = pe.dests_ok; + r.placement = pe.config.placement; + r.admission = pe.config.admission; + r.bundle = f.bundle; + r.route_cost = f.cost; + const auto members = f.members(); + r.edges.reserve(static_cast(std::count_if(f.alloc.begin(), f.alloc.end(), [](Amount x) { return x != 0; }))); + for (std::size_t m = 0; m < members.size(); ++m) { + if (f.alloc[m] != 0) r.edges.emplace_back(members[m], f.alloc[m]); + } + if (pe.config.admission == Admission::Lossy) { + r.offered = f.offered; + r.delivered = f.delivered; + r.drops.reserve(static_cast( + std::count_if(f.drops.begin(), f.drops.end(), [](const Counter128& x) { return !x.is_zero(); }))); + for (std::size_t m = 0; m < members.size(); ++m) { + if (!f.drops[m].is_zero()) r.drops.emplace_back(members[m], f.drops[m]); + } + } else { + r.delivered = f.T; + } + return d; +} + +// The epoch state after the commit is built before it (every allocation), then swapped in. +std::vector> next_cohorts(const std::vector& runs, std::size_t num_roster) { + std::vector> out(num_roster); + for (const auto& st : runs) { + auto& c = out[st.ri]; + c.reserve(st.flows.size()); + for (const auto& f : st.flows) c.push_back(LiveFlow {f.id, f.src, f.plan, f.bundle, f.cost, f.bundle_index}); + } + return out; +} + +std::vector> merge_drops(std::vector> drops) { + std::sort(drops.begin(), drops.end()); + std::vector> out; + for (const auto& [e, x] : drops) { + if (!out.empty() && out.back().first == e) { + out.back().second += x; + } else { + out.emplace_back(e, x); + } + } + return out; +} + +std::string key(NodeId v) { return std::to_string(v); } + +PlacementResult place_flows(PolicyEngine& pe, std::optional v, const OutputOptions& outputs, Trace* trace, + const std::function* staged) { + const auto& g = *pe.graph; + Working W(pe.fg); + Call c {pe, g, W}; +#if NETGRAPH_CORE_PRIVATE_HOOKS + c.cap = max_flows_per_call().load(std::memory_order_relaxed); +#endif + c.trace = trace; + const std::size_t k = pe.roster.size(); + std::vector runs(k); + if (is(pe)) { + runs[0].offer = v; + } else if (v.has_value()) { + const auto inc = cumulative_offers(pe.requested, *v, k); + for (std::size_t i = 0; i < k; ++i) runs[i].offer = inc[i]; + } + Trace sources_tr = Trace::map(); + for (std::size_t i = 0; i < k; ++i) { + auto& st = runs[i]; + st.ri = i; + st.src = pe.roster[i]; + st.B = st.offer; + st.flows.reserve(pe.cohorts[i].size()); + for (const auto& s : pe.cohorts[i]) st.flows.push_back(working_copy(pe, s)); + for (const auto& f : st.flows) { + st.P += pe.config.admission == Admission::Lossy ? f.delivered : f.T; + st.T_entry += f.T; + } + } + for (auto& st : runs) { + Trace* tr = nullptr; + if (kTraced && trace) tr = &sources_tr.set(key(st.src), Trace::map()); + st.tr = tr; + run_source(c, st); + st.tr = nullptr; + } + // The commit: retained flows (by source, FlowId order) then new flows in creation order. + std::vector drafts; + // (run, flow) of the new flows, per thread: cleared here, read until the epoch state is set. + thread_local std::vector> fresh_scratch; + auto& fresh = fresh_scratch; + fresh.clear(); + for (std::size_t i = 0; i < k; ++i) { + for (std::size_t j = 0; j < runs[i].flows.size(); ++j) { + const auto& f = runs[i].flows[j]; + if (f.is_new()) { + fresh.emplace_back(i, j); + } else { + drafts.push_back(draft_of(pe, f)); + } + } + } + std::sort(fresh.begin(), fresh.end(), [&](const auto& a, const auto& b) { + return runs[a.first].flows[a.second].seq < runs[b.first].flows[b.second].seq; + }); + for (const auto& [i, j] : fresh) drafts.push_back(draft_of(pe, runs[i].flows[j])); + // Everything the publication of the epoch state needs is allocated before the commit. + Counter128 requested = pe.requested; + if (v.has_value() && !requested.add(*v)) throw std::invalid_argument("the cumulative request counter would reach 2^127"); + auto next = next_cohorts(runs, k); + Amount placed = 0; + std::vector> outcomes; + outcomes.reserve(k); + Termination term = Termination::Complete; + for (const auto& st : runs) { + Amount after = 0; + for (const auto& f : st.flows) after += pe.config.admission == Admission::Lossy ? f.delivered : f.T; + placed += after - st.P; + const Termination o = local_outcome(pe, st); + outcomes.emplace_back(st.src, o); + if (rank(o) > rank(term)) term = o; + } + PlacementResult out; + out.placed = placed; + if (v.has_value()) out.remaining = *v - placed; + out.termination = term; + out.local_outcomes = std::move(outcomes); + out.routes_changed = c.routes_changed; + out.weights_changed = c.weights_changed; + out.drops = merge_drops(std::move(c.drops)); + if (kTraced && trace) { + Trace offers_tr = Trace::map(); + Trace outcomes_tr = Trace::map(); + for (std::size_t i = 0; i < k; ++i) { + offers_tr.set(key(runs[i].src), amount_or_inf(runs[i].offer)); + outcomes_tr.set(key(runs[i].src), Trace::str(outcome_name(out.local_outcomes[i].second))); + } + trace->set("offers", std::move(offers_tr)); + trace->set("local_outcomes", std::move(outcomes_tr)); + trace->set("created", Trace::integer(c.created)); + trace->set("sources", std::move(sources_tr)); + std::vector wf; + W.dense(wf); + trace->set("W_final", Trace::ints(wf)); + } + if (any_output(outputs)) prepare_outputs(pe, outputs, draft_edges(drafts), out); + if (staged != nullptr && *staged) (*staged)(); + std::vector ids; + if (!drafts.empty()) ids = LedgerAccess::commit(pe.fg, pe.owner, drafts); + // Committed: the epoch state follows (nothing below throws). + for (std::size_t n = 0; n < fresh.size(); ++n) next[fresh[n].first][fresh[n].second].id = ids[n].second; + pe.cohorts.swap(next); + pe.requested = requested; + if (!v.has_value()) pe.infinite = true; + return out; +} + +// ---------------------------------------------------------------- Global + +// The canonical per-source decomposition (9.1): sources ascending; for each, while it has +// supply, the lexicographically smallest EdgeId walk with positive remaining flow from it to the +// first destination it reaches, saturated by min(the walk's bottleneck, the remaining supply). +// Per node the first out-edge with remaining flow only moves forward (remaining flow never grows). +std::vector>> decompose(const Graph& g, std::vector rem, + std::span roster, + std::span dests) { + const auto row = g.row_offsets(); + const auto edge_dst = g.edge_dst(); + const auto in_row = g.in_row_offsets(); + const auto in_adj = g.in_adj_edge_index(); + std::vector is_dst(static_cast(g.num_nodes()), 0); + for (NodeId d : dests) is_dst[static_cast(d)] = 1; + std::vector cur(row.begin(), row.end() - 1); + std::vector>> out(roster.size()); + std::vector walk; + std::vector mine; + for (std::size_t i = 0; i < roster.size(); ++i) { + const NodeId s = roster[i]; + const auto si = static_cast(s); + // The supply of s: its net outflow (transit through s from another source is not supply). + Amount left = 0; + for (auto e = row[si]; e < row[si + 1]; ++e) left += rem[static_cast(e)]; + for (auto k = in_row[si]; k < in_row[si + 1]; ++k) left -= rem[static_cast(in_adj[static_cast(k)])]; + std::vector> acc; + while (left > 0) { + walk.clear(); + NodeId u = s; + while (walk.empty() || is_dst[static_cast(u)] == 0) { + auto& p = cur[static_cast(u)]; + while (p < row[static_cast(u) + 1] && rem[static_cast(p)] == 0) ++p; + if (p == row[static_cast(u) + 1]) { + throw std::logic_error("Global decomposition: flow conservation broken (no continuation)"); + } + walk.push_back(p); + u = edge_dst[static_cast(p)]; + if (walk.size() > static_cast(g.num_nodes())) { + throw std::logic_error("Global decomposition: a cycle with positive flow"); + } + } + Amount amount = left; + for (EdgeId e : walk) amount = std::min(amount, rem[static_cast(e)]); + for (EdgeId e : walk) { + rem[static_cast(e)] -= amount; + acc.emplace_back(e, amount); + } + left -= amount; + } + std::sort(acc.begin(), acc.end()); + auto& res = out[i]; + for (const auto& [e, x] : acc) { + if (!res.empty() && res.back().first == e) { + res.back().second += x; + } else { + res.emplace_back(e, x); + } + } + } + for (Amount x : rem) { + if (x != 0) throw std::logic_error("Global decomposition: the walks do not cover the flow"); + } + return out; +} + +PlacementResult place_global(PolicyEngine& pe, std::optional v, const OutputOptions& outputs, Trace* trace, + const std::function* staged) { + const auto& g = *pe.graph; + std::vector V; + LedgerAccess::residual_into(pe.fg, V); + Amount P = 0; + const bool created = std::all_of(pe.cohorts.begin(), pe.cohorts.end(), [](const auto& c) { return c.empty(); }); + for (std::size_t i = 0; i < pe.roster.size(); ++i) { + for (const auto& s : pe.cohorts[i]) { + const FlowRecord& rec = LedgerAccess::live(pe.fg, s.id); + P += rec.delivered; + for (const auto& [e, x] : rec.edges) V[static_cast(e)] += x; + } + } + const std::optional cap = v.has_value() ? std::optional(P + *v) : std::nullopt; + if (pe.capture != nullptr) pe.capture->graph = pe.graph; + auto sol = place_global_units(g, V, pe.roster, pe.dests_ok, pe.elig, cap, pe.capture); + if (sol.placed < P) throw std::logic_error("Global: a call reduced placed_demand()"); + auto shares = decompose(g, sol.edge_flows, pe.roster, pe.dests_ok); + bool changed = created; + std::vector supplies(pe.roster.size(), 0); + std::vector drafts; + std::vector fresh; + std::vector> next(pe.roster.size()); + for (std::size_t i = 0; i < pe.roster.size(); ++i) { + const NodeId s = pe.roster[i]; + std::vector route; + route.reserve(shares[i].size()); + Amount supply = 0; + for (const auto& [e, x] : shares[i]) route.push_back(e); + { + // The supply of s: what its share delivers (its out-edges' share). + const auto row = g.row_offsets(); + for (const auto& [e, x] : shares[i]) { + if (e >= row[static_cast(s)] && e < row[static_cast(s) + 1]) supply += x; + } + } + std::shared_ptr bundle; + Cost cost = kUnreachable; + const LiveFlow* old = pe.cohorts[i].empty() ? nullptr : &pe.cohorts[i].front(); + if (old != nullptr && PredDAGBuilder::ascending(*old->bundle).size() == route.size() && + std::equal(route.begin(), route.end(), PredDAGBuilder::ascending(*old->bundle).begin())) { + bundle = old->bundle; + cost = old->cost; + } else { + if (old != nullptr) changed = true; + bundle = std::make_shared(PredDAGBuilder::of_edges(g, route)); + if (!route.empty()) cost = route_cost(g, *bundle, s, pe.dests_ok); + } + Draft d; + if (old != nullptr) d.id = old->id; + auto& r = d.record; + r.sources = {s}; + r.destinations = pe.dests_ok; + r.placement = FlowPlacement::MaxFlow; + r.admission = Admission::Lossless; + r.bundle = bundle; + r.route_cost = cost; + r.delivered = supply; + supplies[i] = supply; + r.edges = shares[i]; + if (old == nullptr) fresh.push_back(i); + next[i].push_back(LiveFlow {old != nullptr ? old->id : -1, s, nullptr, bundle, cost, -1}); + drafts.push_back(std::move(d)); + } + // New records in source order (ascending NodeId), after the retained ones. + std::stable_partition(drafts.begin(), drafts.end(), [](const Draft& d) { return d.id.has_value(); }); + Counter128 requested = pe.requested; + if (v.has_value() && !requested.add(*v)) throw std::invalid_argument("the cumulative request counter would reach 2^127"); + PlacementResult out; + out.placed = sol.placed - P; + if (v.has_value()) out.remaining = *v - out.placed; + if (v.has_value() && out.placed == *v) { + out.termination = Termination::Complete; + } else if (sol.placed == 0) { + out.termination = Termination::NoRoute; + } else { + out.termination = Termination::Exhausted; + } + out.local_outcomes.reserve(pe.roster.size()); + for (NodeId s : pe.roster) out.local_outcomes.emplace_back(s, out.termination); + out.routes_changed = changed; + out.cost_distribution = sol.cost_distribution; + if (kTraced && trace) { + trace->set("V", Trace::ints(V)); + trace->set("cap", cap.has_value() ? Trace::integer(*cap) : Trace::null()); + Trace fc = Trace::map(); + fc.set("placed", Trace::integer(sol.placed)); + fc.set("cost_distribution", Trace::pairs(sol.cost_distribution)); + fc.set("edge_flows", Trace::ints(sol.edge_flows)); + fc.set("min_cut", sol.min_cut.has_value() ? Trace::ints(*sol.min_cut) : Trace::null()); + fc.set("potentials", Trace::ints(sol.potentials)); + Trace counters = Trace::map(); + counters.set("dijkstra_runs", Trace::integer(sol.counters.dijkstra_runs)); + counters.set("dijkstra_pops", Trace::integer(sol.counters.dijkstra_pops)); + counters.set("settled", Trace::integer(sol.counters.settled)); + counters.set("arc_scans", Trace::integer(sol.counters.arc_scans)); + counters.set("dinic_phases", Trace::integer(sol.counters.dinic_phases)); + counters.set("augmentations", Trace::integer(sol.counters.augmentations)); + fc.set("counters", std::move(counters)); + Trace supply = Trace::map(); + Trace shares_tr = Trace::map(); + for (std::size_t i = 0; i < pe.roster.size(); ++i) { + supply.set(key(pe.roster[i]), Trace::integer(supplies[i])); + shares_tr.set(key(pe.roster[i]), Trace::pairs(shares[i])); + } + fc.set("supply", std::move(supply)); + trace->set("flow_core", std::move(fc)); + trace->set("shares", std::move(shares_tr)); + } + if (outputs.bottleneck) out.min_cut = std::move(sol.min_cut); + if (any_output(outputs)) prepare_outputs(pe, outputs, draft_edges(drafts), out); + auto curve = *out.cost_distribution; // the cumulative curve a later zero call reports (9.1) + if (staged != nullptr && *staged) (*staged)(); + const auto ids = LedgerAccess::commit(pe.fg, pe.owner, drafts); + // Committed: the epoch state follows (nothing below throws). + for (std::size_t n = 0; n < fresh.size(); ++n) next[fresh[n]].front().id = ids[n].second; + pe.cohorts.swap(next); + pe.curve.swap(curve); + pe.requested = requested; + if (!v.has_value()) pe.infinite = true; + return out; +} + +// ---------------------------------------------------------------- construction + +// The static reverse distances toward the allowed destinations over the statically eligible +// member graph (capacity >= 1 unit, no residual). +std::vector static_distances(const Graph& g, const Eligibility* elig, std::span dests) { + const PathSelection full(EdgeTieBreak::Deterministic, EdgePriority::Capacity, true, true); + const auto member = member_mask(g, elig, full, {}, 1); + return distances(g, member, dests, Direction::Reverse); +} + +} // namespace + +void validate_config(const FlowPolicyConfig& cfg, std::size_t num_sources, std::size_t num_destinations) { + const auto& r = cfg.routing; + if (r.valueless_by_exception()) invalid("routing: the variant holds no routing mode"); + check_member(cfg.placement, "placement"); + check_member(cfg.admission, "admission"); + check_selection(cfg.selection); + check_kernel(cfg.placement, cfg.admission, cfg.selection.priority); + if (cfg.admission == Admission::Lossy && + (std::holds_alternative(r) || std::holds_alternative(r))) { + invalid("Lossy admission is rejected with Global and CapacityAware routing (hop-by-hop forwarding without " + "admission control neither grows nor reroutes)"); + } + if (std::holds_alternative(r) && cfg.placement != FlowPlacement::MaxFlow) { + invalid("Global routing requires placement = MaxFlow"); + } + if (const auto* ca = std::get_if(&r)) { + if (ca->flow_count < 1) invalid("CapacityAware flow_count must be >= 1 (got " + str(ca->flow_count) + ")"); + if (ca->max_flow_count.has_value() && *ca->max_flow_count < ca->flow_count) { + invalid("CapacityAware max_flow_count (" + str(*ca->max_flow_count) + ") must be >= flow_count (" + + str(ca->flow_count) + ")"); + } + if (ca->min_flow_bandwidth < 0 || ca->min_flow_bandwidth >= kDomainLimit) { + invalid("CapacityAware min_flow_bandwidth must be in [0, 2^62) units (got " + str(ca->min_flow_bandwidth) + ")"); + } + if (ca->max_path_cost.has_value()) { + if (const auto* a = std::get_if(&*ca->max_path_cost)) { + if (a->cost < 0) invalid("max_path_cost Absolute cost must be >= 0 (got " + str(a->cost) + ")"); + } else { + const double f = std::get(*ca->max_path_cost).factor; + if (!(f >= 1.0) || !std::isfinite(f)) { + invalid("max_path_cost Stretch factor must be finite and in [1, inf) (got " + std::to_string(f) + ")"); + } + } + } + if (ca->diminishing.has_value()) { + const auto& d = *ca->diminishing; + if (d.window < 1) invalid("diminishing window must be >= 1 (got " + str(d.window) + ")"); + if (!(d.fraction > 0.0 && d.fraction < 1.0)) { + invalid("diminishing fraction must be in (0, 1) (got " + std::to_string(d.fraction) + ")"); + } + } + } + if (std::holds_alternative(r) && (num_sources != 1 || num_destinations != 1)) { + invalid("Pinned routing requires single endpoints (bundles bind one source and one destination)"); + } +} + +} // namespace detail + +// ================================================================ FlowPolicyConfig + +// The enum-typed fields inside their enumerations, in validate_config's order. +FlowPolicyConfig::FlowPolicyConfig(Routing routing_, FlowPlacement placement_, Admission admission_, + PathSelection selection_) + : routing(std::move(routing_)), placement(placement_), admission(admission_), selection(selection_) { + detail::check_member(placement, "placement"); + detail::check_member(admission, "admission"); + detail::check_selection(selection); +} + +// ================================================================ FlowPolicy + +using detail::LedgerAccess; +using detail::PolicyEngine; + +FlowPolicy::FlowPolicy(FlowLedger& ledger, std::span sources, std::span destinations, + FlowPolicyConfig config, const Eligibility* elig, DagCache* cache) { + auto pe = std::make_unique(ledger, std::move(config)); + pe->graph = ledger.graph(); + const auto& g = *pe->graph; + if (elig != nullptr && elig->graph_uid() != g.uid()) { + throw std::invalid_argument("foreign Eligibility: built for another graph"); + } + if (cache != nullptr && cache->graph_uid() != g.uid()) { + throw std::invalid_argument("foreign DagCache: bound to another graph"); + } + pe->elig = elig; + pe->sources = detail::node_set(sources, g.num_nodes(), "sources"); + pe->destinations = detail::node_set(destinations, g.num_nodes(), "destinations"); + detail::check_disjoint(pe->sources, pe->destinations, "sources", "destinations"); + detail::validate_config(pe->config, pe->sources.size(), pe->destinations.size()); + auto node_ok = [&](NodeId v) { return elig == nullptr || elig->node_ok(v); }; + for (NodeId d : pe->destinations) { + if (node_ok(d)) pe->dests_ok.push_back(d); + } + if (detail::is(*pe)) { + const auto& bundles = std::get(pe->config.routing).bundles; + if (bundles.empty()) throw std::invalid_argument("Pinned: an empty bundle list is rejected"); + const NodeId src = pe->sources.front(); + const NodeId dst[1] = {pe->destinations.front()}; + for (std::size_t i = 0; i < bundles.size(); ++i) { + const auto& b = bundles[i]; + if (!b) throw std::invalid_argument("Pinned: bundle " + std::to_string(i) + " is null"); + if (b->graph_uid() != g.uid()) throw std::invalid_argument("foreign PredDAG: bundle built for another graph"); + for (std::size_t j = 0; j < i; ++j) { + if (*bundles[j] == *b) { + throw std::invalid_argument("Pinned: duplicate bundle " + std::to_string(i) + " (equal to bundle " + + std::to_string(j) + " before pruning)"); + } + } + Cost cost = kUnreachable; + auto plan = detail::make_route_plan(g, pe->config.placement, pe->config.admission, + pe->config.selection.priority, src, dst, *b, elig, &cost); + if (plan.route.empty() && detail::prune(g, *b, src, dst).empty()) { + throw std::invalid_argument("Pinned: malformed bundle " + std::to_string(i) + ", no src -> dst walk (src " + + std::to_string(src) + ", dst " + std::to_string(dst[0]) + ")"); + } + detail::PreparedPinnedBundle pb; + pb.cost = cost; + pb.route_dag = detail::bundle_of(g, plan.route, b); + pb.route_plan = std::make_shared(std::move(plan)); + if (!pb.route_plan->route.empty()) pe->usable.push_back(static_cast(i)); + pe->bundles.push_back(std::move(pb)); + } + if (!pe->usable.empty()) pe->roster.push_back(src); + } else if (detail::is(*pe)) { + pe->support = cache != nullptr + ? cache->lookup(g, pe->destinations, Direction::Reverse, elig) + : std::make_shared(detail::static_support(g, pe->destinations, Direction::Reverse, elig)); + const auto edges = detail::PredDAGBuilder::ascending(*pe->support); + const auto row = g.row_offsets(); + for (NodeId s : pe->sources) { + if (!node_ok(s)) continue; + const auto it = std::lower_bound(edges.begin(), edges.end(), row[static_cast(s)]); + if (it != edges.end() && *it < row[static_cast(s) + 1]) pe->roster.push_back(s); + } + } else { + const auto dist = detail::static_distances(g, elig, pe->dests_ok); + for (NodeId s : pe->sources) { + if (!node_ok(s) || dist[static_cast(s)] == kUnreachable) continue; + pe->roster.push_back(s); + pe->baseline.push_back(dist[static_cast(s)]); + } + } + pe->cohorts.resize(pe->roster.size()); + pe->owner = LedgerAccess::bind(ledger); // last: nothing is bound when construction fails + engine_ = std::move(pe); +} + +// The destruction law (section 8): the records stay in the ledger as orphans. +FlowPolicy::~FlowPolicy() { LedgerAccess::release(engine_->fg, engine_->owner); } + +namespace { + +// `staged` (optional) runs once the call's result and trace are final and before anything is +// published (the ledger commit, the epoch state): an exception from it aborts the call with +// nothing changed (the Python binding builds its result objects there). +PlacementResult place_impl(PolicyEngine& pe, std::optional volume, const OutputOptions& outputs, + detail::Trace* trace, const std::function* staged) { + // Validation before any mutation, also before the zero shortcut (section 8). + if (volume.has_value() && (*volume < 0 || *volume >= kDomainLimit)) { + throw std::invalid_argument("request " + std::to_string(*volume) + + " is outside [0, 2^62) units (requests are integer units or +inf)"); + } + if (pe.infinite && volume.has_value()) { + throw std::invalid_argument( + "a finite request after +inf is rejected within an epoch (the cumulative finite request is undefined; " + "rule 13)"); + } + if (volume.has_value()) { + Counter128 probe = pe.requested; + if (!probe.add(*volume)) throw std::invalid_argument("the cumulative request counter would reach 2^127"); + } + PlacementResult out; + if (volume.has_value() && *volume == 0) { + out.remaining = 0; + out.local_outcomes.reserve(pe.roster.size()); + for (NodeId s : pe.roster) out.local_outcomes.emplace_back(s, Termination::Complete); + // Global: the cumulative curve (9.1: its amounts sum to placed_demand(); empty before the + // first placement); a zero call solves nothing, so it claims no cut (2.2's cap-zero payload). + if (detail::is(pe)) out.cost_distribution = pe.curve; + detail::prepare_outputs(pe, outputs, detail::current_edges(pe), out); + if (staged != nullptr && *staged) (*staged)(); + return out; + } + if (pe.roster.empty()) { + out.remaining = volume; + out.termination = Termination::NoRoute; + if (detail::is(pe)) { + // No source reaches an allowed destination: the cumulative flow is 0 and maximal, so its + // marginal curve is empty and the full-residual cut of 2.2 is empty (the closure of the + // sources has no arc of positive view leaving it); None would claim a residual path. + out.cost_distribution.emplace(); + if (outputs.bottleneck) out.min_cut.emplace(); + } + if (detail::kTraced && trace) trace->set("roster", detail::Trace::list()); + detail::prepare_outputs(pe, outputs, detail::current_edges(pe), out); + if (staged != nullptr && *staged) (*staged)(); + // The epoch records every positive request, also one that finds no roster (section 8); + // nothing below throws (the counter was checked above). + if (volume.has_value()) { + (void)pe.requested.add(*volume); + } else { + pe.infinite = true; + } + return out; + } + if (detail::is(pe)) return detail::place_global(pe, volume, outputs, trace, staged); + return detail::place_flows(pe, volume, outputs, trace, staged); +} + +} // namespace + +PlacementResult FlowPolicy::place(std::optional volume, const OutputOptions& outputs) { + return place_impl(*engine_, volume, outputs, nullptr, nullptr); +} + +#if NETGRAPH_CORE_PRIVATE_HOOKS +PlacementResult detail::place_traced(FlowPolicy& policy, std::optional volume, const OutputOptions& outputs, + Trace& trace, const std::function* staged) { + trace = Trace::map(); + return place_impl(PolicyAccess::engine(policy), volume, outputs, &trace, staged); +} +#endif + +void FlowPolicy::remove() { + auto& pe = *engine_; + (void)LedgerAccess::detach(pe.fg, pe.owner); + // A new epoch (nothing below throws). + for (auto& c : pe.cohorts) c.clear(); + pe.curve.clear(); + pe.requested = Counter128 {}; + pe.infinite = false; +} + +Amount FlowPolicy::placed_demand() const { + const auto& pe = *engine_; + Amount total = 0; + for (const auto& c : pe.cohorts) { + for (const auto& s : c) total += LedgerAccess::live(pe.fg, s.id).delivered; + } + return total; +} + +std::vector FlowPolicy::records() const { + const auto& pe = *engine_; + std::vector ids; + for (const auto& c : pe.cohorts) { + for (const auto& s : c) ids.push_back(s.id); + } + std::sort(ids.begin(), ids.end()); + std::vector out; + out.reserve(ids.size()); + for (std::int64_t id : ids) out.push_back(LedgerAccess::live(pe.fg, id)); + return out; +} + +FlowRecord FlowPolicy::record(const FlowId& flow_id) const { + const auto& pe = *engine_; + const FlowLedger& fg = pe.fg; + // The ledger's checks first (another ledger, a retired or unknown id), then the owner. + FlowRecord rec = fg.record(flow_id); + if (rec.owner != pe.owner) { + throw std::invalid_argument("FlowId (" + std::to_string(flow_id.first) + ", " + std::to_string(flow_id.second) + + ") is not a record of this policy"); + } + return rec; +} + +std::int32_t FlowPolicy::usable_bundles() const noexcept { + return detail::is(*engine_) ? static_cast(engine_->usable.size()) : 0; +} + +const FlowPolicyConfig& FlowPolicy::config() const noexcept { return engine_->config; } + +} // namespace netgraph::core diff --git a/src/strict_multidigraph.cpp b/src/graph.cpp similarity index 74% rename from src/strict_multidigraph.cpp rename to src/graph.cpp index bdfca20..3cc2595 100644 --- a/src/strict_multidigraph.cpp +++ b/src/graph.cpp @@ -1,5 +1,5 @@ /* - StrictMultiDiGraph: the one graph type on integer units (APPROACH 2.1, 3). + Graph: the one graph type on integer units (APPROACH 2.1, 3). Construction validates the inputs in the documented order, then orders the edges by the stable key (src, cost, dst, input): a counting pass by src places every input position in @@ -10,7 +10,7 @@ (cost, src, id). Within one destination, equal (cost, src) means equal (src, cost, dst), whose ids are in input order, so (cost, src, id) is (cost, src, input). */ -#include "netgraph/core/strict_multidigraph.hpp" +#include "netgraph/core/graph.hpp" #include #include @@ -88,12 +88,12 @@ double as_float(Amount x, double resolution) { return std::ldexp(static_cast(x), k); } -StrictMultiDiGraph::StrictMultiDiGraph(std::int32_t num_nodes, - std::span src, - std::span dst, - std::span capacity_units, - std::span cost, - double resolution) { +Graph::Graph(std::int32_t num_nodes, + std::span src, + std::span dst, + std::span capacity_units, + std::span cost, + double resolution) { (void)resolution_exponent(resolution); // The unit values. for (std::size_t i = 0; i < capacity_units.size(); ++i) { @@ -220,7 +220,7 @@ StrictMultiDiGraph::StrictMultiDiGraph(std::int32_t num_nodes, } } -StrictMultiDiGraph::StrictMultiDiGraph(const StrictMultiDiGraph& other) +Graph::Graph(const Graph& other) : uid_(next_uid()), resolution_(other.resolution_), num_nodes_(other.num_nodes_), @@ -235,7 +235,7 @@ StrictMultiDiGraph::StrictMultiDiGraph(const StrictMultiDiGraph& other) in_row_offsets_(other.in_row_offsets_), in_adj_edge_index_(other.in_adj_edge_index_) {} -StrictMultiDiGraph::StrictMultiDiGraph(StrictMultiDiGraph&& other) noexcept +Graph::Graph(Graph&& other) noexcept : uid_(std::exchange(other.uid_, 0)), resolution_(other.resolution_), num_nodes_(std::exchange(other.num_nodes_, 0)), @@ -253,60 +253,4 @@ StrictMultiDiGraph::StrictMultiDiGraph(StrictMultiDiGraph&& other) noexcept other.in_row_offsets_.assign(1, 0); } -Eligibility StrictMultiDiGraph::eligibility(std::span excluded_nodes, - std::span excluded_edges) const { - const auto n = static_cast(num_nodes_); - const auto m = capacity_.size(); - for (NodeId v : excluded_nodes) { - if (v < 0 || static_cast(v) >= n) { - throw std::invalid_argument("excluded node " + std::to_string(v) + " out of node range [0, " + - std::to_string(n) + ")"); - } - } - for (EdgeId e : excluded_edges) { - if (e < 0 || static_cast(e) >= m) { - throw std::invalid_argument("excluded edge " + std::to_string(e) + " out of edge range [0, " + - std::to_string(m) + ")"); - } - } - Eligibility el; - el.graph_uid_ = uid_; - el.node_allowed_.assign(n, 1); - el.edge_allowed_.assign(m, 1); - for (EdgeId e : excluded_edges) el.edge_allowed_[static_cast(e)] = 0; - for (NodeId v : excluded_nodes) { - const auto vi = static_cast(v); - if (el.node_allowed_[vi] == 0) continue; // duplicate - el.node_allowed_[vi] = 0; - for (auto e = row_offsets_[vi]; e < row_offsets_[vi + 1]; ++e) { - el.edge_allowed_[static_cast(e)] = 0; - } - for (auto s = in_row_offsets_[vi]; s < in_row_offsets_[vi + 1]; ++s) { - el.edge_allowed_[static_cast(in_adj_edge_index_[static_cast(s)])] = 0; - } - } - el.uid_ = (excluded_nodes.empty() && excluded_edges.empty()) ? 0 : next_uid(); - return el; -} - -Eligibility Eligibility::intersect(const Eligibility& a, const Eligibility& b) { - if (a.graph_uid_ != b.graph_uid_) { - throw std::invalid_argument("Eligibility.intersect: the two values belong to different graphs"); - } - Eligibility out; - out.graph_uid_ = a.graph_uid_; - out.node_allowed_.resize(a.node_allowed_.size()); - out.edge_allowed_.resize(a.edge_allowed_.size()); - for (std::size_t v = 0; v < out.node_allowed_.size(); ++v) { - out.node_allowed_[v] = static_cast(a.node_allowed_[v] & b.node_allowed_[v]); - } - for (std::size_t e = 0; e < out.edge_allowed_.size(); ++e) { - out.edge_allowed_[e] = static_cast(a.edge_allowed_[e] & b.edge_allowed_[e]); - } - if (out.node_allowed_ == a.node_allowed_ && out.edge_allowed_ == a.edge_allowed_) return a; - if (out.node_allowed_ == b.node_allowed_ && out.edge_allowed_ == b.edge_allowed_) return b; - out.uid_ = next_uid(); - return out; -} - } // namespace netgraph::core diff --git a/src/kernels.cpp b/src/kernels.cpp index 9c8019f..3043847 100644 --- a/src/kernels.cpp +++ b/src/kernels.cpp @@ -15,22 +15,15 @@ #include #include +#include "netgraph/core/detail/enum_domain.hpp" #include "netgraph/core/detail/flow_core.hpp" #include "netgraph/core/detail/int128.hpp" -#include "netgraph/core/detail/node_map.hpp" #include "netgraph/core/detail/pred_dag_builder.hpp" namespace netgraph::core::detail { // ---------------------------------------------------------------- apportionment -void round_robin(Amount total, std::span out) noexcept { - const auto k = static_cast(out.size()); - const Amount base = total / k; - const Amount extra = total % k; - for (Amount i = 0; i < k; ++i) out[static_cast(i)] = base + (i < extra ? 1 : 0); -} - namespace { // w_i / d_i > w_j / d_j, ties by the lower index: the next unit's recipient order. @@ -88,6 +81,9 @@ void highest_averages(Amount total, std::span weights, std::span edge; // candidate arcs, ascending EdgeId - std::vector tail; // local tail per arc - std::vector head; // local head per arc - std::vector in_off; // in-CSR over local heads - std::vector in_arc; - std::vector cursor; - std::vector state; // backward DFS: 0 unseen, 1 open, 2 done (reaches a destination) - std::vector fwd; // reached from the source over arcs of reached tails - std::vector order; // postorder: every node after all of its parents - std::vector> stack; // (node, next in-arc) - std::vector member_arc; // per member its arc - std::vector position; - std::vector first; // per local tail: its first member - std::vector count; // per local tail: its number of members - std::vector dist; // the cost DP -}; - -SplitScratch& split_scratch() { - thread_local SplitScratch s; - return s; -} - -// The split structure of a flow from src to the destination set over `edges` (ascending -// EdgeId), restricted to the edges `keep(i, e)` accepts: the kept edges on some walk from src to -// a destination (destinations terminal) become plan.members, with the plan's topological fields. -// One pass: a backward DFS from the destinations over the in-arcs lists the nodes that reach a -// destination in postorder (every node after its parents, a topological order); a forward pass -// over that order marks the nodes the source reaches. Members ascend by EdgeId and B' ids rank -// edges by source first, so the members of one tail are consecutive. Any topological order gives -// the same allocation (a node splits once its inflow is complete). -// -// With `cost`, the forward pass also computes the minimum walk cost from the source to a -// destination over the members (kUnreachable without members): the DP runs in the same -// topological order. -template -void build_split(const StrictMultiDiGraph& g, std::span edges, NodeId src, std::span dsts, - Keep keep, KernelPlan& plan, Cost* cost = nullptr) { - plan.members.clear(); - plan.topo.clear(); - plan.terminal.clear(); - plan.out_begin.clear(); - plan.out_end.clear(); - plan.head_pos.clear(); - if (cost != nullptr) *cost = kUnreachable; - plan.src_out_begin = 0; - plan.src_out_end = 0; - const auto edge_src = g.edge_src(); - const auto edge_dst = g.edge_dst(); - NodeMap& map = NodeMap::local(); - map.reset(static_cast(g.num_nodes())); - (void)map.add(src); // local 0 - for (NodeId d : dsts) (void)map.add(d); - const auto num_dst = static_cast(map.size()); // locals 1 .. num_dst - 1 - auto is_dst = [num_dst](std::int32_t v) { return v >= 1 && v < num_dst; }; - SplitScratch& s = split_scratch(); - s.edge.clear(); - s.tail.clear(); - s.head.clear(); - NodeId last_src = -1; - std::int32_t last_tail = -1; - for (std::size_t i = 0; i < edges.size(); ++i) { - const EdgeId e = edges[i]; - if (!keep(i, e)) continue; - const auto ei = static_cast(e); - if (edge_src[ei] != last_src) { - last_src = edge_src[ei]; - last_tail = map.add(last_src); - } - if (is_dst(last_tail)) continue; // destinations are terminal - s.edge.push_back(e); - s.tail.push_back(last_tail); - s.head.push_back(map.add(edge_dst[ei])); - } - const std::size_t n = map.size(); - const std::size_t k = s.edge.size(); - s.in_off.assign(n + 1, 0); - for (std::size_t a = 0; a < k; ++a) ++s.in_off[static_cast(s.head[a]) + 1]; - for (std::size_t v = 0; v < n; ++v) s.in_off[v + 1] += s.in_off[v]; - s.in_arc.resize(k); - s.cursor.assign(s.in_off.begin(), s.in_off.end() - 1); - for (std::size_t a = 0; a < k; ++a) { - s.in_arc[static_cast(s.cursor[static_cast(s.head[a])]++)] = static_cast(a); - } - // Backward DFS from the destinations, postorder. - s.state.assign(n, 0); - s.order.clear(); - for (std::int32_t d = 1; d < num_dst; ++d) { - if (s.state[static_cast(d)] != 0) continue; - s.state[static_cast(d)] = 1; - s.stack.clear(); - s.stack.emplace_back(d, s.in_off[static_cast(d)]); - while (!s.stack.empty()) { - const auto [v, it] = s.stack.back(); - if (it < s.in_off[static_cast(v) + 1]) { - s.stack.back().second = it + 1; - const std::int32_t u = s.tail[static_cast(s.in_arc[static_cast(it)])]; - const auto su = s.state[static_cast(u)]; - if (su == 0) { - s.state[static_cast(u)] = 1; - s.stack.emplace_back(u, s.in_off[static_cast(u)]); - } else if (su == 1) { - throw std::logic_error("kernel plan: the route is cyclic"); - } - } else { - s.state[static_cast(v)] = 2; - s.order.push_back(v); - s.stack.pop_back(); - } - } - } - // Forward over the postorder (with the cost DP when asked). - s.fwd.assign(n, 0); - if (s.state[0] == 2) s.fwd[0] = 1; - if (cost == nullptr) { - for (std::int32_t v : s.order) { - if (v == 0) continue; - const auto vi = static_cast(v); - for (auto it = s.in_off[vi]; it < s.in_off[vi + 1]; ++it) { - if (s.fwd[static_cast(s.tail[static_cast(s.in_arc[static_cast(it)])])] != 0) { - s.fwd[vi] = 1; - break; - } - } - } - } else { - const auto edge_cost = g.cost(); - s.dist.assign(n, kUnreachable); - s.dist[0] = 0; - Cost best = kUnreachable; - for (std::int32_t v : s.order) { - const auto vi = static_cast(v); - if (v != 0) { - Cost dv = kUnreachable; - for (auto it = s.in_off[vi]; it < s.in_off[vi + 1]; ++it) { - const auto a = static_cast(s.in_arc[static_cast(it)]); - const auto u = static_cast(s.tail[a]); - if (s.fwd[u] == 0) continue; - dv = std::min(dv, s.dist[u] + edge_cost[static_cast(s.edge[a])]); - } - if (dv == kUnreachable) continue; - s.fwd[vi] = 1; - s.dist[vi] = dv; - } - if (s.fwd[vi] != 0 && is_dst(v)) best = std::min(best, s.dist[vi]); - } - *cost = best; - } - // Members: the arcs from a reached tail to a head that reaches a destination, ascending. - s.member_arc.clear(); - for (std::size_t a = 0; a < k; ++a) { - if (s.fwd[static_cast(s.tail[a])] != 0 && s.state[static_cast(s.head[a])] == 2) { - s.member_arc.push_back(static_cast(a)); - } - } - const std::size_t num_members = s.member_arc.size(); - if (num_members == 0) return; - plan.members.reserve(num_members); - for (std::int32_t a : s.member_arc) plan.members.push_back(s.edge[static_cast(a)]); - // Positions: the postorder restricted to reached nodes; the source precedes every node it - // reaches, so it is first. - s.position.assign(n, -1); - std::int32_t num_pos = 0; - for (std::int32_t v : s.order) { - if (s.fwd[static_cast(v)] != 0) s.position[static_cast(v)] = num_pos++; - } - if (s.position[0] != 0) throw std::logic_error("kernel plan: the split graph does not start at its source"); - s.first.assign(n, -1); - s.count.assign(n, 0); - for (std::size_t m = 0; m < num_members; ++m) { - const auto t = static_cast(s.tail[static_cast(s.member_arc[m])]); - if (s.count[t] == 0) { - s.first[t] = static_cast(m); - } else if (static_cast(s.first[t] + s.count[t]) != m) { - throw std::logic_error("kernel plan: the members of a node are not consecutive (EdgeIds not in B' order)"); - } - ++s.count[t]; - } - const auto P = static_cast(num_pos); - plan.topo.reserve(P); - plan.terminal.reserve(P); - plan.out_begin.reserve(P); - plan.out_end.reserve(P); - for (std::int32_t v : s.order) { - const auto vi = static_cast(v); - if (s.fwd[vi] == 0) continue; - plan.topo.push_back(map.nodes()[vi]); - plan.terminal.push_back(is_dst(v) ? 1 : 0); - const std::int32_t b = s.count[vi] == 0 ? 0 : s.first[vi]; - plan.out_begin.push_back(b); - plan.out_end.push_back(b + s.count[vi]); - } - plan.head_pos.resize(num_members); - for (std::size_t m = 0; m < num_members; ++m) { - plan.head_pos[m] = s.position[static_cast(s.head[static_cast(s.member_arc[m])])]; - } - plan.src_out_begin = plan.out_begin[0]; - plan.src_out_end = plan.out_end[0]; -} - -// Per-thread per-node state of build_split_dag, indexed by NodeId and valid for a node only +// Per-thread per-node state of build_split, indexed by NodeId and valid for a node only // when its stamp is the current epoch (no clearing between calls; a call that throws leaves stale // entries behind an old epoch). One array of slots, so growing it either succeeds whole or leaves // the previous array in place (review-1b-fixes N1: separately grown arrays could be left at @@ -315,6 +110,8 @@ struct DagScratch { std::int32_t position {0}; std::int32_t first {0}; // the node's first member std::int32_t count {0}; // the node's number of members + std::int32_t entry_begin {0}; // the node's predecessor entries in the DAG's sparse storage + std::int32_t entry_end {0}; Cost dist {0}; }; std::uint32_t epoch {0}; @@ -345,28 +142,40 @@ DagScratch& dag_scratch() { return s; } -// build_split over a PredDAG, on the DAG's own predecessor lists (no local renumbering, no -// arc arrays, no CSR to build): the same members and structure as build_split over the DAG's -// ascending edge set. `keep(e)` filters edges. +// The split structure of a flow from src to the destination set over a DAG, on the DAG's own +// predecessor lists (no local renumbering, no arc arrays, no CSR to build), restricted to the +// edges `keep(e)` accepts: the kept edges on some walk from src to a destination (destinations +// terminal) become plan.members, with the plan's topological fields. One pass: a backward DFS +// from the destinations over the predecessor entries lists the nodes that reach a destination in +// postorder (every node after its parents, a topological order); a forward pass over that order +// marks the nodes the source reaches. Members ascend by EdgeId and B' ids rank edges by source +// first, so the members of one tail are consecutive. Any topological order gives the same +// allocation (a node splits once its inflow is complete). +// +// With `cost`, the forward pass also computes the minimum walk cost from the source to a +// destination over the members (kUnreachable without members): the DP runs in the same +// topological order. Without `out` (route_cost) the pass ends with the DP: no plan is built. template -void build_split_dag(const StrictMultiDiGraph& g, const PredDAG& dag, NodeId src, std::span dsts, - Keep keep, KernelPlan& plan, Cost* cost) { - plan.members.clear(); - plan.topo.clear(); - plan.terminal.clear(); - plan.out_begin.clear(); - plan.out_end.clear(); - plan.head_pos.clear(); - plan.src_out_begin = 0; - plan.src_out_end = 0; +void build_split(const Graph& g, const PredDAG& dag, NodeId src, std::span dsts, Keep keep, + KernelPlan* out, Cost* cost) { if (cost != nullptr) *cost = kUnreachable; - const auto off = dag.parent_offsets(); const auto parents = dag.parents(); const auto via = dag.via_edges(); DagScratch& s = dag_scratch(); s.begin(static_cast(g.num_nodes())); + // The DAG's sparse storage scattered into the per-node slots (O(member nodes)); every other + // node is touched with no entries. + { + const auto nodes = PredDAGBuilder::member_nodes(dag); + const auto offs = PredDAGBuilder::member_offsets(dag); + for (std::size_t i = 0; i < nodes.size(); ++i) { + s.touch(nodes[i]); + s.at(nodes[i]).entry_begin = offs[i]; + s.at(nodes[i]).entry_end = offs[i + 1]; + } + } for (NodeId d : dsts) { - s.touch(d); + if (!s.seen(d)) s.touch(d); s.at(d).target = 1; } if (!s.seen(src)) s.touch(src); @@ -378,10 +187,10 @@ void build_split_dag(const StrictMultiDiGraph& g, const PredDAG& dag, NodeId src if (s.at(d).state != 0) continue; s.at(d).state = 1; s.stack.clear(); - s.stack.emplace_back(d, off[static_cast(d)]); + s.stack.emplace_back(d, s.at(d).entry_begin); while (!s.stack.empty()) { const auto [v, k] = s.stack.back(); - if (k < off[static_cast(v) + 1]) { + if (k < s.at(v).entry_end) { s.stack.back().second = k + 1; const auto ki = static_cast(k); const NodeId u = parents[ki]; @@ -390,7 +199,7 @@ void build_split_dag(const StrictMultiDiGraph& g, const PredDAG& dag, NodeId src const auto su = s.at(u).state; if (su == 0) { s.at(u).state = 1; - s.stack.emplace_back(u, off[static_cast(u)]); + s.stack.emplace_back(u, s.at(u).entry_begin); } else if (su == 1) { throw std::logic_error("kernel plan: the route is cyclic"); } @@ -409,12 +218,11 @@ void build_split_dag(const StrictMultiDiGraph& g, const PredDAG& dag, NodeId src s.at(src).dist = 0; } for (NodeId v : s.order) { - const auto vi = static_cast(v); auto& nv = s.at(v); if (v != src) { Cost dv = kUnreachable; bool reached = false; - for (auto k = off[vi]; k < off[vi + 1]; ++k) { + for (auto k = nv.entry_begin; k < nv.entry_end; ++k) { const auto ki = static_cast(k); const NodeId u = parents[ki]; if (!s.seen(u)) continue; @@ -431,23 +239,44 @@ void build_split_dag(const StrictMultiDiGraph& g, const PredDAG& dag, NodeId src if (cost != nullptr && nv.fwd != 0 && nv.target != 0) best = std::min(best, nv.dist); } if (cost != nullptr) *cost = best; + if (out == nullptr) return; + KernelPlan& plan = *out; + plan.members.clear(); + plan.topo.clear(); + plan.terminal.clear(); + plan.out_begin.clear(); + plan.out_end.clear(); + plan.head_pos.clear(); + plan.src_out_begin = 0; + plan.src_out_end = 0; // Members, ascending: the DAG's edges kept, from a reached tail (not a destination) to a head // that reaches a destination. const auto edge_src = g.edge_src(); const auto edge_dst = g.edge_dst(); - s.member_tail.clear(); - s.member_head.clear(); - for (EdgeId e : PredDAGBuilder::ascending(dag)) { + // The member and node loops write by index into vectors sized to a known bound (the DAG's edges + // here, the reached nodes below): libc++ 22's emplace_back (a fast-path and a slow-path lambda) + // made the push_back form about ten percent slower on the kernel rows under Xcode 27. + const auto asc = PredDAGBuilder::ascending(dag); + const std::size_t bound = asc.size(); + plan.members.resize(bound); + s.member_tail.resize(bound); + s.member_head.resize(bound); + std::size_t n_members = 0; + for (EdgeId e : asc) { const auto ei = static_cast(e); const NodeId u = edge_src[ei]; const NodeId v = edge_dst[ei]; if (!s.seen(u) || s.at(u).fwd == 0 || s.at(u).target != 0) continue; if (!s.seen(v) || s.at(v).state != 2 || !keep(e)) continue; - plan.members.push_back(e); - s.member_tail.push_back(u); - s.member_head.push_back(v); - } - const std::size_t num_members = plan.members.size(); + plan.members[n_members] = e; + s.member_tail[n_members] = u; + s.member_head[n_members] = v; + ++n_members; + } + plan.members.resize(n_members); + s.member_tail.resize(n_members); + s.member_head.resize(n_members); + const std::size_t num_members = n_members; if (num_members == 0) return; std::int32_t num_pos = 0; for (NodeId v : s.order) { @@ -466,18 +295,20 @@ void build_split_dag(const StrictMultiDiGraph& g, const PredDAG& dag, NodeId src ++nt.count; } const auto P = static_cast(num_pos); - plan.topo.reserve(P); - plan.terminal.reserve(P); - plan.out_begin.reserve(P); - plan.out_end.reserve(P); + plan.topo.resize(P); + plan.terminal.resize(P); + plan.out_begin.resize(P); + plan.out_end.resize(P); + std::size_t pos = 0; for (NodeId v : s.order) { const auto& nv = s.at(v); if (nv.fwd == 0) continue; - plan.topo.push_back(v); - plan.terminal.push_back(nv.target); + plan.topo[pos] = v; + plan.terminal[pos] = nv.target; const std::int32_t b = nv.count == 0 ? 0 : nv.first; - plan.out_begin.push_back(b); - plan.out_end.push_back(b + nv.count); + plan.out_begin[pos] = b; + plan.out_end[pos] = b + nv.count; + ++pos; } plan.head_pos.resize(num_members); for (std::size_t m = 0; m < num_members; ++m) { @@ -487,85 +318,57 @@ void build_split_dag(const StrictMultiDiGraph& g, const PredDAG& dag, NodeId src plan.src_out_end = plan.out_end[0]; } -// The minimum walk cost from the source to a terminal over a split structure (positions in -// topological order, the source first). -Cost split_cost(const StrictMultiDiGraph& g, const KernelPlan& plan) { - if (plan.members.empty()) return kUnreachable; - const auto cost = g.cost(); - thread_local std::vector dist; - dist.assign(plan.topo.size(), kUnreachable); - dist[0] = 0; - Cost best = kUnreachable; - for (std::size_t p = 0; p < plan.topo.size(); ++p) { - if (dist[p] == kUnreachable) continue; - if (plan.terminal[p] != 0) { - best = std::min(best, dist[p]); - continue; - } - for (auto k = plan.out_begin[p]; k < plan.out_end[p]; ++k) { - const auto m = static_cast(k); - const auto h = static_cast(plan.head_pos[m]); - dist[h] = std::min(dist[h], dist[p] + cost[static_cast(plan.members[m])]); - } - } - return best; -} - } // namespace -std::vector prune(const StrictMultiDiGraph& g, std::span edges, NodeId src, - std::span dsts) { - std::vector sorted; - if (!std::is_sorted(edges.begin(), edges.end())) { - sorted.assign(edges.begin(), edges.end()); - std::sort(sorted.begin(), sorted.end()); - edges = sorted; - } +std::vector prune(const Graph& g, const PredDAG& dag, NodeId src, std::span dsts) { KernelPlan plan; - build_split(g, edges, src, dsts, [](std::size_t, EdgeId) { return true; }, plan); + build_split(g, dag, src, dsts, [](EdgeId) { return true; }, &plan, nullptr); return std::move(plan.members); } -KernelPlan make_plan(const StrictMultiDiGraph& g, FlowPlacement placement, Admission admission, EdgePriority priority, - NodeId src, std::span dsts, std::span route, - std::span creation_view) { +Cost route_cost(const Graph& g, const PredDAG& route, NodeId src, std::span dsts) { + Cost cost = kUnreachable; + build_split(g, route, src, dsts, [](EdgeId) { return true; }, nullptr, &cost); + return cost; +} + +KernelPlan make_plan(const Graph& g, FlowPlacement placement, Admission admission, EdgePriority priority, + NodeId src, std::span dsts, const PredDAG& route, + std::span creation_view, Cost* cost) { check_kernel(placement, admission, priority); - if (!std::is_sorted(route.begin(), route.end())) throw std::logic_error("make_plan: the route is not ascending"); + const auto edges = PredDAGBuilder::ascending(route); const bool by_view = placement == FlowPlacement::Weighted && priority == EdgePriority::Residual; - if (by_view && creation_view.size() != route.size()) { + if (by_view && creation_view.size() != edges.size()) { throw std::logic_error("make_plan: the creation view is not route-aligned"); } const auto capacity = g.capacity_units(); KernelPlan plan; plan.placement = placement; plan.admission = admission; - plan.priority = priority; plan.src = src; plan.dsts.assign(dsts.begin(), dsts.end()); - plan.route.assign(route.begin(), route.end()); + plan.route.assign(edges.begin(), edges.end()); + // The creation view of a route edge (the route ascends: a binary search). + auto creation = [&](EdgeId e) { + return creation_view[static_cast(std::lower_bound(edges.begin(), edges.end(), e) - edges.begin())]; + }; // Membership: MaxFlow every route edge (its view decides), Weighted Residual creation view // >= 1 unit, every other kernel static capacity >= 1 unit. - auto member = [&](std::size_t i, EdgeId e) { - if (by_view) return creation_view[i] >= 1; + auto member = [&](EdgeId e) { + if (by_view) return creation(e) >= 1; return placement == FlowPlacement::MaxFlow || capacity[static_cast(e)] >= 1; }; - build_split(g, route, src, dsts, member, plan); + build_split(g, route, src, dsts, member, &plan, cost); + // The route's cost is the members' only when every route edge is a member. + if (cost != nullptr && plan.members.size() != plan.route.size()) *cost = route_cost(g, route, src, dsts); if (placement == FlowPlacement::Weighted) { plan.weights.reserve(plan.members.size()); - std::size_t i = 0; - for (EdgeId e : plan.members) { - if (by_view) { - while (route[i] != e) ++i; // members are a subsequence of the route - plan.weights.push_back(creation_view[i]); - } else { - plan.weights.push_back(capacity[static_cast(e)]); - } - } + for (EdgeId e : plan.members) plan.weights.push_back(by_view ? creation(e) : capacity[static_cast(e)]); } return plan; } -KernelPlan make_route_plan(const StrictMultiDiGraph& g, FlowPlacement placement, Admission admission, +KernelPlan make_route_plan(const Graph& g, FlowPlacement placement, Admission admission, EdgePriority priority, NodeId src, std::span dsts, const PredDAG& bundle, const Eligibility* elig, Cost* cost) { check_kernel(placement, admission, priority); @@ -573,13 +376,12 @@ KernelPlan make_route_plan(const StrictMultiDiGraph& g, FlowPlacement placement, KernelPlan plan; plan.placement = placement; plan.admission = admission; - plan.priority = priority; plan.src = src; plan.dsts.assign(dsts.begin(), dsts.end()); auto usable = [&](EdgeId e) { return capacity[static_cast(e)] >= 1 && (elig == nullptr || elig->edge_ok(e)); }; - build_split_dag(g, bundle, src, dsts, usable, plan, cost); + build_split(g, bundle, src, dsts, usable, &plan, cost); plan.route = plan.members; if (placement == FlowPlacement::Weighted && priority == EdgePriority::Capacity) { plan.weights.reserve(plan.members.size()); @@ -588,19 +390,6 @@ KernelPlan make_route_plan(const StrictMultiDiGraph& g, FlowPlacement placement, return plan; } -Cost route_cost(const StrictMultiDiGraph& g, const KernelPlan& plan) { - // The bundle's minimum walk cost (APPROACH 8, rule 12): over the route (the record's - // bundle), not the split graph, so a Weighted plan whose membership excludes a zero-residual - // edge keeps its route's cost. members == route (members are a subset): the plan's own - // structure serves. - if (plan.route.empty()) return kUnreachable; - if (plan.members.size() == plan.route.size()) return split_cost(g, plan); - KernelPlan whole; - Cost cost = kUnreachable; - build_split(g, plan.route, plan.src, plan.dsts, [](std::size_t, EdgeId) { return true; }, whole, &cost); - return cost; -} - // ---------------------------------------------------------------- allocation bool allocate(const KernelPlan& plan, Amount total, std::span out, std::span limit) { @@ -628,17 +417,19 @@ bool allocate(const KernelPlan& plan, Amount total, std::span out, std:: inflow[static_cast(plan.head_pos[m])] += x; } } else { - // Round robin inline: floor(amount / k) each, one more to the first amount mod k. - const auto kk = static_cast(k); - const Amount base = amount / kk; - const auto extra = b + static_cast(amount % kk); - const std::size_t used = base == 0 ? extra : e; // members beyond carry 0 - for (std::size_t m = b; m < used; ++m) { - const Amount x = base + (m < extra ? 1 : 0); - if (limited && x > limit[m]) return false; + // Only the members with a positive share are written and visited (the rest stay 0). + bool fits = true; + round_robin(amount, k, [&](std::size_t i, Amount x) { + const std::size_t m = b + i; + if (limited && x > limit[m]) { + fits = false; + return false; + } out[m] = x; inflow[static_cast(plan.head_pos[m])] += x; - } + return true; + }); + if (!fits) return false; } } return true; @@ -768,7 +559,7 @@ TMax t_max(const KernelPlan& plan, std::span view_m) { return out; } -LosslessStep lossless_step(const StrictMultiDiGraph& g, const KernelPlan& plan, Amount total, +LosslessStep lossless_step(const Graph& g, const KernelPlan& plan, Amount total, std::span view_m, std::span own_m, std::optional request) { if (plan.admission != Admission::Lossless) throw std::logic_error("lossless_step on a Lossy plan"); @@ -782,6 +573,7 @@ LosslessStep lossless_step(const StrictMultiDiGraph& g, const KernelPlan& plan, if (r.placed < total) throw std::logic_error("MaxFlow kernel: the replacement solve lost committed flow"); out.total = r.placed; out.alloc = std::move(r.flows); + out.cost_distribution = std::move(r.cost_distribution); return out; } if (plan.members.empty()) { @@ -832,6 +624,7 @@ LossyStep lossy_step(const KernelPlan& plan, std::optional offer, std::s const std::size_t k = e - b; part.resize(k); Amount amount = p == 0 ? offer.value_or(0) : inflow[p]; + std::size_t n = k; // the members with a share (round robin leaves the rest at 0, unwritten) if (p == 0 && !offer.has_value()) { for (std::size_t i = 0; i < k; ++i) part[i] = w_m[b + i]; } else { @@ -839,10 +632,13 @@ LossyStep lossy_step(const KernelPlan& plan, std::optional offer, std::s if (weighted) { highest_averages(amount, std::span(plan.weights.data() + b, k), std::span(part.data(), k)); } else { - round_robin(amount, std::span(part.data(), k)); + n = round_robin(amount, k, [&](std::size_t i, Amount x) { + part[i] = x; + return true; + }); } } - for (std::size_t i = 0; i < k; ++i) { + for (std::size_t i = 0; i < n; ++i) { const Amount x = part[i]; if (p == 0) out.injected += x; if (x == 0) continue; diff --git a/src/max_flow.cpp b/src/max_flow.cpp deleted file mode 100644 index c77fb90..0000000 --- a/src/max_flow.cpp +++ /dev/null @@ -1,581 +0,0 @@ -/* - The one integer minimum-cost flow core (APPROACH 2.2). - - One objective on every view: minimum cost for the maximum value attainable under the value - cap. Successive shortest paths with potentials and blocking flows. Each tier runs Dijkstra on - reduced costs cost(e) + pi(u) - pi(v) to completion over the residual network reachable from - the virtual source s* (through the virtual sink t* and onward), adds the distances to the - potentials of the vertices it settled, then drains the zero-reduced-cost admissible subgraph - by Dinic phases (BFS levels, DFS with current-arc pointers) until no admissible augmenting - path remains or the cap is reached; then the next tier. Positive original costs do not - exclude zero-reduced-cost residual cycles, hence the repeated phases. The solve ends when a - Dijkstra does not reach t* or the cap is reached; there is no completion phase. - - Arc identity: every eligible edge is its own arc pair (forward with the view capacity, - reverse with the flow). At a node the forward arcs of its out-edges and the reverse arcs of - its in-edges are visited in one sequence by ascending EdgeId (a self-loop's forward arc before - its reverse arc); at a real endpoint its virtual arc comes first; s* visits its arcs by - ascending source NodeId and t* by ascending destination NodeId. Virtual arcs cost 0 and carry - 2^63 - 2 units, above every attainable value. Every solve starts from zero flow, so the zero - potentials are valid (all costs are positive). - - Dijkstra's queue is a radix heap: keys pop in increasing order and entries with equal keys pop - in a fixed order (last pushed first within the lowest bucket), so the settle order, and with it - every counter, is a function of the input alone. Equal-key order affects only the counters, - never a distance, a potential or an augmenting path. The solve runs to the cap or to - disconnection; there is no clamp of the cap (APPROACH 2.2, revision 25). - - Arithmetic stays in int64: every true residual distance is the cost of a simple residual path, - below the cost sum bound 2^62; potentials are such distances; a path's bottleneck contains a - real arc (sources and destinations are disjoint), so every amount is below 2^62. -*/ -#include "netgraph/core/max_flow.hpp" - -#include -#include -#include -#include -#include -#include - -#include "netgraph/core/detail/flow_core.hpp" -#include "netgraph/core/detail/node_map.hpp" - -namespace netgraph::core { - -namespace { - -constexpr Cost kInfDist = std::numeric_limits::max(); -constexpr Amount kUncapped = std::numeric_limits::max(); - -// Sorted, duplicate-free endpoint set; throws on an empty set or an id out of range. -std::vector endpoint_set(std::span nodes, std::int32_t n, const char* name) { - if (nodes.empty()) { - throw std::invalid_argument(std::string(name) + ": the endpoint set is empty"); - } - std::vector out(nodes.begin(), nodes.end()); - for (NodeId v : out) { - if (v < 0 || v >= n) { - throw std::invalid_argument(std::string(name) + ": node " + std::to_string(v) + - " out of node range [0, " + std::to_string(n) + ")"); - } - } - std::sort(out.begin(), out.end()); - out.erase(std::unique(out.begin(), out.end()), out.end()); - return out; -} - -// Monotone priority queue for Dijkstra on non-negative integer keys (a radix heap): a key goes -// to the bucket of the highest bit in which it differs from the last popped key, and a pop -// redistributes the first non-empty bucket below its minimum. Entries are (key, vertex); equal -// keys pop in a fixed order, so every run is a function of its input. -class RadixHeap { -public: - void clear() { - for (auto& b : buckets_) b.clear(); - last_ = 0; - size_ = 0; - } - [[nodiscard]] bool empty() const noexcept { return size_ == 0; } - void push(Cost key, std::int32_t v) { - buckets_[bucket(key)].emplace_back(key, v); - ++size_; - } - std::pair pop() { - if (buckets_[0].empty()) { - std::size_t i = 1; - while (buckets_[i].empty()) ++i; - auto& from = buckets_[i]; - Cost lowest = from.front().first; - for (const auto& entry : from) lowest = std::min(lowest, entry.first); - last_ = lowest; - for (const auto& entry : from) buckets_[bucket(entry.first)].push_back(entry); - from.clear(); - } - const auto top = buckets_[0].back(); - buckets_[0].pop_back(); - --size_; - return top; - } - -private: - [[nodiscard]] std::size_t bucket(Cost key) const noexcept { - return static_cast( - std::bit_width(static_cast(key) ^ static_cast(last_))); - } - std::array>, 65> buckets_ {}; - Cost last_ {0}; - std::size_t size_ {0}; -}; - -// The input of one solve in local coordinates: real vertices 0..num_nodes-1, the arcs of the -// active edges in ascending EdgeId (tail, head, cost and capacity per edge), and the endpoint -// sets as local vertices listed in ascending original NodeId. -struct SolveInput { - std::int32_t num_nodes {0}; - std::vector edges {}; - std::vector tail {}; - std::vector head {}; - std::vector cost {}; - std::vector cap {}; - std::vector sources {}; - std::vector destinations {}; -}; - -// The residual network of one solve: real vertices 0..N-1, s* = N, t* = N + 1. Arcs are stored -// as slots in adjacency order (the slots of u are [off_[u], off_[u + 1])), each with its head, -// its cost, its residual and the slot of its partner arc. -class Solver { -public: - // One reusable instance per thread (`local()`): init() resizes every buffer for the input, - // keeping capacity across calls, so repeated solves do not allocate once warm. Not - // reentrant (a solve never starts another). - static Solver& local() { - thread_local Solver solver; - return solver; - } - - void init(const SolveInput& in) { - in_ = ∈ - n_ = in.num_nodes + 2; - s_ = in.num_nodes; - t_ = in.num_nodes + 1; - mark_epoch_ = 0; - const auto n = static_cast(n_); - off_.assign(n + 1, 0); - auto deg = [&](std::int32_t v) { ++off_[static_cast(v) + 1]; }; - for (std::size_t k = 0; k < in.edges.size(); ++k) { - deg(in.tail[k]); - deg(in.head[k]); - } - for (std::int32_t s : in.sources) { deg(s); deg(s_); } - for (std::int32_t d : in.destinations) { deg(d); deg(t_); } - for (std::size_t v = 0; v < n; ++v) off_[v + 1] += off_[v]; - const auto num_slots = static_cast(off_[n]); - head_.resize(num_slots); - rev_.resize(num_slots); - cost_.resize(num_slots); - res_.resize(num_slots); - pos_.assign(off_.begin(), off_.end() - 1); - auto take = [&](std::int32_t v) { return pos_[static_cast(v)]++; }; - auto link = [&](std::int32_t a, std::int32_t to_a, Amount cap, Cost w, std::int32_t b, std::int32_t to_b) { - const auto ai = static_cast(a); - const auto bi = static_cast(b); - head_[ai] = to_a; - rev_[ai] = b; - cost_[ai] = w; - res_[ai] = cap; - head_[bi] = to_b; - rev_[bi] = a; - cost_[bi] = -w; - res_[bi] = 0; - }; - // Virtual arcs first at the real endpoints, then the real arcs by ascending EdgeId (forward - // slot at the tail, reverse slot at the head), then the slots of s* and t*. - src_rev_.resize(in.sources.size()); - dst_fwd_.resize(in.destinations.size()); - for (std::size_t i = 0; i < in.sources.size(); ++i) src_rev_[i] = take(in.sources[i]); - for (std::size_t i = 0; i < in.destinations.size(); ++i) dst_fwd_[i] = take(in.destinations[i]); - edge_slot_.resize(in.edges.size()); - for (std::size_t k = 0; k < in.edges.size(); ++k) { - const std::int32_t u = in.tail[k]; - const std::int32_t v = in.head[k]; - const std::int32_t fwd = take(u); - const std::int32_t bwd = take(v); - link(fwd, v, in.cap[k], in.cost[k], bwd, u); - edge_slot_[k] = fwd; - } - for (std::size_t i = 0; i < in.sources.size(); ++i) { - link(take(s_), in.sources[i], kMaxFlowCap, 0, src_rev_[i], s_); - } - for (std::size_t i = 0; i < in.destinations.size(); ++i) { - link(dst_fwd_[i], t_, kMaxFlowCap, 0, take(t_), in.destinations[i]); - } - pi_.assign(n, 0); - ever_.assign(n, 0); - dist_.resize(n); - level_.resize(n); - reach_.assign(n, 0); - cur_.resize(n); - } - - // The solve to the cap or to disconnection. Output in local coordinates: `flows` aligned with - // the input edges, `cut` as input edge indices (positive capacity, from the full residual - // closure of the real sources to its complement, ascending EdgeId) when the destinations are - // outside the closure, potentials per local vertex (kNeverReached when never settled). - struct Output { - Amount placed {0}; - std::vector> cost_distribution {}; - std::vector flows {}; - std::optional> cut {}; - std::vector potentials {}; - FlowCoreCounters counters {}; - }; - - // `closure` adds the cut; `potentials` the per-vertex potentials (the edge-subset entry - // needs neither). - Output run(Amount limit, bool closure, bool potentials = true) { - Output out; - auto& c = out.counters; - Amount total = 0; - while (total < limit) { - if (!dijkstra(c)) break; - const Cost tier_cost = pi_[static_cast(t_)] - pi_[static_cast(s_)]; - const Amount before = total; - while (total < limit) { - if (!build_levels(c)) break; - std::copy(off_.begin(), off_.end() - 1, cur_.begin()); - while (total < limit) { - if (!find_path(c)) break; - Amount amount = limit - total; - for (std::int32_t a : path_) amount = std::min(amount, res_[static_cast(a)]); - for (std::int32_t a : path_) { - res_[static_cast(a)] -= amount; - res_[static_cast(rev_[static_cast(a)])] += amount; - } - total += amount; - ++c.augmentations; - } - } - // A successful Dijkstra reaches t* over arcs with positive residual and zero reduced cost - // along its shortest paths, so the drain moves at least one unit. - if (total == before) { - throw std::logic_error("flow core: a successful Dijkstra drained nothing"); - } - out.cost_distribution.emplace_back(tier_cost, total - before); - } - out.placed = total; - - out.flows.resize(in_->edges.size()); - for (std::size_t k = 0; k < in_->edges.size(); ++k) { - const auto fwd = static_cast(edge_slot_[k]); - out.flows[k] = res_[static_cast(rev_[fwd])]; - } - if (closure) { - // Full residual closure seeded at the real sources (reverse and virtual arcs included). - std::vector seen(static_cast(n_), 0); - std::vector queue; - queue.reserve(static_cast(n_)); - for (std::int32_t s : in_->sources) { - seen[static_cast(s)] = 1; - queue.push_back(s); - } - for (std::size_t qi = 0; qi < queue.size(); ++qi) { - const auto u = static_cast(queue[qi]); - for (auto p = off_[u]; p < off_[u + 1]; ++p) { - const auto pi = static_cast(p); - const auto v = static_cast(head_[pi]); - if (res_[pi] > 0 && seen[v] == 0) { - seen[v] = 1; - queue.push_back(static_cast(v)); - } - } - } - if (seen[static_cast(t_)] == 0) { - std::vector cut; - for (std::size_t k = 0; k < in_->edges.size(); ++k) { - if (seen[static_cast(in_->tail[k])] != 0 && seen[static_cast(in_->head[k])] == 0 && - in_->cap[k] > 0) { - cut.push_back(static_cast(k)); - } - } - out.cut = std::move(cut); - } - } - if (potentials) { - out.potentials.resize(static_cast(in_->num_nodes)); - for (std::size_t v = 0; v < out.potentials.size(); ++v) { - out.potentials[v] = ever_[v] != 0 ? pi_[v] : kNeverReached; - } - } - return out; - } - -private: - // Dijkstra on reduced costs to completion; potentials of the settled vertices updated. - // Returns whether t* was reached. - bool dijkstra(FlowCoreCounters& c) { - ++c.dijkstra_runs; - std::fill(dist_.begin(), dist_.end(), kInfDist); - settled_.clear(); - heap_.clear(); - dist_[static_cast(s_)] = 0; - heap_.push(0, s_); - while (!heap_.empty()) { - const auto [du, ui] = heap_.pop(); - ++c.dijkstra_pops; - const auto u = static_cast(ui); - if (du != dist_[u]) continue; - settled_.push_back(ui); - ++c.settled; - const Cost base = du + pi_[u]; - const auto end = off_[u + 1]; - c.arc_scans += end - off_[u]; - for (auto p = off_[u]; p < end; ++p) { - const auto pi = static_cast(p); - if (res_[pi] <= 0) continue; - const auto v = static_cast(head_[pi]); - const Cost nd = base + cost_[pi] - pi_[v]; - if (nd < dist_[v]) { - dist_[v] = nd; - heap_.push(nd, static_cast(v)); - } - } - } - for (std::int32_t v : settled_) { - const auto vi = static_cast(v); - pi_[vi] += dist_[vi]; - ever_[vi] = 1; - } - return dist_[static_cast(t_)] != kInfDist; - } - - // BFS levels over admissible arcs (positive residual, zero reduced cost). Counts one phase. - // Only vertices that can reach t* over admissible arcs are levelled: every vertex on a - // shortest admissible path from s* to such a vertex reaches t* too, so their levels equal the - // levels of a full BFS, and the other vertices are dead ends that the DFS would only enter and - // leave. The forward search also stops once t*'s level is complete. The augmenting paths are - // therefore the ones a full BFS gives. - bool build_levels(FlowCoreCounters& c) { - const auto t = static_cast(t_); - // Reverse search from t*: the arc into v from head_[p] is the partner of v's slot p. - ++mark_epoch_; - queue_.clear(); - reach_[t] = mark_epoch_; - queue_.push_back(t_); - for (std::size_t qi = 0; qi < queue_.size(); ++qi) { - const auto v = static_cast(queue_[qi]); - const Cost pv = pi_[v]; - const auto end = off_[v + 1]; - c.arc_scans += end - off_[v]; - for (auto p = off_[v]; p < end; ++p) { - const auto pi = static_cast(p); - const auto u = static_cast(head_[pi]); - if (reach_[u] == mark_epoch_) continue; - const auto in = static_cast(rev_[pi]); - if (res_[in] > 0 && cost_[in] + pi_[u] == pv) { - reach_[u] = mark_epoch_; - queue_.push_back(static_cast(u)); - } - } - } - std::fill(level_.begin(), level_.end(), -1); - ++c.dinic_phases; - const auto s = static_cast(s_); - if (reach_[s] != mark_epoch_) return false; - queue_.clear(); - level_[s] = 0; - queue_.push_back(s_); - for (std::size_t qi = 0; qi < queue_.size(); ++qi) { - const auto u = static_cast(queue_[qi]); - if (level_[t] >= 0 && level_[u] >= level_[t]) break; - const Cost pu = pi_[u]; - const auto next = level_[u] + 1; - const auto end = off_[u + 1]; - c.arc_scans += end - off_[u]; - for (auto p = off_[u]; p < end; ++p) { - const auto pi = static_cast(p); - const auto v = static_cast(head_[pi]); - if (level_[v] < 0 && reach_[v] == mark_epoch_ && res_[pi] > 0 && cost_[pi] + pu == pi_[v]) { - level_[v] = next; - queue_.push_back(static_cast(v)); - } - } - } - return level_[t] >= 0; - } - - // One augmenting path s* -> t* in the level graph into path_ (current-arc pointers kept: an - // arc that is pushed through stays current; a dead end advances its parent's pointer). - bool find_path(FlowCoreCounters& c) { - stack_.clear(); - path_.clear(); - stack_.push_back(s_); - while (!stack_.empty()) { - const auto u = static_cast(stack_.back()); - if (static_cast(u) == t_) return true; - bool advanced = false; - const auto end = off_[u + 1]; - const auto next = level_[u] + 1; - const Cost pu = pi_[u]; - while (cur_[u] < end) { - ++c.arc_scans; - const auto pi = static_cast(cur_[u]); - const auto v = static_cast(head_[pi]); - if (res_[pi] > 0 && level_[v] == next && cost_[pi] + pu == pi_[v]) { - stack_.push_back(static_cast(v)); - path_.push_back(static_cast(pi)); - advanced = true; - break; - } - ++cur_[u]; - } - if (!advanced) { - stack_.pop_back(); - if (!path_.empty()) { - path_.pop_back(); - ++cur_[static_cast(stack_.back())]; - } - } - } - return false; - } - - const SolveInput* in_ {nullptr}; - std::int32_t n_ {0}; - std::int32_t s_ {0}; - std::int32_t t_ {0}; - std::vector pos_ {}; - std::vector src_rev_ {}; - std::vector dst_fwd_ {}; - std::vector off_ {}; - std::vector head_ {}; - std::vector rev_ {}; - std::vector cost_ {}; - std::vector res_ {}; - std::vector edge_slot_ {}; - std::vector pi_ {}; - std::vector ever_ {}; - std::vector dist_ {}; - std::vector settled_ {}; - RadixHeap heap_ {}; - std::vector level_ {}; - std::vector reach_ {}; - std::uint32_t mark_epoch_ {0}; - std::vector queue_ {}; - std::vector cur_ {}; - std::vector stack_ {}; - std::vector path_ {}; -}; - -} // namespace - -FlowCoreResult place_global_units(const StrictMultiDiGraph& g, - std::span view, - std::span sources, - std::span destinations, - const Eligibility* elig, - std::optional cap) { - const auto num_edges = static_cast(g.num_edges()); - if (view.size() != num_edges) { - throw std::invalid_argument("view has length " + std::to_string(view.size()) + ", expected " + - std::to_string(num_edges) + " (one entry per edge)"); - } - const auto capacity = g.capacity_units(); - for (std::size_t e = 0; e < num_edges; ++e) { - if (view[e] < 0 || view[e] > capacity[e]) { - throw std::invalid_argument("view[" + std::to_string(e) + "] = " + std::to_string(view[e]) + - " is outside [0, capacity_units[" + std::to_string(e) + "] = " + - std::to_string(capacity[e]) + "]"); - } - } - const auto srcs = endpoint_set(sources, g.num_nodes(), "sources"); - const auto dsts = endpoint_set(destinations, g.num_nodes(), "destinations"); - for (std::size_t i = 0, k = 0; i < srcs.size() && k < dsts.size();) { - if (srcs[i] == dsts[k]) { - throw std::invalid_argument("node " + std::to_string(srcs[i]) + - " is in both endpoint sets: sources and destinations must not overlap"); - } - if (srcs[i] < dsts[k]) ++i; else ++k; - } - if (cap.has_value() && (*cap < 0 || *cap > kMaxFlowCap)) { - throw std::invalid_argument("cap " + std::to_string(*cap) + " is outside [0, 2^63 - 2]"); - } - if (elig != nullptr && elig->graph_uid() != g.uid()) { - throw std::invalid_argument("foreign Eligibility: built for another graph"); - } - if (cap.has_value() && *cap == 0) { - // The input cap 0 runs nothing: the prescribed zero payload. - FlowCoreResult out; - out.edge_flows.assign(num_edges, 0); - out.potentials.assign(static_cast(g.num_nodes()), kNeverReached); - return out; - } - // Identity coordinates: every real vertex, the allowed edges and endpoints. - SolveInput in; - in.num_nodes = g.num_nodes(); - const auto edge_src = g.edge_src(); - const auto edge_dst = g.edge_dst(); - const auto cost = g.cost(); - in.edges.reserve(num_edges); - for (std::size_t e = 0; e < num_edges; ++e) { - if (elig != nullptr && !elig->edge_ok(static_cast(e))) continue; - in.edges.push_back(static_cast(e)); - in.tail.push_back(edge_src[e]); - in.head.push_back(edge_dst[e]); - in.cost.push_back(cost[e]); - in.cap.push_back(view[e]); - } - for (NodeId s : srcs) { - if (elig == nullptr || elig->node_ok(s)) in.sources.push_back(s); - } - for (NodeId d : dsts) { - if (elig == nullptr || elig->node_ok(d)) in.destinations.push_back(d); - } - Solver& solver = Solver::local(); - solver.init(in); - auto r = solver.run(cap.value_or(kUncapped), /*closure=*/true); - FlowCoreResult out; - out.placed = r.placed; - out.cost_distribution = std::move(r.cost_distribution); - out.edge_flows.assign(num_edges, 0); - for (std::size_t k = 0; k < in.edges.size(); ++k) { - out.edge_flows[static_cast(in.edges[k])] = r.flows[k]; - } - if (r.cut.has_value()) { - std::vector cut; - cut.reserve(r.cut->size()); - for (std::int32_t k : *r.cut) cut.push_back(in.edges[static_cast(k)]); - out.min_cut = std::move(cut); - } - out.potentials = std::move(r.potentials); - out.counters = r.counters; - return out; -} - -namespace detail { - -SubsetFlow solve_subset(const StrictMultiDiGraph& g, std::span edges, std::span caps, - std::span sources, std::span destinations, - std::optional cap) { - SubsetFlow out; - out.flows.assign(edges.size(), 0); - if (cap.has_value() && *cap == 0) return out; - const auto edge_src = g.edge_src(); - const auto edge_dst = g.edge_dst(); - const auto cost = g.cost(); - // Compressed vertices in first-touch order. The numbering changes no flow, curve or path: - // the arc order at every vertex is by EdgeId (virtual arc first) and the virtual arcs of s* - // and t* follow the endpoint lists, which stay in ascending NodeId. - NodeMap& map = NodeMap::local(); - map.reset(static_cast(g.num_nodes())); - // The input's storage is per thread and reused (every field is rewritten here). - thread_local SolveInput in; - in.edges.assign(edges.begin(), edges.end()); - in.tail.clear(); - in.head.clear(); - in.cost.clear(); - in.sources.clear(); - in.destinations.clear(); - for (EdgeId e : edges) { - const auto ei = static_cast(e); - in.tail.push_back(map.add(edge_src[ei])); - in.head.push_back(map.add(edge_dst[ei])); - in.cost.push_back(cost[ei]); - } - for (NodeId v : sources) in.sources.push_back(map.add(v)); - for (NodeId v : destinations) in.destinations.push_back(map.add(v)); - in.num_nodes = static_cast(map.size()); - in.cap.assign(caps.begin(), caps.end()); - Solver& solver = Solver::local(); - solver.init(in); - auto r = solver.run(cap.value_or(kUncapped), /*closure=*/false, /*potentials=*/false); - out.placed = r.placed; - out.flows = std::move(r.flows); - out.cost_distribution = std::move(r.cost_distribution); - return out; -} - -} // namespace detail - -} // namespace netgraph::core diff --git a/src/pinned_flow.cpp b/src/pinned_flow.cpp deleted file mode 100644 index 7888890..0000000 --- a/src/pinned_flow.cpp +++ /dev/null @@ -1,252 +0,0 @@ -/* - A Pinned placement with one bundle (the single-flow transaction of sub-PR 1b): the - per-flow kernel steps of detail/kernels.hpp committed through the ledger's internal entry - points, one atomic exchange per call (APPROACH 8, 9.2). -*/ -#include "netgraph/core/detail/pinned_flow.hpp" - -#include -#include -#include - -#include "netgraph/core/detail/ledger_access.hpp" -#include "netgraph/core/detail/pred_dag_builder.hpp" - -namespace netgraph::core::detail { - -PinnedFlow::PinnedFlow(FlowGraph& fg, NodeId src, NodeId dst, std::shared_ptr bundle, - FlowPlacement placement, Admission admission, EdgePriority priority, - std::shared_ptr elig) - : fg_(fg), src_(src), dst_(dst), bundle_(std::move(bundle)), placement_(placement), admission_(admission), - priority_(priority), elig_(std::move(elig)) { - check_kernel(placement, admission, priority); - const auto& g = *fg_.graph(); - for (NodeId v : {src, dst}) { - if (v < 0 || v >= g.num_nodes()) { - throw std::invalid_argument("Pinned: node " + std::to_string(v) + " out of node range [0, " + - std::to_string(g.num_nodes()) + ")"); - } - } - if (src == dst) { - throw std::invalid_argument("node " + std::to_string(src) + - " is in both endpoint sets: sources and destinations must not overlap"); - } - if (!bundle_) throw std::invalid_argument("Pinned: the bundle is null"); - if (bundle_->graph_uid() != g.uid()) throw std::invalid_argument("foreign PredDAG: built for another graph"); - if (elig_ && elig_->graph_uid() != g.uid()) throw std::invalid_argument("foreign Eligibility: built for another graph"); - // The usable route: statically eligible routable members on src -> dst walks. An empty one - // is either all-down (no_route at placement) or malformed (no walk at all: rejected). - const NodeId dsts[1] = {dst}; - const auto edges = PredDAGBuilder::ascending(*bundle_); - route_plan_ = make_route_plan(g, placement, admission, priority, src, dsts, *bundle_, elig_.get(), &route_cost_); - if (route_plan_.route.empty() && prune(g, edges, src, dsts).empty()) { - throw std::invalid_argument("Pinned: malformed bundle, no src -> dst walk (src " + std::to_string(src) + ", dst " + - std::to_string(dst) + ")"); - } - owner_ = LedgerAccess::bind(fg_); - bound_ = true; -} - -PinnedFlow::~PinnedFlow() { release(); } - -void PinnedFlow::release() noexcept { - if (!bound_) return; - LedgerAccess::release(fg_, owner_); - bound_ = false; -} - -void PinnedFlow::check_bound() const { - if (!bound_) throw std::logic_error("Pinned: the policy was released (destroyed)"); -} - -Amount PinnedFlow::placed_demand() const { - check_bound(); - return flow_id_.has_value() ? LedgerAccess::live(fg_, flow_id_->second).delivered : 0; -} - -void PinnedFlow::remove() { - check_bound(); - (void)LedgerAccess::detach(fg_, owner_); - flow_id_.reset(); - plan_ = nullptr; - captured_.reset(); - total_ = 0; - requested_ = Counter128 {}; - infinite_ = false; -} - -namespace { - -// Member index of e in the ascending member list (members contain every allocated edge). -std::size_t member_index(const std::vector& members, EdgeId e) { - const auto it = std::lower_bound(members.begin(), members.end(), e); - if (it == members.end() || *it != e) throw std::logic_error("Pinned: an allocation outside the split graph"); - return static_cast(it - members.begin()); -} - -// Per-thread member-aligned buffers of one call (nothing survives a call). -struct CallScratch { - std::vector own; - std::vector working; - std::vector view; - std::vector drops; -}; - -CallScratch& call_scratch() { - thread_local CallScratch s; - return s; -} - -} // namespace - -PinnedResult PinnedFlow::place(std::optional volume) { - check_bound(); - if (volume.has_value() && (*volume < 0 || *volume >= kDomainLimit)) { - throw std::invalid_argument("request " + std::to_string(*volume) + - " is outside [0, 2^62) units (requests are integer units or +inf)"); - } - if (infinite_ && volume.has_value()) { - throw std::invalid_argument( - "a finite request after +inf is rejected within an epoch (the cumulative finite request is undefined; " - "rule 13)"); - } - PinnedResult out; - if (volume.has_value() && *volume == 0) { - out.remaining = 0; - return out; - } - const auto& g = *fg_.graph(); - const auto base = LedgerAccess::baseline(fg_); - const auto held = LedgerAccess::allocated(fg_); - const bool created = !flow_id_.has_value(); - std::optional fresh; - const KernelPlan* plan = plan_; - if (created) { - if (route_plan_.route.empty()) { - // Nothing is created, but the epoch's call state records every positive request - // (the cumulative request and the finite-or-infinite state; APPROACH 8, 9.2). - if (volume.has_value()) { - Counter128 requested = requested_; - if (!requested.add(*volume)) { - throw std::invalid_argument("the cumulative requested counter would reach 2^127"); - } - requested_ = requested; - } else { - infinite_ = true; - } - out.remaining = volume; - out.termination = Termination::NoRoute; - return out; - } - if (placement_ == FlowPlacement::Weighted && priority_ == EdgePriority::Residual) { - // Membership and weights captured on the creation view W (route-aligned). - auto& view = call_scratch().view; - view.clear(); - for (EdgeId e : route_plan_.route) view.push_back(base[static_cast(e)] - held[static_cast(e)]); - const NodeId dsts[1] = {dst_}; - fresh = make_plan(g, placement_, admission_, priority_, src_, dsts, route_plan_.route, view); - plan = &*fresh; - } else { - plan = &route_plan_; - } - } - const auto& members = plan->members; - const FlowRecord* old = flow_id_.has_value() ? &LedgerAccess::live(fg_, flow_id_->second) : nullptr; - CallScratch& scratch = call_scratch(); - auto& own_m = scratch.own; - own_m.assign(members.size(), 0); - if (old != nullptr) { - for (const auto& [e, x] : old->edges) own_m[member_index(members, e)] = x; - } - auto& w_m = scratch.working; - w_m.resize(members.size()); - for (std::size_t m = 0; m < members.size(); ++m) { - const auto e = static_cast(members[m]); - w_m[m] = base[e] - held[e]; - } - - Draft d; - if (flow_id_.has_value()) d.id = flow_id_->second; - d.sources = {src_}; - d.destinations = {dst_}; - d.placement = placement_; - d.admission = admission_; - const Amount delivered0 = old != nullptr ? old->delivered : 0; - bool served = false; - Amount total_after = total_; - if (admission_ == Admission::Lossy) { - const auto step = lossy_step(*plan, volume, w_m); - Counter128 offered = (old != nullptr && old->offered.has_value()) ? *old->offered : Counter128 {}; - if (!offered.add(step.offered)) { - throw std::invalid_argument("the cumulative offered counter would reach 2^127"); - } - d.offered = offered; - auto& drops_m = scratch.drops; - drops_m.assign(members.size(), Counter128 {}); - if (old != nullptr) { - for (const auto& [e, x] : old->drops) drops_m[member_index(members, e)] = x; - } - for (std::size_t m = 0; m < members.size(); ++m) { - if (step.drops[m] == 0) continue; - if (!drops_m[m].add(step.drops[m])) { - throw std::invalid_argument("a cumulative drop counter would reach 2^127"); - } - out.drops.emplace_back(members[m], step.drops[m]); - } - for (std::size_t m = 0; m < members.size(); ++m) { - if (own_m[m] + step.carried[m] != 0) d.edges.emplace_back(members[m], own_m[m] + step.carried[m]); - if (!drops_m[m].is_zero()) d.drops.emplace_back(members[m], drops_m[m]); - } - d.delivered = delivered0 + step.delivered; - served = volume.has_value() && step.injected == *volume; - } else { - auto& v_m = scratch.view; - v_m.resize(members.size()); - for (std::size_t m = 0; m < members.size(); ++m) v_m[m] = w_m[m] + own_m[m]; - const auto step = lossless_step(g, *plan, total_, v_m, own_m, volume); - for (std::size_t m = 0; m < members.size(); ++m) { - if (step.alloc[m] != 0) d.edges.emplace_back(members[m], step.alloc[m]); - } - d.delivered = step.total; - total_after = step.total; - out.iterations = step.iterations; - served = volume.has_value() && step.total - delivered0 == *volume; - } - // The record's bundle is the usable route: the supplied DAG itself when nothing was pruned, - // else one DAG of the route made at the first creation and kept (the route is fixed). - std::shared_ptr route_dag = route_dag_; - if (!route_dag) { - route_dag = route_plan_.route.size() == bundle_->via_edges().size() - ? bundle_ - : std::make_shared(PredDAGBuilder::from_edges(g, route_plan_.route)); - } - d.bundle = route_dag; - d.route_cost = route_cost_; - const Amount delivered = d.delivered; - Counter128 requested = requested_; - if (volume.has_value() && !requested.add(*volume)) { - throw std::invalid_argument("the cumulative requested counter would reach 2^127"); - } - const auto ids = LedgerAccess::commit(fg_, owner_, std::span(&d, 1)); - // Committed: the epoch state follows (nothing below throws). - route_dag_ = std::move(route_dag); - if (created) { - if (fresh.has_value()) { - captured_ = std::move(fresh); - plan_ = &*captured_; - } else { - plan_ = &route_plan_; - } - flow_id_ = ids.front(); - } - total_ = total_after; - requested_ = requested; - if (!volume.has_value()) infinite_ = true; - out.placed = delivered - delivered0; - if (volume.has_value()) out.remaining = *volume - out.placed; - out.termination = served ? Termination::Complete : Termination::Exhausted; - out.routes_changed = created; - return out; -} - -} // namespace netgraph::core::detail diff --git a/src/pred_dag.cpp b/src/pred_dag.cpp index ec7f1ab..67d18a1 100644 --- a/src/pred_dag.cpp +++ b/src/pred_dag.cpp @@ -18,7 +18,7 @@ std::string idx(std::size_t i) { return std::to_string(i); } } // namespace -PredDAG PredDAG::from_csr(const StrictMultiDiGraph& g, std::span offsets, +PredDAG PredDAG::from_csr(const Graph& g, std::span offsets, std::span parents, std::span via) { const auto n = static_cast(g.num_nodes()); const auto m = static_cast(g.num_edges()); @@ -123,7 +123,13 @@ PredDAG PredDAG::from_csr(const StrictMultiDiGraph& g, std::span(v)); + out.node_offsets_.push_back(offsets[v + 1]); + } + } out.parents_.assign(parents.begin(), parents.end()); out.via_.assign(via.begin(), via.end()); out.edges_ = out.via_; @@ -131,43 +137,71 @@ PredDAG PredDAG::from_csr(const StrictMultiDiGraph& g, std::span PredDAG::parent_offsets() const { + std::vector out(static_cast(num_nodes_) + 1, 0); + std::size_t i = 0; + for (std::size_t v = 0; v < static_cast(num_nodes_); ++v) { + if (i < nodes_.size() && static_cast(nodes_[i]) == v) ++i; + out[v + 1] = node_offsets_[i]; + } + return out; +} + namespace detail { -PredDAG PredDAGBuilder::from_edges(const StrictMultiDiGraph& g, std::span edges) { - const auto n = static_cast(g.num_nodes()); +PredDAG PredDAGBuilder::of_edges(const Graph& g, std::span edges) { const auto edge_src = g.edge_src(); const auto edge_dst = g.edge_dst(); PredDAG out; out.graph_uid_ = g.uid(); - out.offsets_.assign(n + 1, 0); + out.num_nodes_ = g.num_nodes(); std::vector sorted(edges.begin(), edges.end()); - std::sort(sorted.begin(), sorted.end()); - for (EdgeId e : sorted) ++out.offsets_[static_cast(edge_dst[static_cast(e)]) + 1]; - for (std::size_t v = 0; v < n; ++v) out.offsets_[v + 1] += out.offsets_[v]; - out.parents_.resize(sorted.size()); - out.via_.resize(sorted.size()); - std::vector pos(out.offsets_.begin(), out.offsets_.end() - 1); - for (EdgeId e : sorted) { // ascending: each node's entries stay ascending - const auto ei = static_cast(e); - const auto k = static_cast(pos[static_cast(edge_dst[ei])]++); + if (!std::is_sorted(sorted.begin(), sorted.end())) std::sort(sorted.begin(), sorted.end()); + // Entries grouped by child (ascending NodeId), ascending via within a child: a stable + // counting sort of the ascending edges by their child. + std::vector slot(static_cast(out.num_nodes_) + 1, 0); + for (EdgeId e : sorted) ++slot[static_cast(edge_dst[static_cast(e)]) + 1]; + for (std::size_t v = 1; v < slot.size(); ++v) slot[v] += slot[v - 1]; + std::vector by_child(sorted.size()); + for (EdgeId e : sorted) { + by_child[static_cast(slot[static_cast(edge_dst[static_cast(e)])]++)] = e; + } + out.parents_.resize(by_child.size()); + out.via_.resize(by_child.size()); + for (std::size_t k = 0; k < by_child.size(); ++k) { + const auto ei = static_cast(by_child[k]); + const NodeId child = edge_dst[ei]; + if (out.nodes_.empty() || out.nodes_.back() != child) { + if (!out.nodes_.empty()) out.node_offsets_.push_back(static_cast(k)); + out.nodes_.push_back(child); + } out.parents_[k] = edge_src[ei]; - out.via_[k] = e; + out.via_[k] = by_child[k]; } + if (!out.nodes_.empty()) out.node_offsets_.push_back(static_cast(by_child.size())); out.edges_ = std::move(sorted); return out; } -PredDAG PredDAGBuilder::from_children(std::uint64_t graph_uid, const std::vector>& per_child, - const std::vector& parent_of) { +PredDAG PredDAGBuilder::of_path(std::uint64_t graph_uid, std::int32_t num_nodes, + const std::vector>& hops, const std::vector& nodes) { + // Hop h's child is nodes[h + 1]; the children are distinct, so ordering the hops by child + // gives the grouped entries. + std::vector order(hops.size()); + for (std::size_t h = 0; h < hops.size(); ++h) order[h] = h; + std::sort(order.begin(), order.end(), [&](std::size_t a, std::size_t b) { return nodes[a + 1] < nodes[b + 1]; }); PredDAG out; out.graph_uid_ = graph_uid; - out.offsets_.assign(per_child.size() + 1, 0); - for (std::size_t v = 0; v < per_child.size(); ++v) { - out.offsets_[v + 1] = out.offsets_[v] + static_cast(per_child[v].size()); - for (EdgeId e : per_child[v]) { - out.parents_.push_back(parent_of[v]); + out.num_nodes_ = num_nodes; + for (std::size_t h : order) { + std::vector members(hops[h]); + std::sort(members.begin(), members.end()); + out.nodes_.push_back(nodes[h + 1]); + for (EdgeId e : members) { + out.parents_.push_back(nodes[h]); out.via_.push_back(e); } + out.node_offsets_.push_back(static_cast(out.via_.size())); } out.edges_ = out.via_; std::sort(out.edges_.begin(), out.edges_.end()); @@ -176,9 +210,18 @@ PredDAG PredDAGBuilder::from_children(std::uint64_t graph_uid, const std::vector std::span PredDAGBuilder::ascending(const PredDAG& dag) noexcept { return dag.edges_; } +std::span PredDAGBuilder::member_nodes(const PredDAG& dag) noexcept { return dag.nodes_; } + +std::span PredDAGBuilder::member_offsets(const PredDAG& dag) noexcept { return dag.node_offsets_; } + +std::size_t PredDAGBuilder::payload_bytes(const PredDAG& dag) noexcept { + return sizeof(NodeId) * (dag.nodes_.size() + dag.parents_.size()) + sizeof(std::int32_t) * dag.node_offsets_.size() + + sizeof(EdgeId) * (dag.via_.size() + dag.edges_.size()); +} + } // namespace detail -Path::Path(const StrictMultiDiGraph& g, std::vector> hops) { +Path::Path(const Graph& g, std::vector> hops) { const auto m = static_cast(g.num_edges()); // Every member in range before any endpoint is read. for (std::size_t h = 0; h < hops.size(); ++h) { @@ -251,18 +294,10 @@ Path::Path(const StrictMultiDiGraph& g, std::vector> hops) { } PredDAG Path::to_dag() const { - // Built here (not through the builder) because a Path keeps no graph reference: the parent of - // every member of hop h is nodes_[h], its child nodes_[h + 1]; no node repeats, so each node - // holds the members of at most one hop. - std::vector> per_child(static_cast(num_nodes_)); - std::vector parent_of(static_cast(num_nodes_), -1); - for (std::size_t h = 0; h < hops_.size(); ++h) { - const auto child = static_cast(nodes_[h + 1]); - per_child[child] = hops_[h]; - std::sort(per_child[child].begin(), per_child[child].end()); - parent_of[child] = nodes_[h]; - } - return detail::PredDAGBuilder::from_children(graph_uid_, per_child, parent_of); + // From the hops and the visited nodes, as a Path keeps no graph reference: the parent of every + // member of hop h is nodes_[h], its child nodes_[h + 1]; no node repeats, so each node holds + // the members of at most one hop. + return detail::PredDAGBuilder::of_path(graph_uid_, num_nodes_, hops_, nodes_); } } // namespace netgraph::core diff --git a/src/profiling.cpp b/src/profiling.cpp deleted file mode 100644 index 7acfed9..0000000 --- a/src/profiling.cpp +++ /dev/null @@ -1,131 +0,0 @@ -/* - Profiling implementation - singleton definitions. - - These are defined in a .cpp file (not inline in the header) to avoid ODR - violations when linking a static library into a shared library (Python module). - With inline definitions, separate static variable instances can exist in each - translation unit, causing profiling data to be recorded in one instance while - being read from another. -*/ -#include "netgraph/core/profiling.hpp" - -#include // std::sort in dump() -#include - -namespace netgraph::core { - -bool profiling_enabled() noexcept { - static const bool enabled = [] { - const char* env = std::getenv("NGRAPH_CORE_PROFILE"); - return env && env[0] == '1'; - }(); - return enabled; -} - -ProfilingStats& ProfilingStats::instance() { - static auto* inst = new ProfilingStats(); - return *inst; -} - -ProfilingStats::LocalStats::LocalStats(ProfilingStats& owner) : owner_(owner) { - owner_.register_local(this); -} - -ProfilingStats::LocalStats::~LocalStats() { - owner_.finalize_local(this); -} - -void ProfilingStats::LocalStats::record(const char* name, double micros) { - std::lock_guard lock(mutex_); - auto& e = stats_[name]; - e.total_us += micros; - e.count++; - if (micros < e.min_us) e.min_us = micros; - if (micros > e.max_us) e.max_us = micros; -} - -void ProfilingStats::LocalStats::append_to(std::unordered_map& out) const { - std::lock_guard lock(mutex_); - for (const auto& [name, entry] : stats_) { - merge_entry(out[name], entry); - } -} - -void ProfilingStats::LocalStats::clear() { - std::lock_guard lock(mutex_); - stats_.clear(); -} - -ProfilingStats::LocalStats& ProfilingStats::local_stats() { - static thread_local LocalStats local(*this); - return local; -} - -void ProfilingStats::register_local(LocalStats* local) { - std::lock_guard lock(mutex_); - active_locals_.push_back(local); -} - -void ProfilingStats::finalize_local(LocalStats* local) { - std::lock_guard lock(mutex_); - local->append_to(finished_stats_); - auto it = std::find(active_locals_.begin(), active_locals_.end(), local); - if (it != active_locals_.end()) { - active_locals_.erase(it); - } -} - -void ProfilingStats::merge_entry(Entry& dst, const Entry& src) { - if (src.count == 0) { - return; - } - dst.total_us += src.total_us; - dst.count += src.count; - dst.min_us = std::min(dst.min_us, src.min_us); - dst.max_us = std::max(dst.max_us, src.max_us); -} - -std::unordered_map ProfilingStats::snapshot_unlocked() const { - auto snapshot = finished_stats_; - for (const LocalStats* local : active_locals_) { - local->append_to(snapshot); - } - return snapshot; -} - -void ProfilingStats::record(const char* name, double micros) { - local_stats().record(name, micros); -} - -void ProfilingStats::dump(std::ostream& out) const { - std::lock_guard lock(mutex_); - const auto snapshot = snapshot_unlocked(); - std::vector> ordered(snapshot.begin(), snapshot.end()); - std::sort( - ordered.begin(), - ordered.end(), - [](const auto& left, const auto& right) { return left.first < right.first; } - ); - - out << "\n=== NetGraph-Core Profiling Stats ===\n"; - for (const auto& [name, entry] : ordered) { - const double avg = entry.count > 0 ? entry.total_us / static_cast(entry.count) : 0.0; - out << name << ":\n" - << " calls: " << entry.count << "\n" - << " total: " << entry.total_us / 1000.0 << " ms\n" - << " avg: " << avg << " us\n" - << " min: " << entry.min_us << " us\n" - << " max: " << entry.max_us << " us\n"; - } - out << "=====================================\n"; -} - -void ProfilingStats::reset() { - std::lock_guard lock(mutex_); - finished_stats_.clear(); - for (LocalStats* local : active_locals_) { - local->clear(); - } -} - -} // namespace netgraph::core diff --git a/src/search.cpp b/src/search.cpp index 44b7e38..230fde4 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -1,5 +1,6 @@ /* - The search semantics of APPROACH 6 as internal routines (sub-PR 1b; public in 1d). + The search semantics of APPROACH 6 as internal routines, and the constructor of their + PathSelection. Distances are int64 path costs: every cost is positive and the sum of all costs is below 2^62, so no simple path cost overflows and kUnreachable (INT64_MAX) never collides. Dijkstra @@ -15,8 +16,10 @@ #include #include +#include "netgraph/core/detail/endpoints.hpp" +#include "netgraph/core/detail/enum_domain.hpp" #include "netgraph/core/detail/int128.hpp" -#include "netgraph/core/flow.hpp" +#include "netgraph/core/detail/pred_dag_builder.hpp" namespace netgraph::core::detail { @@ -28,39 +31,20 @@ int compare(const Priority& a, const Priority& b) noexcept { return left < right ? -1 : 1; } +Priority priority_value(EdgePriority priority, Amount residual, Amount capacity) noexcept { + if (priority == EdgePriority::Capacity) return {capacity, 1, false}; + if (priority == EdgePriority::Residual) return {residual, 1, false}; + return {residual, capacity, false}; +} + Priority PriorityValues::of(EdgeId e) const noexcept { const auto ei = static_cast(e); const Amount cap = g->capacity_units()[ei]; - if (residual.empty() || priority == EdgePriority::Capacity) return {cap, 1, false}; - if (priority == EdgePriority::Residual) return {residual[ei], 1, false}; - return {residual[ei], cap, false}; -} - -std::vector node_set(std::span nodes, std::int32_t num_nodes, const char* name) { - if (nodes.empty()) throw std::invalid_argument(std::string(name) + ": the node set is empty"); - std::vector out(nodes.begin(), nodes.end()); - for (NodeId v : out) { - if (v < 0 || v >= num_nodes) { - throw std::invalid_argument(std::string(name) + ": node " + std::to_string(v) + " out of node range [0, " + - std::to_string(num_nodes) + ")"); - } - } - std::sort(out.begin(), out.end()); - out.erase(std::unique(out.begin(), out.end()), out.end()); - return out; -} - -void check_disjoint(std::span a, std::span b) { - for (std::size_t i = 0, k = 0; i < a.size() && k < b.size();) { - if (a[i] == b[k]) { - throw std::invalid_argument("node " + std::to_string(a[i]) + - " is in both endpoint sets: the sets must not overlap"); - } - if (a[i] < b[k]) ++i; else ++k; - } + if (residual.empty()) return {cap, 1, false}; + return priority_value(priority, residual[ei], cap); } -std::vector member_mask(const StrictMultiDiGraph& g, const Eligibility* elig, +std::vector member_mask(const Graph& g, const Eligibility* elig, const PathSelection& selection, std::span residual, Amount min_residual) { const auto m = static_cast(g.num_edges()); @@ -75,8 +59,8 @@ std::vector member_mask(const StrictMultiDiGraph& g, const Eligibi if (selection.multi_edge) return member; // Collapse: one member per (u, v) by select_adjacency. const PriorityValues prio {&g, selection.priority, residual}; + const auto value = [&prio](EdgeId e) { return prio.of(e); }; const bool prefer = selection.tie_break == EdgeTieBreak::PreferHigherPriority; - const auto cost = g.cost(); const auto edge_dst = g.edge_dst(); const auto row = g.row_offsets(); std::vector best(static_cast(g.num_nodes()), -1); @@ -93,15 +77,7 @@ std::vector member_mask(const StrictMultiDiGraph& g, const Eligibi touched.push_back(static_cast(v)); continue; } - const auto ci = static_cast(cur); - bool better = false; - if (cost[ei] != cost[ci]) { - better = cost[ei] < cost[ci]; - } else if (prefer) { - const int c = compare(prio.of(e), prio.of(cur)); - better = c > 0; // equal priority: the lower EdgeId (cur, seen first) stays - } - if (better) best[v] = e; + if (replaces(g, e, cur, prefer, value)) best[v] = e; } for (std::size_t e = static_cast(row[static_cast(u)]); e < static_cast(row[static_cast(u) + 1]); ++e) { @@ -119,7 +95,7 @@ using MinHeap = std::priority_queue, std::grea } // namespace -std::vector distances(const StrictMultiDiGraph& g, std::span member, +std::vector distances(const Graph& g, std::span member, std::span roots, Direction direction) { const auto n = static_cast(g.num_nodes()); std::vector dist(n, kUnreachable); @@ -166,7 +142,7 @@ std::vector distances(const StrictMultiDiGraph& g, std::span full_dag(const StrictMultiDiGraph& g, std::span member, +std::vector full_dag(const Graph& g, std::span member, std::span dist, Direction direction) { const auto cost = g.cost(); const auto edge_src = g.edge_src(); @@ -183,7 +159,7 @@ std::vector full_dag(const StrictMultiDiGraph& g, std::span member, std::span sources, +TightDag tight_dag(const Graph& g, std::span member, std::span sources, std::span destinations) { const auto n = static_cast(g.num_nodes()); const auto cost = g.cost(); @@ -260,9 +236,9 @@ TightDag tight_dag(const StrictMultiDiGraph& g, std::span me return out; } -std::optional canonical_path(const StrictMultiDiGraph& g, std::span member, +std::optional canonical_path(const Graph& g, std::span member, std::span sources, std::span destinations, - const PriorityValues* priority, std::optional prefix_cap) { + const PriorityValues* priority) { TightDag tight = tight_dag(g, member, sources, destinations); if (tight.cost == kUnreachable) return std::nullopt; const auto n = static_cast(g.num_nodes()); @@ -303,8 +279,6 @@ std::optional canonical_path(const StrictMultiDiGraph& g, std::span< if (!b_star.has_value() || compare(*best[ti], *b_star) > 0) b_star = best[ti]; } if (!b_star.has_value()) return std::nullopt; - if (prefix_cap.has_value() && compare(*prefix_cap, *b_star) < 0) b_star = prefix_cap; - out.b_star = b_star; std::vector kept; for (EdgeId e : dag) { if (compare(priority->of(e), *b_star) >= 0) kept.push_back(e); @@ -367,25 +341,19 @@ std::optional canonical_path(const StrictMultiDiGraph& g, std::span< Cost walk_cost = 0; for (EdgeId e : out.walk) walk_cost += cost[static_cast(e)]; if (walk_cost != out.cost) throw std::logic_error("canonical path: the walk is not tight"); - if (priority != nullptr) { - Priority b {0, 1, true}; - for (EdgeId e : out.walk) { - const Priority p = priority->of(e); - if (compare(p, b) < 0) b = p; - } - out.bottleneck = b; - } return out; } -SpfOut spf(const StrictMultiDiGraph& g, std::span roots, Direction direction, +SpfOut spf(const Graph& g, std::span roots, Direction direction, std::optional> targets, const PathSelection& selection, std::optional> residual_arg, Amount min_residual, const Eligibility* elig) { + check_member(direction, "direction"); + check_selection(selection); const auto root_set = node_set(roots, g.num_nodes(), "roots"); std::vector target_set; if (targets.has_value()) { target_set = node_set(*targets, g.num_nodes(), "targets"); - check_disjoint(root_set, target_set); + check_disjoint(root_set, target_set, "roots", "targets"); } else if (!selection.multipath) { throw std::invalid_argument("multipath = false requires targets (the single path joins a root to a target)"); } @@ -449,4 +417,22 @@ SpfOut spf(const StrictMultiDiGraph& g, std::span roots, Direction return out; } +PredDAG static_support(const Graph& g, std::span roots, Direction direction, + const Eligibility* elig) { + const PathSelection full(EdgeTieBreak::Deterministic, EdgePriority::Capacity, true, true); + const auto out = spf(g, roots, direction, std::nullopt, full, std::nullopt, 1, elig); + return PredDAGBuilder::of_edges(g, out.edges); +} + } // namespace netgraph::core::detail + +namespace netgraph::core { + +// The selection of a search (options.hpp): its two enum-typed fields inside their enumerations. +PathSelection::PathSelection(EdgeTieBreak tie_break_, EdgePriority priority_, bool multipath_, bool multi_edge_) + : tie_break(tie_break_), priority(priority_), multipath(multipath_), multi_edge(multi_edge_) { + detail::check_member(tie_break, "tie_break"); + detail::check_member(priority, "priority"); +} + +} // namespace netgraph::core diff --git a/tests/cpp/masking_tests.cpp b/tests/cpp/eligibility_tests.cpp similarity index 93% rename from tests/cpp/masking_tests.cpp rename to tests/cpp/eligibility_tests.cpp index 30a2c6b..ca503ea 100644 --- a/tests/cpp/masking_tests.cpp +++ b/tests/cpp/eligibility_tests.cpp @@ -1,12 +1,12 @@ -// Eligibility (APPROACH 4) as the 1a graph builds it: dense node and edge flags from excluded -// ids, graph-bound, uid 0 for the all-allowed value. +// Eligibility (APPROACH 4) as the graph builds it: dense node and edge flags from excluded ids, +// graph-bound, uid 0 for the all-allowed value. #include #include #include #include -#include "netgraph/core/strict_multidigraph.hpp" +#include "netgraph/core/graph.hpp" #include "test_utils.hpp" using namespace netgraph::core; @@ -73,7 +73,7 @@ TEST(Eligibility, OutOfRangeIdsRejected) { EXPECT_THROW((void)g.eligibility(neg_node, {}), std::invalid_argument); } -// Eligibility.intersect (sub-PR 1b): an operand when the result equals it (its uid kept, a +// Eligibility.intersect: an operand when the result equals it (its uid kept, a // first), otherwise a new value with a fresh uid; values of different graphs rejected. TEST(Eligibility, IntersectReturnsAnOperandWhenEqual) { auto g = make_graph(3, {{0, 1, 1, 1}, {1, 2, 1, 1}, {0, 2, 1, 5}}); diff --git a/tests/cpp/fault_injection.hpp b/tests/cpp/fault_injection.hpp new file mode 100644 index 0000000..472a375 --- /dev/null +++ b/tests/cpp/fault_injection.hpp @@ -0,0 +1,101 @@ +// The allocation-failure hook of the fault-injection executable (netgraph_core_fault_tests) and +// the state its sweeps compare: flow_ledger_fault_tests.cpp replaces the global operator new so that +// the chosen allocation throws std::bad_alloc; every test file of the executable sweeps its +// operations through fails_at(). The counter is shared by every thread (the evaluators' workers +// allocate too): the k-th allocation in the process fails, whichever thread makes it. +#pragma once + +#include + +#include +#include +#include +#include +#include + +#include "netgraph/core/detail/ledger_access.hpp" +#include "netgraph/core/flow_ledger.hpp" +#include "netgraph/core/flow_policy.hpp" + +// Allocations left before the injected failure; negative: no injection. +extern std::atomic g_countdown; + +// Runs fn with the k-th allocation (from 0) failing; returns whether that failure happened +// (and expects that a reached failure propagated as std::bad_alloc). +bool fails_at(long k, const std::function& fn); + +// As fails_at, counting only the allocations of threads other than the caller's (the +// evaluators' worker threads): the k-th worker allocation fails. +bool fails_in_workers_at(long k, const std::function& fn); + +namespace netgraph::core::test { + +// The ledger as a sweep compares it: the aggregate, the residual, every record field (FlowIds +// keep their local ids: a reference sequence runs on another ledger), the next FlowId and the +// bound and orphaned owners. +struct LedgerState { + std::vector allocated; + std::vector residual; + std::vector records; + std::int64_t next_id {0}; + std::vector bound; + std::vector orphans; +}; + +inline LedgerState ledger_state(const FlowLedger& fg) { + using detail::LedgerAccess; + LedgerState s {fg.allocated(), fg.residual(), fg.records(), LedgerAccess::next_flow_id(fg), + LedgerAccess::bound_owners(fg), LedgerAccess::orphan_owners(fg)}; + for (auto& r : s.records) { + EXPECT_EQ(r.flow_id.first, fg.uid()); + r.flow_id.first = 0; + } + return s; +} + +inline bool same_record(const FlowRecord& a, const FlowRecord& b) { + return a.flow_id == b.flow_id && a.owner == b.owner && a.sources == b.sources && a.destinations == b.destinations && + a.placement == b.placement && a.admission == b.admission && a.bundle && b.bundle && *a.bundle == *b.bundle && + a.route_cost == b.route_cost && a.offered == b.offered && a.delivered == b.delivered && a.edges == b.edges && + a.drops == b.drops; +} + +inline void expect_same(const LedgerState& a, const LedgerState& b, const std::string& where) { + EXPECT_EQ(a.allocated, b.allocated) << where; + EXPECT_EQ(a.residual, b.residual) << where; + EXPECT_EQ(a.next_id, b.next_id) << where; + EXPECT_EQ(a.bound, b.bound) << where; + EXPECT_EQ(a.orphans, b.orphans) << where; + ASSERT_EQ(a.records.size(), b.records.size()) << where; + for (std::size_t i = 0; i < a.records.size(); ++i) { + EXPECT_TRUE(same_record(a.records[i], b.records[i])) << where << " record " << i; + } +} + +// The ledger invariant: the aggregate is the sum of the records, within [0, baseline]. +inline void expect_balanced(const FlowLedger& fg, const std::string& where) { + std::vector sum(fg.allocated().size(), 0); + for (const auto& r : fg.records()) { + for (const auto& [e, x] : r.edges) sum[static_cast(e)] += x; + } + EXPECT_EQ(sum, fg.allocated()) << where; + for (Amount x : fg.residual()) EXPECT_GE(x, 0) << where; +} + +// Every field of a placement result. +inline void expect_same(const PlacementResult& a, const PlacementResult& b, const std::string& where) { + EXPECT_EQ(a.placed, b.placed) << where; + EXPECT_EQ(a.remaining, b.remaining) << where; + EXPECT_EQ(a.termination, b.termination) << where; + EXPECT_EQ(a.local_outcomes, b.local_outcomes) << where; + EXPECT_EQ(a.routes_changed, b.routes_changed) << where; + EXPECT_EQ(a.weights_changed, b.weights_changed) << where; + EXPECT_EQ(a.drops, b.drops) << where; + EXPECT_EQ(a.cost_distribution, b.cost_distribution) << where; + EXPECT_EQ(a.edge_flows, b.edge_flows) << where; + EXPECT_EQ(a.residual, b.residual) << where; + EXPECT_EQ(a.bottleneck, b.bottleneck) << where; + EXPECT_EQ(a.min_cut, b.min_cut) << where; +} + +} // namespace netgraph::core::test diff --git a/tests/cpp/flow_core_deletion_fault_tests.cpp b/tests/cpp/flow_core_deletion_fault_tests.cpp new file mode 100644 index 0000000..95acf70 --- /dev/null +++ b/tests/cpp/flow_core_deletion_fault_tests.cpp @@ -0,0 +1,453 @@ +// Allocation-failure atomicity of the deletion re-solve's paths (APPROACH 2.2, 13): the capture +// of a solve's final state (on cold and growing threads), the copy and the copy assignment of a +// state (between states of different sizes, on 1, 2 and 4 threads), a resolver's construction +// and every re-solve are swept over every allocation they make, and Global sensitivity over every +// allocation on 1, 2 and 4 workers. After each injected std::bad_alloc nothing is captured or +// constructed, an assigned state keeps its old value, the saved state is unchanged, and a clean +// retry (the same resolver included) gives the reference result. Runs in the fault-injection +// executable (fault_injection.hpp). +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fault_injection.hpp" +#include "netgraph/core/detail/flow_core.hpp" +#include "netgraph/core/evaluators.hpp" +#include "test_utils.hpp" + +using namespace netgraph::core; +using namespace netgraph::core::test; +using detail::DeletionCapture; +using detail::DeletionResolver; +using detail::DeletionResult; +using detail::DeletionState; + +namespace { + +// Two sources and two destinations; deletions with full, partial and no reroute. +std::shared_ptr graph() { + return std::make_shared( + make_graph(8, {{0, 2, 2, 1}, {1, 2, 1, 2}, {2, 4, 2, 1}, {2, 5, 2, 3}, {1, 5, 1, 7}, {0, 4, 1, 9}, + {4, 5, 1, 1}, {3, 6, 4, 1}, {6, 7, 4, 1}, {5, 2, 1, 1}, {2, 3, 1, 2}, {6, 4, 2, 1}})); +} + +const std::vector kSources {0, 1}; +const std::vector kDestinations {4, 5, 7}; + +FlowCoreResult capture(const Graph& g, std::shared_ptr owner, + DeletionCapture& req) { + req.graph = std::move(owner); + const std::vector view(g.capacity_units().begin(), g.capacity_units().end()); + return place_global_units(g, view, kSources, kDestinations, nullptr, std::nullopt, &req); +} + +bool same_result(const DeletionResult& a, const DeletionResult& b) { + const auto& x = a.counters; + const auto& y = b.counters; + return a.value == b.value && a.potentials == b.potentials && a.cut == b.cut && a.edge_flows == b.edge_flows && + x.dijkstra_runs == y.dijkstra_runs && x.dijkstra_pops == y.dijkstra_pops && x.settled == y.settled && + x.arc_scans == y.arc_scans && x.dinic_phases == y.dinic_phases && x.augmentations == y.augmentations; +} + +bool same_state(const DeletionState& a, const DeletionState& b) { + return std::vector(a.residual().begin(), a.residual().end()) == + std::vector(b.residual().begin(), b.residual().end()) && + std::vector(a.potentials().begin(), a.potentials().end()) == + std::vector(b.potentials().begin(), b.potentials().end()) && + a.value() == b.value() && a.maximum() == b.maximum(); +} + +// Every field of two populated states: the graph and the network by identity (copies share +// them), the rest by value. +bool identical(const DeletionState& a, const DeletionState& b) { + const auto& x = a.counters(); + const auto& y = b.counters(); + return &a.graph() == &b.graph() && &a.network() == &b.network() && same_state(a, b) && + x.dijkstra_runs == y.dijkstra_runs && x.dijkstra_pops == y.dijkstra_pops && x.settled == y.settled && + x.arc_scans == y.arc_scans && x.dinic_phases == y.dinic_phases && x.augmentations == y.augmentations; +} + +// A state that never held a solve: no fields, and no resolver accepts it. +bool empty_state(const DeletionState& s) { + if (!s.residual().empty() || !s.potentials().empty() || s.value() != 0 || s.maximum()) return false; + try { + DeletionResolver rs(s); + } catch (const std::invalid_argument&) { + return true; + } + return false; +} + +// A captured state with its re-solve of every positive-flow edge. +struct Saved { + DeletionState state; + std::vector> results; +}; + +Saved saved(std::shared_ptr g, const std::vector& sources, + const std::vector& destinations) { + DeletionCapture req; + req.graph = g; + (void)place_global_units(*g, g->capacity_units(), sources, destinations, nullptr, std::nullopt, &req); + Saved out {*req.state, {}}; + const auto flows = out.state.edge_flows(); + for (EdgeId e = 0; e < g->num_edges(); ++e) { + if (flows[static_cast(e)] > 0) out.results.emplace_back(e, DeletionResolver(out.state).resolve(e)); + } + return out; +} + +// The path 0 -> 1 -> ... -> n - 1 (cost 1 per edge) at capacity c. +Saved chain(std::int32_t n, Amount c) { + std::vector edges; + for (NodeId v = 0; v + 1 < n; ++v) edges.push_back({v, v + 1, c, 1}); + return saved(std::make_shared(make_graph(n, edges)), {0}, {n - 1}); +} + +// The state is identical to `want`'s and serves its re-solves: a resolver on it gives every +// reference result (never run on a state that is not identical: a hybrid could crash it). +bool holds(const DeletionState& s, const Saved& want) { + if (!identical(s, want.state)) return false; + DeletionResolver rs(s); + for (const auto& [e, r] : want.results) { + if (!same_result(rs.resolve(e), r)) return false; + } + return !want.results.empty(); +} + +// Whether a copy of `from` needs more storage than `to` holds: then every assignment allocates. +bool grows(const DeletionState& to, const DeletionState& from) { + return from.residual().size() > to.residual().size() || from.potentials().size() > to.potentials().size(); +} + +// States of different coordinate sizes (N + 2 vertices and slots: 4 and 6 for the 2-node path, +// 8 and 14 for the 6-node paths, 10 and 34 for the 8-node graph, 13 and 24 for the 11-node path), +// and two of equal sizes with different residuals and values. +std::vector assignment_states() { + std::vector out; + out.push_back(chain(2, 2)); + out.push_back(chain(6, 2)); + out.push_back(chain(6, 3)); + const auto g = graph(); + out.push_back(saved(g, kSources, kDestinations)); + out.push_back(chain(11, 1)); + return out; +} + +} // namespace + +TEST(DeletionFaults, CaptureFailsWithoutAStateOrAChangedPayload) { + const auto g = graph(); + DeletionCapture want; + const auto payload = capture(*g, g, want); + ASSERT_TRUE(want.state.has_value()); + int failures = 0; + for (long at = 0;; ++at) { + DeletionCapture req; + FlowCoreResult got; + if (!fails_at(at, [&] { got = capture(*g, g, req); })) { + EXPECT_TRUE(same_payload(got, payload)); + ASSERT_TRUE(req.state.has_value()); + EXPECT_TRUE(same_state(*req.state, *want.state)); + break; + } + ++failures; + EXPECT_FALSE(req.state.has_value()) << "allocation " << at; + DeletionCapture retry; + EXPECT_TRUE(same_payload(capture(*g, g, retry), payload)) << "retry after allocation " << at; + } + EXPECT_GT(failures, 10); +} + +TEST(DeletionFaults, CopyAndResolverConstructionFailAtomically) { + const auto g = graph(); + DeletionCapture req; + (void)capture(*g, g, req); + const DeletionState saved = *req.state; + int failures = 0; + for (long at = 0;; ++at) { + std::optional copy; + if (!fails_at(at, [&] { copy.emplace(*req.state); })) break; + ++failures; + EXPECT_FALSE(copy.has_value()) << "copy, allocation " << at; + EXPECT_TRUE(same_state(*req.state, saved)) << "copy, allocation " << at; + } + for (long at = 0;; ++at) { + std::unique_ptr rs; + if (!fails_at(at, [&] { rs = std::make_unique(*req.state); })) break; + ++failures; + EXPECT_EQ(rs, nullptr) << "resolver, allocation " << at; + EXPECT_TRUE(same_state(*req.state, saved)) << "resolver, allocation " << at; + } + EXPECT_GT(failures, 10); +} + +// Every positive-flow edge, every allocation of a re-solve on a fresh resolver (its working +// storage grows) and on a warm one; the failed resolver serves the next call correctly. +TEST(DeletionFaults, EveryReSolveFailsWithoutPoisoningItsResolver) { + const auto g = graph(); + DeletionCapture req; + (void)capture(*g, g, req); + const DeletionState saved = *req.state; + const auto flows = saved.edge_flows(); + int failures = 0; + int candidates = 0; + for (EdgeId e = 0; e < g->num_edges(); ++e) { + if (flows[static_cast(e)] <= 0) continue; + ++candidates; + const auto want = DeletionResolver(saved).resolve(e); + for (bool warm : {false, true}) { + DeletionResolver kept(saved); + if (warm) (void)kept.resolve(e); + for (long at = 0;; ++at) { + DeletionResolver fresh(saved); + DeletionResolver& rs = warm ? kept : fresh; + DeletionResult got; + if (!fails_at(at, [&] { got = rs.resolve(e); })) { + EXPECT_TRUE(same_result(got, want)) << "edge " << e; + break; + } + ++failures; + EXPECT_TRUE(same_state(*req.state, saved)) << "edge " << e << " allocation " << at; + EXPECT_TRUE(same_result(rs.resolve(e), want)) << "edge " << e << " retry after allocation " << at; + } + } + } + EXPECT_GE(candidates, 5); + EXPECT_GT(failures, 50); +} + +// Review-coherence RC1: a copy assignment between populated states of different coordinate sizes +// (and onto itself) fails at every allocation without changing its target; the reviewer's +// reproducer (the 6-node path assigned to the 2-node path) is one of the pairs. A failed target is +// identical to its old value and serves its re-solves; a finished one is identical to the source. +TEST(DeletionFaults, CopyAssignmentFailsWithoutChangingItsTarget) { + const auto states = assignment_states(); + int sweeps = 0; + int failures = 0; + for (std::size_t i = 0; i < states.size(); ++i) { + for (std::size_t j = 0; j < states.size(); ++j) { + int failed_here = 0; + for (long at = 0;; ++at) { + DeletionState target = states[i].state; + const std::string where = "target " + std::to_string(i) + " source " + std::to_string(j) + " allocation " + + std::to_string(at); + const bool failed = fails_at(at, [&] { + if (i == j) { + const DeletionState& self = target; + target = self; + } else { + target = states[j].state; + } + }); + if (!failed) { + EXPECT_TRUE(holds(target, states[j])) << where; + break; + } + ++failed_here; + EXPECT_TRUE(holds(target, states[i])) << where; + ASSERT_FALSE(::testing::Test::HasFailure()) << where; + } + ++sweeps; + failures += failed_here; + if (grows(states[i].state, states[j].state)) EXPECT_GE(failed_here, 1) << "target " << i << " source " << j; + } + } + EXPECT_EQ(sweeps, 25); + EXPECT_GE(failures, 2 * sweeps); // the copy built aside allocates the residual and the potentials +} + +// An empty state (no solve) as the target keeps nothing after a failure and accepts no resolver; +// as the source it empties the target without allocating. +TEST(DeletionFaults, CopyAssignmentWithAnEmptyStateIsAllOrNothing) { + const auto states = assignment_states(); + for (std::size_t j = 0; j < states.size(); ++j) { + for (long at = 0;; ++at) { + DeletionState target; + if (!fails_at(at, [&] { target = states[j].state; })) { + EXPECT_TRUE(holds(target, states[j])) << "source " << j; + break; + } + EXPECT_TRUE(empty_state(target)) << "source " << j << " allocation " << at; + } + DeletionState target = states[j].state; + const DeletionState none; + EXPECT_FALSE(fails_at(0, [&] { target = none; })) << "target " << j; + EXPECT_TRUE(empty_state(target)) << "target " << j; + } +} + +// The moves of a state, of a resolver and of a capture request neither allocate nor throw (the +// exchange of the copy assignment); a moved resolver serves its state. +TEST(DeletionFaults, MovesNeverAllocate) { + const auto states = assignment_states(); + for (std::size_t i = 0; i + 1 < states.size(); ++i) { + DeletionState target = states[i].state; + DeletionState source = states[i + 1].state; + EXPECT_FALSE(fails_at(0, [&] { target = std::move(source); })) << i; + EXPECT_TRUE(identical(target, states[i + 1].state)) << i; + EXPECT_FALSE(fails_at(0, [&] { DeletionState moved(std::move(target)); })) << i; + DeletionResolver first(states[i].state); + DeletionResolver second(states[i + 1].state); + EXPECT_FALSE(fails_at(0, [&] { first = std::move(second); })) << i; + const auto& [e, want] = states[i + 1].results.front(); + EXPECT_TRUE(same_result(first.resolve(e), want)) << i; + DeletionCapture req; + req.state = states[i].state; + DeletionCapture other; + EXPECT_FALSE(fails_at(0, [&] { other = std::move(req); })) << i; + ASSERT_TRUE(other.state.has_value()); + EXPECT_TRUE(identical(*other.state, states[i].state)) << i; + } +} + +// Copy assignments on 1, 2 and 4 threads at once, each thread into its own target from a source +// whose graph and network the other threads' sources share; every allocation of the run fails +// once (any thread's, or the workers' only). A target whose assignment failed or never started +// keeps its old value, every other one holds its source. +TEST(DeletionFaults, CopyAssignmentsOnWorkerThreadsAreAllOrNothing) { + const auto states = assignment_states(); + const auto k = states.size(); + for (std::size_t workers : {1, 2, 4}) { + for (bool workers_only : {false, true}) { + if (workers == 1 && workers_only) continue; + int failures = 0; + for (long at = 0;; ++at) { + std::vector targets; + for (std::size_t t = 0; t < workers; ++t) targets.push_back(states[t % k].state); + std::vector done(workers, 0); + std::vector errors(workers); + auto body = [&](std::size_t t) noexcept { + try { + targets[t] = states[(t + 1) % k].state; + done[t] = 1; + } catch (...) { + errors[t] = std::current_exception(); + } + }; + auto run = [&] { + if (workers == 1) { + body(0); + } else { + std::vector pool; + pool.reserve(workers); + try { + for (std::size_t t = 0; t < workers; ++t) pool.emplace_back(body, t); + } catch (...) { + for (auto& th : pool) th.join(); + throw; + } + for (auto& th : pool) th.join(); + } + for (const auto& e : errors) { + if (e) std::rethrow_exception(e); + } + }; + const bool failed = workers_only ? fails_in_workers_at(at, run) : fails_at(at, run); + for (std::size_t t = 0; t < workers; ++t) { + const auto& want = done[t] != 0 ? states[(t + 1) % k] : states[t % k]; + EXPECT_TRUE(holds(targets[t], want)) << "workers " << workers << " thread " << t << " allocation " << at; + } + if (!failed) { + for (std::size_t t = 0; t < workers; ++t) EXPECT_EQ(done[t], 1) << "workers " << workers << " thread " << t; + break; + } + ++failures; + ASSERT_FALSE(::testing::Test::HasFailure()); + ASSERT_LT(at, 1000); + } + // Thread 0 assigns the 6-node path to the 2-node path: it allocates in every run. + EXPECT_GE(failures, 1) << "workers " << workers << " workers_only " << workers_only; + } + } +} + +// Review-coherence's strengthened sweep, kept as regression evidence: every allocation of the +// capture on a thread whose solver is cold, and on one whose solver first served a smaller graph +// (its storage grows), leaves no state and an unchanged payload; the retry on the same thread +// gives the reference payload and state. +TEST(DeletionFaults, CaptureFailsAtEveryAllocationOnAColdOrGrowingThread) { + const auto g = graph(); + DeletionCapture ref; + const auto payload = capture(*g, g, ref); + for (bool grow : {false, true}) { + int failures = 0; + for (long at = 0;; ++at) { + bool failed = false; + std::thread worker([&] { + if (grow) (void)chain(2, 2); + DeletionCapture req; + FlowCoreResult got; + failed = fails_at(at, [&] { got = capture(*g, g, req); }); + if (failed) { + EXPECT_FALSE(req.state.has_value()) << "allocation " << at; + got = capture(*g, g, req); + } + EXPECT_TRUE(same_payload(got, payload)) << "allocation " << at; + ASSERT_TRUE(req.state.has_value()); + EXPECT_TRUE(same_state(*req.state, *ref.state)) << "allocation " << at; + }); + worker.join(); + if (!failed) break; + ++failures; + ASSERT_FALSE(::testing::Test::HasFailure()); + ASSERT_LT(at, 1000); + } + EXPECT_GT(failures, 30) << "grow " << grow; + } +} + +// Review-coherence's strengthened sweep, kept as regression evidence: Global sensitivity at every +// allocation (any thread's, and the workers' only) on 1, 2 and 4 workers; after each failure a +// retry gives the reference baseline and every entry. +TEST(DeletionFaults, GlobalSensitivityFailsAtEveryAllocation) { + const auto g = std::make_shared( + make_graph(5, {{0, 1, 2, 1}, {1, 2, 2, 1}, {1, 3, 1, 2}, {3, 2, 1, 2}, {2, 4, 2, 1}, {0, 3, 1, 5}})); + const FlowPolicyConfig cfg {Global {}, FlowPlacement::MaxFlow, Admission::Lossless, + PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity)}; + const SensitivityOptions opts {cfg, nullptr, nullptr, OutputOptions {true, true, true}}; + const std::vector sources {0}; + const std::vector destinations {4}; + const auto want = sensitivity(*g, sources, destinations, opts, 1); + ASSERT_EQ(want.entries.size(), 3U); // the cheapest path 0 -> 1 -> 2 -> 4 carries the maximum 2 + const auto same = [&](const SensitivityResult& got, const std::string& where) { + expect_same(got.baseline, want.baseline, where); + ASSERT_EQ(got.entries.size(), want.entries.size()) << where; + for (std::size_t i = 0; i < want.entries.size(); ++i) { + EXPECT_EQ(got.entries[i].edge, want.entries[i].edge) << where; + EXPECT_EQ(got.entries[i].loss, want.entries[i].loss) << where; + EXPECT_EQ(got.entries[i].status, want.entries[i].status) << where; + EXPECT_EQ(got.entries[i].reason, want.entries[i].reason) << where; + } + }; + for (std::int32_t workers : {1, 2, 4}) { + for (bool workers_only : {false, true}) { + if (workers == 1 && workers_only) continue; + int failures = 0; + for (long at = 0;; ++at) { + SensitivityResult got; + const auto call = [&] { got = sensitivity(*g, sources, destinations, opts, workers); }; + const std::string where = "workers " + std::to_string(workers) + (workers_only ? " (workers only)" : "") + + " allocation " + std::to_string(at); + if (!(workers_only ? fails_in_workers_at(at, call) : fails_at(at, call))) { + same(got, where); + break; + } + ++failures; + same(sensitivity(*g, sources, destinations, opts, workers), "retry after " + where); + ASSERT_FALSE(::testing::Test::HasFailure()); + ASSERT_LT(at, 10000); + } + EXPECT_GT(failures, workers_only ? 10 : 100) << "workers " << workers << " workers_only " << workers_only; + } + } +} diff --git a/tests/cpp/flow_core_deletion_tests.cpp b/tests/cpp/flow_core_deletion_tests.cpp new file mode 100644 index 0000000..7a86141 --- /dev/null +++ b/tests/cpp/flow_core_deletion_tests.cpp @@ -0,0 +1,856 @@ +// The deletion re-solve of the flow core (APPROACH 2.2) and the exact sensitivity model that uses +// it (APPROACH 11). Every check is independent of the repair: a solve from zero on the deleted +// view, the Bellman-Ford curve of test_utils.hpp, residuals rebuilt from flows, divergences and +// reduced costs computed here in __int128. +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/detail/flow_core.hpp" +#include "netgraph/core/evaluators.hpp" +#include "test_utils.hpp" + +using namespace netgraph::core; +using namespace netgraph::core::test; + +namespace { + +using detail::DeletionCapture; +using detail::DeletionResolver; +using detail::DeletionResult; +using detail::DeletionState; +using detail::FlowCoreCounters; +using detail::I128; +using detail::Network; + +constexpr std::size_t kNone = std::numeric_limits::max(); + +__int128 wide(I128 x) { + return static_cast<__int128>((static_cast(static_cast(x.hi)) << 64) | x.lo); +} + +// A solve to capture: the graph, the view, the endpoint sets, the eligibility and the cap. +struct Case { + std::shared_ptr g; + std::vector view; + std::vector sources; + std::vector destinations; + std::optional elig {}; + std::optional cap {}; + [[nodiscard]] const Eligibility* el() const { return elig ? &*elig : nullptr; } +}; + +Case make_case(std::int32_t n, const std::vector& edges, std::vector s, std::vector d) { + Case c; + c.g = std::make_shared(make_graph(n, edges)); + c.view.assign(c.g->capacity_units().begin(), c.g->capacity_units().end()); + c.sources = std::move(s); + c.destinations = std::move(d); + return c; +} + +DeletionCapture capture_of(const Case& c, FlowCoreResult* payload = nullptr) { + DeletionCapture req; + req.graph = c.g; + auto r = place_global_units(*c.g, c.view, c.sources, c.destinations, c.el(), c.cap, &req); + if (payload != nullptr) *payload = std::move(r); + return req; +} + +__int128 cost_of(const Graph& g, const std::vector& flows) { + __int128 total = 0; + for (std::size_t e = 0; e < flows.size(); ++e) total += static_cast<__int128>(g.cost()[e]) * flows[e]; + return total; +} + +__int128 cost_sum(const Graph& g) { + __int128 c = 0; + for (Cost w : g.cost()) c += w; + return c; +} + +// The local index of edge e in the state's network. +std::size_t local_of(const Network& net, EdgeId e) { + const auto it = std::lower_bound(net.edges.begin(), net.edges.end(), e); + return it != net.edges.end() && *it == e ? static_cast(it - net.edges.begin()) : kNone; +} + +// The tail of every slot. +std::vector slot_tails(const Network& net) { + std::vector tail(net.arc_head.size()); + for (std::int32_t x = 0; x < net.num_nodes + 2; ++x) { + for (auto p = net.off[static_cast(x)]; p < net.off[static_cast(x) + 1]; ++p) { + tail[static_cast(p)] = x; + } + } + return tail; +} + +// The residual of a flow (per EdgeId) on the network: real arcs from the flow and the view +// capacity, virtual arcs from the endpoints' divergences; the deleted edge's arcs zero. +std::vector residual_of(const Network& net, const std::vector& flows, std::size_t deleted) { + std::vector res(net.arc_head.size(), 0); + std::vector div(static_cast(net.num_nodes) + 2, 0); + for (std::size_t k = 0; k < net.edges.size(); ++k) { + const Amount f = flows[static_cast(net.edges[k])]; + const auto fwd = static_cast(net.edge_slot[k]); + const auto bwd = static_cast(net.rev[fwd]); + if (k == deleted) continue; + res[fwd] = net.cap[k] - f; + res[bwd] = f; + div[static_cast(net.tail[k])] += f; + div[static_cast(net.head[k])] -= f; + } + for (std::size_t i = 0; i < net.sources.size(); ++i) { + const Amount x = div[static_cast(net.sources[i])]; + const auto a = static_cast(net.src_rev[i]); + res[a] = x; + res[static_cast(net.rev[a])] = kMaxFlowCap - x; + } + for (std::size_t i = 0; i < net.destinations.size(); ++i) { + const Amount y = -div[static_cast(net.destinations[i])]; + const auto a = static_cast(net.dst_fwd[i]); + res[a] = kMaxFlowCap - y; + res[static_cast(net.rev[a])] = y; + } + return res; +} + +// The divergences (outflow minus inflow) every vertex should have: `at` lists (vertex, value). +std::vector<__int128> divergences(const Network& net, std::initializer_list> at) { + std::vector<__int128> d(static_cast(net.num_nodes) + 2, 0); + for (auto [v, x] : at) d[static_cast(v)] += x; + return d; +} + +// The failed checks of a residual with potentials on the network (APPROACH 2.2): capacity +// bounds (both arcs of a pair non-negative, summing to the pair's capacity; the deleted edge's +// pair zero), the divergence vector `want`, non-negative reduced cost on every positive +// residual arc (real and virtual), and the bound `bound` on every potential (non-negative). +std::vector check_residual(const Network& net, std::span res, std::span pi, + std::size_t deleted, const std::vector<__int128>& want, __int128 bound) { + std::vector fails; + const auto n = static_cast(net.num_nodes) + 2; + if (res.size() != net.arc_head.size() || pi.size() != n) return {"shape"}; + std::vector<__int128> div(n, 0); + for (std::size_t k = 0; k < net.edges.size(); ++k) { + const auto fwd = static_cast(net.edge_slot[k]); + const auto bwd = static_cast(net.rev[fwd]); + if (k == deleted) { + if (res[fwd] != 0 || res[bwd] != 0) fails.push_back("the deleted edge keeps a residual arc"); + continue; + } + if (res[fwd] < 0 || res[bwd] < 0 || res[fwd] + res[bwd] != net.cap[k]) { + fails.push_back("capacity bounds at edge " + std::to_string(net.edges[k])); + } + div[static_cast(net.tail[k])] += res[bwd]; + div[static_cast(net.head[k])] -= res[bwd]; + } + const auto S = static_cast(net.num_nodes); + const std::size_t T = S + 1; + auto virtual_pair = [&](std::size_t a, std::size_t from, std::size_t to, Amount flow) { + if (res[a] < 0 || res[static_cast(net.rev[a])] < 0 || + static_cast<__int128>(res[a]) + res[static_cast(net.rev[a])] != kMaxFlowCap) { + fails.push_back("capacity bounds at a virtual arc"); + } + div[from] += flow; + div[to] -= flow; + }; + for (std::size_t i = 0; i < net.sources.size(); ++i) { + const auto a = static_cast(net.src_rev[i]); + virtual_pair(a, S, static_cast(net.sources[i]), res[a]); + } + for (std::size_t i = 0; i < net.destinations.size(); ++i) { + const auto a = static_cast(net.dst_fwd[i]); + virtual_pair(a, static_cast(net.destinations[i]), T, res[static_cast(net.rev[a])]); + } + if (div != want) fails.push_back("divergence vector"); + const auto tail = slot_tails(net); + for (std::size_t a = 0; a < res.size(); ++a) { + if (res[a] <= 0) continue; + const auto u = static_cast(tail[a]); + const auto v = static_cast(net.arc_head[a]); + if (static_cast<__int128>(net.arc_cost[a]) + wide(pi[u]) - wide(pi[v]) < 0) { + fails.push_back("negative reduced cost on slot " + std::to_string(a) + (u >= S || v >= S ? " (virtual)" : "")); + } + } + for (std::size_t v = 0; v < n; ++v) { + if (wide(pi[v]) < 0 || wide(pi[v]) > bound) fails.push_back("potential outside [0, bound] at " + std::to_string(v)); + } + return fails; +} + +// Whether the real sources reach t* over positive residual arcs. +bool connected(const Network& net, std::span res) { + const auto n = static_cast(net.num_nodes) + 2; + std::vector seen(n, 0); + std::vector queue; + for (std::int32_t s : net.sources) { + seen[static_cast(s)] = 1; + queue.push_back(static_cast(s)); + } + for (std::size_t qi = 0; qi < queue.size(); ++qi) { + const auto u = queue[qi]; + for (auto p = net.off[u]; p < net.off[u + 1]; ++p) { + const auto v = static_cast(net.arc_head[static_cast(p)]); + if (res[static_cast(p)] > 0 && seen[v] == 0) { + seen[v] = 1; + queue.push_back(v); + } + } + } + return seen[n - 1] != 0; +} + +#if NETGRAPH_CORE_PRIVATE_HOOKS +// The re-solve of e observed by the probe: every observed state (after the deletion, after every +// potential update and push of the three repairs, at completion) is checked against the +// capacity bounds, the exact divergence vector of 2.2 for the amounts moved so far and dual +// feasibility on every positive residual arc. `updates` collects the counters after every +// potential update with its repair. +DeletionResult observed(DeletionResolver& rs, const DeletionState& st, EdgeId e, std::vector& fails, + bool rerun = false, std::vector>* updates = nullptr) { + const Network& net = st.network(); + const std::size_t k = local_of(net, e); + const std::int32_t u = net.tail[k]; + const std::int32_t v = net.head[k]; + const Amount F = st.value(); + const Amount f = st.edge_flows()[static_cast(e)]; + const __int128 n = net.num_nodes + 2; + const __int128 bound = (22 * n + 15) * cost_sum(st.graph()); + Amount moved[5] = {0, 0, 0, 0, 0}; + detail::DeletionProbe probe; + probe.rerun_failed_search = rerun; + probe.observe = [&](const detail::DeletionEvent& ev) { + if (ev.repair >= 1 && ev.repair <= 3) moved[ev.repair] = ev.moved; + const auto want = divergences(net, {{net.num_nodes, F - moved[2]}, + {net.num_nodes + 1, -static_cast<__int128>(F) + moved[3]}, + {u, -static_cast<__int128>(f) + moved[1] + moved[2]}, + {v, static_cast<__int128>(f) - moved[1] - moved[3]}}); + for (const auto& s : check_residual(net, ev.residual, ev.potentials, k, want, bound)) { + fails.push_back("repair " + std::to_string(ev.repair) + (ev.push ? " push" : " update") + ": " + s); + } + if (updates != nullptr && !ev.push && ev.repair >= 1 && ev.repair <= 3) updates->emplace_back(ev.repair, *ev.counters); + }; + return rs.resolve(e, probe); +} +#endif + +// Every check of 2.2 for the deletion of positive-flow edge e from the captured state of c. +void check_deletion(const Case& c, const DeletionState& st, EdgeId e, const std::string& where) { + const Network& net = st.network(); + const std::size_t k = local_of(net, e); + ASSERT_NE(k, kNone) << where; + const Amount f = st.edge_flows()[static_cast(e)]; + ASSERT_GT(f, 0) << where; + DeletionResolver rs(st); + const auto r = rs.resolve(e); +#if NETGRAPH_CORE_PRIVATE_HOOKS + std::vector fails; + const auto ro = observed(rs, st, e, fails); + for (const auto& s : fails) ADD_FAILURE() << where << ": " << s; + EXPECT_EQ(ro.value, r.value) << where; + EXPECT_EQ(ro.edge_flows, r.edge_flows) << where; +#endif + // From zero on V \ {e}, and the independent Bellman-Ford curve. + auto view = c.view; + view[static_cast(e)] = 0; + const auto fresh = place_global_units(*c.g, view, c.sources, c.destinations, c.el(), std::nullopt); + const auto ref = independent_curve(*c.g, view, c.sources, c.destinations, c.el()); + EXPECT_EQ(r.value, fresh.placed) << where; + EXPECT_EQ(r.value, ref.back().first) << where; + EXPECT_TRUE(cost_of(*c.g, r.edge_flows) == cost_of(*c.g, fresh.edge_flows)) << where; + EXPECT_TRUE(cost_of(*c.g, r.edge_flows) == ref.back().second) << where; + ASSERT_TRUE(fresh.min_cut.has_value()) << where; + EXPECT_EQ(r.cut, *fresh.min_cut) << where; + Amount cut_value = 0; + for (EdgeId x : r.cut) cut_value += view[static_cast(x)]; + EXPECT_EQ(cut_value, r.value) << where; + EXPECT_EQ(r.edge_flows[static_cast(e)], 0) << where; + // The final residual from the flow: bounds, divergences (value at s*), working potentials + // valid on every positive residual arc, the destinations disconnected. + const auto res = residual_of(net, r.edge_flows, k); + const __int128 n = net.num_nodes + 2; + const auto want = divergences(net, {{net.num_nodes, r.value}, {net.num_nodes + 1, -static_cast<__int128>(r.value)}}); + for (const auto& s : check_residual(net, res, r.potentials, k, want, (22 * n + 15) * cost_sum(*c.g))) { + ADD_FAILURE() << where << ": final: " << s; + } + EXPECT_FALSE(connected(net, res)) << where; + // At most m + 2 r Dijkstra runs. + const Amount rem = st.value() - r.value; + const Amount m = f - rem; + EXPECT_GE(rem, 0) << where; + EXPECT_LE(r.counters.dijkstra_runs, m + 2 * rem) << where; +} + +// The captured state's own checks: bounds, divergences, valid working potentials within +// (n + 1) C, and the certificate. +void check_state(const Case& c, const DeletionState& st, const FlowCoreResult& payload, const std::string& where) { + const Network& net = st.network(); + EXPECT_EQ(st.value(), payload.placed) << where; + EXPECT_EQ(st.edge_flows(), payload.edge_flows) << where; + const __int128 n = net.num_nodes + 2; + const auto want = + divergences(net, {{net.num_nodes, st.value()}, {net.num_nodes + 1, -static_cast<__int128>(st.value())}}); + for (const auto& s : check_residual(net, st.residual(), st.potentials(), kNone, want, (n + 1) * cost_sum(*c.g))) { + ADD_FAILURE() << where << ": state: " << s; + } + EXPECT_EQ(st.maximum(), !connected(net, st.residual())) << where; + const auto& x = st.counters(); + const auto& y = payload.counters; + EXPECT_TRUE(x.dijkstra_runs == y.dijkstra_runs && x.dijkstra_pops == y.dijkstra_pops && x.settled == y.settled && + x.arc_scans == y.arc_scans && x.dinic_phases == y.dinic_phases && x.augmentations == y.augmentations) + << where; +} + +// Every allowed edge of the captured state: positive flow by the re-solve, zero flow by a +// solve from zero on V \ {e} (the baseline's value and cost; no re-solve, which rejects it). +void check_every_edge(const Case& c, const std::string& where) { + FlowCoreResult payload; + const auto req = capture_of(c, &payload); + ASSERT_TRUE(req.state.has_value()) << where; + const DeletionState& st = *req.state; + check_state(c, st, payload, where); + ASSERT_TRUE(st.maximum()) << where; + const auto flows = st.edge_flows(); + for (EdgeId e : st.network().edges) { + const std::string at = where + " edge " + std::to_string(e); + if (flows[static_cast(e)] > 0) { + check_deletion(c, st, e, at); + } else { + auto view = c.view; + view[static_cast(e)] = 0; + const auto fresh = place_global_units(*c.g, view, c.sources, c.destinations, c.el(), std::nullopt); + EXPECT_EQ(fresh.placed, st.value()) << at; + EXPECT_TRUE(cost_of(*c.g, fresh.edge_flows) == cost_of(*c.g, flows)) << at; + DeletionResolver rs(st); + EXPECT_THROW((void)rs.resolve(e), std::invalid_argument) << at; + } + if (::testing::Test::HasFailure()) return; + } +} + +Case random_case(std::mt19937_64& rng) { + auto pick = [&](std::int64_t lo, std::int64_t hi) { return std::uniform_int_distribution(lo, hi)(rng); }; + const auto n = static_cast(pick(3, 8)); + std::vector nodes(static_cast(n)); + for (std::int32_t v = 0; v < n; ++v) nodes[static_cast(v)] = v; + std::shuffle(nodes.begin(), nodes.end(), rng); + const auto ns = static_cast(pick(1, std::min(3, n - 1))); + const auto nd = static_cast(pick(1, static_cast(nodes.size() - ns))); + std::vector s(nodes.begin(), nodes.begin() + static_cast(ns)); + std::vector d(nodes.begin() + static_cast(ns), + nodes.begin() + static_cast(ns + nd)); + std::vector edges; + const auto m = pick(1, 24); + for (std::int64_t i = 0; i < m; ++i) { + edges.push_back({static_cast(pick(0, n - 1)), static_cast(pick(0, n - 1)), pick(0, 4), pick(1, 13)}); + } + Case c = make_case(n, edges, s, d); + for (auto& x : c.view) { + if (pick(0, 1) == 1) x = pick(0, x); // a reduced view + } + if (pick(0, 1) == 1) { + std::vector xn; + std::vector xe; + for (NodeId v = 0; v < n; ++v) { + if (pick(0, 10) == 0) xn.push_back(v); + } + for (EdgeId e = 0; e < c.g->num_edges(); ++e) { + if (pick(0, 9) == 0) xe.push_back(e); + } + c.elig = c.g->eligibility(xn, xe); + } + return c; +} + +} // namespace + +// ------------------------------------------------------------------ the arithmetic + +TEST(FlowCoreDeletion, SignedWideArithmeticMatchesInt128) { + std::mt19937_64 rng(11); + auto any = [&] { + I128 x; + x.hi = static_cast(rng()) >> (rng() % 64); + x.lo = rng(); + return x; + }; + for (int i = 0; i < 20000; ++i) { + const I128 a = any(); + const I128 b = any(); + const __int128 x = wide(a); + const __int128 y = wide(b); + const auto wrap = [](__int128 z) { return static_cast<__int128>(static_cast(z)); }; + EXPECT_TRUE(wide(a + b) == wrap(static_cast<__int128>(static_cast(x) + static_cast(y)))); + EXPECT_TRUE(wide(a - b) == wrap(static_cast<__int128>(static_cast(x) - static_cast(y)))); + EXPECT_EQ(a < b, x < y); + EXPECT_EQ(a == b, x == y); + const auto c = static_cast(rng()); + EXPECT_TRUE(wide(I128 {c}) == c); + } +} + +// ------------------------------------------------------------------ the capture + +// The capture changes no field of the ordinary payload (every field, all six counters): cap 0 +// (no search), an infeasible positive cap, caps below, at and above the maximum, uncapped; +// never-reached sentinels and the final failed tier included. +TEST(FlowCoreDeletion, CaptureLeavesTheFromZeroPayloadUnchanged) { + std::mt19937_64 rng(5); + int compared = 0; + for (int trial = 0; trial < 250; ++trial) { + Case c = random_case(rng); + const auto ref = independent_curve(*c.g, c.view, c.sources, c.destinations, c.el()); + const Amount fmax = ref.back().first; + for (std::optional cap : {std::optional {}, std::optional {0}, + std::optional {fmax / 2}, std::optional {fmax}, + std::optional {fmax + 3}}) { + c.cap = cap; + const auto plain = place_global_units(*c.g, c.view, c.sources, c.destinations, c.el(), cap); + FlowCoreResult with; + const auto req = capture_of(c, &with); + ASSERT_TRUE(same_payload(plain, with)) << "trial " << trial; + ASSERT_TRUE(req.state.has_value()); + EXPECT_EQ(req.state->maximum(), !cap.has_value() || *cap >= fmax || fmax == 0) << "trial " << trial; + ++compared; + } + } + // An infeasible positive cap: no path, the failed first tier. + Case none = make_case(3, {{0, 1, 2, 1}}, {0}, {2}); + none.cap = 5; + const auto plain = place_global_units(*none.g, none.view, none.sources, none.destinations, nullptr, none.cap); + FlowCoreResult with; + const auto req = capture_of(none, &with); + EXPECT_TRUE(same_payload(plain, with)); + EXPECT_EQ(with.placed, 0); + EXPECT_EQ(with.counters.dijkstra_runs, 1); + EXPECT_TRUE(req.state->maximum()); + EXPECT_GT(compared, 1000); +} + +#if NETGRAPH_CORE_PRIVATE_HOOKS +namespace { + +// Captures c with the independent iterative all-vertex update beside: after every search, each +// vertex by its distance or, unreached, by the search's largest finite distance, from 0. +DeletionCapture capture_iterative(const Case& c, std::vector<__int128>& pi, int& searches) { + pi.assign(static_cast(c.g->num_nodes()) + 2, 0); + searches = 0; + DeletionCapture req; + req.graph = c.g; + req.on_search = [&](std::span d) { + Cost top = 0; + for (Cost x : d) { + if (x != std::numeric_limits::max()) top = std::max(top, x); + } + for (std::size_t v = 0; v < d.size(); ++v) pi[v] += d[v] != std::numeric_limits::max() ? d[v] : top; + ++searches; + }; + (void)place_global_units(*c.g, c.view, c.sources, c.destinations, c.el(), c.cap, &req); + return req; +} + +} // namespace +#endif + +// The running-sum checkpoints equal the iterative all-vertex update exactly, real and virtual +// vertices, a final failed search, never-settled vertices and the no-search cap-zero case +// included; a common offset keeps every reduced cost valid and fails this convention check. +TEST(FlowCoreDeletion, CheckpointsEqualTheIterativeUpdate) { +#if NETGRAPH_CORE_PRIVATE_HOOKS + std::mt19937_64 rng(17); + int searched = 0; + for (int trial = 0; trial < 300; ++trial) { + Case c = random_case(rng); + const Amount fmax = independent_curve(*c.g, c.view, c.sources, c.destinations, c.el()).back().first; + for (std::optional cap : {std::optional {}, std::optional {0}, + std::optional {fmax / 2}, std::optional {fmax + 1}}) { + c.cap = cap; + std::vector<__int128> pi; + int searches = 0; + const auto req = capture_iterative(c, pi, searches); + const auto& st = *req.state; + ASSERT_EQ(st.potentials().size(), pi.size()); + for (std::size_t v = 0; v < pi.size(); ++v) ASSERT_TRUE(wide(st.potentials()[v]) == pi[v]) << trial << " " << v; + EXPECT_EQ(searches, st.counters().dijkstra_runs); + if (cap == 0) EXPECT_EQ(searches, 0); + searched += searches; + } + } + EXPECT_GT(searched, 1000); + // The common-offset mutant: valid reduced costs, the wrong convention. + Case c = make_case(5, {{0, 1, 3, 3}, {0, 2, 1, 3}, {1, 2, 2, 7}, {2, 3, 1, 3}, {3, 1, 1, 2}}, {0}, {3}); + std::vector<__int128> pi; + int searches = 0; + const auto req = capture_iterative(c, pi, searches); + const auto& st = *req.state; + std::vector shifted(st.potentials().begin(), st.potentials().end()); + for (auto& p : shifted) p += 1000; + const Network& net = st.network(); + const auto want = + divergences(net, {{net.num_nodes, st.value()}, {net.num_nodes + 1, -static_cast<__int128>(st.value())}}); + EXPECT_TRUE(check_residual(net, st.residual(), shifted, kNone, want, cost_sum(*c.g) * 1000).empty()); + bool same = true; + for (std::size_t v = 0; v < pi.size(); ++v) same = same && wide(shifted[v]) == pi[v]; + EXPECT_FALSE(same); +#else + GTEST_SKIP() << "needs the private hooks (NETGRAPH_CORE_PRIVATE_HOOKS)"; +#endif +} + +// ------------------------------------------------------------------ random views + +TEST(FlowCoreDeletion, EveryEdgeOfRandomViewsAgreesWithAFreshSolve) { + std::mt19937_64 rng(3005); + int deletions = 0; + for (int trial = 0; trial < 700; ++trial) { + Case c = random_case(rng); + const Amount fmax = independent_curve(*c.g, c.view, c.sources, c.destinations, c.el()).back().first; + // Uncapped, and positive caps at and above the independently established maximum. + for (std::optional cap : {std::optional {}, std::optional {std::max(fmax, 1)}, + std::optional {fmax + 2}}) { + c.cap = cap; + check_every_edge(c, "trial " + std::to_string(trial)); + if (::testing::Test::HasFailure()) return; + } + const auto req = capture_of(c); + for (Amount x : req.state->edge_flows()) deletions += x > 0 ? 1 : 0; + } + EXPECT_GT(deletions, 600); +} + +// ------------------------------------------------------------------ witnesses + +// Repair 1 reroutes everything (m = f, r = 0): u -> w -> v replaces u -> v. +TEST(FlowCoreDeletion, FullRepairOneReroute) { + const Case c = make_case(5, {{0, 1, 1, 1}, {1, 2, 1, 1}, {1, 3, 1, 5}, {3, 2, 1, 5}, {2, 4, 1, 1}}, {0}, {4}); + check_every_edge(c, "full reroute"); + const auto req = capture_of(c); + const EdgeId uv = id_of_input(*c.g)[1]; + const auto r = DeletionResolver(*req.state).resolve(uv); + EXPECT_EQ(r.value, 1); + EXPECT_TRUE(cost_of(*c.g, r.edge_flows) == 12); + EXPECT_EQ(r.counters.dijkstra_runs, 1); +} + +// No reroute (m = 0, r = 1): deleting the sole unit-capacity source-to-destination edge takes +// exactly two Dijkstra runs; the control that reruns repair 1's failed search takes three and +// fails the m + 2 r bound, with the same flow, potentials, phases and paths; the reuse skips +// exactly that search's work. +TEST(FlowCoreDeletion, SoleEdgeTakesTwoSearches) { + const Case c = make_case(2, {{0, 1, 1, 1}}, {0}, {1}); + check_every_edge(c, "sole edge"); + const auto req = capture_of(c); + DeletionResolver rs(*req.state); + const auto r = rs.resolve(0); + EXPECT_EQ(r.value, 0); + EXPECT_EQ(r.counters.dijkstra_runs, 2); +#if NETGRAPH_CORE_PRIVATE_HOOKS + std::vector fails; + std::vector> reuse_updates; + std::vector> rerun_updates; + const auto a = observed(rs, *req.state, 0, fails, false, &reuse_updates); + const auto b = observed(rs, *req.state, 0, fails, true, &rerun_updates); + for (const auto& s : fails) ADD_FAILURE() << s; + EXPECT_EQ(a.counters.dijkstra_runs, 2); + EXPECT_EQ(b.counters.dijkstra_runs, 3); // > m + 2 r = 2: the control fails the bound + EXPECT_EQ(a.edge_flows, b.edge_flows); + EXPECT_EQ(a.potentials, b.potentials); + EXPECT_EQ(a.cut, b.cut); + EXPECT_EQ(a.counters.dinic_phases, b.counters.dinic_phases); + EXPECT_EQ(a.counters.augmentations, b.counters.augmentations); + // The control's extra search: the counters between repair 1's last update and repair 2's + // first (no Dinic work between them). + ASSERT_EQ(rerun_updates.size(), reuse_updates.size() + 1); + std::size_t i = 0; + while (rerun_updates[i].first != 2) ++i; + const auto& before = rerun_updates[i - 1].second; + const auto& after = rerun_updates[i].second; + EXPECT_EQ(after.dinic_phases, before.dinic_phases); + EXPECT_EQ(after.augmentations, before.augmentations); + EXPECT_EQ(b.counters.dijkstra_runs - (after.dijkstra_runs - before.dijkstra_runs), a.counters.dijkstra_runs); + EXPECT_EQ(b.counters.dijkstra_pops - (after.dijkstra_pops - before.dijkstra_pops), a.counters.dijkstra_pops); + EXPECT_EQ(b.counters.settled - (after.settled - before.settled), a.counters.settled); + EXPECT_EQ(b.counters.arc_scans - (after.arc_scans - before.arc_scans), a.counters.arc_scans); + EXPECT_GT(after.settled - before.settled, 0); +#endif +} + +// Repair 1 moves part (m = 1 of f = 2); the remainder 1 goes back to s* and then from t* to v. +TEST(FlowCoreDeletion, PartialRerouteThenBothRemainderPushes) { + const Case c = make_case(5, {{0, 1, 2, 1}, {1, 2, 2, 1}, {1, 3, 1, 2}, {3, 2, 1, 2}, {2, 4, 2, 1}}, {0}, {4}); + check_every_edge(c, "partial reroute"); + const auto req = capture_of(c); + const EdgeId uv = id_of_input(*c.g)[1]; + ASSERT_EQ(req.state->edge_flows()[static_cast(uv)], 2); + const auto r = DeletionResolver(*req.state).resolve(uv); + EXPECT_EQ(r.value, 1); // r = 1, m = 1 + EXPECT_TRUE(cost_of(*c.g, r.edge_flows) == 6); + EXPECT_LE(r.counters.dijkstra_runs, 1 + 2 * 1); +} + +// Total loss: a bridge carrying every unit. +TEST(FlowCoreDeletion, TotalLoss) { + const Case c = make_case(3, {{0, 1, 3, 1}, {1, 2, 2, 1}, {1, 2, 1, 3}}, {0}, {2}); + check_every_edge(c, "total loss"); + const auto req = capture_of(c); + const auto r = DeletionResolver(*req.state).resolve(id_of_input(*c.g)[0]); + EXPECT_EQ(r.value, 0); + EXPECT_TRUE(r.cut.empty()); +} + +// Zero-flow deletions with positive view capacity (the deletion changes the canonical cut, not +// the value) and with zero view capacity (an arc crossing the closure without capacity). +TEST(FlowCoreDeletion, ZeroFlowDeletions) { + const Case positive = make_case(4, {{0, 1, 1, 1}, {1, 2, 1, 1}, {2, 3, 1, 1}, {0, 2, 1, 3}}, {0}, {3}); + check_every_edge(positive, "zero flow, positive capacity"); + const EdgeId spare = id_of_input(*positive.g)[3]; + const auto req = capture_of(positive); + EXPECT_EQ(req.state->edge_flows()[static_cast(spare)], 0); + FlowCoreResult base; + (void)capture_of(positive, &base); + auto view = positive.view; + view[static_cast(spare)] = 0; + const auto fresh = place_global_units(*positive.g, view, positive.sources, positive.destinations, nullptr, std::nullopt); + EXPECT_NE(*base.min_cut, *fresh.min_cut); + const Case zero = make_case(2, {{0, 1, 0, 1}}, {0}, {1}); + check_every_edge(zero, "zero view capacity"); + const auto zreq = capture_of(zero); + EXPECT_TRUE(zreq.state->maximum()); + EXPECT_EQ(zreq.state->value(), 0); +} + +// Parallel equal-cost ties: the re-solve's flow differs from the fresh solve's, its value, cost +// and cut do not (review-resolve/focused.py). +TEST(FlowCoreDeletion, TiedFlowsDifferValueCostAndCutAgree) { + const Case c = make_case(3, {{0, 1, 1, 2}, {0, 1, 2, 3}, {1, 2, 1, 1}, {1, 2, 1, 1}}, {0}, {2}); + check_every_edge(c, "ties"); + const auto req = capture_of(c); + const EdgeId dear = id_of_input(*c.g)[1]; + const auto r = DeletionResolver(*req.state).resolve(dear); + auto view = c.view; + view[static_cast(dear)] = 0; + const auto fresh = place_global_units(*c.g, view, c.sources, c.destinations, nullptr, std::nullopt); + EXPECT_EQ(r.edge_flows, (std::vector {1, 0, 0, 1})); + EXPECT_EQ(fresh.edge_flows, (std::vector {1, 0, 1, 0})); + EXPECT_EQ(r.value, 1); + EXPECT_TRUE(cost_of(*c.g, r.edge_flows) == 3); + EXPECT_EQ(r.cut, *fresh.min_cut); + EXPECT_EQ(r.cut, (std::vector {0})); +} + +// Several sources and destinations; edges out of a source, into a destination and between +// endpoints; masked endpoints; vertices no search reaches. +TEST(FlowCoreDeletion, EndpointsMasksAndUnreachedVertices) { + Case c = make_case(8, + {{0, 2, 2, 1}, {1, 2, 1, 2}, {2, 4, 2, 1}, {2, 5, 2, 3}, {1, 5, 1, 7}, {0, 4, 1, 9}, + {4, 5, 1, 1}, {3, 6, 4, 1}, {6, 7, 4, 1}, {5, 2, 1, 1}}, + {0, 1, 3}, {4, 5}); + check_every_edge(c, "endpoints"); + c.elig = c.g->eligibility(std::vector {1}, std::vector {}); + check_every_edge(c, "masked source"); + c.elig = c.g->eligibility(std::vector {4}, std::vector {id_of_input(*c.g)[3]}); + check_every_edge(c, "masked destination and edge"); +} + +// The near-domain cost witness of review-resolve/REPORT.md C2: H = 2^61 and a cost sum H + 11 +// below 2^62; the working potentials exceed int64 after the deletion, vertex 4 is never +// reached (the published sentinel, a numeric working potential). +TEST(FlowCoreDeletion, NearDomainCostsNeedWideWorkingPotentials) { + const Cost H = Cost {1} << 61; + const Case c = make_case(5, {{0, 1, 3, 3}, {0, 2, 1, 3}, {1, 2, 2, H}, {2, 3, 1, 3}, {3, 1, 1, 2}}, {0}, {3}); + ASSERT_TRUE(cost_sum(*c.g) == static_cast<__int128>(H) + 11); + FlowCoreResult payload; + const auto req = capture_of(c, &payload); + const auto& st = *req.state; + EXPECT_EQ(payload.potentials[4], kNeverReached); + const std::vector<__int128> base {0, 3, static_cast<__int128>(H) + 3, static_cast<__int128>(H) + 6, + static_cast<__int128>(H) + 6, 0, static_cast<__int128>(H) + 6}; + for (std::size_t v = 0; v < base.size(); ++v) EXPECT_TRUE(wide(st.potentials()[v]) == base[v]) << v; + const EdgeId e = id_of_input(*c.g)[3]; + check_deletion(c, st, e, "near domain"); + const auto r = DeletionResolver(st).resolve(e); + const __int128 h = H; + const std::vector<__int128> after {3 * h + 5, 2 * h + 8, 3 * h + 8, 2 * h + 6, 4 * h + 11, 3 * h + 5, 2 * h + 6}; + for (std::size_t v = 0; v < after.size(); ++v) EXPECT_TRUE(wide(r.potentials[v]) == after[v]) << v; + EXPECT_TRUE(wide(r.potentials[4]) > std::numeric_limits::max()); +} + +// The precondition: a state stopped by a cap below the maximum with a residual path left is +// rejected before any change; maximum states (uncapped, cap at the maximum) are accepted. +TEST(FlowCoreDeletion, OnlyMaximumStatesAreAccepted) { + Case c = make_case(2, {{0, 1, 1, 1}, {0, 1, 3, 2}}, {0}, {1}); + c.cap = 1; + const auto req = capture_of(c); + ASSERT_FALSE(req.state->maximum()); + const DeletionState saved = *req.state; + DeletionResolver rs(*req.state); + const EdgeId cheap = id_of_input(*c.g)[0]; + EXPECT_THROW((void)rs.resolve(cheap), std::invalid_argument); + EXPECT_EQ(std::vector(saved.residual().begin(), saved.residual().end()), + std::vector(req.state->residual().begin(), req.state->residual().end())); + for (std::optional cap : {std::optional {}, std::optional {4}}) { + c.cap = cap; + const auto ok = capture_of(c); + ASSERT_TRUE(ok.state->maximum()); + EXPECT_EQ(DeletionResolver(*ok.state).resolve(cheap).value, 3); + } + // Not an edge of the view, no flow, an empty state. + c.cap = std::nullopt; + c.elig = c.g->eligibility(std::vector {}, std::vector {cheap}); + const auto masked = capture_of(c); + EXPECT_THROW((void)DeletionResolver(*masked.state).resolve(cheap), std::invalid_argument); + EXPECT_THROW(DeletionResolver {DeletionState {}}, std::invalid_argument); +} + +// ------------------------------------------------------------------ the saved state + +// A saved state survives another solve on the same thread and serves candidates A, B, A: it +// never changes and both A results (flow, certificates, counters) agree, on 1, 2 and 4 threads +// each with its own resolver over one shared state. +TEST(FlowCoreDeletion, ASavedStateServesCandidatesIndependently) { + std::mt19937_64 rng(29); + int checked = 0; + for (int trial = 0; trial < 400 && checked < 20; ++trial) { + const Case c = random_case(rng); + const auto req = capture_of(c); + const DeletionState& st = *req.state; + std::vector positive; + for (EdgeId e : st.network().edges) { + if (st.edge_flows()[static_cast(e)] > 0) positive.push_back(e); + } + if (positive.size() < 2) continue; + ++checked; + const std::vector res0(st.residual().begin(), st.residual().end()); + const std::vector pi0(st.potentials().begin(), st.potentials().end()); + const DeletionState copy = st; // a copy shares the network only + const Case other = random_case(rng); + (void)place_global_units(*other.g, other.view, other.sources, other.destinations, other.el(), std::nullopt); + const EdgeId A = positive.front(); + const EdgeId B = positive.back(); + auto same = [](const DeletionResult& x, const DeletionResult& y) { + return x.value == y.value && x.potentials == y.potentials && x.cut == y.cut && x.edge_flows == y.edge_flows && + x.counters.dijkstra_runs == y.counters.dijkstra_runs && x.counters.dijkstra_pops == y.counters.dijkstra_pops && + x.counters.settled == y.counters.settled && x.counters.arc_scans == y.counters.arc_scans && + x.counters.dinic_phases == y.counters.dinic_phases && x.counters.augmentations == y.counters.augmentations; + }; + DeletionResolver one(copy); + const auto a1 = one.resolve(A); + const auto b1 = one.resolve(B); + const auto a2 = one.resolve(A); + EXPECT_TRUE(same(a1, a2)) << trial; + EXPECT_EQ(b1.value, DeletionResolver(st).resolve(B).value) << trial; + for (int threads : {2, 4}) { + std::vector resolvers; + for (int t = 0; t < threads; ++t) resolvers.emplace_back(st); + std::vector> got(static_cast(threads)); + std::vector pool; + for (int t = 0; t < threads; ++t) { + pool.emplace_back([&, t] { + auto& rs = resolvers[static_cast(t)]; + auto& out = got[static_cast(t)]; + out.push_back(rs.resolve(A)); + out.push_back(rs.resolve(B)); + out.push_back(rs.resolve(A)); + }); + } + for (auto& th : pool) th.join(); + for (const auto& out : got) { + EXPECT_TRUE(same(out[0], a1) && same(out[2], a1) && same(out[1], b1)) << trial << " threads " << threads; + } + } + EXPECT_EQ(std::vector(st.residual().begin(), st.residual().end()), res0) << trial; + EXPECT_EQ(std::vector(st.potentials().begin(), st.potentials().end()), pi0) << trial; + EXPECT_EQ(std::vector(copy.residual().begin(), copy.residual().end()), res0) << trial; + EXPECT_EQ(©.network(), &st.network()); + } + EXPECT_EQ(checked, 20); +} + +// ------------------------------------------------------------------ sensitivity + +namespace { + +FlowPolicyConfig global_config() { + return FlowPolicyConfig {Global {}, FlowPlacement::MaxFlow, Admission::Lossless, + PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity)}; +} + +} // namespace + +// The exact model's loss is placed minus capacity() with the edge excluded, for every +// candidate (the baseline's positive-flow edges), on 1, 2 and 4 workers; zero-flow edges are +// not candidates. +TEST(Sensitivity, GlobalLossesEqualCapacityWithTheEdgeExcluded) { + std::mt19937_64 rng(41); + int entries = 0; + for (int trial = 0; trial < 300; ++trial) { + const Case c = random_case(rng); + const SensitivityOptions opts {global_config(), c.el()}; + const auto base = capacity(*c.g, c.sources, c.destinations, CapacityOptions {global_config(), c.el(), nullptr, + OutputOptions {true, false, false}}); + const auto s1 = sensitivity(*c.g, c.sources, c.destinations, opts, 1); + EXPECT_EQ(s1.baseline.placed, base.placed) << trial; + std::vector want; + for (EdgeId e = 0; e < c.g->num_edges(); ++e) { + if ((*base.edge_flows)[static_cast(e)] > 0) want.push_back(e); + } + ASSERT_EQ(s1.entries.size(), want.size()) << trial; + for (std::size_t i = 0; i < want.size(); ++i) { + const auto& entry = s1.entries[i]; + EXPECT_EQ(entry.edge, want[i]) << trial; + EXPECT_EQ(entry.status, SensitivityStatus::Evaluated) << trial; + const EdgeId one[1] = {entry.edge}; + Eligibility without = c.g->eligibility({}, one); + if (c.elig) without = Eligibility::intersect(*c.elig, without); + const auto fresh = capacity(*c.g, c.sources, c.destinations, CapacityOptions {global_config(), &without}); + EXPECT_EQ(entry.loss, base.placed - fresh.placed) << trial << " edge " << entry.edge; + ++entries; + } + for (std::int32_t workers : {2, 4}) { + const auto sw = sensitivity(*c.g, c.sources, c.destinations, opts, workers); + ASSERT_EQ(sw.entries.size(), s1.entries.size()); + for (std::size_t i = 0; i < sw.entries.size(); ++i) { + EXPECT_EQ(sw.entries[i].edge, s1.entries[i].edge); + EXPECT_EQ(sw.entries[i].loss, s1.entries[i].loss); + } + } + } + EXPECT_GT(entries, 300); +} + +// OutputOptions controls only the published baseline: every combination captures the same state +// (the same entries) and the baseline's optional fields are capacity()'s. +TEST(Sensitivity, GlobalBaselineKeepsEveryOutputsCombination) { + const Case c = make_case(5, {{0, 1, 2, 1}, {1, 2, 2, 1}, {1, 3, 1, 2}, {3, 2, 1, 2}, {2, 4, 2, 1}, {0, 3, 1, 5}}, + {0}, {4}); + const auto reference = sensitivity(*c.g, c.sources, c.destinations, SensitivityOptions {global_config()}, 1); + ASSERT_FALSE(reference.entries.empty()); + for (int bits = 0; bits < 8; ++bits) { + const OutputOptions outputs {(bits & 1) != 0, (bits & 2) != 0, (bits & 4) != 0}; + SensitivityOptions opts {global_config()}; + opts.outputs = outputs; + const auto s = sensitivity(*c.g, c.sources, c.destinations, opts, 1); + const auto cap = capacity(*c.g, c.sources, c.destinations, CapacityOptions {global_config(), nullptr, nullptr, outputs}); + EXPECT_EQ(s.baseline.placed, cap.placed) << bits; + EXPECT_EQ(s.baseline.cost_distribution, cap.cost_distribution) << bits; + EXPECT_EQ(s.baseline.edge_flows, cap.edge_flows) << bits; + EXPECT_EQ(s.baseline.residual, cap.residual) << bits; + EXPECT_EQ(s.baseline.bottleneck, cap.bottleneck) << bits; + EXPECT_EQ(s.baseline.min_cut, cap.min_cut) << bits; + EXPECT_EQ(s.baseline.edge_flows.has_value(), outputs.edge_flows) << bits; + ASSERT_EQ(s.entries.size(), reference.entries.size()) << bits; + for (std::size_t i = 0; i < s.entries.size(); ++i) { + EXPECT_EQ(s.entries[i].edge, reference.entries[i].edge) << bits; + EXPECT_EQ(s.entries[i].loss, reference.entries[i].loss) << bits; + } + } +} diff --git a/tests/cpp/max_flow_tests.cpp b/tests/cpp/flow_core_tests.cpp similarity index 96% rename from tests/cpp/max_flow_tests.cpp rename to tests/cpp/flow_core_tests.cpp index b49378b..b9177c2 100644 --- a/tests/cpp/max_flow_tests.cpp +++ b/tests/cpp/flow_core_tests.cpp @@ -1,4 +1,5 @@ -// The flow core of APPROACH 2.2 through place_global_units: the review witnesses (expected +// The flow core of APPROACH 2.2 through place_global_units: a first solve on a fresh graph, the +// review witnesses (expected // values derived by hand and from prototype 1, `prototypes/p1_amount/flowcore.py`), the // certificate in its two branches, input validation, determinism, and randomized graphs // certified against an exhaustive optimum (tier a) and an independent solver (tier b). @@ -12,7 +13,7 @@ #include #include -#include "netgraph/core/max_flow.hpp" +#include "netgraph/core/detail/flow_core.hpp" #include "test_utils.hpp" using namespace netgraph::core; @@ -39,7 +40,7 @@ std::vector chain_plus_bypasses(Cost factor = 1) { return {{0, 1, 1, factor}, {1, 2, 1, factor}, {2, 3, 1, factor}, {0, 2, 1, 3 * factor}, {1, 3, 1, 3 * factor}}; } -void expect_certified(const StrictMultiDiGraph& g, const std::vector& s, const std::vector& d, +void expect_certified(const Graph& g, const std::vector& s, const std::vector& d, std::optional cap, const FlowCoreResult& r, const std::vector* view = nullptr, const Eligibility* elig = nullptr, bool tier_a = true) { std::vector full(g.capacity_units().begin(), g.capacity_units().end()); @@ -59,6 +60,18 @@ std::string error_of(const std::function& fn) { } // namespace +TEST(FlowCore, ConstructAndSolve) { + // 0 -> 1 (cost 10), 1 -> 2 (cost 20), 0 -> 2 (cost 100); capacities 10 units each. + const auto g = make_graph(3, {{0, 1, 10, 10}, {1, 2, 10, 20}, {0, 2, 10, 100}}); + EXPECT_EQ(g.num_nodes(), 3); + EXPECT_EQ(g.num_edges(), 3); + const auto r = solve(g, {0}, {2}, std::nullopt); + EXPECT_EQ(r.placed, 20); + ASSERT_EQ(r.cost_distribution.size(), 2u); + EXPECT_EQ(r.cost_distribution[0], (std::pair {30, 10})); // 0 -> 1 -> 2 first + EXPECT_EQ(r.cost_distribution[1], (std::pair {100, 10})); // then the direct edge +} + TEST(FlowCore, QuantisedCapacitiesFamily) { // Physical 2.5q is 3 units and 0.5q is 1 unit (review-2 R1 at 1a: the quantised network). const double q = std::ldexp(1.0, -12); @@ -380,8 +393,8 @@ TEST(FlowCore, InputValidationBeforeAnyWork) { EXPECT_NE(call({2}, s, d, 1).find("view has length 1, expected 2"), std::string::npos); EXPECT_NE(call({2, 3}, s, d, 1).find("capacity_units"), std::string::npos); EXPECT_NE(call({-1, 2}, s, d, 1).find("outside"), std::string::npos); - EXPECT_NE(call(view, {}, d, 1).find("sources: the endpoint set is empty"), std::string::npos); - EXPECT_NE(call(view, s, {}, 1).find("destinations: the endpoint set is empty"), std::string::npos); + EXPECT_NE(call(view, {}, d, 1).find("sources: the node set is empty"), std::string::npos); + EXPECT_NE(call(view, s, {}, 1).find("destinations: the node set is empty"), std::string::npos); EXPECT_NE(call(view, {3}, d, 1).find("out of node range"), std::string::npos); EXPECT_NE(call(view, s, {-1}, 1).find("out of node range"), std::string::npos); EXPECT_NE(call(view, {0, 2}, {2}, 1).find("both endpoint sets"), std::string::npos); @@ -416,7 +429,7 @@ TEST(FlowCore, IdenticalCallsIdenticalPayloads) { namespace { struct RandomCase { - StrictMultiDiGraph g; + Graph g; std::vector s, d; std::vector view; std::optional cap; diff --git a/tests/cpp/ledger_fault_tests.cpp b/tests/cpp/flow_ledger_fault_tests.cpp similarity index 63% rename from tests/cpp/ledger_fault_tests.cpp rename to tests/cpp/flow_ledger_fault_tests.cpp index d343dcd..8bc6526 100644 --- a/tests/cpp/ledger_fault_tests.cpp +++ b/tests/cpp/flow_ledger_fault_tests.cpp @@ -1,5 +1,5 @@ -// Allocation-failure atomicity of the ledger and the single-bundle transaction (APPROACH 8, -// 9.2; review-1b D1). This executable replaces the global operator new so that one chosen +// Allocation-failure atomicity of the ledger and of a one-bundle Pinned policy's transaction +// (APPROACH 8, 9.2; review-1b D1). This executable replaces the global operator new so that one chosen // allocation throws std::bad_alloc. Every operation is swept over every allocation it makes: // after each injected failure the ledger (aggregate, residual, every record field, bound and // orphaned owners, next FlowId) and the policy (epoch state, flow id, plan) must equal their @@ -18,18 +18,24 @@ #include "netgraph/core/detail/ledger_access.hpp" #include "netgraph/core/detail/node_map.hpp" -#include "netgraph/core/detail/pinned_flow.hpp" #include "netgraph/core/detail/pred_dag_builder.hpp" -#include "netgraph/core/flow_graph.hpp" +#include "netgraph/core/flow_ledger.hpp" +#include "fault_injection.hpp" +#include "pinned_one.hpp" #include "test_utils.hpp" -namespace { -// Allocations left before the injected failure; negative: no injection. -long g_countdown = -1; -} // namespace +// Allocations left before the injected failure; negative: no injection (fault_injection.hpp). +// Shared by every thread; with g_workers_only the calling test thread's allocations do not count. +std::atomic g_countdown {-1}; +static std::atomic g_workers_only {false}; +static thread_local bool t_test_thread = false; void* operator new(std::size_t n) { - if (g_countdown >= 0 && g_countdown-- == 0) throw std::bad_alloc(); + if (g_countdown.load(std::memory_order_relaxed) >= 0 && + !(t_test_thread && g_workers_only.load(std::memory_order_relaxed)) && + g_countdown.fetch_sub(1, std::memory_order_relaxed) == 0) { + throw std::bad_alloc(); + } if (void* p = std::malloc(n == 0 ? 1 : n)) return p; throw std::bad_alloc(); } @@ -42,13 +48,15 @@ void operator delete[](void* p, std::size_t) noexcept { std::free(p); } using namespace netgraph::core; using namespace netgraph::core::test; using detail::LedgerAccess; -using detail::PinnedFlow; -using detail::PinnedResult; namespace { -// Runs fn with the k-th allocation (from 0) failing; returns whether that failure happened. -bool fails_at(long k, const std::function& fn) { +// The shared comparators of fault_injection.hpp, beside this file's own overloads. +using test::expect_same; + +bool inject(long k, bool workers_only, const std::function& fn) { + t_test_thread = true; + g_workers_only = workers_only; g_countdown = k; bool failed = false; try { @@ -56,58 +64,22 @@ bool fails_at(long k, const std::function& fn) { } catch (const std::bad_alloc&) { failed = true; } - const bool reached = g_countdown < 0; + const bool reached = g_countdown.load() < 0; g_countdown = -1; - EXPECT_EQ(failed, reached) << "a std::bad_alloc was swallowed or replaced at allocation " << k; + g_workers_only = false; + EXPECT_EQ(failed, reached) << "a std::bad_alloc was swallowed or replaced at allocation " << k + << (workers_only ? " (worker threads)" : ""); return failed; } -struct Snapshot { - std::vector allocated; - std::vector residual; - std::vector records; - std::int64_t next_id {0}; - std::vector bound; - std::vector orphans; -}; - -// Records keep their local ids only: the reference sequence runs on another ledger (uid). -Snapshot snapshot(const FlowGraph& fg) { - Snapshot s {fg.allocated(), fg.residual(), fg.records(), LedgerAccess::next_flow_id(fg), - LedgerAccess::bound_owners(fg), LedgerAccess::orphan_owners(fg)}; - for (auto& r : s.records) { - EXPECT_EQ(r.flow_id.first, fg.uid()); - r.flow_id.first = 0; - } - return s; -} +} // namespace -bool same(const FlowRecord& a, const FlowRecord& b) { - return a.flow_id == b.flow_id && a.owner == b.owner && a.sources == b.sources && a.destinations == b.destinations && - a.placement == b.placement && a.admission == b.admission && a.bundle && b.bundle && *a.bundle == *b.bundle && - a.route_cost == b.route_cost && a.offered == b.offered && a.delivered == b.delivered && a.edges == b.edges && - a.drops == b.drops; -} +// Runs fn with the k-th allocation (from 0) failing; returns whether that failure happened. +bool fails_at(long k, const std::function& fn) { return inject(k, false, fn); } -void expect_same(const Snapshot& a, const Snapshot& b, const std::string& where) { - EXPECT_EQ(a.allocated, b.allocated) << where; - EXPECT_EQ(a.residual, b.residual) << where; - EXPECT_EQ(a.next_id, b.next_id) << where; - EXPECT_EQ(a.bound, b.bound) << where; - EXPECT_EQ(a.orphans, b.orphans) << where; - ASSERT_EQ(a.records.size(), b.records.size()) << where; - for (std::size_t i = 0; i < a.records.size(); ++i) EXPECT_TRUE(same(a.records[i], b.records[i])) << where << " record " << i; -} +bool fails_in_workers_at(long k, const std::function& fn) { return inject(k, true, fn); } -// The ledger invariant: the aggregate is the sum of the records, within [0, baseline]. -void expect_balanced(const FlowGraph& fg, const std::string& where) { - std::vector sum(fg.allocated().size(), 0); - for (const auto& r : fg.records()) { - for (const auto& [e, x] : r.edges) sum[static_cast(e)] += x; - } - EXPECT_EQ(sum, fg.allocated()) << where; - for (Amount x : fg.residual()) EXPECT_GE(x, 0) << where; -} +namespace { struct Epoch { Counter128 requested; @@ -117,7 +89,7 @@ struct Epoch { Amount placed {0}; }; -Epoch epoch(const PinnedFlow& p) { +Epoch epoch(const PinnedOne& p) { auto id = p.flow_id(); if (id.has_value()) id->first = 0; return {p.requested(), p.infinite(), id, p.plan() != nullptr, p.placed_demand()}; @@ -131,15 +103,6 @@ void expect_same(const Epoch& a, const Epoch& b, const std::string& where) { EXPECT_EQ(a.placed, b.placed) << where; } -void expect_same(const PinnedResult& a, const PinnedResult& b, const std::string& where) { - EXPECT_EQ(a.placed, b.placed) << where; - EXPECT_EQ(a.remaining, b.remaining) << where; - EXPECT_EQ(a.termination, b.termination) << where; - EXPECT_EQ(a.routes_changed, b.routes_changed) << where; - EXPECT_EQ(a.drops, b.drops) << where; - EXPECT_EQ(a.iterations, b.iterations) << where; -} - struct Kernel { FlowPlacement placement; Admission admission; @@ -160,13 +123,13 @@ const std::vector& kernels() { struct Case { std::string name; - std::shared_ptr g; + std::shared_ptr g; NodeId src; NodeId dst; }; std::vector cases() { - auto shared = [](StrictMultiDiGraph g) { return std::make_shared(std::move(g)); }; + auto shared = [](Graph g) { return std::make_shared(std::move(g)); }; return { {"edge", shared(make_graph(2, {{0, 1, 9, 1}})), 0, 1}, {"chain", shared(make_graph(3, {{0, 1, 9, 1}, {1, 2, 9, 1}})), 0, 2}, @@ -177,10 +140,10 @@ std::vector cases() { }; } -std::shared_ptr all_edges(const StrictMultiDiGraph& g) { +std::shared_ptr all_edges(const Graph& g) { std::vector e(static_cast(g.num_edges())); for (std::size_t i = 0; i < e.size(); ++i) e[i] = static_cast(i); - return std::make_shared(detail::PredDAGBuilder::from_edges(g, e)); + return std::make_shared(detail::PredDAGBuilder::of_edges(g, e)); } // One scenario: a third party holding 1 unit on every edge, then the policy's earlier calls, @@ -214,48 +177,48 @@ TEST(LedgerFaults, EveryAllocationOfAPlacementFailsAtomically) { std::to_string(static_cast(k.admission)) + "/" + std::to_string(static_cast(k.priority)) + " " + sc.name; // The reference: the same sequence without a failure. - FlowGraph ref(c.g); - PinnedFlow third_ref(ref, c.src, c.dst, dag, FlowPlacement::EqualBalanced, Admission::Lossless, + FlowLedger ref(c.g); + PinnedOne third_ref(ref, c.src, c.dst, dag, FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); (void)third_ref.place(1); - PinnedFlow pref(ref, c.src, c.dst, dag, k.placement, k.admission, k.priority); + PinnedOne pref(ref, c.src, c.dst, dag, k.placement, k.admission, k.priority); for (auto v : sc.before) (void)pref.place(v); - const Snapshot initial = snapshot(ref); - const PinnedResult want = pref.place(sc.call); - const Snapshot after = snapshot(ref); + const LedgerState initial = ledger_state(ref); + const PlacementResult want = pref.place(sc.call); + const LedgerState after = ledger_state(ref); const Epoch want_epoch = epoch(pref); pref.remove(); - const Snapshot removed = snapshot(ref); + const LedgerState removed = ledger_state(ref); ++sweeps; for (long at = 0;; ++at) { const std::string w = where + " allocation " + std::to_string(at); - FlowGraph fg(c.g); - PinnedFlow third(fg, c.src, c.dst, dag, FlowPlacement::EqualBalanced, Admission::Lossless, + FlowLedger fg(c.g); + PinnedOne third(fg, c.src, c.dst, dag, FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); (void)third.place(1); - PinnedFlow p(fg, c.src, c.dst, dag, k.placement, k.admission, k.priority); + PinnedOne p(fg, c.src, c.dst, dag, k.placement, k.admission, k.priority); for (auto v : sc.before) (void)p.place(v); - const Snapshot before = snapshot(fg); + const LedgerState before = ledger_state(fg); const Epoch before_epoch = epoch(p); expect_same(before, initial, w + " (setup)"); - PinnedResult got; + PlacementResult got; if (!fails_at(at, [&] { got = p.place(sc.call); })) { expect_same(got, want, w + " (no failure)"); - expect_same(snapshot(fg), after, w + " (no failure)"); + expect_same(ledger_state(fg), after, w + " (no failure)"); break; } ++injected; // Failed: nothing changed, ledger and policy. - expect_same(snapshot(fg), before, w + " (failed call)"); + expect_same(ledger_state(fg), before, w + " (failed call)"); expect_same(epoch(p), before_epoch, w + " (failed call)"); expect_balanced(fg, w + " (failed call)"); // A clean retry is the reference call (no poisoned per-thread scratch). expect_same(p.place(sc.call), want, w + " (retry)"); - expect_same(snapshot(fg), after, w + " (retry)"); + expect_same(ledger_state(fg), after, w + " (retry)"); expect_same(epoch(p), want_epoch, w + " (retry)"); expect_balanced(fg, w + " (retry)"); p.remove(); - expect_same(snapshot(fg), removed, w + " (remove)"); + expect_same(ledger_state(fg), removed, w + " (remove)"); if (::testing::Test::HasFailure()) return; } } @@ -270,19 +233,19 @@ TEST(LedgerFaults, PolicyConstructionFailsWithoutBinding) { const auto dag = all_edges(*c.g); for (const auto& k : kernels()) { for (long at = 0;; ++at) { - FlowGraph fg(c.g); + FlowLedger fg(c.g); { - PinnedFlow other(fg, c.src, c.dst, dag, FlowPlacement::EqualBalanced, Admission::Lossless, + PinnedOne other(fg, c.src, c.dst, dag, FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); (void)other.place(1); } // an orphan - const Snapshot before = snapshot(fg); - std::optional p; + const LedgerState before = ledger_state(fg); + std::optional p; if (!fails_at(at, [&] { p.emplace(fg, c.src, c.dst, dag, k.placement, k.admission, k.priority); })) { EXPECT_EQ(LedgerAccess::bound_owners(fg).size(), 1U); break; } - expect_same(snapshot(fg), before, c.name + " construction allocation " + std::to_string(at)); + expect_same(ledger_state(fg), before, c.name + " construction allocation " + std::to_string(at)); EXPECT_FALSE(p.has_value()); } } @@ -298,10 +261,10 @@ TEST(LedgerFaults, ScratchGrowthFailureLeavesNothingHalfGrown) { std::int32_t n = 200; // Construction (the route plan's per-node scratch): one edge between the last two nodes. for (long at = 0;; ++at, n += 150) { - auto g = std::make_shared(make_graph(n, {{n - 2, n - 1, 3, 1}})); + auto g = std::make_shared(make_graph(n, {{n - 2, n - 1, 3, 1}})); const auto dag = all_edges(*g); - FlowGraph fg(g); - std::optional p; + FlowLedger fg(g); + std::optional p; const bool failed = fails_at(at, [&] { p.emplace(fg, n - 2, n - 1, dag, FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); }); @@ -321,16 +284,16 @@ TEST(LedgerFaults, ScratchGrowthFailureLeavesNothingHalfGrown) { for (long at = 0;; ++at, n += 150) { std::vector chain; for (NodeId v = 0; v + 1 < n; ++v) chain.push_back({v, v + 1, 5, 1}); - auto g = std::make_shared(make_graph(n, chain)); + auto g = std::make_shared(make_graph(n, chain)); const auto dag = all_edges(*g); - FlowGraph fg(g); - PinnedFlow p(fg, 0, n - 1, dag, FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); - const Snapshot before = snapshot(fg); - PinnedResult got; + FlowLedger fg(g); + PinnedOne p(fg, 0, n - 1, dag, FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); + const LedgerState before = ledger_state(fg); + PlacementResult got; const bool failed = fails_at(at, [&] { got = p.place(2); }); if (failed) { ++failures; - expect_same(snapshot(fg), before, "placement allocation " + std::to_string(at)); + expect_same(ledger_state(fg), before, "placement allocation " + std::to_string(at)); got = p.place(2); } EXPECT_EQ(got.placed, 2) << "placement allocation " << at; @@ -344,42 +307,39 @@ TEST(LedgerFaults, ScratchGrowthFailureLeavesNothingHalfGrown) { } // The bimodality guard (NOTES "Bimodality"): NodeMap::add runs once per edge endpoint in the -// flow core's subset solve and in the plan builder. It must stay allocation-free (reset() sizes -// both arrays) so that it stays a few inlined instructions: the push_back version was an -// out-of-line call whose register save/restore stalled in some process layouts. +// flow core's subset solve. It must stay allocation-free (reset() sizes both arrays) so that it +// stays a few inlined instructions: the push_back version was an out-of-line call whose register +// save/restore stalled in some process layouts. static_assert(noexcept(std::declval().add(NodeId {0}))); TEST(LedgerFaults, NodeMapAddNeverAllocates) { auto& node_map = detail::NodeMap::local(); - // Called through a member pointer: the inventory scanner attributes this member call on a - // receiver it does not list to a deleted 1a owner of the same member name. - constexpr auto reset = &detail::NodeMap::reset; for (std::size_t n : {std::size_t {10}, std::size_t {5000}, std::size_t {300}}) { - std::invoke(reset, node_map, n); + node_map.reset(n); EXPECT_FALSE(fails_at(0, [&] { for (std::size_t v = n; v-- > 0;) (void)node_map.add(static_cast(v)); for (std::size_t v = 0; v < n; ++v) (void)node_map.add(static_cast(v)); })); ASSERT_EQ(node_map.size(), n); EXPECT_EQ(node_map.of(static_cast(n - 1)), 0); // first-touch order - EXPECT_EQ(node_map.nodes()[0], static_cast(n - 1)); + EXPECT_EQ(node_map.of(0), static_cast(n - 1)); } - std::invoke(reset, node_map, std::size_t {0}); + node_map.reset(0); } TEST(LedgerFaults, RemovalAndReleaseNeverAllocate) { for (const auto& c : cases()) { const auto dag = all_edges(*c.g); for (const auto& k : kernels()) { - FlowGraph fg(c.g); - auto p = std::make_unique(fg, c.src, c.dst, dag, k.placement, k.admission, k.priority); + FlowLedger fg(c.g); + auto p = std::make_unique(fg, c.src, c.dst, dag, k.placement, k.admission, k.priority); (void)p->place(3); - // remove() and the destructor's release() make no allocation (release() is noexcept). + // remove() and the destructor (the records become orphans) make no allocation. EXPECT_FALSE(fails_at(0, [&] { p->remove(); })); (void)p->place(2); - const Snapshot held = snapshot(fg); + const LedgerState held = ledger_state(fg); EXPECT_FALSE(fails_at(0, [&] { p.reset(); })); - const Snapshot orphaned = snapshot(fg); + const LedgerState orphaned = ledger_state(fg); EXPECT_EQ(orphaned.allocated, held.allocated); EXPECT_TRUE(orphaned.bound.empty()); EXPECT_EQ(orphaned.orphans.size(), 1U); diff --git a/tests/cpp/flow_graph_tests.cpp b/tests/cpp/flow_ledger_tests.cpp similarity index 75% rename from tests/cpp/flow_graph_tests.cpp rename to tests/cpp/flow_ledger_tests.cpp index 6107b24..6115b2a 100644 --- a/tests/cpp/flow_graph_tests.cpp +++ b/tests/cpp/flow_ledger_tests.cpp @@ -1,6 +1,6 @@ -// The integer ledger FlowGraph (APPROACH 8): the baseline, snapshots, FlowIds, bottleneck, and -// every row and transition of the lifetime table, driven through the internal entry points by -// the single-bundle Pinned transaction of sub-PR 1b (detail::PinnedFlow). +// The integer ledger FlowLedger (APPROACH 8): the baseline, snapshots, FlowIds, bottleneck, and +// every row and transition of the lifetime table, driven through a FlowPolicy with Pinned +// routing and one bundle (test::PinnedOne, pinned_one.hpp). // // | State | place | remove() | destroyed | reset | copy | // | bound, no flows | place(0) no-op; positive | resets the epoch | none | rejected | rejected | @@ -9,38 +9,41 @@ // | orphaned records | n/a | n/a | n/a | clears | copied | #include +#include #include +#include #include #include #include #include #include #include +#include #include +#include "netgraph/core/detail/int128.hpp" #include "netgraph/core/detail/ledger_access.hpp" -#include "netgraph/core/detail/pinned_flow.hpp" #include "netgraph/core/detail/pred_dag_builder.hpp" -#include "netgraph/core/flow_graph.hpp" +#include "netgraph/core/flow_ledger.hpp" +#include "pinned_one.hpp" #include "test_utils.hpp" using namespace netgraph::core; using namespace netgraph::core::test; using detail::LedgerAccess; -using detail::PinnedFlow; -using detail::Termination; +using netgraph::core::Termination; namespace { -std::shared_ptr shared(StrictMultiDiGraph g) { - return std::make_shared(std::move(g)); +std::shared_ptr shared(Graph g) { + return std::make_shared(std::move(g)); } -std::shared_ptr bundle(const StrictMultiDiGraph& g, std::vector edges) { - return std::make_shared(detail::PredDAGBuilder::from_edges(g, edges)); +std::shared_ptr bundle(const Graph& g, std::vector edges) { + return std::make_shared(detail::PredDAGBuilder::of_edges(g, edges)); } -std::shared_ptr all_edges(const StrictMultiDiGraph& g) { +std::shared_ptr all_edges(const Graph& g) { std::vector e(static_cast(g.num_edges())); for (std::size_t i = 0; i < e.size(); ++i) e[i] = static_cast(i); return bundle(g, e); @@ -55,30 +58,30 @@ std::string error_of(const std::function& fn) { return ""; } -std::vector alloc_in(const FlowGraph& fg) { return to_input(*fg.graph(), fg.allocated()); } +std::vector alloc_in(const FlowLedger& fg) { return to_input(*fg.graph(), fg.allocated()); } } // namespace -TEST(FlowGraph, BaselineValidationAndSnapshots) { +TEST(FlowLedger, BaselineValidationAndSnapshots) { auto g = shared(make_graph(2, {{0, 1, 5, 1}})); - EXPECT_THROW(FlowGraph(g, std::vector {6}), std::invalid_argument); - EXPECT_THROW(FlowGraph(g, std::vector {-1}), std::invalid_argument); - EXPECT_THROW(FlowGraph(g, std::vector {1, 1}), std::invalid_argument); - EXPECT_THROW(FlowGraph(nullptr), std::invalid_argument); - FlowGraph fg(g, std::vector {3}); + EXPECT_THROW(FlowLedger(g, std::vector {6}), std::invalid_argument); + EXPECT_THROW(FlowLedger(g, std::vector {-1}), std::invalid_argument); + EXPECT_THROW(FlowLedger(g, std::vector {1, 1}), std::invalid_argument); + EXPECT_THROW(FlowLedger(nullptr), std::invalid_argument); + FlowLedger fg(g, std::vector {3}); EXPECT_EQ(fg.residual(), (std::vector{3})); EXPECT_EQ(fg.allocated(), (std::vector{0})); EXPECT_EQ(std::vector(fg.capacity_units().begin(), fg.capacity_units().end()), (std::vector{5})); EXPECT_TRUE(fg.records().empty()); EXPECT_EQ(fg.graph().get(), g.get()); - FlowGraph other(g); + FlowLedger other(g); EXPECT_NE(fg.uid(), other.uid()); } -TEST(FlowGraph, BoundNoFlowsRow) { +TEST(FlowLedger, BoundNoFlowsRow) { auto g = shared(make_graph(2, {{0, 1, 5, 1}})); - FlowGraph fg(g); - PinnedFlow p(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); + FlowLedger fg(g); + PinnedOne p(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); // place(0): a no-op that creates nothing. auto r = p.place(0); EXPECT_EQ(r.termination, Termination::Complete); @@ -87,8 +90,8 @@ TEST(FlowGraph, BoundNoFlowsRow) { EXPECT_EQ(LedgerAccess::next_flow_id(fg), 0); // reset and copy rejected while a policy is bound; move rejected too. EXPECT_NE(error_of([&] { fg.reset(); }).find("bound"), std::string::npos); - EXPECT_THROW(FlowGraph copy(fg), std::invalid_argument); - EXPECT_THROW(FlowGraph moved(std::move(fg)), std::invalid_argument); + EXPECT_THROW(FlowLedger copy(fg), std::invalid_argument); + EXPECT_THROW(FlowLedger moved(std::move(fg)), std::invalid_argument); // remove() with no flows: no allocation to revert; the epoch state resets. p.remove(); EXPECT_TRUE(fg.records().empty()); @@ -102,12 +105,12 @@ TEST(FlowGraph, BoundNoFlowsRow) { EXPECT_EQ(p.flow_id()->second, 0); } -TEST(FlowGraph, NoRouteCreatesNothing) { +TEST(FlowLedger, NoRouteCreatesNothing) { // A bundle whose only walk is excluded by the Eligibility: usable_bundles() == 0. auto g = shared(make_graph(2, {{0, 1, 5, 1}})); - FlowGraph fg(g); + FlowLedger fg(g); auto el = std::make_shared(g->eligibility({}, std::vector {0})); - PinnedFlow p(fg, 0, 1, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual, el); + PinnedOne p(fg, 0, 1, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual, el); EXPECT_EQ(p.usable_bundles(), 0); auto r = p.place(3); EXPECT_EQ(r.termination, Termination::NoRoute); @@ -119,18 +122,18 @@ TEST(FlowGraph, NoRouteCreatesNothing) { EXPECT_EQ(LedgerAccess::next_flow_id(fg), 0); // A bundle without any walk before pruning is malformed. auto g2 = shared(make_graph(3, {{0, 1, 5, 1}, {2, 1, 5, 1}})); - FlowGraph fg2(g2); + FlowLedger fg2(g2); EXPECT_NE(error_of([&] { - PinnedFlow bad(fg2, 0, 2, all_edges(*g2), FlowPlacement::MaxFlow, Admission::Lossless, + PinnedOne bad(fg2, 0, 2, all_edges(*g2), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); }).find("malformed bundle"), std::string::npos); } -TEST(FlowGraph, BoundWithFlowsRowAndEpochs) { +TEST(FlowLedger, BoundWithFlowsRowAndEpochs) { auto g = shared(make_graph(2, {{0, 1, 5, 1}})); - FlowGraph fg(g); - PinnedFlow p(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); + FlowLedger fg(g); + PinnedOne p(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); EXPECT_EQ(p.place(2).placed, 2); // Adds: T grows within the epoch. auto r = p.place(std::nullopt); @@ -144,7 +147,7 @@ TEST(FlowGraph, BoundWithFlowsRowAndEpochs) { EXPECT_NE(error_of([&] { (void)p.place(0); }).find("after +inf"), std::string::npos); EXPECT_EQ(alloc_in(fg), (std::vector{5})); EXPECT_NE(error_of([&] { fg.reset(); }).find("bound"), std::string::npos); - EXPECT_THROW(FlowGraph copy(fg), std::invalid_argument); + EXPECT_THROW(FlowLedger copy(fg), std::invalid_argument); // remove(): allocations reverted, record gone, id retired. const auto old = *p.flow_id(); p.remove(); @@ -163,13 +166,13 @@ TEST(FlowGraph, BoundWithFlowsRowAndEpochs) { EXPECT_EQ(LedgerAccess::next_flow_id(fg), 2); } -TEST(FlowGraph, OrphanedRecordsResetAndCopy) { +TEST(FlowLedger, OrphanedRecordsResetAndCopy) { auto g = shared(make_graph(2, {{0, 1, 5, 1}})); - FlowGraph fg(g); - auto p = std::make_unique(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, + FlowLedger fg(g); + auto p = std::make_unique(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); { - PinnedFlow q(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); + PinnedOne q(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); EXPECT_EQ(q.place(2).placed, 2); } // destroyed without remove(): the record stays as an orphan ASSERT_EQ(fg.records().size(), 1u); @@ -182,7 +185,7 @@ TEST(FlowGraph, OrphanedRecordsResetAndCopy) { p->remove(); p.reset(); // destroyed: nothing bound // Copy of an unbound ledger: new uid, same ids under the new uid. - FlowGraph c(fg); + FlowLedger c(fg); EXPECT_NE(c.uid(), fg.uid()); ASSERT_EQ(c.records().size(), 1u); EXPECT_EQ(c.records()[0].flow_id, (FlowId {c.uid(), 0})); @@ -194,17 +197,17 @@ TEST(FlowGraph, OrphanedRecordsResetAndCopy) { EXPECT_EQ(fg.residual(), (std::vector{4})); EXPECT_TRUE(LedgerAccess::orphan_owners(fg).empty()); EXPECT_EQ(LedgerAccess::next_flow_id(fg), 2); - PinnedFlow r(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); + PinnedOne r(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); EXPECT_EQ(r.place(9).placed, 4); EXPECT_EQ(r.flow_id()->second, 2); // The copy is independent of the original. EXPECT_EQ(c.records()[0].delivered, 2); } -TEST(FlowGraph, MovedFromLedgerIsUnusable) { +TEST(FlowLedger, MovedFromLedgerIsUnusable) { auto g = shared(make_graph(2, {{0, 1, 5, 1}})); - FlowGraph fg(g); - FlowGraph moved(std::move(fg)); + FlowLedger fg(g); + FlowLedger moved(std::move(fg)); EXPECT_THROW((void)fg.uid(), std::logic_error); EXPECT_THROW((void)fg.records(), std::logic_error); EXPECT_THROW((void)fg.residual(), std::logic_error); @@ -213,10 +216,10 @@ TEST(FlowGraph, MovedFromLedgerIsUnusable) { EXPECT_EQ(moved.residual(), (std::vector{5})); } -TEST(FlowGraph, FlowIdsOfOtherLedgersAndUnknownIds) { +TEST(FlowLedger, FlowIdsOfOtherLedgersAndUnknownIds) { auto g = shared(make_graph(2, {{0, 1, 5, 1}})); - FlowGraph a(g), b(g); - PinnedFlow p(a, 0, 1, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + FlowLedger a(g), b(g); + PinnedOne p(a, 0, 1, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); (void)p.place(1); const auto id = *p.flow_id(); EXPECT_EQ(a.record(id).delivered, 1); @@ -224,12 +227,12 @@ TEST(FlowGraph, FlowIdsOfOtherLedgersAndUnknownIds) { EXPECT_NE(error_of([&] { (void)a.record(FlowId {a.uid(), 7}); }).find("retired or unknown"), std::string::npos); } -TEST(FlowGraph, RecordsCarryTheContractFields) { +TEST(FlowLedger, RecordsCarryTheContractFields) { // Lossy EqualBalanced on the diamond 10/10/1/10 (prototype 1 lossy_diamond): 10 then 1. auto g = shared(make_graph(4, {{0, 1, 10, 1}, {0, 2, 10, 1}, {1, 3, 1, 1}, {2, 3, 10, 1}})); const auto inv = id_of_input(*g); - FlowGraph fg(g); - PinnedFlow p(fg, 0, 3, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossy, EdgePriority::Residual); + FlowLedger fg(g); + PinnedOne p(fg, 0, 3, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossy, EdgePriority::Residual); auto r = p.place(10); EXPECT_EQ(r.placed, 6); EXPECT_EQ(r.termination, Termination::Complete); @@ -247,7 +250,8 @@ TEST(FlowGraph, RecordsCarryTheContractFields) { EXPECT_EQ(rec.placement, FlowPlacement::EqualBalanced); EXPECT_EQ(rec.admission, Admission::Lossy); ASSERT_TRUE(rec.bundle); - EXPECT_EQ(rec.bundle->edges(), (std::vector{0, 1, 2, 3})); + EXPECT_EQ(rec.bundle->via_edges().size(), 4u); + EXPECT_TRUE(*rec.bundle == *all_edges(*g)); EXPECT_EQ(rec.route_cost, 2); ASSERT_TRUE(rec.offered.has_value()); EXPECT_EQ(rec.offered->lo, 11u); @@ -256,26 +260,26 @@ TEST(FlowGraph, RecordsCarryTheContractFields) { EXPECT_EQ(rec.drops[0].first, inv[2]); EXPECT_EQ(rec.drops[0].second.lo, 5u); // +inf on a fresh ledger: offered is the injection (20), delivered 11, drops 9. - FlowGraph f2(g); - PinnedFlow q(f2, 0, 3, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossy, EdgePriority::Residual); + FlowLedger f2(g); + PinnedOne q(f2, 0, 3, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossy, EdgePriority::Residual); r = q.place(std::nullopt); EXPECT_EQ(r.placed, 11); EXPECT_EQ(r.termination, Termination::Exhausted); EXPECT_EQ(f2.records()[0].offered->lo, 20u); // Lossless records carry no offered. - FlowGraph f3(g); - PinnedFlow m(f3, 0, 3, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + FlowLedger f3(g); + PinnedOne m(f3, 0, 3, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); (void)m.place(3); EXPECT_FALSE(f3.records()[0].offered.has_value()); EXPECT_TRUE(f3.records()[0].drops.empty()); } -TEST(FlowGraph, CountersBeyondSixtyFourBits) { +TEST(FlowLedger, CountersBeyondSixtyFourBits) { // Prototype 1 lossy_oversized_and_saturated_offers: every unit of a 2^62 - 1 offer dropped on // a saturated link, many times: offered and drops grow beyond 2^64. auto g = shared(make_graph(2, {{0, 1, 1, 1}})); - FlowGraph fg(g); - PinnedFlow p(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossy, EdgePriority::Residual); + FlowLedger fg(g); + PinnedOne p(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossy, EdgePriority::Residual); const Amount big = kDomainLimit - 1; for (int i = 0; i < 9; ++i) (void)p.place(big); const auto rec = fg.records().at(0); @@ -288,10 +292,45 @@ TEST(FlowGraph, CountersBeyondSixtyFourBits) { EXPECT_EQ(p.requested().hi, 2u); } -TEST(FlowGraph, RequestDomainAndFailedCallsLeaveState) { +// The public counter is self-contained (flow_record.hpp): its own hi and lo, no base class from +// the private arithmetic, the same carry and the same failure at 2^127, which changes nothing. +static_assert(std::is_aggregate_v && std::is_trivially_copyable_v); +static_assert(std::is_standard_layout_v && sizeof(Counter128) == 2 * sizeof(std::uint64_t)); +static_assert(!std::is_base_of_v && + !std::is_convertible_v); + +TEST(FlowLedger, Counter128CarriesAndFailsAt2To127) { + const std::uint64_t max64 = std::numeric_limits::max(); + Counter128 c; + EXPECT_TRUE(c.is_zero()); + EXPECT_EQ(c, Counter128 {}); + // A carry from lo into hi. + c.lo = max64 - 2; + EXPECT_TRUE(c.add(5)); + EXPECT_EQ(c.hi, 1u); + EXPECT_EQ(c.lo, 2u); + EXPECT_FALSE(c.is_zero()); + EXPECT_EQ(c, (Counter128 {1, 2})); + EXPECT_NE(c, (Counter128 {0, 2})); + EXPECT_NE(c, (Counter128 {1, 3})); + // 2^127 - 1 is the largest value; adding 0 keeps it, any positive amount fails unchanged, + // also through the carry. + Counter128 top {(std::uint64_t {1} << 63) - 1, max64}; + EXPECT_TRUE(top.add(0)); + EXPECT_FALSE(top.add(1)); + EXPECT_EQ(top, (Counter128 {(std::uint64_t {1} << 63) - 1, max64})); + Counter128 near {(std::uint64_t {1} << 63) - 1, max64 - 4}; + EXPECT_TRUE(near.add(4)); + EXPECT_EQ(near, top); + Counter128 below {(std::uint64_t {1} << 63) - 1, max64 - 4}; + EXPECT_FALSE(below.add(kDomainLimit - 1)); + EXPECT_EQ(below, (Counter128 {(std::uint64_t {1} << 63) - 1, max64 - 4})); +} + +TEST(FlowLedger, RequestDomainAndFailedCallsLeaveState) { auto g = shared(make_graph(2, {{0, 1, 5, 1}})); - FlowGraph fg(g); - PinnedFlow p(fg, 0, 1, all_edges(*g), FlowPlacement::Weighted, Admission::Lossless, EdgePriority::Capacity); + FlowLedger fg(g); + PinnedOne p(fg, 0, 1, all_edges(*g), FlowPlacement::Weighted, Admission::Lossless, EdgePriority::Capacity); (void)p.place(2); const auto before = fg.records(); EXPECT_THROW((void)p.place(kDomainLimit), std::invalid_argument); @@ -301,34 +340,34 @@ TEST(FlowGraph, RequestDomainAndFailedCallsLeaveState) { // Constructor validation. auto other = make_graph(2, {{0, 1, 5, 1}}); EXPECT_NE(error_of([&] { - PinnedFlow x(fg, 0, 1, bundle(other, {0}), FlowPlacement::MaxFlow, Admission::Lossless, + PinnedOne x(fg, 0, 1, bundle(other, {0}), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); }).find("foreign PredDAG"), std::string::npos); EXPECT_NE(error_of([&] { - PinnedFlow x(fg, 0, 0, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + PinnedOne x(fg, 0, 0, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); }).find("both endpoint sets"), std::string::npos); EXPECT_NE(error_of([&] { - PinnedFlow x(fg, 0, 1, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossy, EdgePriority::Residual); + PinnedOne x(fg, 0, 1, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossy, EdgePriority::Residual); }).find("Lossy"), std::string::npos); EXPECT_NE(error_of([&] { - PinnedFlow x(fg, 0, 1, all_edges(*g), FlowPlacement::Weighted, Admission::Lossless, EdgePriority::Headroom); + PinnedOne x(fg, 0, 1, all_edges(*g), FlowPlacement::Weighted, Admission::Lossless, EdgePriority::Headroom); }).find("Headroom"), std::string::npos); // A rejected construction binds nothing. EXPECT_EQ(LedgerAccess::bound_owners(fg), (std::vector{0})); } -TEST(FlowGraph, BottleneckVersusMinCut) { +TEST(FlowLedger, BottleneckVersusMinCut) { // Prototype 1 ledger_bottleneck_vs_min_cut: (0->1 cap 1 cost 1), (0->2 cap 2 cost 3), // (1->2 cap 1), (2->3 cap 2). After the MaxFlow +inf [1, 1, 1, 2] the bottleneck (forward // residual) is {0->1, 2->3}, not the cut {2->3}. auto g = shared(make_graph(4, {{0, 1, 1, 1}, {0, 2, 2, 3}, {1, 2, 1, 1}, {2, 3, 2, 1}})); const auto inv = id_of_input(*g); - FlowGraph fg(g); - PinnedFlow p(fg, 0, 3, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + FlowLedger fg(g); + PinnedOne p(fg, 0, 3, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); EXPECT_EQ(p.place(std::nullopt).placed, 2); EXPECT_EQ(alloc_in(fg), (std::vector{1, 1, 1, 2})); std::vector want = {inv[0], inv[3]}; @@ -336,7 +375,7 @@ TEST(FlowGraph, BottleneckVersusMinCut) { EXPECT_EQ(fg.bottleneck(std::vector {0}), want); // A zero-capacity edge at the frontier is not listed; an excluded edge is not crossed. auto g2 = shared(make_graph(3, {{0, 1, 0, 1}, {0, 2, 1, 1}, {2, 1, 1, 1}})); - FlowGraph f2(g2); + FlowLedger f2(g2); EXPECT_TRUE(f2.bottleneck(std::vector {0}).empty()); const auto inv2 = id_of_input(*g2); auto el = g2->eligibility({}, std::vector {inv2[2]}); @@ -351,17 +390,17 @@ TEST(FlowGraph, BottleneckVersusMinCut) { EXPECT_THROW((void)f2.bottleneck(std::vector {}), std::invalid_argument); } -TEST(FlowGraph, TwoOwnersNeverCancelEachOther) { +TEST(FlowLedger, TwoOwnersNeverCancelEachOther) { // Prototype 1 maxflow_two_owner_no_cross_cancellation: A holds the chain 0-1-2-3; B's bundle // (chain plus bypasses) could reach 2 only by cancelling A's middle edge: B places 0 (record // at zero delivery, exhausted) and A is untouched; after A's remove() B places 2. auto g = shared(make_graph(4, {{0, 1, 1, 1}, {1, 2, 1, 1}, {2, 3, 1, 1}, {0, 2, 1, 3}, {1, 3, 1, 3}})); const auto inv = id_of_input(*g); - FlowGraph fg(g); - PinnedFlow a(fg, 0, 3, bundle(*g, {inv[0], inv[1], inv[2]}), FlowPlacement::MaxFlow, Admission::Lossless, + FlowLedger fg(g); + PinnedOne a(fg, 0, 3, bundle(*g, {inv[0], inv[1], inv[2]}), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); EXPECT_EQ(a.place(1).placed, 1); - PinnedFlow b(fg, 0, 3, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + PinnedOne b(fg, 0, 3, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); auto r = b.place(2); EXPECT_EQ(r.placed, 0); EXPECT_TRUE(r.routes_changed); @@ -374,7 +413,7 @@ TEST(FlowGraph, TwoOwnersNeverCancelEachOther) { EXPECT_EQ(alloc_in(fg), (std::vector{1, 0, 1, 1, 1})); } -TEST(FlowGraph, AggregatesEqualTheSumOfRecords) { +TEST(FlowLedger, AggregatesEqualTheSumOfRecords) { // Random sequences of placements, removals and destructions on one ledger: allocated() is the // exact sum of the records' edges and residual() = baseline - allocated() within [0, baseline]. std::mt19937 rng(5); @@ -387,14 +426,14 @@ TEST(FlowGraph, AggregatesEqualTheSumOfRecords) { edges.push_back({u, v, static_cast(rng() % 7), 1 + static_cast(rng() % 3)}); } auto g = shared(make_graph(n, edges)); - FlowGraph fg(g); - std::vector> pols; + FlowLedger fg(g); + std::vector> pols; const FlowPlacement kinds[3] = {FlowPlacement::MaxFlow, FlowPlacement::Weighted, FlowPlacement::EqualBalanced}; for (int k = 0; k < 4; ++k) { const auto kind = kinds[rng() % 3]; const auto adm = kind != FlowPlacement::MaxFlow && rng() % 3 == 0 ? Admission::Lossy : Admission::Lossless; try { - pols.push_back(std::make_unique(fg, 0, n - 1, all_edges(*g), kind, adm, EdgePriority::Capacity)); + pols.push_back(std::make_unique(fg, 0, n - 1, all_edges(*g), kind, adm, EdgePriority::Capacity)); } catch (const std::invalid_argument&) { break; // no walk at all } @@ -424,14 +463,14 @@ TEST(FlowGraph, AggregatesEqualTheSumOfRecords) { } } -TEST(FlowGraph, NoRouteCallsRecordTheEpochState) { +TEST(FlowLedger, NoRouteCallsRecordTheEpochState) { // A positive request with no usable bundle creates nothing, but the epoch records it: after // +inf a finite request (place(0) included) is rejected until remove() (APPROACH 8, 9.2; // prototype 2's reading; prototype 1 kept no state on such a call). auto g = shared(make_graph(2, {{0, 1, 5, 1}})); - FlowGraph fg(g); + FlowLedger fg(g); auto el = std::make_shared(g->eligibility({}, std::vector {0})); - PinnedFlow p(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual, + PinnedOne p(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual, el); EXPECT_EQ(p.place(3).termination, Termination::NoRoute); EXPECT_EQ(p.requested().lo, 3u); @@ -448,14 +487,14 @@ TEST(FlowGraph, NoRouteCallsRecordTheEpochState) { EXPECT_EQ(LedgerAccess::next_flow_id(fg), 0); } -TEST(FlowGraph, RouteCostIsTheBundlesMinimumWalk) { +TEST(FlowLedger, RouteCostIsTheBundlesMinimumWalk) { // Weighted Residual on a route whose only edge another owner fills: the split graph is // empty (record at zero delivery), route_cost is still the bundle's walk cost 3. auto g = shared(make_graph(2, {{0, 1, 2, 3}})); - FlowGraph fg(g); - PinnedFlow holder(fg, 0, 1, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + FlowLedger fg(g); + PinnedOne holder(fg, 0, 1, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); EXPECT_EQ(holder.place(2).placed, 2); - PinnedFlow w(fg, 0, 1, all_edges(*g), FlowPlacement::Weighted, Admission::Lossless, EdgePriority::Residual); + PinnedOne w(fg, 0, 1, all_edges(*g), FlowPlacement::Weighted, Admission::Lossless, EdgePriority::Residual); auto r = w.place(1); EXPECT_EQ(r.placed, 0); EXPECT_EQ(r.termination, Termination::Exhausted); @@ -467,19 +506,19 @@ TEST(FlowGraph, RouteCostIsTheBundlesMinimumWalk) { EXPECT_EQ(rec.delivered, 0); // A mixed-cost bundle: the cheapest walk (1 + 1 + 1) is the route cost, not the cost-4 edge. auto g2 = shared(make_graph(4, {{0, 1, 1, 1}, {1, 2, 1, 1}, {2, 3, 1, 1}, {0, 3, 1, 4}})); - FlowGraph f2(g2); - PinnedFlow m(f2, 0, 3, all_edges(*g2), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + FlowLedger f2(g2); + PinnedOne m(f2, 0, 3, all_edges(*g2), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); (void)m.place(1); EXPECT_EQ(f2.records()[0].route_cost, 3); } -TEST(FlowGraph, RemovalRestoresTheBaselineAndLeavesOtherOwnersIntact) { +TEST(FlowLedger, RemovalRestoresTheBaselineAndLeavesOtherOwnersIntact) { // review-2 R7: removing a flow restores the baseline residual (5 on a capacity-10 link), not // the capacity; review-check check_review6: another owner's allocation stays intact. auto g = shared(make_graph(2, {{0, 1, 10, 1}})); - FlowGraph fg(g, std::vector {5}); - PinnedFlow a(fg, 0, 1, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); - PinnedFlow b(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); + FlowLedger fg(g, std::vector {5}); + PinnedOne a(fg, 0, 1, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + PinnedOne b(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); EXPECT_EQ(a.place(3).placed, 3); EXPECT_EQ(b.place(1).placed, 1); EXPECT_EQ(fg.residual(), (std::vector{1})); @@ -489,16 +528,16 @@ TEST(FlowGraph, RemovalRestoresTheBaselineAndLeavesOtherOwnersIntact) { b.remove(); EXPECT_EQ(fg.residual(), (std::vector{5})); // A baseline one unit above the quantised capacity is rejected (no clamp at this boundary). - EXPECT_THROW(FlowGraph(g, std::vector {11}), std::invalid_argument); + EXPECT_THROW(FlowLedger(g, std::vector {11}), std::invalid_argument); } -TEST(FlowGraph, LossyOffersArePerCall) { +TEST(FlowLedger, LossyOffersArePerCall) { // review-13 lossy / rule 19: on the diamond, two calls of 1 offer each unit to the lowest // EdgeId first (both on the first branch) while one call of 2 splits [1, 1]: not additive. auto g = shared(make_graph(4, {{0, 1, 10, 1}, {0, 2, 10, 1}, {1, 3, 1, 1}, {2, 3, 10, 1}})); - FlowGraph f1(g), f2(g); - PinnedFlow p1(f1, 0, 3, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossy, EdgePriority::Residual); - PinnedFlow p2(f2, 0, 3, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossy, EdgePriority::Residual); + FlowLedger f1(g), f2(g); + PinnedOne p1(f1, 0, 3, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossy, EdgePriority::Residual); + PinnedOne p2(f2, 0, 3, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossy, EdgePriority::Residual); auto r = p1.place(1); EXPECT_EQ(r.placed, 1); r = p1.place(1); @@ -519,16 +558,17 @@ detail::Draft draft_on(const std::shared_ptr& dag, std::vector id = std::nullopt) { detail::Draft d; d.id = id; - d.sources = {0}; - d.destinations = {1}; - d.bundle = dag; - d.route_cost = 1; - for (const auto& [e, x] : edges) d.delivered += x; - d.edges = std::move(edges); + FlowRecord& r = d.record; + r.sources = {0}; + r.destinations = {1}; + r.bundle = dag; + r.route_cost = 1; + for (const auto& [e, x] : edges) r.delivered += x; + r.edges = std::move(edges); return d; } -Amount record_sum(const FlowGraph& fg, EdgeId e) { +Amount record_sum(const FlowLedger& fg, EdgeId e) { Amount sum = 0; for (const auto& r : fg.records()) { for (const auto& [f, x] : r.edges) { @@ -540,10 +580,10 @@ Amount record_sum(const FlowGraph& fg, EdgeId e) { } // namespace -TEST(FlowGraph, MultiRecordCommitsPublishEachEdgeOnce) { +TEST(FlowLedger, MultiRecordCommitsPublishEachEdgeOnce) { auto g = shared(make_graph(2, {{0, 1, 3, 1}})); const auto dag = all_edges(*g); - FlowGraph fg(g); + FlowLedger fg(g); const auto owner = LedgerAccess::bind(fg); std::vector first {draft_on(dag, {{0, 1}})}; const auto ids = LedgerAccess::commit(fg, owner, first); @@ -573,7 +613,7 @@ TEST(FlowGraph, MultiRecordCommitsPublishEachEdgeOnce) { LedgerAccess::release(fg, owner); } -TEST(FlowGraph, RandomMultiOwnerCommitsMatchAModel) { +TEST(FlowLedger, RandomMultiOwnerCommitsMatchAModel) { // Three owners on four edges; every commit carries all of its owner's records (each kept, // changed, emptied or moved) plus up to two new ones, so deltas cancel and reappear on shared // edges; detaches between commits. The ledger must equal an independent model after every call, @@ -582,7 +622,7 @@ TEST(FlowGraph, RandomMultiOwnerCommitsMatchAModel) { auto g = shared(make_graph(4, {{0, 1, 6, 1}, {1, 2, 4, 1}, {2, 3, 5, 1}, {0, 3, 3, 1}})); const auto dag = all_edges(*g); const auto m = static_cast(g->num_edges()); - FlowGraph fg(g); + FlowLedger fg(g); std::vector owners; for (int i = 0; i < 3; ++i) owners.push_back(LedgerAccess::bind(fg)); // The model: per owner, its live records (id -> per-edge amounts). diff --git a/tests/cpp/flow_policy_fault_tests.cpp b/tests/cpp/flow_policy_fault_tests.cpp new file mode 100644 index 0000000..abf64a8 --- /dev/null +++ b/tests/cpp/flow_policy_fault_tests.cpp @@ -0,0 +1,462 @@ +// Allocation-failure atomicity of the policy engine, the DagCache and the evaluators (APPROACH 8, +// 9.2, 10, 11, 13). Every path that grows a buffer or publishes state is swept over every +// allocation it makes: after each injected std::bad_alloc the ledger (aggregate, residual, every +// record field, owners, next FlowId) and the policy's epoch state (requested, +inf, cohorts with +// their ids and plans) equal their state before the call; a clean retry gives the reference +// result (no poisoned per-thread scratch); and remove() restores the ledger. A supplied building +// cache is a memo (APPROACH 13, limit 2): it may keep the complete entries of the lookups that +// succeeded before the failure, never a partial one. Runs in the fault-injection executable +// (fault_injection.hpp). +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "fault_injection.hpp" +#include "netgraph/core/dag_cache.hpp" +#include "netgraph/core/detail/ledger_access.hpp" +#include "netgraph/core/detail/policy_engine.hpp" +#include "netgraph/core/detail/pred_dag_builder.hpp" +#include "netgraph/core/detail/search.hpp" +#include "netgraph/core/evaluators.hpp" +#include "netgraph/core/flow_ledger.hpp" +#include "netgraph/core/flow_policy.hpp" +#include "test_utils.hpp" + +using namespace netgraph::core; +using namespace netgraph::core::test; +using detail::LedgerAccess; + +namespace { + +// The shared comparators of fault_injection.hpp, beside this file's own overloads. +using test::expect_same; + +// The policy's epoch state: the call state and per roster source its flows (ids, routes, members). +struct Epoch { + Counter128 requested; + bool infinite {false}; + std::vector>>> cohorts; +}; + +Epoch epoch(const FlowPolicy& p) { + const auto& pe = detail::PolicyAccess::engine(p); + Epoch e {pe.requested, pe.infinite, {}}; + for (const auto& c : pe.cohorts) { + auto& out = e.cohorts.emplace_back(); + for (const auto& f : c) { + if (f.plan) { + out.emplace_back(f.id, f.plan->members); + } else { + const auto edges = detail::PredDAGBuilder::ascending(*f.bundle); + out.emplace_back(f.id, std::vector(edges.begin(), edges.end())); + } + } + } + return e; +} + +void expect_same(const Epoch& a, const Epoch& b, const std::string& where) { + EXPECT_EQ(a.requested, b.requested) << where; + EXPECT_EQ(a.infinite, b.infinite) << where; + EXPECT_EQ(a.cohorts, b.cohorts) << where; +} + +PathSelection sel(EdgeTieBreak t, EdgePriority p, bool multipath = true, bool multi_edge = true) { + return PathSelection(t, p, multipath, multi_edge); +} + +// Sources 0 and 1, destinations 5 and 6: three equal-cost routes with a parallel pair and a +// shared middle, a costlier bypass, and a second destination. +std::shared_ptr graph() { + return std::make_shared( + make_graph(7, {{0, 2, 6, 1}, {0, 3, 4, 1}, {1, 3, 5, 1}, {1, 4, 3, 1}, {2, 5, 3, 1}, {2, 5, 2, 1}, + {3, 5, 4, 1}, {4, 6, 9, 1}, {3, 6, 2, 2}, {0, 6, 3, 4}, {2, 4, 2, 1}})); +} + +struct Mode { + std::string name; + FlowPolicyConfig cfg; + std::vector sources; + std::vector destinations; +}; + +std::vector modes(const Graph& g) { + std::vector out; + const auto det = sel(EdgeTieBreak::Deterministic, EdgePriority::Capacity); + const auto res = sel(EdgeTieBreak::Deterministic, EdgePriority::Residual); + const auto tunnel = sel(EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual, false, false); + const std::vector S = {0, 1}; + const std::vector D = {5, 6}; + out.push_back({"costonly maxflow", {CostOnly {}, FlowPlacement::MaxFlow, Admission::Lossless, det}, S, D}); + out.push_back({"costonly eb", {CostOnly {}, FlowPlacement::EqualBalanced, Admission::Lossless, det}, S, D}); + out.push_back({"costonly weighted residual", {CostOnly {}, FlowPlacement::Weighted, Admission::Lossless, res}, S, D}); + out.push_back({"costonly lossy", {CostOnly {}, FlowPlacement::EqualBalanced, Admission::Lossy, det}, S, D}); + out.push_back({"costonly single path collapse", + {CostOnly {}, FlowPlacement::EqualBalanced, Admission::Lossless, tunnel}, S, D}); + CapacityAware grow; + grow.flow_count = 1; + grow.max_flow_count = 4; + out.push_back({"capacityaware eb growth", {grow, FlowPlacement::EqualBalanced, Admission::Lossless, tunnel}, S, D}); + CapacityAware reopt; + reopt.flow_count = 2; + reopt.reoptimize = true; + reopt.max_path_cost = Stretch {2.0}; + out.push_back({"capacityaware maxflow reopt", {reopt, FlowPlacement::MaxFlow, Admission::Lossless, det}, S, D}); + CapacityAware dim; + dim.flow_count = 1; + dim.diminishing = DiminishingReturns {1, 0.5}; + dim.min_flow_bandwidth = 1; + out.push_back({"capacityaware weighted diminishing", {dim, FlowPlacement::Weighted, Admission::Lossless, res}, S, D}); + out.push_back({"global", {Global {}, FlowPlacement::MaxFlow, Admission::Lossless, det}, S, D}); + Pinned two; + const auto inv = id_of_input(g); + two.bundles = {std::make_shared(detail::PredDAGBuilder::of_edges(g, std::vector {inv[0], inv[4], inv[5]})), + std::make_shared(detail::PredDAGBuilder::of_edges(g, std::vector {inv[1], inv[6]}))}; + out.push_back({"pinned eb cohort", {two, FlowPlacement::EqualBalanced, Admission::Lossless, det}, {0}, {5}}); + out.push_back({"pinned lossy", {two, FlowPlacement::Weighted, Admission::Lossy, det}, {0}, {5}}); + return out; +} + +struct Scenario { + std::vector> before; + std::optional call; + OutputOptions outputs; + std::string name; +}; + +std::vector scenarios() { + return { + {{}, 5, {}, "create"}, + {{3}, 4, {}, "update"}, + {{2}, std::nullopt, {}, "update to +inf"}, + {{}, 7, {true, true, true}, "create with outputs"}, + {{4}, 0, {true, true, true}, "zero with outputs"}, + }; +} + +// A third party holding one unit on the first route's edges. +std::unique_ptr third_party(FlowLedger& fg, const Graph& g) { + const auto inv = id_of_input(g); + Pinned p; + p.bundles = {std::make_shared(detail::PredDAGBuilder::of_edges(g, std::vector {inv[0], inv[4]}))}; + auto t = std::make_unique(fg, std::vector {0}, std::vector {5}, + FlowPolicyConfig {p, FlowPlacement::MaxFlow, Admission::Lossless, + PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity)}); + (void)t->place(1); + return t; +} + +} // namespace + +TEST(PolicyFaults, EveryAllocationOfEveryRoutingModeFailsAtomically) { + const auto g = graph(); + int sweeps = 0; + long injected = 0; + for (const auto& m : modes(*g)) { + for (const auto& sc : scenarios()) { + const std::string where = m.name + " / " + sc.name; + FlowLedger ref(g); + auto ref_third = third_party(ref, *g); + FlowPolicy pref(ref, m.sources, m.destinations, m.cfg); + for (auto v : sc.before) (void)pref.place(v); + const LedgerState initial = ledger_state(ref); + const Epoch initial_epoch = epoch(pref); + const PlacementResult want = pref.place(sc.call, sc.outputs); + const LedgerState after = ledger_state(ref); + const Epoch want_epoch = epoch(pref); + pref.remove(); + const LedgerState removed = ledger_state(ref); + ++sweeps; + for (long at = 0;; ++at) { + const std::string w = where + " allocation " + std::to_string(at); + FlowLedger fg(g); + auto third = third_party(fg, *g); + FlowPolicy p(fg, m.sources, m.destinations, m.cfg); + for (auto v : sc.before) (void)p.place(v); + expect_same(ledger_state(fg), initial, w + " (setup)"); + PlacementResult got; + if (!fails_at(at, [&] { got = p.place(sc.call, sc.outputs); })) { + expect_same(got, want, w + " (no failure)"); + expect_same(ledger_state(fg), after, w + " (no failure)"); + break; + } + ++injected; + expect_same(ledger_state(fg), initial, w + " (failed call)"); + expect_same(epoch(p), initial_epoch, w + " (failed call)"); + expect_balanced(fg, w + " (failed call)"); + expect_same(p.place(sc.call, sc.outputs), want, w + " (retry)"); + expect_same(ledger_state(fg), after, w + " (retry)"); + expect_same(epoch(p), want_epoch, w + " (retry)"); + p.remove(); + expect_same(ledger_state(fg), removed, w + " (remove)"); + if (::testing::Test::HasFailure()) return; + } + } + } + EXPECT_EQ(sweeps, 11 * 5); + EXPECT_GT(injected, sweeps * 3); // not vacuous +} + +// The calls that place nothing publish only their outputs and, for a positive request, the +// epoch's request: a zero request, and a positive or +inf request when no source reaches a +// destination (an empty roster; the endpoints swapped: 5 and 6 have no out-edges). A failure +// changes neither the ledger nor the epoch state. +TEST(PolicyFaults, ZeroAndEmptyRosterCallsFailAtomically) { + const auto g = graph(); + const OutputOptions all {true, true, true}; + long injected = 0; + struct Case { + bool swap; + std::optional volume; + std::string name; + }; + const std::vector cases = {{false, 0, "zero"}, {true, 4, "empty roster"}, {true, std::nullopt, "empty roster +inf"}}; + for (const auto& m : modes(*g)) { + if (std::holds_alternative(m.cfg.routing)) continue; // bundles bind their endpoints + for (const auto& c : cases) { + const auto& s = c.swap ? m.destinations : m.sources; + const auto& d = c.swap ? m.sources : m.destinations; + FlowLedger ref(g); + auto ref_third = third_party(ref, *g); + FlowPolicy pref(ref, s, d, m.cfg); + const PlacementResult want = pref.place(c.volume, all); + const Epoch want_epoch = epoch(pref); + if (c.swap) EXPECT_TRUE(detail::PolicyAccess::engine(pref).roster.empty()) << m.name; + for (long at = 0;; ++at) { + const std::string w = m.name + " / " + c.name + " allocation " + std::to_string(at); + FlowLedger fg(g); + auto third = third_party(fg, *g); + FlowPolicy p(fg, s, d, m.cfg); + const LedgerState before = ledger_state(fg); + const Epoch before_epoch = epoch(p); + PlacementResult got; + if (!fails_at(at, [&] { got = p.place(c.volume, all); })) { + expect_same(got, want, w + " (no failure)"); + break; + } + ++injected; + expect_same(ledger_state(fg), before, w + " (failed call)"); + expect_same(epoch(p), before_epoch, w + " (failed call)"); + expect_same(p.place(c.volume, all), want, w + " (retry)"); + expect_same(epoch(p), want_epoch, w + " (retry)"); + if (::testing::Test::HasFailure()) return; + } + } + } + EXPECT_GT(injected, 27); // every mode and case fails at least once +} + +TEST(PolicyFaults, ConstructionFailsWithoutBindingAndTheCacheStaysConsistent) { + const auto g = graph(); + for (const auto& m : modes(*g)) { + for (const bool with_cache : {false, true}) { + for (long at = 0;; ++at) { + FlowLedger fg(g); + DagCache cache(*g); + const LedgerState before = ledger_state(fg); + std::optional p; + if (!fails_at(at, [&] { p.emplace(fg, m.sources, m.destinations, m.cfg, nullptr, with_cache ? &cache : nullptr); })) { + EXPECT_EQ(LedgerAccess::bound_owners(fg).size(), 1U); + break; + } + const std::string w = m.name + (with_cache ? " cached" : "") + " construction allocation " + std::to_string(at); + EXPECT_FALSE(p.has_value()) << w; + expect_same(ledger_state(fg), before, w); + // The cache holds whole entries only: its statistics agree with a fresh lookup. + const auto s = cache.stats(); + EXPECT_EQ(s.live_values, s.entries) << w; + EXPECT_LE(s.entries, 1) << w; + // A clean construction works and places like the reference. + p.emplace(fg, m.sources, m.destinations, m.cfg, nullptr, with_cache ? &cache : nullptr); + EXPECT_GE(p->place(2).placed, 0) << w; + expect_balanced(fg, w); + } + } + } +} + +TEST(PolicyFaults, DagCacheInsertionIsAllOrNothing) { + const auto g = graph(); + const std::vector roots = {5, 6}; + for (long at = 0;; ++at) { + DagCache cache(*g); + (void)cache.lookup(*g, std::vector {5}, Direction::Reverse, nullptr); // one entry before + const auto before = cache.stats(); + std::shared_ptr got; + if (!fails_at(at, [&] { got = cache.lookup(*g, roots, Direction::Reverse, nullptr); })) { + EXPECT_EQ(cache.stats().entries, 2); + break; + } + const auto s = cache.stats(); + const std::string w = "lookup allocation " + std::to_string(at); + EXPECT_EQ(s.entries, before.entries) << w; + EXPECT_EQ(s.bytes, before.bytes) << w; + EXPECT_EQ(s.live_values, before.live_values) << w; + EXPECT_EQ(s.hits, before.hits) << w; + EXPECT_LE(s.misses, before.misses + 1) << w; // a failed search counts its miss + const auto v = cache.lookup(*g, roots, Direction::Reverse, nullptr); // a clean retry inserts + EXPECT_EQ(cache.stats().entries, 2) << w; + EXPECT_TRUE(*v == detail::static_support(*g, roots, Direction::Reverse, nullptr)) << w; + } +} + +namespace { + +// The evaluators under a failure on `workers` threads: anywhere in the process (every `step`-th +// allocation: the evaluators allocate a lot) or, with several workers, in the worker threads only +// (their setup and their tasks; every third worker allocation, from the first). The failure +// propagates as std::bad_alloc after every worker has joined, never terminating the process +// (review-1c F1: a worker's cache allocation escaped its thread), and a clean retry gives the +// reference. Returns the number of injected failures. +long sweep_evaluator(const std::string& name, std::int32_t workers, bool in_workers, long step, + const std::function& call, const std::function& retry) { + long failures = 0; + for (long at = 0;; at += in_workers ? 3 : step) { + const auto fn = [&] { call(workers); }; + if (!(in_workers ? fails_in_workers_at(at, fn) : fails_at(at, fn))) break; + ++failures; + retry(workers); + if (::testing::Test::HasFailure()) { + ADD_FAILURE() << name << " workers " << workers << (in_workers ? " (worker threads)" : "") << " allocation " << at; + break; + } + } + return failures; +} + +void sweep_batch(const Mode& m, const Graph& g, std::span workers_set) { + const std::vector, std::vector>> pairs = { + {m.sources, m.destinations}, {{1}, {6}}, {{0}, {5}}}; + const auto want = batch_capacity(g, pairs, CapacityOptions {m.cfg}, {}, 1); + for (const std::int32_t workers : workers_set) { + for (const bool in_workers : {false, true}) { + if (in_workers && workers == 1) continue; // one worker runs on the calling thread + const long failures = sweep_evaluator( + m.name + " batch", workers, in_workers, 7, + [&](std::int32_t w) { (void)batch_capacity(g, pairs, CapacityOptions {m.cfg}, {}, w); }, + [&](std::int32_t w) { + const auto b = batch_capacity(g, pairs, CapacityOptions {m.cfg}, {}, w); + ASSERT_EQ(b.size(), want.size()); + for (std::size_t i = 0; i < b.size(); ++i) expect_same(b[i], want[i], m.name + " batch retry"); + }); + EXPECT_GT(failures, 2) << m.name << " batch workers " << workers; // not vacuous + } + } +} + +void sweep_sensitivity(const Mode& m, const Graph& g, std::span workers_set) { + const SensitivityOptions opts {m.cfg}; + const auto want = sensitivity(g, m.sources, m.destinations, opts, 1); + for (const std::int32_t workers : workers_set) { + for (const bool in_workers : {false, true}) { + if (in_workers && workers == 1) continue; + const long failures = sweep_evaluator( + m.name + " sensitivity", workers, in_workers, 11, + [&](std::int32_t w) { (void)sensitivity(g, m.sources, m.destinations, opts, w); }, + [&](std::int32_t w) { + const auto s = sensitivity(g, m.sources, m.destinations, opts, w); + ASSERT_EQ(s.entries.size(), want.entries.size()); + for (std::size_t i = 0; i < s.entries.size(); ++i) { + EXPECT_EQ(s.entries[i].edge, want.entries[i].edge) << m.name; + EXPECT_EQ(s.entries[i].loss, want.entries[i].loss) << m.name; + } + }); + EXPECT_GT(failures, 2) << m.name << " sensitivity workers " << workers; + } + } +} + +} // namespace + +TEST(PolicyFaults, EvaluatorsLeaveNoPoisonedScratch) { + const auto g = graph(); + const std::int32_t one[] = {1}; + for (const auto& m : modes(*g)) { + const CapacityOptions copts {m.cfg, nullptr, nullptr, OutputOptions {true, true, true}}; + const auto want = capacity(*g, m.sources, m.destinations, copts); + for (long at = 0;; ++at) { + PlacementResult got; + if (!fails_at(at, [&] { got = capacity(*g, m.sources, m.destinations, copts); })) { + expect_same(got, want, m.name + " capacity (no failure)"); + break; + } + expect_same(capacity(*g, m.sources, m.destinations, copts), want, m.name + " capacity retry " + std::to_string(at)); + } + if (std::holds_alternative(m.cfg.routing)) continue; + sweep_batch(m, *g, one); + sweep_sensitivity(m, *g, one); + if (::testing::Test::HasFailure()) return; + } +} + +TEST(PolicyFaults, BatchWorkersFailWithoutTerminating) { + const auto g = graph(); + const std::int32_t several[] = {2, 4}; + for (const auto& m : modes(*g)) { + if (std::holds_alternative(m.cfg.routing)) continue; + sweep_batch(m, *g, several); + if (::testing::Test::HasFailure()) return; + } +} + +TEST(PolicyFaults, SensitivityWorkersFailWithoutTerminating) { + const auto g = graph(); + const std::int32_t several[] = {2, 4}; + for (const auto& m : modes(*g)) { + if (std::holds_alternative(m.cfg.routing)) continue; + sweep_sensitivity(m, *g, several); + if (::testing::Test::HasFailure()) return; + } +} + +TEST(PolicyFaults, RemovalAndReleaseNeverAllocate) { + const auto g = graph(); + for (const auto& m : modes(*g)) { + FlowLedger fg(g); + auto p = std::make_unique(fg, m.sources, m.destinations, m.cfg); + (void)p->place(5); + EXPECT_FALSE(fails_at(0, [&] { p->remove(); })) << m.name; + EXPECT_TRUE(fg.records().empty()) << m.name; + (void)p->place(3); + const auto held = fg.allocated(); + EXPECT_FALSE(fails_at(0, [&] { p.reset(); })) << m.name; + EXPECT_EQ(fg.allocated(), held) << m.name; + EXPECT_TRUE(LedgerAccess::bound_owners(fg).empty()) << m.name; + } +} + +// The working-residual overlay and the per-source scratch grow with the graph: every position +// fails on a graph larger than any before, so the failure lands while they grow; the failed call +// changes nothing and a clean retry on the same graph works (ASan reports any access outside a +// partly grown array). +TEST(PolicyFaults, ScratchGrowthFailureLeavesNothingHalfGrown) { + int failures = 0; + std::int32_t n = 400; + for (long at = 0;; ++at, n += 300) { + std::vector chain; + for (NodeId u = 0; u + 1 < n; ++u) chain.push_back({u, u + 1, 5, 1}); + auto g = std::make_shared(make_graph(n, chain)); + FlowLedger fg(g); + FlowPolicy p(fg, std::vector {0}, std::vector {n - 1}, + FlowPolicyConfig {CostOnly {}, FlowPlacement::EqualBalanced, Admission::Lossless, + PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity)}); + const LedgerState before = ledger_state(fg); + PlacementResult got; + const bool failed = fails_at(at, [&] { got = p.place(2, OutputOptions {true, true, true}); }); + if (failed) { + ++failures; + expect_same(ledger_state(fg), before, "growth allocation " + std::to_string(at)); + got = p.place(2, OutputOptions {true, true, true}); + } + EXPECT_EQ(got.placed, 2) << "growth allocation " << at; + expect_balanced(fg, "growth allocation " + std::to_string(at)); + if (!failed) break; + } + EXPECT_GT(failures, 4); +} diff --git a/tests/cpp/flow_policy_fixture_tests.cpp b/tests/cpp/flow_policy_fixture_tests.cpp new file mode 100644 index 0000000..f0806b1 --- /dev/null +++ b/tests/cpp/flow_policy_fixture_tests.cpp @@ -0,0 +1,891 @@ +// Prototype 2's 59 fixtures replayed through FlowPolicy (APPROACH 8, 9). +// +// tests/data/policy_fixtures.json was exported from the reference, prototype 2 of the design tree +// (its "source" names it; the design tree is untracked by design; its maintained exporter is +// equivalence/export_policy_fixtures.py there): every ledger, policy and call +// of every fixture in order, with the prototype's values after each operation. The replay checks +// every one of them on the engine: each call's result (placed, remaining, termination, local +// outcomes, flags, drops, the cost curve, the requested outputs), its trace (prototype 2's keys), +// the ledger after it (allocations, residuals, every record field, next FlowId, owners) and the +// policy state (roster, requested, +inf, placed demand, usable bundles, per source the flows' +// ids, routes, T, deliveries, allocations, members and weights). An operation the prototype +// rejects must throw here too (the messages differ). Normalised on both sides: the T_max probe +// counts, the flow core's operation counters and ledger uids. The replay needs the private hooks +// (the trace and the creation cap; NETGRAPH_CORE_PRIVATE_HOOKS) and is skipped without them. +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/detail/ledger_access.hpp" +#include "netgraph/core/detail/policy_engine.hpp" +#include "netgraph/core/detail/pred_dag_builder.hpp" +#include "netgraph/core/detail/private_hooks.hpp" +#include "netgraph/core/detail/trace.hpp" +#include "netgraph/core/flow_ledger.hpp" +#include "netgraph/core/flow_policy.hpp" +#include "netgraph/core/graph.hpp" +#include "test_utils.hpp" + +#ifndef NETGRAPH_CORE_TEST_DATA +#error "NETGRAPH_CORE_TEST_DATA (the tests/data directory) must be defined by the build" +#endif + +#if NETGRAPH_CORE_PRIVATE_HOOKS + +using namespace netgraph::core; +using detail::LedgerAccess; + +namespace { + +// ---------------------------------------------------------------- a small JSON value + +struct J { + enum class K { Null, Bool, Int, Dbl, Str, Arr, Obj }; + K k {K::Null}; + bool b {false}; + __int128 i {0}; + double d {0.0}; + std::string s {}; + std::vector a {}; + std::vector> o {}; + + static J null() { return {}; } + static J boolean(bool v) { + J j; + j.k = K::Bool; + j.b = v; + return j; + } + static J integer(__int128 v) { + J j; + j.k = K::Int; + j.i = v; + return j; + } + static J str(std::string v) { + J j; + j.k = K::Str; + j.s = std::move(v); + return j; + } + static J arr() { + J j; + j.k = K::Arr; + return j; + } + static J obj() { + J j; + j.k = K::Obj; + return j; + } + J& set(const std::string& key, J v) { + k = K::Obj; + o.emplace_back(key, std::move(v)); + return o.back().second; + } + J& push(J v) { + k = K::Arr; + a.push_back(std::move(v)); + return a.back(); + } + [[nodiscard]] const J* get(const std::string& key) const { + for (const auto& [kk, v] : o) { + if (kk == key) return &v; + } + return nullptr; + } + [[nodiscard]] const J& at(const std::string& key) const { + const J* p = get(key); + if (p == nullptr) throw std::runtime_error("missing key " + key); + return *p; + } + [[nodiscard]] bool is_null() const { return k == K::Null; } + [[nodiscard]] std::int64_t int64() const { + if (k != K::Int) throw std::runtime_error("not an integer"); + return static_cast(i); + } +}; + +std::string int128_text(__int128 v) { + if (v == 0) return "0"; + const bool neg = v < 0; + unsigned __int128 u = neg ? static_cast(-(v + 1)) + 1 : static_cast(v); + std::string out; + while (u != 0) { + out.push_back(static_cast('0' + static_cast(u % 10))); + u /= 10; + } + if (neg) out.push_back('-'); + return {out.rbegin(), out.rend()}; +} + +std::string dump(const J& j) { + switch (j.k) { + case J::K::Null: return "null"; + case J::K::Bool: return j.b ? "true" : "false"; + case J::K::Int: return int128_text(j.i); + case J::K::Dbl: { + std::ostringstream os; + os << j.d; + return os.str(); + } + case J::K::Str: return "\"" + j.s + "\""; + case J::K::Arr: { + std::string out = "["; + for (std::size_t n = 0; n < j.a.size(); ++n) out += (n ? "," : "") + dump(j.a[n]); + return out + "]"; + } + case J::K::Obj: { + std::string out = "{"; + for (std::size_t n = 0; n < j.o.size(); ++n) out += (n ? "," : "") + ("\"" + j.o[n].first + "\":") + dump(j.o[n].second); + return out + "}"; + } + } + return "?"; +} + +class Parser { +public: + explicit Parser(const std::string& text) : t_(text) {} + J parse() { + J v = value(); + ws(); + if (p_ != t_.size()) fail("trailing data"); + return v; + } + +private: + [[noreturn]] void fail(const std::string& why) const { + throw std::runtime_error("JSON: " + why + " at offset " + std::to_string(p_)); + } + void ws() { + while (p_ < t_.size() && (t_[p_] == ' ' || t_[p_] == '\n' || t_[p_] == '\r' || t_[p_] == '\t')) ++p_; + } + bool lit(const char* word) { + const std::string w(word); + if (t_.compare(p_, w.size(), w) == 0) { + p_ += w.size(); + return true; + } + return false; + } + std::string string() { + if (t_[p_] != '"') fail("expected a string"); + ++p_; + std::string out; + while (p_ < t_.size() && t_[p_] != '"') { + char c = t_[p_++]; + if (c == '\\') { + const char e = t_[p_++]; + switch (e) { + case 'n': c = '\n'; break; + case 't': c = '\t'; break; + case 'r': c = '\r'; break; + case 'u': { + const auto code = std::strtol(t_.substr(p_, 4).c_str(), nullptr, 16); + p_ += 4; + if (code > 0x7f) fail("non-ASCII escape"); + c = static_cast(code); + break; + } + default: c = e; + } + } + out.push_back(c); + } + if (p_ >= t_.size()) fail("unterminated string"); + ++p_; + return out; + } + J value() { + ws(); + if (p_ >= t_.size()) fail("unexpected end"); + const char c = t_[p_]; + if (c == '{') { + ++p_; + J j = J::obj(); + ws(); + if (t_[p_] == '}') { + ++p_; + return j; + } + while (true) { + ws(); + std::string key = string(); + ws(); + if (t_[p_++] != ':') fail("expected ':'"); + j.o.emplace_back(std::move(key), value()); + ws(); + if (t_[p_] == ',') { + ++p_; + continue; + } + if (t_[p_] == '}') { + ++p_; + return j; + } + fail("expected ',' or '}'"); + } + } + if (c == '[') { + ++p_; + J j = J::arr(); + ws(); + if (t_[p_] == ']') { + ++p_; + return j; + } + while (true) { + j.a.push_back(value()); + ws(); + if (t_[p_] == ',') { + ++p_; + continue; + } + if (t_[p_] == ']') { + ++p_; + return j; + } + fail("expected ',' or ']'"); + } + } + if (c == '"') return J::str(string()); + if (lit("null")) return J::null(); + if (lit("true")) return J::boolean(true); + if (lit("false")) return J::boolean(false); + // A number: an integer of any size below 2^127, or a double. + const std::size_t start = p_; + if (t_[p_] == '-') ++p_; + while (p_ < t_.size() && std::isdigit(static_cast(t_[p_]))) ++p_; + bool real = false; + while (p_ < t_.size() && (t_[p_] == '.' || t_[p_] == 'e' || t_[p_] == 'E' || t_[p_] == '+' || t_[p_] == '-' || + std::isdigit(static_cast(t_[p_])))) { + real = true; + ++p_; + } + const std::string num = t_.substr(start, p_ - start); + if (num.empty() || num == "-") fail("bad value"); + if (real) { + J j; + j.k = J::K::Dbl; + j.d = std::strtod(num.c_str(), nullptr); + return j; + } + __int128 v = 0; + const bool neg = num[0] == '-'; + for (std::size_t n = neg ? 1 : 0; n < num.size(); ++n) v = v * 10 + (num[n] - '0'); + return J::integer(neg ? -v : v); + } + + const std::string& t_; + std::size_t p_ {0}; +}; + +const J& data() { + static const J d = [] { + const std::string path = std::string(NETGRAPH_CORE_TEST_DATA) + "/policy_fixtures.json"; + std::ifstream in(path); + if (!in) throw std::runtime_error("cannot open " + path); + std::stringstream ss; + ss << in.rdbuf(); + const std::string text = ss.str(); + return Parser(text).parse(); + }(); + return d; +} + +// Structural equality; objects by key set, arrays in order. `why` names the first difference. +bool same(const J& a, const J& b, const std::string& path, std::string& why) { + if (a.k != b.k) { + why = path + ": " + dump(a) + " (expected) != " + dump(b) + " (engine)"; + return false; + } + switch (a.k) { + case J::K::Null: return true; + case J::K::Bool: + if (a.b == b.b) return true; + break; + case J::K::Int: + if (a.i == b.i) return true; + break; + case J::K::Dbl: + if (a.d == b.d) return true; + break; + case J::K::Str: + if (a.s == b.s) return true; + break; + case J::K::Arr: + if (a.a.size() != b.a.size()) { + why = path + ": length " + std::to_string(a.a.size()) + " (expected) != " + std::to_string(b.a.size()) + + " (engine): " + dump(a) + " vs " + dump(b); + return false; + } + for (std::size_t n = 0; n < a.a.size(); ++n) { + if (!same(a.a[n], b.a[n], path + "[" + std::to_string(n) + "]", why)) return false; + } + return true; + case J::K::Obj: + for (const auto& [key, v] : a.o) { + const J* w = b.get(key); + if (w == nullptr) { + why = path + "." + key + ": missing in the engine's value"; + return false; + } + if (!same(v, *w, path + "." + key, why)) return false; + } + for (const auto& [key, v] : b.o) { + if (a.get(key) == nullptr) { + why = path + "." + key + ": only in the engine's value (" + dump(v) + ")"; + return false; + } + } + return true; + } + why = path + ": " + dump(a) + " (expected) != " + dump(b) + " (engine)"; + return false; +} + +// ---------------------------------------------------------------- the engine as JSON values + +template +J ints(const T& v) { + J j = J::arr(); + for (const auto& x : v) j.push(J::integer(static_cast<__int128>(x))); + return j; +} + +template +J pairs(const std::vector>& v) { + J j = J::arr(); + for (const auto& [x, y] : v) { + J p = J::arr(); + p.push(J::integer(static_cast<__int128>(x))); + p.push(J::integer(static_cast<__int128>(y))); + j.push(std::move(p)); + } + return j; +} + +__int128 wide(const Counter128& c) { + return (static_cast<__int128>(c.hi) << 64) | static_cast<__int128>(c.lo); +} + +const char* placement_name(FlowPlacement p) { + switch (p) { + case FlowPlacement::MaxFlow: return "MaxFlow"; + case FlowPlacement::Weighted: return "Weighted"; + case FlowPlacement::EqualBalanced: return "EqualBalanced"; + } + return "?"; +} + +const char* outcome_name(Termination t) { + switch (t) { + case Termination::Complete: return "complete"; + case Termination::LimitFlows: return "limit_flows"; + case Termination::LimitDiminishing: return "limit_diminishing"; + case Termination::NoRoute: return "no_route"; + case Termination::Exhausted: return "exhausted"; + } + return "?"; +} + +J result_json(const PlacementResult& r) { + J j = J::obj(); + j.set("placed", J::integer(r.placed)); + j.set("remaining", r.remaining.has_value() ? J::integer(*r.remaining) : J::str("inf")); + j.set("termination", J::str(outcome_name(r.termination))); + j.set("routes_changed", J::boolean(r.routes_changed)); + j.set("weights_changed", J::boolean(r.weights_changed)); + j.set("drops", pairs(r.drops)); + J lo = J::arr(); + for (const auto& [s, o] : r.local_outcomes) { + J p = J::arr(); + p.push(J::integer(s)); + p.push(J::str(outcome_name(o))); + lo.push(std::move(p)); + } + j.set("local_outcomes", std::move(lo)); + if (r.cost_distribution.has_value()) j.set("cost_distribution", pairs(*r.cost_distribution)); + if (r.edge_flows.has_value()) j.set("edge_flows", ints(*r.edge_flows)); + if (r.residual.has_value()) j.set("residual", ints(*r.residual)); + if (r.bottleneck.has_value()) j.set("bottleneck", ints(*r.bottleneck)); + if (r.min_cut.has_value()) j.set("min_cut", ints(*r.min_cut)); + return j; +} + +J ledger_json(const FlowLedger& fg) { + J j = J::obj(); + j.set("allocated", ints(fg.allocated())); + j.set("residual", ints(fg.residual())); + J recs = J::arr(); + for (const auto& r : fg.records()) { + J x = J::obj(); + x.set("flow_id", J::integer(r.flow_id.second)); + x.set("owner", J::integer(r.owner)); + x.set("sources", ints(r.sources)); + x.set("destinations", ints(r.destinations)); + x.set("placement", J::str(placement_name(r.placement))); + x.set("admission", J::str(r.admission == Admission::Lossy ? "Lossy" : "Lossless")); + x.set("bundle", ints(detail::PredDAGBuilder::ascending(*r.bundle))); + x.set("route_cost", r.route_cost == kUnreachable ? J::null() : J::integer(r.route_cost)); + x.set("offered", r.offered.has_value() ? J::integer(wide(*r.offered)) : J::null()); + x.set("delivered", J::integer(r.delivered)); + x.set("edges", pairs(r.edges)); + J drops = J::arr(); + for (const auto& [e, c] : r.drops) { + J p = J::arr(); + p.push(J::integer(e)); + p.push(J::integer(wide(c))); + drops.push(std::move(p)); + } + x.set("drops", std::move(drops)); + recs.push(std::move(x)); + } + j.set("records", std::move(recs)); + j.set("next_flow_id", J::integer(LedgerAccess::next_flow_id(fg))); + j.set("bound", ints(LedgerAccess::bound_owners(fg))); + j.set("orphans", ints(LedgerAccess::orphan_owners(fg))); + return j; +} + +J policy_json(const FlowPolicy& policy) { + const auto& pe = detail::PolicyAccess::engine(policy); + const bool lossy = pe.config.admission == Admission::Lossy; + J j = J::obj(); + j.set("roster", ints(pe.roster)); + j.set("requested", J::integer(wide(pe.requested))); + j.set("infinite", J::boolean(pe.infinite)); + j.set("placed_demand", J::integer(policy.placed_demand())); + j.set("usable", J::integer(policy.usable_bundles())); + J cohorts = J::arr(); + for (std::size_t i = 0; i < pe.roster.size(); ++i) { + if (pe.cohorts[i].empty()) continue; + J flows = J::arr(); + for (const auto& s : pe.cohorts[i]) { + const FlowRecord& rec = LedgerAccess::live(pe.fg, s.id); + std::vector route; + std::vector members; + if (s.plan) { + route = s.plan->route; + members = s.plan->members; + } else { + const auto e = detail::PredDAGBuilder::ascending(*s.bundle); + route.assign(e.begin(), e.end()); + members = route; + } + J f = J::obj(); + f.set("id", J::integer(s.id)); + f.set("route", ints(route)); + f.set("T", lossy ? J::null() : J::integer(rec.delivered)); + f.set("delivered", J::integer(rec.delivered)); + f.set("alloc", pairs(rec.edges)); + f.set("members", ints(members)); + if (s.plan && s.plan->placement == FlowPlacement::Weighted) { + J w = J::arr(); + for (std::size_t m = 0; m < s.plan->members.size(); ++m) { + J p = J::arr(); + p.push(J::integer(s.plan->members[m])); + p.push(J::integer(s.plan->weights[m])); + w.push(std::move(p)); + } + f.set("weights", std::move(w)); + } else { + f.set("weights", J::null()); + } + flows.push(std::move(f)); + } + J entry = J::arr(); + entry.push(J::integer(pe.roster[i])); + entry.push(std::move(flows)); + cohorts.push(std::move(entry)); + } + j.set("cohorts", std::move(cohorts)); + return j; +} + +// The trace with the normalisation of the exported data: probe counts, counters and ledger uids +// dropped, FlowIds as ids. +J trace_json(const detail::Trace& t) { + using K = detail::Trace::Kind; + switch (t.kind) { + case K::Null: return J::null(); + case K::Bool: return J::boolean(t.b); + case K::Int: return J::integer(t.i); + case K::Str: return J::str(t.s); + case K::List: { + J j = J::arr(); + for (const auto& x : t.items) j.push(trace_json(x)); + return j; + } + case K::Map: { + J j = J::obj(); + for (const auto& [key, x] : t.fields) { + if (key == "evals" || key == "ledger_uid" || key == "counters" || key == "evaluations") continue; + if ((key == "flow" || key == "flow_id") && x.kind == K::List && x.items.size() == 2) { + j.set(key, J::integer(x.items[1].i)); + continue; + } + j.set(key, trace_json(x)); + } + return j; + } + } + return J::null(); +} + +// ---------------------------------------------------------------- the replay + +std::vector int32s(const J& j) { + std::vector out; + for (const auto& x : j.a) out.push_back(static_cast(x.int64())); + return out; +} + +std::vector amounts(const J& j) { + std::vector out; + for (const auto& x : j.a) out.push_back(x.int64()); + return out; +} + +std::vector endpoints(const J& j) { + if (j.k == J::K::Int) return {static_cast(j.int64())}; + return int32s(j); +} + +std::shared_ptr dag_of(const Graph& g, std::vector edges) { + std::sort(edges.begin(), edges.end()); + std::vector> per(static_cast(g.num_nodes())); + for (EdgeId e : edges) per[static_cast(g.edge_dst()[static_cast(e)])].push_back(e); + std::vector offsets {0}; + std::vector parents; + std::vector via; + for (const auto& entries : per) { + for (EdgeId e : entries) { + parents.push_back(g.edge_src()[static_cast(e)]); + via.push_back(e); + } + offsets.push_back(static_cast(via.size())); + } + return std::make_shared(PredDAG::from_csr(g, offsets, parents, via)); +} + +FlowPolicyConfig config_of(const Graph& g, const J& c) { + const J& r = c.at("routing"); + const std::string kind = r.at("kind").s; + Routing routing = CostOnly {}; + if (kind == "Global") { + routing = Global {}; + } else if (kind == "Pinned") { + Pinned p; + for (const auto& b : r.at("bundles").a) p.bundles.push_back(dag_of(g, int32s(b))); + routing = std::move(p); + } else if (kind == "CapacityAware") { + CapacityAware ca; + ca.flow_count = static_cast(r.at("flow_count").int64()); + if (!r.at("max_flow_count").is_null()) ca.max_flow_count = static_cast(r.at("max_flow_count").int64()); + if (!r.at("max_path_cost").is_null()) { + const J& mc = r.at("max_path_cost"); + if (mc.a[0].s == "Absolute") { + ca.max_path_cost = Absolute {mc.a[1].int64()}; + } else { + ca.max_path_cost = Stretch {mc.a[1].k == J::K::Dbl ? mc.a[1].d : static_cast(mc.a[1].int64())}; + } + } + ca.min_flow_bandwidth = r.at("min_flow_bandwidth").int64(); + ca.reoptimize = r.at("reoptimize").b; + if (!r.at("diminishing").is_null()) { + const J& d = r.at("diminishing"); + ca.diminishing = DiminishingReturns {static_cast(d.a[0].int64()), + d.a[1].k == J::K::Dbl ? d.a[1].d : static_cast(d.a[1].int64())}; + } + routing = ca; + } + const std::string placement = c.at("placement").s; + const FlowPlacement fp = placement == "MaxFlow" ? FlowPlacement::MaxFlow + : placement == "Weighted" ? FlowPlacement::Weighted + : FlowPlacement::EqualBalanced; + const Admission adm = c.at("admission").s == "Lossy" ? Admission::Lossy : Admission::Lossless; + const J& sel = c.at("selection"); + const EdgeTieBreak tie = + sel.at("tie_break").s == "Deterministic" ? EdgeTieBreak::Deterministic : EdgeTieBreak::PreferHigherPriority; + const std::string pr = sel.at("priority").s; + const EdgePriority prio = pr == "Residual" ? EdgePriority::Residual + : pr == "Capacity" ? EdgePriority::Capacity + : EdgePriority::Headroom; + return FlowPolicyConfig {std::move(routing), fp, adm, + PathSelection(tie, prio, sel.at("multipath").b, sel.at("multi_edge").b)}; +} + +// Restores the creation cap whatever happens. +struct CapGuard { + std::int64_t old {detail::max_flows_per_call().load()}; + ~CapGuard() { detail::max_flows_per_call().store(old); } +}; + +// Replays one fixture; returns the failures (empty when every value matches). +std::vector replay(const J& fx) { + std::vector fails; + const std::string name = fx.at("name").s; + CapGuard guard; + std::vector> graphs; + for (const auto& spec : fx.at("graphs").a) { + std::vector edges; + for (const auto& e : spec.at("edges").a) { + edges.push_back({static_cast(e.a[0].int64()), static_cast(e.a[1].int64()), e.a[2].int64(), + e.a[3].int64()}); + } + const double q = spec.at("q").k == J::K::Dbl ? spec.at("q").d : static_cast(spec.at("q").int64()); + auto g = std::make_shared( + test::make_graph(static_cast(spec.at("n").int64()), edges, q)); + const auto ii = g->input_index(); + if (std::vector(ii.begin(), ii.end()) != int32s(spec.at("input_index"))) { + fails.push_back(name + ": the engine ranks the edges differently (input_index)"); + return fails; + } + graphs.push_back(std::move(g)); + } + // Lifetimes: graphs outlive the ledgers and the Eligibility values, which outlive the policies. + std::deque ledgers; + std::vector ledger_of_id; + std::deque eligs; + std::vector> policies; + std::vector policy_ledger; + std::vector curves; // per policy: the cumulative Global curve of the epoch (F5 normalisation) + auto check = [&](const std::string& where, const char* what, const J& expected, const J& actual) { + std::string why; + if (!same(expected, actual, what, why)) fails.push_back(name + " " + where + ": " + why); + }; + const auto& ops = fx.at("ops").a; + for (std::size_t n = 0; n < ops.size(); ++n) { + const J& op = ops[n]; + const std::string kind = op.at("op").s; + const std::string where = "op " + std::to_string(n) + " (" + kind + ")"; + const bool error = op.at("error").b; + const J* expect = op.get("expect"); + if (kind == "ledger") { + const auto& g = graphs[static_cast(op.at("graph").int64())]; + try { + if (op.at("baseline").is_null()) { + ledgers.emplace_back(g); + } else { + ledgers.emplace_back(g, amounts(op.at("baseline"))); + } + if (error) fails.push_back(name + " " + where + ": accepted, the prototype rejects it"); + ledger_of_id.push_back(&ledgers.back()); + } catch (const std::exception& e) { + if (!error) fails.push_back(name + " " + where + ": throws " + e.what()); + } + continue; + } + if (kind == "copy" || kind == "reset") { + FlowLedger& fg = *ledger_of_id[static_cast(op.at("ledger").int64())]; + bool threw = false; + try { + if (kind == "copy") { + ledgers.emplace_back(fg); + ledger_of_id.push_back(&ledgers.back()); + } else if (op.at("baseline").is_null()) { + fg.reset(); + } else { + fg.reset(amounts(op.at("baseline"))); + } + } catch (const std::exception&) { + threw = true; + } + if (threw != error) { + fails.push_back(name + " " + where + (error ? ": accepted, the prototype rejects it" : ": throws")); + return fails; + } + const FlowLedger& shown = (kind == "copy" && !error) ? ledgers.back() : fg; + check(where, "ledger", expect->at("ledger"), ledger_json(shown)); + continue; + } + if (kind == "policy") { + FlowLedger& fg = *ledger_of_id[static_cast(op.at("ledger").int64())]; + const auto& g = *fg.graph(); + std::unique_ptr pol; + try { + const Eligibility* elig = nullptr; + if (!op.at("elig").is_null()) { + eligs.push_back(g.eligibility(int32s(op.at("elig").at("nodes")), int32s(op.at("elig").at("edges")))); + elig = &eligs.back(); + } + auto cfg = config_of(g, op.at("config")); + pol = std::make_unique(fg, endpoints(op.at("sources")), endpoints(op.at("destinations")), + std::move(cfg), elig); + } catch (const std::exception& e) { + if (!error) fails.push_back(name + " " + where + ": throws " + e.what()); + continue; + } + if (error) { + fails.push_back(name + " " + where + ": accepted, the prototype rejects it"); + return fails; + } + policies.push_back(std::move(pol)); + policy_ledger.push_back(&fg); + curves.push_back(J::arr()); + check(where, "policy", expect->at("policy"), policy_json(*policies.back())); + continue; + } + const auto pi = static_cast(op.at("policy").int64()); + const FlowLedger& fg = *policy_ledger[pi]; + if (kind == "destroy") { + policies[pi] = nullptr; // the destruction law: the records stay as orphans + check(where, "ledger", expect->at("ledger"), ledger_json(fg)); + continue; + } + FlowPolicy& pol = *policies[pi]; + if (kind == "remove") { + pol.remove(); + curves[pi] = J::arr(); + check(where, "ledger", expect->at("ledger"), ledger_json(fg)); + check(where, "policy", expect->at("policy"), policy_json(pol)); + continue; + } + // place + std::optional volume; + if (op.at("volume").k == J::K::Int) volume = op.at("volume").int64(); + if (op.at("volume").k == J::K::Str && op.at("volume").s != "inf") { + fails.push_back(name + " " + where + ": unexpected volume " + dump(op.at("volume"))); + continue; + } + if (op.at("volume").k == J::K::Dbl) { + // A non-integer request: the prototype rejects it, as the bindings do; C++ has no such value. + if (!error) fails.push_back(name + " " + where + ": a non-integer request accepted by the prototype"); + continue; + } + OutputOptions outputs; + if (!op.at("outputs").is_null()) { + const auto& o = op.at("outputs").a; + outputs = OutputOptions {o[0].b, o[1].b, o[2].b}; + } + detail::max_flows_per_call().store(op.at("cap").int64()); + detail::Trace trace; + PlacementResult r; + bool threw = false; + try { + r = detail::place_traced(pol, volume, outputs, trace); + } catch (const std::exception&) { + threw = true; + } + if (threw != error) { + fails.push_back(name + " " + where + (error ? ": accepted, the prototype rejects it" : ": throws")); + return fails; + } + if (error) { + check(where, "ledger", expect->at("ledger"), ledger_json(fg)); + continue; + } + J actual = result_json(r); + const J& expected_result = expect->at("result"); + if (std::holds_alternative(pol.config().routing)) { + // Contract over prototype (1c NOTES P3, review-1c F5): a Global zero call reports the + // policy's cumulative curve (9.1), which prototype 2 omits; it must equal the curve of the + // epoch's last positive call ([] before any), and only then is it set aside. + const J* got = actual.get("cost_distribution"); + if (volume.has_value() && *volume == 0 && expected_result.get("cost_distribution") == nullptr && got != nullptr) { + std::string why; + if (!same(curves[pi], *got, "cost_distribution", why)) { + fails.push_back(name + " " + where + ": the zero call's curve is not the cumulative one: " + why); + } + std::erase_if(actual.o, [](const auto& kv) { return kv.first == "cost_distribution"; }); + } + if (const J* c = expected_result.get("cost_distribution"); c != nullptr) curves[pi] = *c; + } + check(where, "result", expected_result, actual); + check(where, "trace", expect->at("trace"), trace_json(trace)); + check(where, "ledger", expect->at("ledger"), ledger_json(fg)); + check(where, "policy", expect->at("policy"), policy_json(pol)); + } + return fails; +} + +} // namespace + +TEST(PolicyFixtures, TheDataHoldsEveryPrototypeFixture) { + const J& d = data(); + EXPECT_EQ(d.at("format").s, "p2-fixtures/1"); + EXPECT_EQ(d.at("fixtures").a.size(), 59U); +} + +TEST(PolicyFixtures, EveryFixtureReplaysIdentically) { + const J& d = data(); + std::size_t ops = 0; + for (const auto& fx : d.at("fixtures").a) { + ops += fx.at("ops").a.size(); + const auto fails = replay(fx); + for (const auto& f : fails) ADD_FAILURE() << f; + } + EXPECT_GT(ops, 600U); +} + +// The comparison is not vacuous: a corrupted expectation is reported. +TEST(PolicyFixtures, ACorruptedExpectationFails) { + const J& d = data(); + std::size_t corrupted = 0; + std::size_t detected = 0; + for (const auto& fx : d.at("fixtures").a) { + for (std::size_t n = 0; n < fx.at("ops").a.size() && corrupted < 200; ++n) { + const J& op = fx.at("ops").a[n]; + if (op.at("op").s != "place" || op.at("error").b) continue; + for (const char* key : {"result", "trace", "ledger", "policy"}) { + J copy = fx; + J* ops = nullptr; + for (auto& [k, v] : copy.o) { + if (k == "ops") ops = &v; + } + J* exp = nullptr; + for (auto& [k, v] : ops->a[n].o) { + if (k == "expect") exp = &v; + } + for (auto& [k, v] : exp->o) { + if (k != key) continue; + // Corrupt the first integer field of this value. + std::function corrupt = [&](J& x) { + if (x.k == J::K::Int) { + x.i += 1; + return true; + } + for (auto& y : x.a) { + if (corrupt(y)) return true; + } + for (auto& [kk, y] : x.o) { + if (corrupt(y)) return true; + } + return false; + }; + if (!corrupt(v)) continue; + ++corrupted; + if (!replay(copy).empty()) ++detected; + } + } + break; // one placement per fixture + } + } + EXPECT_GT(corrupted, 100U); + EXPECT_EQ(detected, corrupted); +} + +#else + +// The replay reads the trace and sets the creation cap: without the private hooks it is skipped. +TEST(PolicyFixtures, TheDataHoldsEveryPrototypeFixture) { GTEST_SKIP() << "needs the private hooks"; } +TEST(PolicyFixtures, EveryFixtureReplaysIdentically) { GTEST_SKIP() << "needs the private hooks"; } +TEST(PolicyFixtures, ACorruptedExpectationFails) { GTEST_SKIP() << "needs the private hooks"; } + +#endif diff --git a/tests/cpp/flow_policy_tests.cpp b/tests/cpp/flow_policy_tests.cpp new file mode 100644 index 0000000..4bcce0d --- /dev/null +++ b/tests/cpp/flow_policy_tests.cpp @@ -0,0 +1,704 @@ +// The policy engine beyond prototype 2's fixtures (those replay in flow_policy_fixture_tests.cpp): +// configuration validation, the outputs, Global's empty roster, record bundles (sparse storage, +// sharing), the DagCache in building mode and the evaluators (capacity, batch_capacity, +// sensitivity) with workers (APPROACH 8 to 11). +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/dag_cache.hpp" +#include "netgraph/core/detail/kernels.hpp" +#include "netgraph/core/detail/ledger_access.hpp" +#include "netgraph/core/detail/policy_engine.hpp" +#include "netgraph/core/detail/pred_dag_builder.hpp" +#include "netgraph/core/detail/private_hooks.hpp" +#include "netgraph/core/detail/search.hpp" +#include "netgraph/core/evaluators.hpp" +#include "netgraph/core/flow_ledger.hpp" +#include "netgraph/core/flow_policy.hpp" +#include "test_utils.hpp" + +using namespace netgraph::core; +using namespace netgraph::core::test; + +namespace { + +std::shared_ptr shared(Graph g) { + return std::make_shared(std::move(g)); +} + +std::shared_ptr dag_of(const Graph& g, std::vector edges) { + return std::make_shared(detail::PredDAGBuilder::of_edges(g, edges)); +} + +PathSelection sel(EdgeTieBreak t = EdgeTieBreak::Deterministic, EdgePriority p = EdgePriority::Capacity, + bool multipath = true, bool multi_edge = true) { + return PathSelection(t, p, multipath, multi_edge); +} + +FlowPolicyConfig cfg(Routing r, FlowPlacement k, Admission a = Admission::Lossless, PathSelection s = sel()) { + return FlowPolicyConfig {std::move(r), k, a, s}; +} + +FlowPolicyConfig global_cfg() { return cfg(Global {}, FlowPlacement::MaxFlow); } + +std::string error_of(const std::function& fn) { + try { + fn(); + } catch (const std::invalid_argument& e) { + return e.what(); + } + return ""; +} + +bool contains(const std::string& s, const std::string& part) { return s.find(part) != std::string::npos; } + +std::vector edges_of(const PredDAG& dag) { + const auto e = detail::PredDAGBuilder::ascending(dag); + return {e.begin(), e.end()}; +} + +std::vector v(std::initializer_list x) { return x; } + +// Two disjoint routes 0 -> 1 -> 3 (caps a, cost 1) and 0 -> 2 -> 3 (caps b, cost c). B' ids: +// 0 = 0->1, 1 = 0->2, 2 = 1->3, 3 = 2->3. +std::shared_ptr diamond(Amount a, Amount b, Cost c = 1) { + return shared(make_graph(4, {{0, 1, a, 1}, {1, 3, a, 1}, {0, 2, b, c}, {2, 3, b, c}})); +} + +} // namespace + +TEST(FlowPolicyConfig, EveryRejectedCombinationBeforeBinding) { + auto g = diamond(5, 5); + FlowLedger fg(g); + auto rejects = [&](const FlowPolicyConfig& c, const std::string& part, std::vector s = {0}, + std::vector d = {3}) { + const auto msg = error_of([&] { FlowPolicy p(fg, s, d, c); }); + EXPECT_TRUE(contains(msg, part)) << "expected '" << part << "', got '" << msg << "'"; + EXPECT_TRUE(detail::LedgerAccess::bound_owners(fg).empty()); + }; + rejects(cfg(CostOnly {}, FlowPlacement::MaxFlow, Admission::Lossy), "Lossy"); + rejects(cfg(CostOnly {}, FlowPlacement::Weighted, Admission::Lossless, sel(EdgeTieBreak::Deterministic, EdgePriority::Headroom)), + "Headroom"); + rejects(cfg(Global {}, FlowPlacement::EqualBalanced), "Global routing requires placement = MaxFlow"); + rejects(cfg(Global {}, FlowPlacement::EqualBalanced, Admission::Lossy), "Lossy"); + rejects(cfg(CapacityAware {}, FlowPlacement::EqualBalanced, Admission::Lossy), "Lossy"); + CapacityAware ca; + ca.flow_count = 0; + rejects(cfg(ca, FlowPlacement::MaxFlow), "flow_count"); + ca = {}; + ca.flow_count = 3; + ca.max_flow_count = 2; + rejects(cfg(ca, FlowPlacement::MaxFlow), "max_flow_count"); + ca = {}; + ca.min_flow_bandwidth = -1; + rejects(cfg(ca, FlowPlacement::MaxFlow), "min_flow_bandwidth"); + ca = {}; + ca.max_path_cost = Absolute {-1}; + rejects(cfg(ca, FlowPlacement::MaxFlow), "Absolute"); + for (double f : {0.5, std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity()}) { + ca = {}; + ca.max_path_cost = Stretch {f}; + rejects(cfg(ca, FlowPlacement::MaxFlow), "Stretch"); + } + ca = {}; + ca.diminishing = DiminishingReturns {0, 0.5}; + rejects(cfg(ca, FlowPlacement::MaxFlow), "window"); + for (double f : {0.0, 1.0, std::numeric_limits::quiet_NaN()}) { + ca = {}; + ca.diminishing = DiminishingReturns {1, f}; + rejects(cfg(ca, FlowPlacement::MaxFlow), "fraction"); + } + Pinned none; + rejects(cfg(none, FlowPlacement::MaxFlow), "empty bundle list"); + Pinned dup; + dup.bundles = {dag_of(*g, {0, 2}), dag_of(*g, {0, 2})}; + rejects(cfg(dup, FlowPlacement::MaxFlow), "duplicate bundle"); + Pinned malformed; + malformed.bundles = {dag_of(*g, {0, 3})}; + rejects(cfg(malformed, FlowPlacement::MaxFlow), "malformed bundle"); + Pinned ok; + ok.bundles = {dag_of(*g, {0, 2})}; + rejects(cfg(ok, FlowPlacement::MaxFlow), "single endpoints", {0, 1}, {3}); + auto other = diamond(5, 5); + Pinned foreign; + foreign.bundles = {dag_of(*other, {0, 2})}; + rejects(cfg(foreign, FlowPlacement::MaxFlow), "foreign PredDAG"); + rejects(cfg(CostOnly {}, FlowPlacement::MaxFlow), "both endpoint sets", {0, 3}, {3}); + rejects(cfg(CostOnly {}, FlowPlacement::MaxFlow), "empty", {}, {3}); + rejects(cfg(CostOnly {}, FlowPlacement::MaxFlow), "out of node range", {9}, {3}); + const auto el = other->eligibility({}, std::vector {0}); + EXPECT_TRUE(contains(error_of([&] { FlowPolicy p(fg, v({0}), v({3}), cfg(CostOnly {}, FlowPlacement::MaxFlow), &el); }), + "foreign Eligibility")); + DagCache foreign_cache(*other); + EXPECT_TRUE(contains(error_of([&] { + FlowPolicy p(fg, v({0}), v({3}), cfg(CostOnly {}, FlowPlacement::MaxFlow), nullptr, &foreign_cache); + }), + "foreign DagCache")); + EXPECT_TRUE(detail::LedgerAccess::bound_owners(fg).empty()); +} + +// Rule 13 at construction: PathSelection and FlowPolicyConfig reject a value outside its +// enumeration (here 99, reachable by a cast) with std::invalid_argument naming the field, in +// validate_config's order; a selection assigned out of its domain after its own construction is +// rejected by the configuration. Combinations are the consumers' (the next test). +TEST(FlowPolicyConfig, ConstructorsRejectEnumValuesOutsideTheirEnumeration) { + EXPECT_EQ(error_of([] { (void)PathSelection(static_cast(99), EdgePriority::Capacity); }), + "tie_break: 99 is not a value of EdgeTieBreak"); + EXPECT_EQ(error_of([] { (void)PathSelection(EdgeTieBreak::Deterministic, static_cast(99)); }), + "priority: 99 is not a value of EdgePriority"); + EXPECT_EQ(error_of([] { (void)PathSelection(static_cast(98), static_cast(99)); }), + "tie_break: 98 is not a value of EdgeTieBreak"); + const auto s = sel(); + EXPECT_EQ(error_of([&] { + (void)FlowPolicyConfig {CostOnly {}, static_cast(99), Admission::Lossless, s}; + }), + "placement: 99 is not a value of FlowPlacement"); + EXPECT_EQ(error_of([&] { + (void)FlowPolicyConfig {CostOnly {}, FlowPlacement::MaxFlow, static_cast(99), s}; + }), + "admission: 99 is not a value of Admission"); + auto tie = s; + tie.tie_break = static_cast(99); + auto priority = s; + priority.priority = static_cast(99); + EXPECT_EQ(error_of([&] { (void)FlowPolicyConfig {CostOnly {}, FlowPlacement::MaxFlow, Admission::Lossless, tie}; }), + "selection.tie_break: 99 is not a value of EdgeTieBreak"); + EXPECT_EQ(error_of([&] { + (void)FlowPolicyConfig {CostOnly {}, FlowPlacement::MaxFlow, Admission::Lossless, priority}; + }), + "selection.priority: 99 is not a value of EdgePriority"); + EXPECT_EQ(error_of([&] { + (void)FlowPolicyConfig {Global {}, static_cast(97), static_cast(98), tie}; + }), + "placement: 97 is not a value of FlowPlacement"); + EXPECT_EQ(error_of([&] { + (void)FlowPolicyConfig {Global {}, FlowPlacement::MaxFlow, static_cast(98), tie}; + }), + "admission: 98 is not a value of Admission"); + // Every value inside its enumeration constructs, whatever the combination (Global with + // Weighted and Lossy constructs; the policy rejects it). + for (auto t : {EdgeTieBreak::Deterministic, EdgeTieBreak::PreferHigherPriority}) { + for (auto p : {EdgePriority::Residual, EdgePriority::Capacity, EdgePriority::Headroom}) { + EXPECT_NO_THROW((void)PathSelection(t, p, false, false)); + for (auto k : {FlowPlacement::MaxFlow, FlowPlacement::Weighted, FlowPlacement::EqualBalanced}) { + for (auto a : {Admission::Lossless, Admission::Lossy}) { + EXPECT_NO_THROW((void)(FlowPolicyConfig {Global {}, k, a, PathSelection(t, p)})); + } + } + } + } +} + +// Rule 13 at every consuming entry: the fields stay assignable after construction, so a value +// outside its enumeration is a domain error named by its field, before any combination check, +// at every entry that takes the field; it never reaches a record. +TEST(FlowPolicyConfig, EnumValuesOutsideTheirEnumerationAreRejected) { + auto g = diamond(5, 5); + FlowLedger fg(g); + const auto bad_placement = static_cast(99); + const auto bad_admission = static_cast(99); + const auto bad_tie = static_cast(99); + const auto bad_priority = static_cast(99); + const auto bad_direction = static_cast(99); + auto tie_sel = sel(); + tie_sel.tie_break = bad_tie; + auto priority_sel = sel(); + priority_sel.priority = bad_priority; + Pinned pinned; + pinned.bundles = {dag_of(*g, {0, 2})}; + const std::vector modes = {CostOnly {}, CapacityAware {}, Global {}, pinned}; + struct Bad { + std::function make; + const char* field; + }; + // A valid configuration with one field assigned outside its enumeration. + const auto assigned = [](const Routing& r, const std::function& set) { + auto c = cfg(r, FlowPlacement::MaxFlow); + set(c); + return c; + }; + const std::vector bads = { + {[&](const Routing& r) { return assigned(r, [&](FlowPolicyConfig& c) { c.placement = bad_placement; }); }, + "placement: 99 is not a value of FlowPlacement"}, + {[&](const Routing& r) { return assigned(r, [&](FlowPolicyConfig& c) { c.admission = bad_admission; }); }, + "admission: 99 is not a value of Admission"}, + {[&](const Routing& r) { return assigned(r, [&](FlowPolicyConfig& c) { c.selection = tie_sel; }); }, + "selection.tie_break: 99 is not a value of EdgeTieBreak"}, + {[&](const Routing& r) { return assigned(r, [&](FlowPolicyConfig& c) { c.selection = priority_sel; }); }, + "selection.priority: 99 is not a value of EdgePriority"}, + }; + for (const auto& mode : modes) { + for (const auto& b : bads) { + const auto c = b.make(mode); + EXPECT_TRUE(contains(error_of([&] { FlowPolicy p(fg, v({0}), v({3}), c); }), b.field)) << b.field; + EXPECT_TRUE(detail::LedgerAccess::bound_owners(fg).empty()); + EXPECT_TRUE(fg.records().empty()); + CapacityOptions co {c}; + EXPECT_TRUE(contains(error_of([&] { (void)capacity(*g, v({0}), v({3}), co); }), b.field)) << b.field; + EXPECT_TRUE(contains(error_of([&] { (void)sensitivity(*g, v({0}), v({3}), SensitivityOptions {c}, 1); }), b.field)) + << b.field; + if (!std::holds_alternative(mode)) { + const std::vector, std::vector>> pairs = {{v({0}), v({3})}}; + EXPECT_TRUE(contains(error_of([&] { (void)batch_capacity(*g, pairs, co, {}, 1); }), b.field)) << b.field; + } + } + } + // The kernel check, the search and the cache's key. + EXPECT_TRUE(contains(error_of([&] { detail::check_kernel(bad_placement, Admission::Lossless, EdgePriority::Capacity); }), + "placement: 99")); + EXPECT_TRUE(contains(error_of([&] { detail::check_kernel(FlowPlacement::MaxFlow, bad_admission, EdgePriority::Capacity); }), + "admission: 99")); + EXPECT_TRUE(contains(error_of([&] { detail::check_kernel(FlowPlacement::MaxFlow, Admission::Lossless, bad_priority); }), + "priority: 99")); + EXPECT_TRUE(contains( + error_of([&] { (void)detail::spf(*g, v({0}), bad_direction, std::nullopt, sel(), std::nullopt, 1, nullptr); }), + "direction: 99 is not a value of Direction")); + EXPECT_TRUE(contains( + error_of([&] { (void)detail::spf(*g, v({0}), Direction::Forward, std::nullopt, tie_sel, std::nullopt, 1, nullptr); }), + "selection.tie_break: 99")); + EXPECT_TRUE(contains( + error_of([&] { (void)detail::spf(*g, v({0}), Direction::Forward, std::nullopt, priority_sel, std::nullopt, 1, nullptr); }), + "selection.priority: 99")); + DagCache cache(*g); + EXPECT_TRUE(contains(error_of([&] { (void)cache.lookup(*g, v({3}), bad_direction, nullptr); }), "direction: 99")); + const auto st = cache.stats(); + EXPECT_EQ(st.hits + st.misses + st.entries, 0); +} + +TEST(FlowPolicyRequests, ValidationPrecedesTheZeroShortcut) { + auto g = diamond(5, 5); + FlowLedger fg(g); + FlowPolicy p(fg, v({0}), v({3}), cfg(CostOnly {}, FlowPlacement::EqualBalanced)); + EXPECT_TRUE(contains(error_of([&] { (void)p.place(-1); }), "request")); + EXPECT_TRUE(contains(error_of([&] { (void)p.place(kDomainLimit); }), "request")); + EXPECT_EQ(p.place(std::nullopt).placed, 10); + EXPECT_TRUE(contains(error_of([&] { (void)p.place(1); }), "after +inf")); + EXPECT_TRUE(contains(error_of([&] { (void)p.place(0); }), "after +inf")); + p.remove(); + EXPECT_EQ(p.place(1).placed, 1); +} + +TEST(FlowPolicyOutputs, OwnAllocationLedgerResidualBottleneckAndGlobalCut) { + auto g = diamond(5, 3); + FlowLedger fg(g); + // A third party holds one unit of the first route. + Pinned third; + third.bundles = {dag_of(*g, {0, 2})}; + FlowPolicy other(fg, v({0}), v({3}), cfg(third, FlowPlacement::MaxFlow)); + ASSERT_EQ(other.place(1).placed, 1); + FlowPolicy p(fg, v({0}), v({3}), cfg(CostOnly {}, FlowPlacement::EqualBalanced)); + const OutputOptions all {true, true, true}; + auto r = p.place(4, all); + ASSERT_TRUE(r.edge_flows && r.residual && r.bottleneck); + EXPECT_FALSE(r.min_cut.has_value()); // not Global + EXPECT_FALSE(r.cost_distribution.has_value()); + std::vector own(4, 0); + for (const auto& rec : p.records()) { + for (const auto& [e, x] : rec.edges) own[static_cast(e)] += x; + } + EXPECT_EQ(*r.edge_flows, own); + EXPECT_EQ(*r.residual, fg.residual()); + EXPECT_EQ(*r.bottleneck, fg.bottleneck(v({0}))); + // Nothing requested: absent. + r = p.place(1); + EXPECT_FALSE(r.edge_flows || r.residual || r.bottleneck || r.min_cut); + // Global: the curve always, min_cut with bottleneck when the solve is maximal (at +inf; none + // under a cap of 2 below the maximum 8, which leaves a residual path). + FlowLedger fg2(g); + FlowPolicy gl(fg2, v({0}), v({3}), global_cfg()); + r = gl.place(2, OutputOptions {false, false, true}); + ASSERT_TRUE(r.cost_distribution.has_value()); + EXPECT_EQ(*r.cost_distribution, curve({{2, 2}})); + EXPECT_LT(gl.placed_demand(), 8); + EXPECT_FALSE(r.min_cut.has_value()); + r = gl.place(std::nullopt, OutputOptions {false, false, true}); + EXPECT_EQ(r.placed, 6); + ASSERT_TRUE(r.min_cut.has_value()); + EXPECT_EQ(r.min_cut->size(), 2U); +} + +TEST(FlowPolicyGlobal, EmptyRosterReportsAnEmptyCurveAndAnEmptyCut) { + // No source reaches the destination: the maximum flow is 0 and maximal (APPROACH 2.2). + auto g = shared(make_graph(3, {{0, 1, 5, 1}})); + FlowLedger fg(g); + FlowPolicy p(fg, v({0}), v({2}), global_cfg()); + auto r = p.place(std::nullopt, OutputOptions {false, false, true}); + EXPECT_EQ(r.termination, Termination::NoRoute); + ASSERT_TRUE(r.cost_distribution.has_value()); + EXPECT_TRUE(r.cost_distribution->empty()); + ASSERT_TRUE(r.min_cut.has_value()); + EXPECT_TRUE(r.min_cut->empty()); + EXPECT_TRUE(p.records().empty()); + // The zero request is a no-op: no solve, no cut; the cumulative curve is empty. + FlowPolicy q(fg, v({1}), v({2}), global_cfg()); + r = q.place(0, OutputOptions {false, false, true}); + EXPECT_FALSE(r.min_cut.has_value()); + ASSERT_TRUE(r.cost_distribution.has_value()); + EXPECT_TRUE(r.cost_distribution->empty()); +} + +// A Global zero call reports the cumulative curve (9.1: its amounts sum to placed_demand(); +// empty before the first placement and after remove()) and changes nothing; it solves nothing, +// so it claims no cut (review-1c F5). +TEST(FlowPolicyGlobal, ZeroCallReportsTheCumulativeCurve) { + using Curve = std::vector>; + auto g = shared(make_graph(3, {{0, 1, 3, 7}, {0, 2, 4, 1}, {2, 1, 4, 1}})); + FlowLedger fg(g); + FlowPolicy p(fg, v({0}), v({1}), global_cfg()); + auto z = p.place(0); + ASSERT_TRUE(z.cost_distribution.has_value()); + EXPECT_TRUE(z.cost_distribution->empty()); + auto r = p.place(5); + EXPECT_EQ(*r.cost_distribution, (Curve {{2, 4}, {7, 1}})); + const auto allocated = fg.allocated(); + const auto requested = detail::PolicyAccess::engine(p).requested; + z = p.place(0, OutputOptions {true, true, true}); + EXPECT_EQ(z.placed, 0); + EXPECT_EQ(*z.cost_distribution, *r.cost_distribution); + EXPECT_FALSE(z.min_cut.has_value()); + EXPECT_EQ(fg.allocated(), allocated); + EXPECT_EQ(detail::PolicyAccess::engine(p).requested, requested); + r = p.place(1); // cumulative 6: the solve's curve on V = W + own + EXPECT_EQ(*r.cost_distribution, (Curve {{2, 4}, {7, 2}})); + z = p.place(0); + EXPECT_EQ(*z.cost_distribution, *r.cost_distribution); + Amount sum = 0; + for (const auto& [c, x] : *z.cost_distribution) sum += x; + EXPECT_EQ(sum, p.placed_demand()); + p.remove(); + z = p.place(0); + ASSERT_TRUE(z.cost_distribution.has_value()); + EXPECT_TRUE(z.cost_distribution->empty()); +} + +TEST(FlowPolicyRecords, BundlesAreSharedOrSparse) { + auto g = diamond(5, 5); + FlowLedger fg(g); + const auto whole = dag_of(*g, {0, 1, 2, 3}); + Pinned pin; + pin.bundles = {whole}; + FlowPolicy p(fg, v({0}), v({3}), cfg(pin, FlowPlacement::EqualBalanced)); + (void)p.place(2); + // An unchanged supplied bundle is shared by the record (no copy). + EXPECT_EQ(p.records().front().bundle.get(), whole.get()); + // A pruned bundle is a new DAG of the usable route. + const auto el = g->eligibility({}, std::vector {2}); + FlowLedger fg2(g); + FlowPolicy q(fg2, v({0}), v({3}), cfg(pin, FlowPlacement::EqualBalanced), &el); + (void)q.place(2); + const auto rec = q.records().front(); + EXPECT_NE(rec.bundle.get(), whole.get()); + EXPECT_EQ(edges_of(*rec.bundle), (std::vector {1, 3})); + EXPECT_EQ(q.usable_bundles(), 1); + // CostOnly: a source whose route is the whole cached support shares it. + DagCache cache(*g); + FlowLedger fg3(g); + FlowPolicy c(fg3, v({0}), v({3}), cfg(CostOnly {}, FlowPlacement::EqualBalanced), nullptr, &cache); + (void)c.place(2); + const auto support = cache.lookup(*g, v({3}), Direction::Reverse, nullptr); + EXPECT_EQ(c.records().front().bundle.get(), support.get()); +} + +TEST(PredDAGStorage, ABundleCostsItsEntriesNotTheGraph) { + const std::int32_t n = 1'000'000; + auto g = shared(make_graph(n, {{n - 3, n - 2, 4, 1}, {n - 2, n - 1, 4, 1}})); + const auto dag = dag_of(*g, {0, 1}); + EXPECT_LT(detail::PredDAGBuilder::payload_bytes(*dag), 128U); // two entries, not N + 1 offsets + const auto off = dag->parent_offsets(); // the from_csr shape, on demand + ASSERT_EQ(off.size(), static_cast(n) + 1); + EXPECT_EQ(off[static_cast(n) - 2], 0); + EXPECT_EQ(off[static_cast(n) - 1], 1); + EXPECT_EQ(off[static_cast(n)], 2); + EXPECT_TRUE(PredDAG::from_csr(*g, off, dag->parents(), dag->via_edges()) == *dag); + FlowLedger fg(g); + Pinned pin; + pin.bundles = {dag}; + FlowPolicy p(fg, v({n - 3}), v({n - 1}), cfg(pin, FlowPlacement::MaxFlow)); + EXPECT_EQ(p.place(3).placed, 3); + EXPECT_LT(detail::PredDAGBuilder::payload_bytes(*p.records().front().bundle), 128U); +} + +TEST(DagCache, KeyStatisticsAndTheBuildingTransitions) { + auto g = diamond(5, 5); + DagCache cache(*g); + EXPECT_EQ(cache.stats().entries, 0); + // Two CostOnly policies differing only in selection share one entry (selection is a + // projection at creation, never part of the key). + FlowLedger fg(g); + FlowPolicy a(fg, v({0}), v({3}), cfg(CostOnly {}, FlowPlacement::EqualBalanced), nullptr, &cache); + FlowPolicy b(fg, v({0}), v({3}), + cfg(CostOnly {}, FlowPlacement::EqualBalanced, Admission::Lossless, + sel(EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual, false, false)), + nullptr, &cache); + auto s = cache.stats(); + EXPECT_EQ(s.misses, 1); + EXPECT_EQ(s.hits, 1); + EXPECT_EQ(s.entries, 1); + EXPECT_GT(s.bytes, 0); + // The key: roots as a set, the direction, the Eligibility uid. + const auto x = cache.lookup(*g, v({3, 3}), Direction::Reverse, nullptr); + EXPECT_EQ(cache.stats().hits, 2); + (void)cache.lookup(*g, v({3}), Direction::Forward, nullptr); + const auto el = g->eligibility({}, std::vector {0}); + const auto y = cache.lookup(*g, v({3}), Direction::Reverse, &el); + s = cache.stats(); + EXPECT_EQ(s.misses, 3); + EXPECT_EQ(s.entries, 3); + EXPECT_EQ(edges_of(*y), (std::vector {1, 2, 3})); // the static support under el (no 0 -> 1) + EXPECT_EQ(edges_of(*x), (std::vector {0, 1, 2, 3})); + // Invalid queries throw and count nothing. + auto other = diamond(5, 5); + EXPECT_THROW((void)cache.lookup(*other, v({3}), Direction::Reverse, nullptr), std::invalid_argument); + const auto foreign = other->eligibility({}, std::vector {0}); + EXPECT_THROW((void)cache.lookup(*g, v({3}), Direction::Reverse, &foreign), std::invalid_argument); + EXPECT_THROW((void)cache.lookup(*g, v({9}), Direction::Reverse, nullptr), std::invalid_argument); + EXPECT_THROW((void)cache.lookup(*g, std::vector {}, Direction::Reverse, nullptr), std::invalid_argument); + EXPECT_EQ(cache.stats().misses, 3); + EXPECT_EQ(cache.stats().hits, 2); + // clear(): empties, counters persist, held values stay valid and alive. + const auto held = x; + EXPECT_EQ(cache.stats().live_values, 3); + cache.clear(); + s = cache.stats(); + EXPECT_EQ(s.entries, 0); + EXPECT_EQ(s.bytes, 0); + EXPECT_EQ(s.hits, 2); + EXPECT_EQ(s.misses, 3); + EXPECT_EQ(s.live_values, 2); // x (= held) and y; the Forward entry died with the clear + EXPECT_EQ(edges_of(*held), (std::vector {0, 1, 2, 3})); + // After clear() a lookup misses and inserts again. + (void)cache.lookup(*g, v({3}), Direction::Reverse, nullptr); + EXPECT_EQ(cache.stats().misses, 4); +} + +TEST(DagCache, ValuesOutliveTheCache) { + auto g = diamond(5, 5); + std::shared_ptr v1; + { + DagCache cache(*g); + v1 = cache.lookup(*g, v({3}), Direction::Reverse, nullptr); + } + EXPECT_EQ(edges_of(*v1), (std::vector {0, 1, 2, 3})); +} + +TEST(Evaluators, CapacityIsPlaceInfOnAFreshLedger) { + auto g = diamond(5, 3, 2); + for (const auto& c : {global_cfg(), cfg(CostOnly {}, FlowPlacement::EqualBalanced), + cfg(CapacityAware {.flow_count = 2, .max_flow_count = 4}, FlowPlacement::MaxFlow)}) { + const auto r = capacity(*g, v({0}), v({3}), CapacityOptions {c, nullptr, nullptr, OutputOptions {true, true, true}}); + FlowLedger fg(g); + FlowPolicy p(fg, v({0}), v({3}), c); + const auto want = p.place(std::nullopt, OutputOptions {true, true, true}); + EXPECT_EQ(r.placed, want.placed); + EXPECT_EQ(r.termination, want.termination); + EXPECT_EQ(r.edge_flows, want.edge_flows); + EXPECT_EQ(r.residual, want.residual); + EXPECT_EQ(r.bottleneck, want.bottleneck); + EXPECT_EQ(r.min_cut, want.min_cut); + EXPECT_EQ(r.cost_distribution, want.cost_distribution); + } + // The exact maximum flow: Global's curve (cheapest walks first). + const auto exact = capacity(*g, v({0}), v({3}), CapacityOptions {global_cfg()}); + EXPECT_EQ(exact.placed, 8); + EXPECT_EQ(*exact.cost_distribution, curve({{2, 5}, {4, 3}})); +} + +TEST(Evaluators, BatchOrderIntersectionsExceptionsAndWorkers) { + auto g = diamond(5, 3); + const auto common = g->eligibility({}, std::vector {3}); // route 2's last edge + const auto first_route = g->eligibility({}, std::vector {0}); // route 1's first edge + std::vector, std::vector>> pairs = { + {{0}, {3}}, {{0}, {3}}, {{1}, {3}}, {{0}, {1, 2}}}; + const std::vector per = {nullptr, &first_route, nullptr, nullptr}; + const CapacityOptions opts {global_cfg(), &common}; + const auto r1 = batch_capacity(*g, pairs, opts, per, 1); + ASSERT_EQ(r1.size(), 4U); + EXPECT_EQ(r1[0].placed, 5); // common only: route 1 + EXPECT_EQ(r1[1].placed, 0); // common and the pair's: both routes cut + EXPECT_EQ(r1[2].placed, 5); + EXPECT_EQ(r1[3].placed, 8); // destination set {1, 2}: both first edges (edge 3 excluded is past 2) + for (std::int32_t w : {2, 3, 8}) { + const auto rw = batch_capacity(*g, pairs, opts, per, w); + ASSERT_EQ(rw.size(), 4U); + for (std::size_t i = 0; i < 4; ++i) EXPECT_EQ(rw[i].placed, r1[i].placed) << "workers " << w << " pair " << i; + } + EXPECT_TRUE(batch_capacity(*g, {}, opts, {}, 4).empty()); + // Validation before any worker: endpoints, lengths, foreign values, Pinned, workers, the + // building cache's one writer. + auto bad = pairs; + bad[2].second = {9}; + EXPECT_THROW((void)batch_capacity(*g, bad, opts, per, 4), std::invalid_argument); + bad = pairs; + bad[1].first = {3}; + EXPECT_TRUE(contains(error_of([&] { (void)batch_capacity(*g, bad, opts, per, 4); }), "both endpoint sets")); + EXPECT_THROW((void)batch_capacity(*g, pairs, opts, std::vector {nullptr}, 1), std::invalid_argument); + auto other = diamond(5, 3); + const auto foreign = other->eligibility({}, std::vector {0}); + const std::vector per_foreign = {nullptr, &foreign, nullptr, nullptr}; + EXPECT_TRUE(contains(error_of([&] { (void)batch_capacity(*g, pairs, opts, per_foreign, 1); }), "foreign")); + Pinned pin; + pin.bundles = {dag_of(*g, {0, 2})}; + EXPECT_TRUE(contains(error_of([&] { (void)batch_capacity(*g, pairs, CapacityOptions {cfg(pin, FlowPlacement::MaxFlow)}, {}, 1); }), + "Pinned")); + EXPECT_TRUE(contains(error_of([&] { (void)batch_capacity(*g, pairs, opts, per, 0); }), "workers")); + DagCache cache(*g); + const CapacityOptions with_cache {cfg(CostOnly {}, FlowPlacement::EqualBalanced), nullptr, &cache}; + EXPECT_TRUE(contains(error_of([&] { (void)batch_capacity(*g, pairs, with_cache, {}, 2); }), "workers == 1")); + const auto cached = batch_capacity(*g, pairs, with_cache, {}, 1); + EXPECT_EQ(cache.stats().misses, 2); // destination sets {3} and {1, 2} + EXPECT_EQ(cache.stats().hits, 2); + EXPECT_EQ(cached[0].placed, 7); // CostOnly EB over routes of 5 and 3: [4, 3] (odd unit to the lower id) +} + +TEST(Evaluators, BatchValidatesInPairOrderBeforeAnyWorker) { + // Two invalid pairs (1 and 3): the batch raises pair 1's error before any worker starts. + auto g = diamond(5, 3); + std::vector, std::vector>> pairs = { + {{0}, {3}}, {{0}, {7}}, {{0}, {3}}, {{0}, {8}}}; + const auto msg = error_of([&] { (void)batch_capacity(*g, pairs, CapacityOptions {global_cfg()}, {}, 4); }); + EXPECT_TRUE(contains(msg, "node 7")) << msg; +} + +// The sensitivity shortcuts (Global from the baseline's maximum flow, CostOnly without a +// re-evaluation of the edges outside the baseline's routes) give the losses of the definition, +// the baseline's placed amount minus capacity() with the edge excluded, on random graphs with +// parallel edges, self-loops, dispersed costs, zero capacities, endpoint sets, eligibility +// masks and every CostOnly kernel, admission and selection. +TEST(Evaluators, SensitivityEqualsTheDefinitionOnRandomGraphs) { + std::mt19937 rng(20261005); + auto pick = [&](int n) { return static_cast(rng() % static_cast(n)); }; + std::vector configs = {global_cfg()}; + for (auto k : {FlowPlacement::MaxFlow, FlowPlacement::EqualBalanced, FlowPlacement::Weighted}) { + for (bool multipath : {true, false}) { + for (bool multi_edge : {true, false}) { + for (auto t : {EdgeTieBreak::Deterministic, EdgeTieBreak::PreferHigherPriority}) { + configs.push_back(cfg(CostOnly {}, k, Admission::Lossless, sel(t, EdgePriority::Capacity, multipath, multi_edge))); + } + } + } + } + configs.push_back(cfg(CostOnly {}, FlowPlacement::EqualBalanced, Admission::Lossy)); + configs.push_back(cfg(CostOnly {}, FlowPlacement::Weighted, Admission::Lossy, sel(EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual, false, false))); + int entries = 0; + int global_losses = 0; + int policy_losses = 0; + int policy_zero = 0; + for (int trial = 0; trial < 60; ++trial) { + const int n = 4 + pick(7); + std::vector es; + for (int k = 0, m = n + pick(3 * n); k < m; ++k) { + es.push_back({pick(n), pick(n), pick(5) == 0 ? 0 : 1 + pick(9), static_cast(1 + pick(4))}); + } + auto g = shared(make_graph(n, es)); + std::vector s = {0}; + std::vector t = {n - 1}; + if (pick(2) == 0) s.push_back(1); + if (pick(2) == 0) t.push_back(n - 2); + std::vector xn; + std::vector xe; + if (pick(3) == 0) xn.push_back(static_cast(2 + pick(n - 4 > 0 ? n - 4 : 1))); + if (pick(2) == 0) xe.push_back(static_cast(pick(static_cast(es.size())))); + const auto mask = g->eligibility(xn, xe); + const Eligibility* elig = pick(2) == 0 ? &mask : nullptr; + for (const auto& c : configs) { + const bool global = std::holds_alternative(c.routing); + const auto r = sensitivity(*g, s, t, SensitivityOptions {c, elig}, 1 + pick(3)); + for (const auto& e : r.entries) { + ASSERT_EQ(e.status, SensitivityStatus::Evaluated); + const EdgeId one[1] = {e.edge}; + auto deleted = g->eligibility({}, one); + if (elig != nullptr) deleted = Eligibility::intersect(*elig, deleted); + const Amount want = r.baseline.placed - capacity(*g, s, t, CapacityOptions {c, &deleted}).placed; + ASSERT_EQ(*e.loss, want) << "trial " << trial << " edge " << e.edge << (global ? " Global" : " CostOnly"); + ++entries; + if (want != 0) ++(global ? global_losses : policy_losses); + if (!global && want == 0) ++policy_zero; + } + } + } + // Not vacuous: many entries, losses under both models and zero-loss CostOnly deletions. + EXPECT_GT(entries, 3000); + EXPECT_GT(global_losses, 100); + EXPECT_GT(policy_losses, 500); + EXPECT_GT(policy_zero, 500); +} + +TEST(Evaluators, SensitivityCandidatesSignedLossesAndUnevaluable) { + // Global: candidates are the baseline's positive-flow edges; an unsaturated critical edge + // appears (rule 1): 0 -> 1 of capacity 9 carries 4 (its tail 1 -> 2 holds 4) and its deletion + // loses 4; the saturated-only candidate set of 0.12 missed it. + auto g = shared(make_graph(3, {{0, 1, 9, 1}, {1, 2, 4, 1}, {0, 2, 2, 5}})); + auto s = sensitivity(*g, v({0}), v({2}), SensitivityOptions {global_cfg()}, 1); + EXPECT_EQ(s.baseline.placed, 6); + EXPECT_FALSE(s.baseline.edge_flows.has_value()); // requested internally, not returned + std::map losses; + for (const auto& e : s.entries) { + EXPECT_EQ(e.status, SensitivityStatus::Evaluated); + losses[e.edge] = *e.loss; + } + const auto inv = id_of_input(*g); + EXPECT_EQ(losses.at(inv[0]), 4); // unsaturated (4 of 9) and critical + EXPECT_EQ(losses.at(inv[1]), 4); + EXPECT_EQ(losses.at(inv[2]), 2); + EXPECT_EQ(losses.size(), 3U); + // A policy model: every allowed edge of >= 1 unit; a loss can be negative (EqualBalanced over + // parallel links of 1 and 10 delivers 2; without the thin link it delivers 10). + auto p = shared(make_graph(2, {{0, 1, 1, 1}, {0, 1, 10, 1}, {0, 1, 0, 1}})); + const auto opts = SensitivityOptions {cfg(CostOnly {}, FlowPlacement::EqualBalanced)}; + s = sensitivity(*p, v({0}), v({1}), opts, 1); + EXPECT_EQ(s.baseline.placed, 2); + ASSERT_EQ(s.entries.size(), 2U); // the zero-capacity link is absent (loss zero) + const auto pin = id_of_input(*p); + for (const auto& e : s.entries) { + if (e.edge == pin[0]) EXPECT_EQ(*e.loss, -8); + if (e.edge == pin[1]) EXPECT_EQ(*e.loss, 1); + } + // Workers give the same entries; an excluded edge is no candidate. + const auto s4 = sensitivity(*p, v({0}), v({1}), opts, 4); + ASSERT_EQ(s4.entries.size(), s.entries.size()); + for (std::size_t i = 0; i < s.entries.size(); ++i) EXPECT_EQ(s4.entries[i].loss, s.entries[i].loss); + const auto el = p->eligibility({}, std::vector {pin[0]}); + const auto se = sensitivity(*p, v({0}), v({1}), SensitivityOptions {opts.cfg, &el}, 1); + ASSERT_EQ(se.entries.size(), 1U); + EXPECT_EQ(se.entries[0].edge, pin[1]); + // Invalid inputs propagate (before any worker). + EXPECT_THROW((void)sensitivity(*p, v({0}), v({0}), opts, 1), std::invalid_argument); + DagCache cache(*p); + EXPECT_THROW((void)sensitivity(*p, v({0}), v({1}), SensitivityOptions {opts.cfg, nullptr, &cache}, 2), + std::invalid_argument); + EXPECT_EQ(sensitivity(*p, v({0}), v({1}), SensitivityOptions {opts.cfg, nullptr, &cache}, 1).entries.size(), 2U); +} + +// A deletion raising a model exception is unevaluable with its message, not raised (rule 1). No +// valid configuration raises under a deletion, so the private hook injects one. +TEST(Evaluators, SensitivityReportsAModelExceptionAsUnevaluable) { +#if NETGRAPH_CORE_PRIVATE_HOOKS + auto p = shared(make_graph(2, {{0, 1, 1, 1}, {0, 1, 10, 1}, {0, 1, 0, 1}})); + const auto opts = SensitivityOptions {cfg(CostOnly {}, FlowPlacement::EqualBalanced)}; + const auto pin = id_of_input(*p); + detail::sensitivity_fail_edge().store(pin[1]); + const auto su = sensitivity(*p, v({0}), v({1}), opts, 2); + detail::sensitivity_fail_edge().store(-1); + ASSERT_EQ(su.entries.size(), 2U); + for (const auto& e : su.entries) { + if (e.edge == pin[1]) { + EXPECT_EQ(e.status, SensitivityStatus::Unevaluable); + EXPECT_FALSE(e.loss.has_value()); + EXPECT_TRUE(contains(e.reason, "injected model exception")); + } else { + EXPECT_EQ(e.status, SensitivityStatus::Evaluated); + } + } +#else + GTEST_SKIP() << "needs the private hooks (NETGRAPH_CORE_PRIVATE_HOOKS)"; +#endif +} diff --git a/tests/cpp/strict_multidigraph_tests.cpp b/tests/cpp/graph_tests.cpp similarity index 97% rename from tests/cpp/strict_multidigraph_tests.cpp rename to tests/cpp/graph_tests.cpp index b68c164..ee30dc0 100644 --- a/tests/cpp/strict_multidigraph_tests.cpp +++ b/tests/cpp/graph_tests.cpp @@ -10,7 +10,7 @@ #include #include -#include "netgraph/core/strict_multidigraph.hpp" +#include "netgraph/core/graph.hpp" #include "test_utils.hpp" using namespace netgraph::core; @@ -31,9 +31,9 @@ std::string error_of(F&& fn) { return ""; } -StrictMultiDiGraph build(std::int32_t n, std::vector src, std::vector dst, std::vector cap, - std::vector cost, double q = 1.0) { - return StrictMultiDiGraph(n, src, dst, cap, cost, q); +Graph build(std::int32_t n, std::vector src, std::vector dst, std::vector cap, + std::vector cost, double q = 1.0) { + return Graph(n, src, dst, cap, cost, q); } bool contains(const std::string& s, const std::string& part) { return s.find(part) != std::string::npos; } @@ -143,7 +143,7 @@ TEST(Graph, ExactUnitsAboveTwo53) { TEST(Graph, CopyIsANewGraph) { auto g = make_graph(3, {{0, 2, 4, 2}, {0, 1, 3, 1}}); - StrictMultiDiGraph c(g); + Graph c(g); EXPECT_NE(c.uid(), g.uid()); EXPECT_EQ(std::vector(c.input_index().begin(), c.input_index().end()), std::vector(g.input_index().begin(), g.input_index().end())); diff --git a/tests/cpp/kernels_tests.cpp b/tests/cpp/kernels_tests.cpp index 5687699..81c68c2 100644 --- a/tests/cpp/kernels_tests.cpp +++ b/tests/cpp/kernels_tests.cpp @@ -74,33 +74,38 @@ std::vector on_members(const KernelPlan& plan, const std::vector } // A member-aligned allocation as a dense per-edge array in input coordinates. -std::vector dense_input(const StrictMultiDiGraph& g, const KernelPlan& plan, const std::vector& alloc_m) { +std::vector dense_input(const Graph& g, const KernelPlan& plan, const std::vector& alloc_m) { std::vector by_id(static_cast(g.num_edges()), 0); for (std::size_t m = 0; m < plan.members.size(); ++m) by_id[static_cast(plan.members[m])] = alloc_m[m]; return to_input(g, by_id); } -std::vector all_ids(const StrictMultiDiGraph& g) { +std::vector all_ids(const Graph& g) { std::vector out(static_cast(g.num_edges())); std::iota(out.begin(), out.end(), 0); return out; } -KernelPlan plan_of(const StrictMultiDiGraph& g, FlowPlacement p, NodeId src, std::vector dsts, +KernelPlan plan_of(const Graph& g, FlowPlacement p, NodeId src, std::vector dsts, std::vector route, const std::vector& view, EdgePriority prio = EdgePriority::Residual, Admission adm = Admission::Lossless) { std::sort(route.begin(), route.end()); std::vector on_route; // the creation view is route-aligned for (EdgeId e : route) on_route.push_back(view[static_cast(e)]); - return make_plan(g, p, adm, prio, src, dsts, route, on_route); + return make_plan(g, p, adm, prio, src, dsts, PredDAGBuilder::of_edges(g, route), on_route); } -std::vector caps(const StrictMultiDiGraph& g) { +// The route cost of a plan's route (its record's bundle). +Cost cost_of(const Graph& g, const KernelPlan& plan) { + return route_cost(g, PredDAGBuilder::of_edges(g, plan.route), plan.src, plan.dsts); +} + +std::vector caps(const Graph& g) { return std::vector(g.capacity_units().begin(), g.capacity_units().end()); } // One Lossless step on a fresh flow at T = 0. -LosslessStep first_step(const StrictMultiDiGraph& g, const KernelPlan& plan, const std::vector& view, +LosslessStep first_step(const Graph& g, const KernelPlan& plan, const std::vector& view, std::optional request) { const auto v = on_members(plan, view); const std::vector own(plan.members.size(), 0); @@ -122,6 +127,39 @@ TEST(Apportion, RoundRobinAndReviewValues) { EXPECT_EQ(ha(7, {1, 3, 3}), (std::vector{1, 3, 3})); } +// The callback form hands out the span form's shares (positive ones only, in index order) and +// stops at the first share its callback refuses. +TEST(Apportion, RoundRobinCallbackFormEqualsSpanFormAndStopsEarly) { + for (Amount total : {Amount {0}, Amount {1}, Amount {2}, Amount {7}, Amount {23}, kDomainLimit - 1}) { + for (std::size_t k : {std::size_t {1}, std::size_t {3}, std::size_t {4}, std::size_t {8}}) { + std::vector span_out(k); + const std::size_t n = round_robin(total, span_out); + std::vector cb_out(k); + std::vector visited; + const std::size_t m = round_robin(total, k, [&](std::size_t i, Amount x) { + visited.push_back(i); + cb_out[i] = x; + return x > 0; + }); + std::vector leading(total < static_cast(k) ? static_cast(total) : k); + std::iota(leading.begin(), leading.end(), std::size_t {0}); + EXPECT_EQ(n, leading.size()) << total << " over " << k; + EXPECT_EQ(m, n) << total << " over " << k; + EXPECT_EQ(visited, leading) << total << " over " << k; + EXPECT_EQ(cb_out, span_out) << total << " over " << k; + } + } + // 7 over 3 is {3, 2, 2}: refusing the share at index 1 returns 1 and visits nothing after it. + std::vector seen; + const std::size_t stop = round_robin(7, 3, [&](std::size_t i, Amount x) { + seen.push_back(i); + return x > 2; + }); + EXPECT_EQ(stop, 1u); + EXPECT_EQ(seen, (std::vector{0, 1})); + EXPECT_EQ(round_robin(7, 3, [](std::size_t, Amount) { return false; }), 0u); +} + TEST(Apportion, ClosedFormEqualsSequentialAndIsHouseMonotone) { std::mt19937 rng(7); int cases = 0; @@ -252,20 +290,23 @@ TEST(Plans, SplitGraphPruning) { std::vector want2 = {inv2[0], inv2[2]}; // 1->2 leaves destination 1; 2->3 leaves 2 std::sort(want2.begin(), want2.end()); EXPECT_EQ(any.members, want2); - EXPECT_EQ(route_cost(g2, any), 1); + EXPECT_EQ(cost_of(g2, any), 1); // An empty split graph: route cost unreachable, T_max 0. auto none = plan_of(g, FlowPlacement::EqualBalanced, 0, {3}, std::vector {inv[1], inv[3]}, caps(g)); EXPECT_TRUE(none.members.empty()); - EXPECT_EQ(route_cost(g, none), kUnreachable); + EXPECT_EQ(cost_of(g, none), kUnreachable); EXPECT_EQ(t_max(none, {}).value, 0); } -TEST(Plans, RoutePlanOnTheDagEqualsTheEdgeListPlan) { - // make_route_plan walks the bundle's own predecessor lists; make_plan prunes an ascending edge - // list. On random DAGs (parallel edges, zero capacities, statically excluded edges and nodes, - // edges into the source and out of a destination, one or two destinations) both give the same - // split graph (members, topological structure, weights), the same route cost and the same - // allocations. +TEST(Plans, SplitGraphEqualsTheWalkDefinition) { + // On random DAGs (parallel edges, zero capacities, statically excluded edges and nodes, edges + // into the source and out of a destination, one or two destinations) the split graph of a + // bundle is, by definition, the usable edges (capacity >= 1 unit, allowed) on some walk from + // the source to a destination that enters no destination before its end: the members equal an + // independent reachability computation, the route cost equals an independent shortest walk, + // the topological structure orders every member's tail before its head, and an allocation + // conserves its total. make_plan on the DAG of the usable edges gives the same plan as + // make_route_plan filtering the bundle (the two entry points of the one builder). std::mt19937 rng(2024); int compared = 0; for (int trial = 0; trial < 2000; ++trial) { @@ -284,7 +325,7 @@ TEST(Plans, RoutePlanOnTheDagEqualsTheEdgeListPlan) { for (EdgeId e : all) { if ((rng() % 5) != 0) in_bundle.push_back(e); } - const auto bundle = PredDAGBuilder::from_edges(g, in_bundle); + const auto bundle = PredDAGBuilder::of_edges(g, in_bundle); const NodeId src = static_cast(rng() % static_cast(n)); std::vector dsts; for (int k = 0; k < 1 + static_cast(rng() % 2); ++k) { @@ -304,34 +345,102 @@ TEST(Plans, RoutePlanOnTheDagEqualsTheEdgeListPlan) { elig = g.eligibility(xn, xe); } const auto capacity = g.capacity_units(); + const auto edge_src = g.edge_src(); + const auto edge_dst = g.edge_dst(); + const auto cost = g.cost(); std::vector usable; for (EdgeId e : PredDAGBuilder::ascending(bundle)) { if (capacity[static_cast(e)] >= 1 && (!elig || elig->edge_ok(e))) usable.push_back(e); } + // The definition: forward from src and backward from the destinations over the usable edges + // that leave no destination, by fixpoint; the shortest walk by Bellman-Ford over the same. + std::vector is_dst(static_cast(n), 0); + for (NodeId d : dsts) is_dst[static_cast(d)] = 1; + std::vector open; + for (EdgeId e : usable) { + if (is_dst[static_cast(edge_src[static_cast(e)])] == 0) open.push_back(e); + } + std::vector fwd(static_cast(n), 0), bwd(static_cast(n), 0); + fwd[static_cast(src)] = 1; + for (NodeId d : dsts) bwd[static_cast(d)] = 1; + std::vector dist(static_cast(n), kUnreachable); + dist[static_cast(src)] = 0; + for (int round = 0; round < n; ++round) { + for (EdgeId e : open) { + const auto u = static_cast(edge_src[static_cast(e)]); + const auto v = static_cast(edge_dst[static_cast(e)]); + if (fwd[u] != 0) fwd[v] = 1; + if (bwd[v] != 0) bwd[u] = 1; + if (dist[u] != kUnreachable) dist[v] = std::min(dist[v], dist[u] + cost[static_cast(e)]); + } + } + std::vector want; + for (EdgeId e : open) { + if (fwd[static_cast(edge_src[static_cast(e)])] != 0 && + bwd[static_cast(edge_dst[static_cast(e)])] != 0) { + want.push_back(e); + } + } + Cost want_cost = kUnreachable; + if (!want.empty()) { + for (NodeId d : dsts) want_cost = std::min(want_cost, dist[static_cast(d)]); + } + const auto usable_dag = PredDAGBuilder::of_edges(g, usable); for (const auto kernel : {FlowPlacement::EqualBalanced, FlowPlacement::Weighted}) { - Cost cost = -1; - const auto on_dag = make_route_plan(g, kernel, Admission::Lossless, EdgePriority::Capacity, src, dsts, bundle, - elig ? &*elig : nullptr, &cost); - const auto on_list = make_plan(g, kernel, Admission::Lossless, EdgePriority::Capacity, src, dsts, usable, {}); - ASSERT_EQ(on_dag.members, on_list.members) << "trial " << trial; - EXPECT_EQ(on_dag.route, on_list.members); - EXPECT_EQ(on_dag.topo, on_list.topo); - EXPECT_EQ(on_dag.out_begin, on_list.out_begin); - EXPECT_EQ(on_dag.out_end, on_list.out_end); - EXPECT_EQ(on_dag.head_pos, on_list.head_pos); - EXPECT_EQ(on_dag.terminal, on_list.terminal); - EXPECT_EQ(on_dag.weights, on_list.weights); - EXPECT_EQ(cost, route_cost(g, on_list)) << "trial " << trial; - EXPECT_EQ(cost, route_cost(g, on_dag)); - std::vector a(on_dag.members.size()); - std::vector b(on_list.members.size()); - for (Amount t : {Amount {0}, Amount {1}, Amount {7}, Amount {50}}) { - if (on_dag.members.empty() && t != 0) continue; - EXPECT_TRUE(allocate(on_dag, t, a)); - EXPECT_TRUE(allocate(on_list, t, b)); - EXPECT_EQ(a, b); + Cost got_cost = -1; + const auto on_bundle = make_route_plan(g, kernel, Admission::Lossless, EdgePriority::Capacity, src, dsts, bundle, + elig ? &*elig : nullptr, &got_cost); + ASSERT_EQ(on_bundle.members, want) << "trial " << trial; + EXPECT_EQ(on_bundle.route, want); + EXPECT_EQ(got_cost, want_cost) << "trial " << trial; + EXPECT_EQ(prune(g, usable_dag, src, dsts), want); + Cost plan_cost = -1; + const auto on_usable = + make_plan(g, kernel, Admission::Lossless, EdgePriority::Capacity, src, dsts, usable_dag, {}, &plan_cost); + EXPECT_EQ(on_usable.members, on_bundle.members); + EXPECT_EQ(on_usable.topo, on_bundle.topo); + EXPECT_EQ(on_usable.out_begin, on_bundle.out_begin); + EXPECT_EQ(on_usable.out_end, on_bundle.out_end); + EXPECT_EQ(on_usable.head_pos, on_bundle.head_pos); + EXPECT_EQ(on_usable.terminal, on_bundle.terminal); + EXPECT_EQ(on_usable.weights, on_bundle.weights); + // The route cost is over the route (every usable edge), not only the members. + dist.assign(static_cast(n), kUnreachable); + dist[static_cast(src)] = 0; + for (int round = 0; round < n; ++round) { + for (EdgeId e : usable) { + const auto u = static_cast(edge_src[static_cast(e)]); + const auto v = static_cast(edge_dst[static_cast(e)]); + if (is_dst[u] == 0 && dist[u] != kUnreachable) dist[v] = std::min(dist[v], dist[u] + cost[static_cast(e)]); + } + } + Cost route_want = kUnreachable; + for (NodeId d : dsts) route_want = std::min(route_want, dist[static_cast(d)]); + EXPECT_EQ(plan_cost, route_want) << "trial " << trial; + EXPECT_EQ(route_cost(g, usable_dag, src, dsts), route_want) << "trial " << trial; // the cost pass alone + if (on_bundle.members.empty()) continue; + // Topology: the source first, every member's tail before its head, members of a node + // consecutive. + EXPECT_EQ(on_bundle.topo.front(), src); + for (std::size_t p = 0; p < on_bundle.topo.size(); ++p) { + for (auto k = on_bundle.out_begin[p]; k < on_bundle.out_end[p]; ++k) { + EXPECT_EQ(edge_src[static_cast(on_bundle.members[static_cast(k)])], on_bundle.topo[p]); + EXPECT_GT(on_bundle.head_pos[static_cast(k)], static_cast(p)); + } + } + // Conservation: what enters the destinations is the total. + std::vector a(on_bundle.members.size()); + for (Amount t : {Amount {1}, Amount {7}, Amount {50}}) { + ASSERT_TRUE(allocate(on_bundle, t, a)); + Amount delivered = 0; + for (std::size_t m = 0; m < a.size(); ++m) { + if (is_dst[static_cast(edge_dst[static_cast(on_bundle.members[m])])] != 0) { + delivered += a[m]; + } + } + EXPECT_EQ(delivered, t) << "trial " << trial; } - if (!on_dag.members.empty()) ++compared; + ++compared; } } EXPECT_GT(compared, 1000); @@ -583,7 +692,7 @@ TEST(MaxFlowKernel, ReleaseWitnessAndMixedCostBundle) { auto g2 = make_graph(4, {{0, 1, 1, 1}, {1, 2, 1, 1}, {2, 3, 1, 1}, {0, 3, 1, 4}}); const auto p2 = plan_of(g2, FlowPlacement::MaxFlow, 0, {3}, all_ids(g2), caps(g2)); EXPECT_EQ(dense_input(g2, p2, first_step(g2, p2, caps(g2), 1).alloc), (std::vector{1, 1, 1, 0})); - EXPECT_EQ(route_cost(g2, p2), 3); + EXPECT_EQ(cost_of(g2, p2), 3); } TEST(WeightedKernel, CapacityVersusResidualMembershipAfterOccupancy) { diff --git a/tests/cpp/pinned_one.hpp b/tests/cpp/pinned_one.hpp new file mode 100644 index 0000000..8d72964 --- /dev/null +++ b/tests/cpp/pinned_one.hpp @@ -0,0 +1,54 @@ +// A manual placement as the contract defines it (APPROACH 8): a FlowPolicy with Pinned routing and +// one bundle. The ledger and kernel tests drive the ledger through it and compare the real +// PlacementResult. +#pragma once + +#include +#include +#include + +#include "netgraph/core/detail/policy_engine.hpp" +#include "netgraph/core/flow_ledger.hpp" +#include "netgraph/core/flow_policy.hpp" +#include "netgraph/core/options.hpp" +#include "netgraph/core/pred_dag.hpp" + +namespace netgraph::core::test { + +class PinnedOne { +public: + PinnedOne(FlowLedger& fg, NodeId src, NodeId dst, std::shared_ptr bundle, FlowPlacement placement, + Admission admission, EdgePriority priority, std::shared_ptr elig = nullptr) + : elig_(std::move(elig)) { + Pinned pinned; + pinned.bundles.push_back(std::move(bundle)); + FlowPolicyConfig cfg {pinned, placement, admission, PathSelection(EdgeTieBreak::Deterministic, priority)}; + const NodeId s[1] = {src}; + const NodeId d[1] = {dst}; + policy_ = std::make_unique(fg, s, d, std::move(cfg), elig_.get()); + } + + PlacementResult place(std::optional volume) { return policy_->place(volume); } + void remove() { policy_->remove(); } + [[nodiscard]] std::optional flow_id() const { + const auto& pe = engine(); + if (pe.cohorts.empty() || pe.cohorts.front().empty()) return std::nullopt; + return FlowId {pe.fg.uid(), pe.cohorts.front().front().id}; + } + [[nodiscard]] Amount placed_demand() const { return policy_->placed_demand(); } + [[nodiscard]] int usable_bundles() const { return policy_->usable_bundles(); } + [[nodiscard]] const detail::KernelPlan* plan() const { + const auto& pe = engine(); + if (pe.cohorts.empty() || pe.cohorts.front().empty()) return nullptr; + return pe.cohorts.front().front().plan.get(); + } + [[nodiscard]] const Counter128& requested() const { return engine().requested; } + [[nodiscard]] bool infinite() const { return engine().infinite; } + +private: + [[nodiscard]] const detail::PolicyEngine& engine() const { return detail::PolicyAccess::engine(*policy_); } + std::shared_ptr elig_; + std::unique_ptr policy_; +}; + +} // namespace netgraph::core::test diff --git a/tests/cpp/pred_dag_tests.cpp b/tests/cpp/pred_dag_tests.cpp index e9e3ff1..bef2484 100644 --- a/tests/cpp/pred_dag_tests.cpp +++ b/tests/cpp/pred_dag_tests.cpp @@ -3,7 +3,9 @@ // to_dag(). #include +#include #include +#include #include #include #include @@ -29,12 +31,18 @@ std::string error_of(const std::function& fn) { bool contains(const std::string& s, const std::string& part) { return s.find(part) != std::string::npos; } // 0 -> 1 (two parallel members), 1 -> 2, 0 -> 2; ids follow (src, cost, dst, input). -StrictMultiDiGraph diamondish() { +Graph diamondish() { return make_graph(3, {{0, 1, 5, 1}, {0, 1, 5, 1}, {1, 2, 5, 1}, {0, 2, 5, 3}, {2, 0, 5, 1}}); } using V = std::vector; +// The DAG's edges in ascending EdgeId (the builder's view of the storage). +std::vector ascending(const PredDAG& dag) { + const auto e = detail::PredDAGBuilder::ascending(dag); + return {e.begin(), e.end()}; +} + } // namespace TEST(PredDAG, FromCsrRoundTripAndAccessors) { @@ -45,17 +53,56 @@ TEST(PredDAG, FromCsrRoundTripAndAccessors) { const V via = {0, 1, 2, 3}; auto dag = PredDAG::from_csr(g, off, par, via); EXPECT_EQ(dag.graph_uid(), g.uid()); - EXPECT_EQ(dag.num_nodes(), 3); - EXPECT_EQ(V(dag.parent_offsets().begin(), dag.parent_offsets().end()), off); + EXPECT_EQ(dag.parent_offsets(), off); EXPECT_EQ(V(dag.parents().begin(), dag.parents().end()), par); EXPECT_EQ(V(dag.via_edges().begin(), dag.via_edges().end()), via); - EXPECT_EQ(dag.edges(), (std::vector{0, 1, 2, 3})); + EXPECT_EQ(ascending(dag), (std::vector{0, 1, 2, 3})); // Structural equality: the builder's value from the same edge set is equal. const std::vector edges = {3, 1, 0, 2}; - EXPECT_TRUE(detail::PredDAGBuilder::from_edges(g, edges) == dag); + EXPECT_TRUE(detail::PredDAGBuilder::of_edges(g, edges) == dag); auto empty = PredDAG::from_csr(g, V {0, 0, 0, 0}, V {}, V {}); EXPECT_FALSE(empty == dag); - EXPECT_TRUE(empty.edges().empty()); + EXPECT_TRUE(ascending(empty).empty()); +} + +// The builder's entries for any edge subset in any input order: children ascending, via +// ascending within a child, the parent each via's tail, and the same value as from_csr. +TEST(PredDAG, BuilderGroupsAnyEdgeSubsetCanonically) { + std::mt19937 rng(5); + for (int trial = 0; trial < 200; ++trial) { + const int n = 2 + static_cast(rng() % 9); + std::vector es; + for (int k = 0, m = 1 + static_cast(rng() % 30); k < m; ++k) { // u < v: every subset is a DAG + const auto u = static_cast(rng() % static_cast(n - 1)); + const auto v = static_cast(u + 1 + static_cast(rng() % static_cast(n - 1 - u))); + es.push_back({u, v, 1, static_cast(1 + rng() % 3)}); + } + const auto g = make_graph(n, es); + std::vector subset; + for (EdgeId e = 0; e < g.num_edges(); ++e) { + if (rng() % 2 == 0) subset.push_back(e); + } + std::shuffle(subset.begin(), subset.end(), rng); + const auto dag = detail::PredDAGBuilder::of_edges(g, subset); + V off(static_cast(n) + 1, 0); + std::vector> entries; + for (EdgeId e : subset) entries.emplace_back(g.edge_dst()[static_cast(e)], e); + std::sort(entries.begin(), entries.end()); + V par; + V via; + for (auto [child, e] : entries) { + ++off[static_cast(child) + 1]; + par.push_back(g.edge_src()[static_cast(e)]); + via.push_back(e); + } + for (std::size_t i = 1; i < off.size(); ++i) off[i] += off[i - 1]; + EXPECT_EQ(dag.parent_offsets(), off) << trial; + EXPECT_EQ(V(dag.parents().begin(), dag.parents().end()), par) << trial; + EXPECT_EQ(V(dag.via_edges().begin(), dag.via_edges().end()), via) << trial; + EXPECT_TRUE(PredDAG::from_csr(g, off, par, via) == dag) << trial; + std::sort(subset.begin(), subset.end()); + EXPECT_EQ(ascending(dag), subset) << trial; + } } TEST(PredDAG, FromCsrShapeChecksInOrder) { @@ -107,7 +154,7 @@ TEST(PredDAG, SelfLoopIsACycle) { TEST(PredDAG, EmptyGraph) { auto g = make_graph(0, {}); auto dag = PredDAG::from_csr(g, V {0}, V {}, V {}); - EXPECT_EQ(dag.num_nodes(), 0); + EXPECT_EQ(dag.parent_offsets(), V {0}); EXPECT_FALSE(error_of([&] { (void)PredDAG::from_csr(g, V {}, V {}, V {}); }).empty()); } @@ -117,12 +164,11 @@ TEST(Path, CostSrcDstAndToDag) { Path p(g, {{1, 0}, {3}}); EXPECT_EQ(p.src(), 0); EXPECT_EQ(p.dst(), 2); - EXPECT_EQ(p.nodes(), (std::vector{0, 1, 2})); EXPECT_EQ(p.cost(), 2); EXPECT_EQ(p.hops(), (std::vector>{{1, 0}, {3}})); EXPECT_EQ(p.graph_uid(), g.uid()); auto dag = p.to_dag(); - EXPECT_EQ(V(dag.parent_offsets().begin(), dag.parent_offsets().end()), (V {0, 0, 2, 3})); + EXPECT_EQ(dag.parent_offsets(), (V {0, 0, 2, 3})); EXPECT_EQ(V(dag.via_edges().begin(), dag.via_edges().end()), (V {0, 1, 3})); EXPECT_EQ(V(dag.parents().begin(), dag.parents().end()), (V {0, 0, 1})); // to_dag gives a canonical value: from_csr accepts it unchanged. diff --git a/tests/cpp/search_tests.cpp b/tests/cpp/search_tests.cpp index 3b6d81d..03c4b98 100644 --- a/tests/cpp/search_tests.cpp +++ b/tests/cpp/search_tests.cpp @@ -1,4 +1,4 @@ -// The internal search of APPROACH 6 (sub-PR 1b; public spf in 1d): the member graph and +// The internal search of APPROACH 6: the member graph and // select_adjacency, the unique DAG in both directions, anycast pruning, the staged canonical // single path on the review witnesses and against exhaustive enumeration, validation. #include @@ -13,12 +13,11 @@ #include #include "netgraph/core/detail/search.hpp" -#include "netgraph/core/flow.hpp" +#include "netgraph/core/options.hpp" #include "test_utils.hpp" using namespace netgraph::core; using namespace netgraph::core::test; -using netgraph::core::detail::Direction; namespace { @@ -44,7 +43,7 @@ std::string error_of(const std::function& fn) { return ""; } -detail::SpfOut run(const StrictMultiDiGraph& g, std::vector roots, Direction dir, +detail::SpfOut run(const Graph& g, std::vector roots, Direction dir, std::optional> targets, const PathSelection& s, std::optional> residual = std::nullopt, Amount min_residual = 1, const Eligibility* elig = nullptr) { @@ -204,8 +203,6 @@ TEST(Search, StagedPathBottleneckThenHopsThenIds) { auto pri = run(g, {0}, Direction::Forward, std::vector {3}, sel(false, true, EdgeTieBreak::PreferHigherPriority, EdgePriority::Capacity)); EXPECT_EQ(pri.path->walk, (std::vector{inv[2], inv[3]})); - ASSERT_TRUE(pri.path->b_star.has_value()); - EXPECT_EQ(pri.path->b_star->num, 3); // Residual priority reads the residual: on capacities 5 everywhere and residuals by input // [5, 4, 1, 3, 2], 0->1->3 has bottleneck 4, 0->2->3 has 1, the direct edge 2: B* = 4. auto g5 = make_graph(4, {{0, 1, 5, 1}, {1, 3, 5, 1}, {0, 2, 5, 1}, {2, 3, 5, 1}, {0, 3, 5, 2}}); @@ -226,7 +223,7 @@ TEST(Search, StagedPathBottleneckThenHopsThenIds) { namespace { // Every simple path from a source to a destination over the members (tiny graphs). -void enumerate(const StrictMultiDiGraph& g, const std::vector& member, NodeId u, +void enumerate(const Graph& g, const std::vector& member, NodeId u, const std::vector& dsts, std::vector& walk, std::vector& on, std::vector>& out) { if (std::find(dsts.begin(), dsts.end(), u) != dsts.end()) { @@ -330,7 +327,7 @@ TEST(Search, StagedPathReview13CanonicalWitnesses) { // (all capacities 1: priority ties), both directions. auto c2 = make_graph(4, {{0, 1, 1, 9}, {0, 2, 1, 1}, {1, 3, 1, 1}, {2, 3, 1, 9}}); auto three = make_graph(6, {{0, 2, 1, 1}, {2, 3, 1, 1}, {3, 5, 1, 1}, {0, 1, 1, 1}, {1, 4, 1, 1}, {4, 5, 1, 1}}); - auto nodes_of = [](const StrictMultiDiGraph& g, const std::vector& walk) { + auto nodes_of = [](const Graph& g, const std::vector& walk) { std::vector out = {g.edge_src()[static_cast(walk.front())]}; for (EdgeId e : walk) out.push_back(g.edge_dst()[static_cast(e)]); return out; diff --git a/tests/cpp/smoke_test.cpp b/tests/cpp/smoke_test.cpp deleted file mode 100644 index bfe6563..0000000 --- a/tests/cpp/smoke_test.cpp +++ /dev/null @@ -1,30 +0,0 @@ -#include - -#include -#include -#include - -#include "netgraph/core/max_flow.hpp" -#include "netgraph/core/strict_multidigraph.hpp" -#include "netgraph/core/types.hpp" - -using namespace netgraph::core; - -TEST(GraphSmoke, ConstructAndSolve) { - // 0 -> 1 (cost 10), 1 -> 2 (cost 20), 0 -> 2 (cost 100); capacities 10 units each. - const std::vector src = {0, 1, 0}; - const std::vector dst = {1, 2, 2}; - const std::vector cap = {10, 10, 10}; - const std::vector cost = {10, 20, 100}; - StrictMultiDiGraph g(3, src, dst, cap, cost, 1.0); - EXPECT_EQ(g.num_nodes(), 3); - EXPECT_EQ(g.num_edges(), 3); - - const std::vector s = {0}; - const std::vector t = {2}; - auto r = place_global_units(g, g.capacity_units(), s, t, nullptr, std::nullopt); - EXPECT_EQ(r.placed, 20); - ASSERT_EQ(r.cost_distribution.size(), 2u); - EXPECT_EQ(r.cost_distribution[0], (std::pair{30, 10})); // 0 -> 1 -> 2 first - EXPECT_EQ(r.cost_distribution[1], (std::pair{100, 10})); // then the direct edge -} diff --git a/tests/cpp/test_utils.hpp b/tests/cpp/test_utils.hpp index f17a174..393c1b4 100644 --- a/tests/cpp/test_utils.hpp +++ b/tests/cpp/test_utils.hpp @@ -12,11 +12,16 @@ #include #include -#include "netgraph/core/max_flow.hpp" -#include "netgraph/core/strict_multidigraph.hpp" +#include "netgraph/core/detail/flow_core.hpp" +#include "netgraph/core/graph.hpp" namespace netgraph::core::test { +using detail::FlowCoreResult; +using detail::kMaxFlowCap; +using detail::kNeverReached; +using detail::place_global_units; + // An edge in input coordinates. struct InEdge { NodeId u; @@ -25,7 +30,7 @@ struct InEdge { Cost cost; }; -inline StrictMultiDiGraph make_graph(std::int32_t n, const std::vector& edges, double q = 1.0) { +inline Graph make_graph(std::int32_t n, const std::vector& edges, double q = 1.0) { std::vector src, dst; std::vector cap; std::vector cost; @@ -35,11 +40,11 @@ inline StrictMultiDiGraph make_graph(std::int32_t n, const std::vector& cap.push_back(e.cap); cost.push_back(e.cost); } - return StrictMultiDiGraph(n, src, dst, cap, cost, q); + return Graph(n, src, dst, cap, cost, q); } // The caller's inverse of input_index(): input position -> EdgeId. -inline std::vector id_of_input(const StrictMultiDiGraph& g) { +inline std::vector id_of_input(const Graph& g) { auto ii = g.input_index(); std::vector inv(ii.size()); for (std::size_t e = 0; e < ii.size(); ++e) inv[static_cast(ii[e])] = static_cast(e); @@ -48,7 +53,7 @@ inline std::vector id_of_input(const StrictMultiDiGraph& g) { // A per-edge array in input order, permuted to id order. template -std::vector to_ids(const StrictMultiDiGraph& g, const std::vector& by_input) { +std::vector to_ids(const Graph& g, const std::vector& by_input) { auto ii = g.input_index(); std::vector out(ii.size()); for (std::size_t e = 0; e < ii.size(); ++e) out[e] = by_input[static_cast(ii[e])]; @@ -57,7 +62,7 @@ std::vector to_ids(const StrictMultiDiGraph& g, const std::vector& by_inpu // A per-edge array in id order, permuted to input order. template -std::vector to_input(const StrictMultiDiGraph& g, std::span by_id) { +std::vector to_input(const Graph& g, std::span by_id) { auto ii = g.input_index(); std::vector out(ii.size()); for (std::size_t e = 0; e < ii.size(); ++e) out[static_cast(ii[e])] = by_id[e]; @@ -65,12 +70,12 @@ std::vector to_input(const StrictMultiDiGraph& g, std::span by_id) { } template -std::vector to_input(const StrictMultiDiGraph& g, const std::vector& by_id) { +std::vector to_input(const Graph& g, const std::vector& by_id) { return to_input(g, std::span(by_id)); } // EdgeIds translated to sorted input positions. -inline std::vector ids_to_input(const StrictMultiDiGraph& g, const std::vector& ids) { +inline std::vector ids_to_input(const Graph& g, const std::vector& ids) { std::vector out; for (EdgeId e : ids) out.push_back(g.input_index()[static_cast(e)]); std::sort(out.begin(), out.end()); @@ -79,7 +84,7 @@ inline std::vector ids_to_input(const StrictMultiDiGraph& g, const inline std::vector> curve(std::initializer_list> c) { return c; } -inline FlowCoreResult solve(const StrictMultiDiGraph& g, std::vector s, std::vector d, +inline FlowCoreResult solve(const Graph& g, std::vector s, std::vector d, std::optional cap, const std::vector* view = nullptr, const Eligibility* elig = nullptr) { std::vector full(g.capacity_units().begin(), g.capacity_units().end()); @@ -97,7 +102,7 @@ inline bool same_payload(const FlowCoreResult& a, const FlowCoreResult& b) { // Value -> minimum cost of every integer flow value, by enumerating every integer flow on the // eligible edges (small graphs only). Independent of the solver. -inline std::map exhaustive_costs(const StrictMultiDiGraph& g, std::span view, +inline std::map exhaustive_costs(const Graph& g, std::span view, const std::vector& srcs, const std::vector& dsts, const Eligibility* elig) { const auto m = static_cast(g.num_edges()); @@ -143,7 +148,7 @@ inline std::map exhaustive_costs(const StrictMultiDiGraph& g, // Breakpoints [(value, cost)] of the min-cost curve by successive shortest paths with // Bellman-Ford on the residual (no potentials, no Dinic, its own arc order); run to // disconnection. Independent of the solver. -inline std::vector> independent_curve(const StrictMultiDiGraph& g, +inline std::vector> independent_curve(const Graph& g, std::span view, const std::vector& srcs, const std::vector& dsts, @@ -228,7 +233,7 @@ inline std::vector> payload_points(const FlowCoreRes // The certificate of APPROACH 2.2 in two ordered branches. Returns the failed clauses. With // `tier_a`, the complete curve is compared with the exhaustive optimum of every value; else with // the independent Bellman-Ford curve at the union of both breakpoint sets. -inline std::vector certify(const StrictMultiDiGraph& g, std::span view, +inline std::vector certify(const Graph& g, std::span view, std::vector srcs, std::vector dsts, std::optional cap, const Eligibility* elig, const FlowCoreResult& r, bool tier_a) { diff --git a/tests/data/policy_fixtures.json b/tests/data/policy_fixtures.json new file mode 100644 index 0000000..0a74e78 --- /dev/null +++ b/tests/data/policy_fixtures.json @@ -0,0 +1 @@ +{"format":"p2-fixtures/1","source":"prototype 2 of the design tree dev/design/2026-10-streamline (untracked by design): prototypes/p2_transaction in integer units (APPROACH 9), exported by gates/1c/export_fixture_data.py","fixtures":[{"name":"creation_fewer_routes","graphs":[{"n":2,"q":1.0,"edges":[[0,1,5,1]],"input_index":[0]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"CapacityAware","flow_count":2,"max_flow_count":2,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":20,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":5,"remaining":15,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":20},"local_outcomes":{"0":"exhausted"},"created":1,"sources":{"0":{"stage1":{"eb_target":10,"reservations":[{"route":[0],"S":[5],"T_bound":5,"reserved":5}],"created_cap_blocked":false},"stage3":{"T":[0,5],"bound":5,"shares":[5]},"stage4":[{"stop":"no candidate"}]}},"W_final":[0]},"ledger":{"allocated":[5],"residual":[0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":5,"edges":[[0,5]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":20,"infinite":false,"placed_demand":5,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":5,"delivered":5,"alloc":[[0,5]],"members":[0],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":1,"config":{"routing":{"kind":"CapacityAware","flow_count":2,"max_flow_count":2,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":20,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":5,"remaining":15,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":20},"local_outcomes":{"0":"exhausted"},"created":1,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0],"S":[5],"T_bound":5,"reserved":5}],"created_cap_blocked":false},"stage3":[{"T":[0,5],"curve":[[1,5]],"alloc":[[0,5]]}],"stage4":[{"stop":"no candidate"}]}},"W_final":[0]},"ledger":{"allocated":[5],"residual":[0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":5,"edges":[[0,5]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":20,"infinite":false,"placed_demand":5,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":5,"delivered":5,"alloc":[[0,5]],"members":[0],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":2,"error":false},{"op":"policy","ledger":2,"sources":0,"destinations":1,"config":{"routing":{"kind":"CapacityAware","flow_count":2,"max_flow_count":2,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"Weighted","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":2,"volume":20,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":5,"remaining":15,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":20},"local_outcomes":{"0":"exhausted"},"created":1,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0],"S":[5],"T_bound":5,"reserved":5}],"created_cap_blocked":false},"stage3":[{"T":[0,5],"curve":null,"alloc":[[0,5]]}],"stage4":[{"stop":"no candidate"}]}},"W_final":[0]},"ledger":{"allocated":[5],"residual":[0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"Weighted","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":5,"edges":[[0,5]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":20,"infinite":false,"placed_demand":5,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":5,"delivered":5,"alloc":[[0,5]],"members":[0],"weights":[[0,5]]}]]]}}}]},{"name":"creation_retry_after_release","graphs":[{"n":2,"q":1.0,"edges":[[0,1,5,1]],"input_index":[0]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":0,"volume":5,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":5,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":5},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,5],"curve":[[1,5]],"alloc":[[0,5]]}]}},"W_final":[0]},"ledger":{"allocated":[5],"residual":[0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":5,"edges":[[0,5]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":5,"infinite":false,"placed_demand":5,"usable":1,"cohorts":[[0,[{"id":0,"route":[0],"T":5,"delivered":5,"alloc":[[0,5]],"members":[0],"weights":null}]]]}}},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":null,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"no_route","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"no_route"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"no_route"},"created":0,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[],"created_cap_blocked":false}}},"W_final":[0]},"ledger":{"allocated":[5],"residual":[0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":5,"edges":[[0,5]],"drops":[]}],"next_flow_id":1,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"remove","policy":0,"error":false,"expect":{"ledger":{"allocated":[0],"residual":[5],"records":[],"next_flow_id":1,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":1,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0],"S":[5],"T_bound":5,"reserved":1}],"created_cap_blocked":false},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[0,1]]}],"stage4":[]}},"W_final":[4]},"ledger":{"allocated":[1],"residual":[4],"records":[{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":1,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]]]}}}]},{"name":"ten_versus_five","graphs":[{"n":4,"q":1.0,"edges":[[0,1,5,1],[1,3,5,1],[0,2,5,1],[2,3,5,1]],"input_index":[0,2,1,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"CapacityAware","flow_count":2,"max_flow_count":2,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":10,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":10,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":10},"local_outcomes":{"0":"complete"},"created":2,"sources":{"0":{"stage1":{"eb_target":5,"reservations":[{"route":[0,2],"S":[5,5,5,5],"T_bound":5,"reserved":5},{"route":[1,3],"S":[0,5,0,5],"T_bound":5,"reserved":5}],"created_cap_blocked":false},"stage3":{"T":[0,10],"bound":10,"shares":[5,5]},"stage4":[]}},"W_final":[0,0,0,0]},"ledger":{"allocated":[5,5,5,5],"residual":[0,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":5,"edges":[[0,5],[2,5]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":5,"edges":[[1,5],[3,5]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":10,"infinite":false,"placed_demand":10,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":5,"delivered":5,"alloc":[[0,5],[2,5]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":5,"delivered":5,"alloc":[[1,5],[3,5]],"members":[1,3],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":3,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":1,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":10,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":5,"remaining":5,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":10},"local_outcomes":{"0":"exhausted"},"created":1,"sources":{"0":{"stage1":{"eb_target":10,"reservations":[{"route":[0,2],"S":[5,5,5,5],"T_bound":5,"reserved":5}],"created_cap_blocked":false},"stage3":{"T":[0,5],"bound":5,"shares":[5]},"stage4":[{"stop":"max_flow_count","k":1}]}},"W_final":[0,5,0,5]},"ledger":{"allocated":[5,0,5,0],"residual":[0,5,0,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":5,"edges":[[0,5],[2,5]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":10,"infinite":false,"placed_demand":5,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":5,"delivered":5,"alloc":[[0,5],[2,5]],"members":[0,2],"weights":null}]]]}}}]},{"name":"eight_versus_ten_capacity","graphs":[{"n":4,"q":1.0,"edges":[[0,1,5,1],[1,3,5,1],[0,2,5,1],[2,3,5,1]],"input_index":[0,2,1,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"CapacityAware","flow_count":2,"max_flow_count":2,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":8,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":5,"remaining":3,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":8},"local_outcomes":{"0":"exhausted"},"created":2,"sources":{"0":{"stage1":{"eb_target":4,"reservations":[{"route":[0,2],"S":[5,5,5,5],"T_bound":5,"reserved":4},{"route":[0,2],"S":[1,5,1,5],"T_bound":1,"reserved":1}],"created_cap_blocked":false},"stage3":{"T":[0,5],"bound":5,"shares":[3,2]},"stage4":[{"stop":"max_flow_count","k":2}]}},"W_final":[0,5,0,5]},"ledger":{"allocated":[5,0,5,0],"residual":[0,5,0,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":3,"edges":[[0,3],[2,3]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,2],[2,2]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":8,"infinite":false,"placed_demand":5,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":3,"delivered":3,"alloc":[[0,3],[2,3]],"members":[0,2],"weights":null},{"id":1,"route":[0,2],"T":2,"delivered":2,"alloc":[[0,2],[2,2]],"members":[0,2],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":3,"config":{"routing":{"kind":"CapacityAware","flow_count":2,"max_flow_count":2,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":10,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":10,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":10},"local_outcomes":{"0":"complete"},"created":2,"sources":{"0":{"stage1":{"eb_target":5,"reservations":[{"route":[0,2],"S":[5,5,5,5],"T_bound":5,"reserved":5},{"route":[1,3],"S":[0,5,0,5],"T_bound":5,"reserved":5}],"created_cap_blocked":false},"stage3":{"T":[0,10],"bound":10,"shares":[5,5]},"stage4":[]}},"W_final":[0,0,0,0]},"ledger":{"allocated":[5,5,5,5],"residual":[0,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":5,"edges":[[0,5],[2,5]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":5,"edges":[[1,5],[3,5]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":10,"infinite":false,"placed_demand":10,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":5,"delivered":5,"alloc":[[0,5],[2,5]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":5,"delivered":5,"alloc":[[1,5],[3,5]],"members":[1,3],"weights":null}]]]}}}]},{"name":"eight_versus_ten_residual","graphs":[{"n":4,"q":1.0,"edges":[[0,1,5,1],[1,3,5,1],[0,2,5,1],[2,3,5,1]],"input_index":[0,2,1,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"CapacityAware","flow_count":2,"max_flow_count":2,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":8,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":8,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":8},"local_outcomes":{"0":"complete"},"created":2,"sources":{"0":{"stage1":{"eb_target":4,"reservations":[{"route":[0,2],"S":[5,5,5,5],"T_bound":5,"reserved":4},{"route":[1,3],"S":[1,5,1,5],"T_bound":5,"reserved":4}],"created_cap_blocked":false},"stage3":{"T":[0,8],"bound":10,"shares":[4,4]},"stage4":[]}},"W_final":[1,1,1,1]},"ledger":{"allocated":[4,4,4,4],"residual":[1,1,1,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":4,"edges":[[0,4],[2,4]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":4,"edges":[[1,4],[3,4]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":8,"infinite":false,"placed_demand":8,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":4,"delivered":4,"alloc":[[0,4],[2,4]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":4,"delivered":4,"alloc":[[1,4],[3,4]],"members":[1,3],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":3,"config":{"routing":{"kind":"CapacityAware","flow_count":2,"max_flow_count":2,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":10,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":10,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":10},"local_outcomes":{"0":"complete"},"created":2,"sources":{"0":{"stage1":{"eb_target":5,"reservations":[{"route":[0,2],"S":[5,5,5,5],"T_bound":5,"reserved":5},{"route":[1,3],"S":[0,5,0,5],"T_bound":5,"reserved":5}],"created_cap_blocked":false},"stage3":{"T":[0,10],"bound":10,"shares":[5,5]},"stage4":[]}},"W_final":[0,0,0,0]},"ledger":{"allocated":[5,5,5,5],"residual":[0,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":5,"edges":[[0,5],[2,5]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":5,"edges":[[1,5],[3,5]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":10,"infinite":false,"placed_demand":10,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":5,"delivered":5,"alloc":[[0,5],[2,5]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":5,"delivered":5,"alloc":[[1,5],[3,5]],"members":[1,3],"weights":null}]]]}}}]},{"name":"eight_versus_ten_headroom","graphs":[{"n":4,"q":1.0,"edges":[[0,1,5,1],[1,3,5,1],[0,2,5,1],[2,3,5,1]],"input_index":[0,2,1,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"CapacityAware","flow_count":2,"max_flow_count":2,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Headroom"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":8,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":8,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":8},"local_outcomes":{"0":"complete"},"created":2,"sources":{"0":{"stage1":{"eb_target":4,"reservations":[{"route":[0,2],"S":[5,5,5,5],"T_bound":5,"reserved":4},{"route":[1,3],"S":[1,5,1,5],"T_bound":5,"reserved":4}],"created_cap_blocked":false},"stage3":{"T":[0,8],"bound":10,"shares":[4,4]},"stage4":[]}},"W_final":[1,1,1,1]},"ledger":{"allocated":[4,4,4,4],"residual":[1,1,1,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":4,"edges":[[0,4],[2,4]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":4,"edges":[[1,4],[3,4]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":8,"infinite":false,"placed_demand":8,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":4,"delivered":4,"alloc":[[0,4],[2,4]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":4,"delivered":4,"alloc":[[1,4],[3,4]],"members":[1,3],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":3,"config":{"routing":{"kind":"CapacityAware","flow_count":2,"max_flow_count":2,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Headroom"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":10,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":10,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":10},"local_outcomes":{"0":"complete"},"created":2,"sources":{"0":{"stage1":{"eb_target":5,"reservations":[{"route":[0,2],"S":[5,5,5,5],"T_bound":5,"reserved":5},{"route":[1,3],"S":[0,5,0,5],"T_bound":5,"reserved":5}],"created_cap_blocked":false},"stage3":{"T":[0,10],"bound":10,"shares":[5,5]},"stage4":[]}},"W_final":[0,0,0,0]},"ledger":{"allocated":[5,5,5,5],"residual":[0,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":5,"edges":[[0,5],[2,5]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":5,"edges":[[1,5],[3,5]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":10,"infinite":false,"placed_demand":10,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":5,"delivered":5,"alloc":[[0,5],[2,5]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":5,"delivered":5,"alloc":[[1,5],[3,5]],"members":[1,3],"weights":null}]]]}}}]},{"name":"request_12_on_5_5","graphs":[{"n":4,"q":1.0,"edges":[[0,1,5,1],[1,3,5,1],[0,2,5,1],[2,3,5,1]],"input_index":[0,2,1,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"CapacityAware","flow_count":2,"max_flow_count":2,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":12,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":10,"remaining":2,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":12},"local_outcomes":{"0":"exhausted"},"created":2,"sources":{"0":{"stage1":{"eb_target":6,"reservations":[{"route":[0,2],"S":[5,5,5,5],"T_bound":5,"reserved":5},{"route":[1,3],"S":[0,5,0,5],"T_bound":5,"reserved":5}],"created_cap_blocked":false},"stage3":{"T":[0,10],"bound":10,"shares":[5,5]},"stage4":[{"stop":"max_flow_count","k":2}]}},"W_final":[0,0,0,0]},"ledger":{"allocated":[5,5,5,5],"residual":[0,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":5,"edges":[[0,5],[2,5]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":5,"edges":[[1,5],[3,5]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":12,"infinite":false,"placed_demand":10,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":5,"delivered":5,"alloc":[[0,5],[2,5]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":5,"delivered":5,"alloc":[[1,5],[3,5]],"members":[1,3],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":3,"config":{"routing":{"kind":"CapacityAware","flow_count":2,"max_flow_count":2,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":12,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":10,"remaining":2,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":12},"local_outcomes":{"0":"exhausted"},"created":2,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0,2],"S":[5,5,5,5],"T_bound":5,"reserved":5},{"route":[1,3],"S":[0,5,0,5],"T_bound":5,"reserved":5}],"created_cap_blocked":false},"stage3":[{"T":[0,5],"curve":[[2,5]],"alloc":[[0,5],[2,5]]},{"T":[0,5],"curve":[[2,5]],"alloc":[[1,5],[3,5]]}],"stage4":[{"stop":"max_flow_count","k":2}]}},"W_final":[0,0,0,0]},"ledger":{"allocated":[5,5,5,5],"residual":[0,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":5,"edges":[[0,5],[2,5]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":5,"edges":[[1,5],[3,5]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":12,"infinite":false,"placed_demand":10,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":5,"delivered":5,"alloc":[[0,5],[2,5]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":5,"delivered":5,"alloc":[[1,5],[3,5]],"members":[1,3],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":2,"error":false},{"op":"policy","ledger":2,"sources":0,"destinations":3,"config":{"routing":{"kind":"CapacityAware","flow_count":2,"max_flow_count":2,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"Weighted","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":2,"volume":12,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":10,"remaining":2,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":12},"local_outcomes":{"0":"exhausted"},"created":2,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0,2],"S":[5,5,5,5],"T_bound":5,"reserved":5},{"route":[1,3],"S":[0,5,0,5],"T_bound":5,"reserved":5}],"created_cap_blocked":false},"stage3":[{"T":[0,5],"curve":null,"alloc":[[0,5],[2,5]]},{"T":[0,5],"curve":null,"alloc":[[1,5],[3,5]]}],"stage4":[{"stop":"max_flow_count","k":2}]}},"W_final":[0,0,0,0]},"ledger":{"allocated":[5,5,5,5],"residual":[0,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":5,"edges":[[0,5],[2,5]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":5,"edges":[[1,5],[3,5]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":12,"infinite":false,"placed_demand":10,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":5,"delivered":5,"alloc":[[0,5],[2,5]],"members":[0,2],"weights":[[0,5],[2,5]]},{"id":1,"route":[1,3],"T":5,"delivered":5,"alloc":[[1,5],[3,5]],"members":[1,3],"weights":[[1,5],[3,5]]}]]]}}}]},{"name":"growth_request_6","graphs":[{"n":4,"q":1.0,"edges":[[0,1,5,1],[1,3,5,1],[0,2,5,1],[2,3,5,1]],"input_index":[0,2,1,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":2,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":6,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":6,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":6},"local_outcomes":{"0":"complete"},"created":2,"sources":{"0":{"stage1":{"eb_target":6,"reservations":[{"route":[0,2],"S":[5,5,5,5],"T_bound":5,"reserved":5}],"created_cap_blocked":false},"stage3":{"T":[0,5],"bound":5,"shares":[5]},"stage4":[{"route":[1,3],"W":[0,5,0,5],"T":5,"T_candidate":6,"bound":10,"shares":[3,3],"decision":"accepted","gain":1}]}},"W_final":[2,2,2,2]},"ledger":{"allocated":[3,3,3,3],"residual":[2,2,2,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":3,"edges":[[0,3],[2,3]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":3,"edges":[[1,3],[3,3]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":6,"infinite":false,"placed_demand":6,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":3,"delivered":3,"alloc":[[0,3],[2,3]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":3,"delivered":3,"alloc":[[1,3],[3,3]],"members":[1,3],"weights":null}]]]}}}]},{"name":"growth_256_request_1278","graphs":[{"n":2,"q":1.0,"edges":[[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1],[0,1,5,1]],"input_index":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":256,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":1278,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1278,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1278},"local_outcomes":{"0":"complete"},"created":256,"sources":{"0":{"stage1":{"eb_target":1278,"reservations":[{"route":[0],"S":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T_bound":5,"reserved":5}],"created_cap_blocked":false},"stage3":{"T":[0,5],"bound":5,"shares":[5]},"stage4":[{"route":[1],"W":[0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":5,"T_candidate":10,"bound":10,"shares":[5,5],"decision":"accepted","gain":5},{"route":[2],"W":[0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":10,"T_candidate":15,"bound":15,"shares":[5,5,5],"decision":"accepted","gain":5},{"route":[3],"W":[0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":15,"T_candidate":20,"bound":20,"shares":[5,5,5,5],"decision":"accepted","gain":5},{"route":[4],"W":[0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":20,"T_candidate":25,"bound":25,"shares":[5,5,5,5,5],"decision":"accepted","gain":5},{"route":[5],"W":[0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":25,"T_candidate":30,"bound":30,"shares":[5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[6],"W":[0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":30,"T_candidate":35,"bound":35,"shares":[5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[7],"W":[0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":35,"T_candidate":40,"bound":40,"shares":[5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[8],"W":[0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":40,"T_candidate":45,"bound":45,"shares":[5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[9],"W":[0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":45,"T_candidate":50,"bound":50,"shares":[5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[10],"W":[0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":50,"T_candidate":55,"bound":55,"shares":[5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[11],"W":[0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":55,"T_candidate":60,"bound":60,"shares":[5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[12],"W":[0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":60,"T_candidate":65,"bound":65,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[13],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":65,"T_candidate":70,"bound":70,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[14],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":70,"T_candidate":75,"bound":75,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[15],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":75,"T_candidate":80,"bound":80,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[16],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":80,"T_candidate":85,"bound":85,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[17],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":85,"T_candidate":90,"bound":90,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[18],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":90,"T_candidate":95,"bound":95,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[19],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":95,"T_candidate":100,"bound":100,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[20],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":100,"T_candidate":105,"bound":105,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[21],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":105,"T_candidate":110,"bound":110,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[22],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":110,"T_candidate":115,"bound":115,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[23],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":115,"T_candidate":120,"bound":120,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[24],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":120,"T_candidate":125,"bound":125,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[25],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":125,"T_candidate":130,"bound":130,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[26],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":130,"T_candidate":135,"bound":135,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[27],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":135,"T_candidate":140,"bound":140,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[28],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":140,"T_candidate":145,"bound":145,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[29],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":145,"T_candidate":150,"bound":150,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[30],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":150,"T_candidate":155,"bound":155,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[31],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":155,"T_candidate":160,"bound":160,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[32],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":160,"T_candidate":165,"bound":165,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[33],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":165,"T_candidate":170,"bound":170,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[34],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":170,"T_candidate":175,"bound":175,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[35],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":175,"T_candidate":180,"bound":180,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[36],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":180,"T_candidate":185,"bound":185,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[37],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":185,"T_candidate":190,"bound":190,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[38],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":190,"T_candidate":195,"bound":195,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[39],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":195,"T_candidate":200,"bound":200,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[40],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":200,"T_candidate":205,"bound":205,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[41],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":205,"T_candidate":210,"bound":210,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[42],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":210,"T_candidate":215,"bound":215,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[43],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":215,"T_candidate":220,"bound":220,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[44],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":220,"T_candidate":225,"bound":225,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[45],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":225,"T_candidate":230,"bound":230,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[46],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":230,"T_candidate":235,"bound":235,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[47],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":235,"T_candidate":240,"bound":240,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[48],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":240,"T_candidate":245,"bound":245,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[49],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":245,"T_candidate":250,"bound":250,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[50],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":250,"T_candidate":255,"bound":255,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[51],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":255,"T_candidate":260,"bound":260,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[52],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":260,"T_candidate":265,"bound":265,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[53],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":265,"T_candidate":270,"bound":270,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[54],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":270,"T_candidate":275,"bound":275,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[55],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":275,"T_candidate":280,"bound":280,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[56],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":280,"T_candidate":285,"bound":285,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[57],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":285,"T_candidate":290,"bound":290,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[58],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":290,"T_candidate":295,"bound":295,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[59],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":295,"T_candidate":300,"bound":300,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[60],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":300,"T_candidate":305,"bound":305,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[61],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":305,"T_candidate":310,"bound":310,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[62],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":310,"T_candidate":315,"bound":315,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[63],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":315,"T_candidate":320,"bound":320,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[64],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":320,"T_candidate":325,"bound":325,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[65],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":325,"T_candidate":330,"bound":330,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[66],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":330,"T_candidate":335,"bound":335,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[67],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":335,"T_candidate":340,"bound":340,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[68],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":340,"T_candidate":345,"bound":345,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[69],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":345,"T_candidate":350,"bound":350,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[70],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":350,"T_candidate":355,"bound":355,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[71],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":355,"T_candidate":360,"bound":360,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[72],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":360,"T_candidate":365,"bound":365,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[73],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":365,"T_candidate":370,"bound":370,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[74],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":370,"T_candidate":375,"bound":375,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[75],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":375,"T_candidate":380,"bound":380,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[76],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":380,"T_candidate":385,"bound":385,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[77],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":385,"T_candidate":390,"bound":390,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[78],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":390,"T_candidate":395,"bound":395,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[79],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":395,"T_candidate":400,"bound":400,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[80],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":400,"T_candidate":405,"bound":405,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[81],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":405,"T_candidate":410,"bound":410,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[82],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":410,"T_candidate":415,"bound":415,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[83],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":415,"T_candidate":420,"bound":420,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[84],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":420,"T_candidate":425,"bound":425,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[85],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":425,"T_candidate":430,"bound":430,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[86],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":430,"T_candidate":435,"bound":435,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[87],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":435,"T_candidate":440,"bound":440,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[88],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":440,"T_candidate":445,"bound":445,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[89],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":445,"T_candidate":450,"bound":450,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[90],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":450,"T_candidate":455,"bound":455,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[91],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":455,"T_candidate":460,"bound":460,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[92],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":460,"T_candidate":465,"bound":465,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[93],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":465,"T_candidate":470,"bound":470,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[94],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":470,"T_candidate":475,"bound":475,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[95],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":475,"T_candidate":480,"bound":480,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[96],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":480,"T_candidate":485,"bound":485,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[97],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":485,"T_candidate":490,"bound":490,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[98],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":490,"T_candidate":495,"bound":495,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[99],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":495,"T_candidate":500,"bound":500,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[100],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":500,"T_candidate":505,"bound":505,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[101],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":505,"T_candidate":510,"bound":510,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[102],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":510,"T_candidate":515,"bound":515,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[103],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":515,"T_candidate":520,"bound":520,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[104],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":520,"T_candidate":525,"bound":525,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[105],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":525,"T_candidate":530,"bound":530,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[106],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":530,"T_candidate":535,"bound":535,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[107],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":535,"T_candidate":540,"bound":540,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[108],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":540,"T_candidate":545,"bound":545,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[109],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":545,"T_candidate":550,"bound":550,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[110],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":550,"T_candidate":555,"bound":555,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[111],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":555,"T_candidate":560,"bound":560,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[112],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":560,"T_candidate":565,"bound":565,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[113],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":565,"T_candidate":570,"bound":570,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[114],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":570,"T_candidate":575,"bound":575,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[115],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":575,"T_candidate":580,"bound":580,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[116],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":580,"T_candidate":585,"bound":585,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[117],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":585,"T_candidate":590,"bound":590,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[118],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":590,"T_candidate":595,"bound":595,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[119],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":595,"T_candidate":600,"bound":600,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[120],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":600,"T_candidate":605,"bound":605,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[121],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":605,"T_candidate":610,"bound":610,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[122],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":610,"T_candidate":615,"bound":615,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[123],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":615,"T_candidate":620,"bound":620,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[124],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":620,"T_candidate":625,"bound":625,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[125],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":625,"T_candidate":630,"bound":630,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[126],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":630,"T_candidate":635,"bound":635,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[127],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":635,"T_candidate":640,"bound":640,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[128],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":640,"T_candidate":645,"bound":645,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[129],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":645,"T_candidate":650,"bound":650,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[130],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":650,"T_candidate":655,"bound":655,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[131],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":655,"T_candidate":660,"bound":660,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[132],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":660,"T_candidate":665,"bound":665,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[133],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":665,"T_candidate":670,"bound":670,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[134],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":670,"T_candidate":675,"bound":675,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[135],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":675,"T_candidate":680,"bound":680,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[136],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":680,"T_candidate":685,"bound":685,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[137],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":685,"T_candidate":690,"bound":690,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[138],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":690,"T_candidate":695,"bound":695,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[139],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":695,"T_candidate":700,"bound":700,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[140],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":700,"T_candidate":705,"bound":705,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[141],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":705,"T_candidate":710,"bound":710,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[142],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":710,"T_candidate":715,"bound":715,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[143],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":715,"T_candidate":720,"bound":720,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[144],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":720,"T_candidate":725,"bound":725,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[145],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":725,"T_candidate":730,"bound":730,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[146],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":730,"T_candidate":735,"bound":735,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[147],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":735,"T_candidate":740,"bound":740,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[148],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":740,"T_candidate":745,"bound":745,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[149],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":745,"T_candidate":750,"bound":750,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[150],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":750,"T_candidate":755,"bound":755,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[151],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":755,"T_candidate":760,"bound":760,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[152],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":760,"T_candidate":765,"bound":765,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[153],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":765,"T_candidate":770,"bound":770,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[154],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":770,"T_candidate":775,"bound":775,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[155],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":775,"T_candidate":780,"bound":780,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[156],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":780,"T_candidate":785,"bound":785,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[157],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":785,"T_candidate":790,"bound":790,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[158],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":790,"T_candidate":795,"bound":795,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[159],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":795,"T_candidate":800,"bound":800,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[160],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":800,"T_candidate":805,"bound":805,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[161],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":805,"T_candidate":810,"bound":810,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[162],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":810,"T_candidate":815,"bound":815,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[163],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":815,"T_candidate":820,"bound":820,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[164],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":820,"T_candidate":825,"bound":825,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[165],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":825,"T_candidate":830,"bound":830,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[166],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":830,"T_candidate":835,"bound":835,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[167],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":835,"T_candidate":840,"bound":840,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[168],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":840,"T_candidate":845,"bound":845,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[169],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":845,"T_candidate":850,"bound":850,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[170],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":850,"T_candidate":855,"bound":855,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[171],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":855,"T_candidate":860,"bound":860,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[172],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":860,"T_candidate":865,"bound":865,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[173],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":865,"T_candidate":870,"bound":870,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[174],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":870,"T_candidate":875,"bound":875,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[175],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":875,"T_candidate":880,"bound":880,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[176],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":880,"T_candidate":885,"bound":885,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[177],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":885,"T_candidate":890,"bound":890,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[178],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":890,"T_candidate":895,"bound":895,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[179],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":895,"T_candidate":900,"bound":900,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[180],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":900,"T_candidate":905,"bound":905,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[181],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":905,"T_candidate":910,"bound":910,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[182],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":910,"T_candidate":915,"bound":915,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[183],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":915,"T_candidate":920,"bound":920,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[184],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":920,"T_candidate":925,"bound":925,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[185],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":925,"T_candidate":930,"bound":930,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[186],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":930,"T_candidate":935,"bound":935,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[187],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":935,"T_candidate":940,"bound":940,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[188],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":940,"T_candidate":945,"bound":945,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[189],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":945,"T_candidate":950,"bound":950,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[190],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":950,"T_candidate":955,"bound":955,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[191],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":955,"T_candidate":960,"bound":960,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[192],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":960,"T_candidate":965,"bound":965,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[193],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":965,"T_candidate":970,"bound":970,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[194],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":970,"T_candidate":975,"bound":975,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[195],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":975,"T_candidate":980,"bound":980,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[196],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":980,"T_candidate":985,"bound":985,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[197],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":985,"T_candidate":990,"bound":990,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[198],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":990,"T_candidate":995,"bound":995,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[199],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":995,"T_candidate":1000,"bound":1000,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[200],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1000,"T_candidate":1005,"bound":1005,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[201],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1005,"T_candidate":1010,"bound":1010,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[202],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1010,"T_candidate":1015,"bound":1015,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[203],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1015,"T_candidate":1020,"bound":1020,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[204],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1020,"T_candidate":1025,"bound":1025,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[205],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1025,"T_candidate":1030,"bound":1030,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[206],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1030,"T_candidate":1035,"bound":1035,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[207],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1035,"T_candidate":1040,"bound":1040,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[208],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1040,"T_candidate":1045,"bound":1045,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[209],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1045,"T_candidate":1050,"bound":1050,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[210],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1050,"T_candidate":1055,"bound":1055,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[211],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1055,"T_candidate":1060,"bound":1060,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[212],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1060,"T_candidate":1065,"bound":1065,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[213],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1065,"T_candidate":1070,"bound":1070,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[214],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1070,"T_candidate":1075,"bound":1075,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[215],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1075,"T_candidate":1080,"bound":1080,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[216],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1080,"T_candidate":1085,"bound":1085,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[217],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1085,"T_candidate":1090,"bound":1090,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[218],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1090,"T_candidate":1095,"bound":1095,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[219],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1095,"T_candidate":1100,"bound":1100,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[220],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1100,"T_candidate":1105,"bound":1105,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[221],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1105,"T_candidate":1110,"bound":1110,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[222],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1110,"T_candidate":1115,"bound":1115,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[223],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1115,"T_candidate":1120,"bound":1120,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[224],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1120,"T_candidate":1125,"bound":1125,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[225],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1125,"T_candidate":1130,"bound":1130,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[226],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1130,"T_candidate":1135,"bound":1135,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[227],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1135,"T_candidate":1140,"bound":1140,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[228],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1140,"T_candidate":1145,"bound":1145,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[229],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1145,"T_candidate":1150,"bound":1150,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[230],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1150,"T_candidate":1155,"bound":1155,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[231],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1155,"T_candidate":1160,"bound":1160,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[232],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1160,"T_candidate":1165,"bound":1165,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[233],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1165,"T_candidate":1170,"bound":1170,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[234],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1170,"T_candidate":1175,"bound":1175,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[235],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1175,"T_candidate":1180,"bound":1180,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[236],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1180,"T_candidate":1185,"bound":1185,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[237],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1185,"T_candidate":1190,"bound":1190,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[238],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1190,"T_candidate":1195,"bound":1195,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[239],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1195,"T_candidate":1200,"bound":1200,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[240],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1200,"T_candidate":1205,"bound":1205,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[241],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1205,"T_candidate":1210,"bound":1210,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[242],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1210,"T_candidate":1215,"bound":1215,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[243],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5,5],"T":1215,"T_candidate":1220,"bound":1220,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[244],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5,5],"T":1220,"T_candidate":1225,"bound":1225,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[245],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5,5],"T":1225,"T_candidate":1230,"bound":1230,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[246],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5,5],"T":1230,"T_candidate":1235,"bound":1235,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[247],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5,5],"T":1235,"T_candidate":1240,"bound":1240,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[248],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5,5],"T":1240,"T_candidate":1245,"bound":1245,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[249],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,5],"T":1245,"T_candidate":1250,"bound":1250,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[250],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5],"T":1250,"T_candidate":1255,"bound":1255,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[251],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5],"T":1255,"T_candidate":1260,"bound":1260,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[252],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5],"T":1260,"T_candidate":1265,"bound":1265,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[253],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5],"T":1265,"T_candidate":1270,"bound":1270,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[254],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5],"T":1270,"T_candidate":1275,"bound":1275,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5],"decision":"accepted","gain":5},{"route":[255],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5],"T":1275,"T_candidate":1278,"bound":1280,"shares":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,4,4],"decision":"accepted","gain":3}]}},"W_final":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1]},"ledger":{"allocated":[5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,4,4],"residual":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":5,"edges":[[0,5]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":5,"edges":[[1,5]],"drops":[]},{"flow_id":2,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[2],"route_cost":1,"offered":null,"delivered":5,"edges":[[2,5]],"drops":[]},{"flow_id":3,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[3],"route_cost":1,"offered":null,"delivered":5,"edges":[[3,5]],"drops":[]},{"flow_id":4,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[4],"route_cost":1,"offered":null,"delivered":5,"edges":[[4,5]],"drops":[]},{"flow_id":5,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[5],"route_cost":1,"offered":null,"delivered":5,"edges":[[5,5]],"drops":[]},{"flow_id":6,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[6],"route_cost":1,"offered":null,"delivered":5,"edges":[[6,5]],"drops":[]},{"flow_id":7,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[7],"route_cost":1,"offered":null,"delivered":5,"edges":[[7,5]],"drops":[]},{"flow_id":8,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[8],"route_cost":1,"offered":null,"delivered":5,"edges":[[8,5]],"drops":[]},{"flow_id":9,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[9],"route_cost":1,"offered":null,"delivered":5,"edges":[[9,5]],"drops":[]},{"flow_id":10,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[10],"route_cost":1,"offered":null,"delivered":5,"edges":[[10,5]],"drops":[]},{"flow_id":11,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[11],"route_cost":1,"offered":null,"delivered":5,"edges":[[11,5]],"drops":[]},{"flow_id":12,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[12],"route_cost":1,"offered":null,"delivered":5,"edges":[[12,5]],"drops":[]},{"flow_id":13,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[13],"route_cost":1,"offered":null,"delivered":5,"edges":[[13,5]],"drops":[]},{"flow_id":14,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[14],"route_cost":1,"offered":null,"delivered":5,"edges":[[14,5]],"drops":[]},{"flow_id":15,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[15],"route_cost":1,"offered":null,"delivered":5,"edges":[[15,5]],"drops":[]},{"flow_id":16,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[16],"route_cost":1,"offered":null,"delivered":5,"edges":[[16,5]],"drops":[]},{"flow_id":17,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[17],"route_cost":1,"offered":null,"delivered":5,"edges":[[17,5]],"drops":[]},{"flow_id":18,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[18],"route_cost":1,"offered":null,"delivered":5,"edges":[[18,5]],"drops":[]},{"flow_id":19,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[19],"route_cost":1,"offered":null,"delivered":5,"edges":[[19,5]],"drops":[]},{"flow_id":20,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[20],"route_cost":1,"offered":null,"delivered":5,"edges":[[20,5]],"drops":[]},{"flow_id":21,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[21],"route_cost":1,"offered":null,"delivered":5,"edges":[[21,5]],"drops":[]},{"flow_id":22,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[22],"route_cost":1,"offered":null,"delivered":5,"edges":[[22,5]],"drops":[]},{"flow_id":23,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[23],"route_cost":1,"offered":null,"delivered":5,"edges":[[23,5]],"drops":[]},{"flow_id":24,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[24],"route_cost":1,"offered":null,"delivered":5,"edges":[[24,5]],"drops":[]},{"flow_id":25,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[25],"route_cost":1,"offered":null,"delivered":5,"edges":[[25,5]],"drops":[]},{"flow_id":26,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[26],"route_cost":1,"offered":null,"delivered":5,"edges":[[26,5]],"drops":[]},{"flow_id":27,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[27],"route_cost":1,"offered":null,"delivered":5,"edges":[[27,5]],"drops":[]},{"flow_id":28,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[28],"route_cost":1,"offered":null,"delivered":5,"edges":[[28,5]],"drops":[]},{"flow_id":29,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[29],"route_cost":1,"offered":null,"delivered":5,"edges":[[29,5]],"drops":[]},{"flow_id":30,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[30],"route_cost":1,"offered":null,"delivered":5,"edges":[[30,5]],"drops":[]},{"flow_id":31,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[31],"route_cost":1,"offered":null,"delivered":5,"edges":[[31,5]],"drops":[]},{"flow_id":32,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[32],"route_cost":1,"offered":null,"delivered":5,"edges":[[32,5]],"drops":[]},{"flow_id":33,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[33],"route_cost":1,"offered":null,"delivered":5,"edges":[[33,5]],"drops":[]},{"flow_id":34,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[34],"route_cost":1,"offered":null,"delivered":5,"edges":[[34,5]],"drops":[]},{"flow_id":35,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[35],"route_cost":1,"offered":null,"delivered":5,"edges":[[35,5]],"drops":[]},{"flow_id":36,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[36],"route_cost":1,"offered":null,"delivered":5,"edges":[[36,5]],"drops":[]},{"flow_id":37,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[37],"route_cost":1,"offered":null,"delivered":5,"edges":[[37,5]],"drops":[]},{"flow_id":38,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[38],"route_cost":1,"offered":null,"delivered":5,"edges":[[38,5]],"drops":[]},{"flow_id":39,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[39],"route_cost":1,"offered":null,"delivered":5,"edges":[[39,5]],"drops":[]},{"flow_id":40,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[40],"route_cost":1,"offered":null,"delivered":5,"edges":[[40,5]],"drops":[]},{"flow_id":41,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[41],"route_cost":1,"offered":null,"delivered":5,"edges":[[41,5]],"drops":[]},{"flow_id":42,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[42],"route_cost":1,"offered":null,"delivered":5,"edges":[[42,5]],"drops":[]},{"flow_id":43,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[43],"route_cost":1,"offered":null,"delivered":5,"edges":[[43,5]],"drops":[]},{"flow_id":44,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[44],"route_cost":1,"offered":null,"delivered":5,"edges":[[44,5]],"drops":[]},{"flow_id":45,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[45],"route_cost":1,"offered":null,"delivered":5,"edges":[[45,5]],"drops":[]},{"flow_id":46,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[46],"route_cost":1,"offered":null,"delivered":5,"edges":[[46,5]],"drops":[]},{"flow_id":47,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[47],"route_cost":1,"offered":null,"delivered":5,"edges":[[47,5]],"drops":[]},{"flow_id":48,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[48],"route_cost":1,"offered":null,"delivered":5,"edges":[[48,5]],"drops":[]},{"flow_id":49,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[49],"route_cost":1,"offered":null,"delivered":5,"edges":[[49,5]],"drops":[]},{"flow_id":50,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[50],"route_cost":1,"offered":null,"delivered":5,"edges":[[50,5]],"drops":[]},{"flow_id":51,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[51],"route_cost":1,"offered":null,"delivered":5,"edges":[[51,5]],"drops":[]},{"flow_id":52,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[52],"route_cost":1,"offered":null,"delivered":5,"edges":[[52,5]],"drops":[]},{"flow_id":53,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[53],"route_cost":1,"offered":null,"delivered":5,"edges":[[53,5]],"drops":[]},{"flow_id":54,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[54],"route_cost":1,"offered":null,"delivered":5,"edges":[[54,5]],"drops":[]},{"flow_id":55,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[55],"route_cost":1,"offered":null,"delivered":5,"edges":[[55,5]],"drops":[]},{"flow_id":56,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[56],"route_cost":1,"offered":null,"delivered":5,"edges":[[56,5]],"drops":[]},{"flow_id":57,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[57],"route_cost":1,"offered":null,"delivered":5,"edges":[[57,5]],"drops":[]},{"flow_id":58,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[58],"route_cost":1,"offered":null,"delivered":5,"edges":[[58,5]],"drops":[]},{"flow_id":59,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[59],"route_cost":1,"offered":null,"delivered":5,"edges":[[59,5]],"drops":[]},{"flow_id":60,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[60],"route_cost":1,"offered":null,"delivered":5,"edges":[[60,5]],"drops":[]},{"flow_id":61,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[61],"route_cost":1,"offered":null,"delivered":5,"edges":[[61,5]],"drops":[]},{"flow_id":62,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[62],"route_cost":1,"offered":null,"delivered":5,"edges":[[62,5]],"drops":[]},{"flow_id":63,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[63],"route_cost":1,"offered":null,"delivered":5,"edges":[[63,5]],"drops":[]},{"flow_id":64,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[64],"route_cost":1,"offered":null,"delivered":5,"edges":[[64,5]],"drops":[]},{"flow_id":65,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[65],"route_cost":1,"offered":null,"delivered":5,"edges":[[65,5]],"drops":[]},{"flow_id":66,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[66],"route_cost":1,"offered":null,"delivered":5,"edges":[[66,5]],"drops":[]},{"flow_id":67,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[67],"route_cost":1,"offered":null,"delivered":5,"edges":[[67,5]],"drops":[]},{"flow_id":68,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[68],"route_cost":1,"offered":null,"delivered":5,"edges":[[68,5]],"drops":[]},{"flow_id":69,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[69],"route_cost":1,"offered":null,"delivered":5,"edges":[[69,5]],"drops":[]},{"flow_id":70,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[70],"route_cost":1,"offered":null,"delivered":5,"edges":[[70,5]],"drops":[]},{"flow_id":71,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[71],"route_cost":1,"offered":null,"delivered":5,"edges":[[71,5]],"drops":[]},{"flow_id":72,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[72],"route_cost":1,"offered":null,"delivered":5,"edges":[[72,5]],"drops":[]},{"flow_id":73,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[73],"route_cost":1,"offered":null,"delivered":5,"edges":[[73,5]],"drops":[]},{"flow_id":74,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[74],"route_cost":1,"offered":null,"delivered":5,"edges":[[74,5]],"drops":[]},{"flow_id":75,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[75],"route_cost":1,"offered":null,"delivered":5,"edges":[[75,5]],"drops":[]},{"flow_id":76,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[76],"route_cost":1,"offered":null,"delivered":5,"edges":[[76,5]],"drops":[]},{"flow_id":77,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[77],"route_cost":1,"offered":null,"delivered":5,"edges":[[77,5]],"drops":[]},{"flow_id":78,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[78],"route_cost":1,"offered":null,"delivered":5,"edges":[[78,5]],"drops":[]},{"flow_id":79,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[79],"route_cost":1,"offered":null,"delivered":5,"edges":[[79,5]],"drops":[]},{"flow_id":80,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[80],"route_cost":1,"offered":null,"delivered":5,"edges":[[80,5]],"drops":[]},{"flow_id":81,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[81],"route_cost":1,"offered":null,"delivered":5,"edges":[[81,5]],"drops":[]},{"flow_id":82,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[82],"route_cost":1,"offered":null,"delivered":5,"edges":[[82,5]],"drops":[]},{"flow_id":83,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[83],"route_cost":1,"offered":null,"delivered":5,"edges":[[83,5]],"drops":[]},{"flow_id":84,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[84],"route_cost":1,"offered":null,"delivered":5,"edges":[[84,5]],"drops":[]},{"flow_id":85,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[85],"route_cost":1,"offered":null,"delivered":5,"edges":[[85,5]],"drops":[]},{"flow_id":86,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[86],"route_cost":1,"offered":null,"delivered":5,"edges":[[86,5]],"drops":[]},{"flow_id":87,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[87],"route_cost":1,"offered":null,"delivered":5,"edges":[[87,5]],"drops":[]},{"flow_id":88,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[88],"route_cost":1,"offered":null,"delivered":5,"edges":[[88,5]],"drops":[]},{"flow_id":89,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[89],"route_cost":1,"offered":null,"delivered":5,"edges":[[89,5]],"drops":[]},{"flow_id":90,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[90],"route_cost":1,"offered":null,"delivered":5,"edges":[[90,5]],"drops":[]},{"flow_id":91,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[91],"route_cost":1,"offered":null,"delivered":5,"edges":[[91,5]],"drops":[]},{"flow_id":92,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[92],"route_cost":1,"offered":null,"delivered":5,"edges":[[92,5]],"drops":[]},{"flow_id":93,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[93],"route_cost":1,"offered":null,"delivered":5,"edges":[[93,5]],"drops":[]},{"flow_id":94,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[94],"route_cost":1,"offered":null,"delivered":5,"edges":[[94,5]],"drops":[]},{"flow_id":95,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[95],"route_cost":1,"offered":null,"delivered":5,"edges":[[95,5]],"drops":[]},{"flow_id":96,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[96],"route_cost":1,"offered":null,"delivered":5,"edges":[[96,5]],"drops":[]},{"flow_id":97,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[97],"route_cost":1,"offered":null,"delivered":5,"edges":[[97,5]],"drops":[]},{"flow_id":98,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[98],"route_cost":1,"offered":null,"delivered":5,"edges":[[98,5]],"drops":[]},{"flow_id":99,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[99],"route_cost":1,"offered":null,"delivered":5,"edges":[[99,5]],"drops":[]},{"flow_id":100,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[100],"route_cost":1,"offered":null,"delivered":5,"edges":[[100,5]],"drops":[]},{"flow_id":101,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[101],"route_cost":1,"offered":null,"delivered":5,"edges":[[101,5]],"drops":[]},{"flow_id":102,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[102],"route_cost":1,"offered":null,"delivered":5,"edges":[[102,5]],"drops":[]},{"flow_id":103,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[103],"route_cost":1,"offered":null,"delivered":5,"edges":[[103,5]],"drops":[]},{"flow_id":104,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[104],"route_cost":1,"offered":null,"delivered":5,"edges":[[104,5]],"drops":[]},{"flow_id":105,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[105],"route_cost":1,"offered":null,"delivered":5,"edges":[[105,5]],"drops":[]},{"flow_id":106,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[106],"route_cost":1,"offered":null,"delivered":5,"edges":[[106,5]],"drops":[]},{"flow_id":107,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[107],"route_cost":1,"offered":null,"delivered":5,"edges":[[107,5]],"drops":[]},{"flow_id":108,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[108],"route_cost":1,"offered":null,"delivered":5,"edges":[[108,5]],"drops":[]},{"flow_id":109,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[109],"route_cost":1,"offered":null,"delivered":5,"edges":[[109,5]],"drops":[]},{"flow_id":110,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[110],"route_cost":1,"offered":null,"delivered":5,"edges":[[110,5]],"drops":[]},{"flow_id":111,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[111],"route_cost":1,"offered":null,"delivered":5,"edges":[[111,5]],"drops":[]},{"flow_id":112,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[112],"route_cost":1,"offered":null,"delivered":5,"edges":[[112,5]],"drops":[]},{"flow_id":113,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[113],"route_cost":1,"offered":null,"delivered":5,"edges":[[113,5]],"drops":[]},{"flow_id":114,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[114],"route_cost":1,"offered":null,"delivered":5,"edges":[[114,5]],"drops":[]},{"flow_id":115,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[115],"route_cost":1,"offered":null,"delivered":5,"edges":[[115,5]],"drops":[]},{"flow_id":116,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[116],"route_cost":1,"offered":null,"delivered":5,"edges":[[116,5]],"drops":[]},{"flow_id":117,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[117],"route_cost":1,"offered":null,"delivered":5,"edges":[[117,5]],"drops":[]},{"flow_id":118,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[118],"route_cost":1,"offered":null,"delivered":5,"edges":[[118,5]],"drops":[]},{"flow_id":119,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[119],"route_cost":1,"offered":null,"delivered":5,"edges":[[119,5]],"drops":[]},{"flow_id":120,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[120],"route_cost":1,"offered":null,"delivered":5,"edges":[[120,5]],"drops":[]},{"flow_id":121,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[121],"route_cost":1,"offered":null,"delivered":5,"edges":[[121,5]],"drops":[]},{"flow_id":122,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[122],"route_cost":1,"offered":null,"delivered":5,"edges":[[122,5]],"drops":[]},{"flow_id":123,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[123],"route_cost":1,"offered":null,"delivered":5,"edges":[[123,5]],"drops":[]},{"flow_id":124,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[124],"route_cost":1,"offered":null,"delivered":5,"edges":[[124,5]],"drops":[]},{"flow_id":125,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[125],"route_cost":1,"offered":null,"delivered":5,"edges":[[125,5]],"drops":[]},{"flow_id":126,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[126],"route_cost":1,"offered":null,"delivered":5,"edges":[[126,5]],"drops":[]},{"flow_id":127,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[127],"route_cost":1,"offered":null,"delivered":5,"edges":[[127,5]],"drops":[]},{"flow_id":128,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[128],"route_cost":1,"offered":null,"delivered":5,"edges":[[128,5]],"drops":[]},{"flow_id":129,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[129],"route_cost":1,"offered":null,"delivered":5,"edges":[[129,5]],"drops":[]},{"flow_id":130,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[130],"route_cost":1,"offered":null,"delivered":5,"edges":[[130,5]],"drops":[]},{"flow_id":131,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[131],"route_cost":1,"offered":null,"delivered":5,"edges":[[131,5]],"drops":[]},{"flow_id":132,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[132],"route_cost":1,"offered":null,"delivered":5,"edges":[[132,5]],"drops":[]},{"flow_id":133,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[133],"route_cost":1,"offered":null,"delivered":5,"edges":[[133,5]],"drops":[]},{"flow_id":134,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[134],"route_cost":1,"offered":null,"delivered":5,"edges":[[134,5]],"drops":[]},{"flow_id":135,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[135],"route_cost":1,"offered":null,"delivered":5,"edges":[[135,5]],"drops":[]},{"flow_id":136,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[136],"route_cost":1,"offered":null,"delivered":5,"edges":[[136,5]],"drops":[]},{"flow_id":137,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[137],"route_cost":1,"offered":null,"delivered":5,"edges":[[137,5]],"drops":[]},{"flow_id":138,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[138],"route_cost":1,"offered":null,"delivered":5,"edges":[[138,5]],"drops":[]},{"flow_id":139,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[139],"route_cost":1,"offered":null,"delivered":5,"edges":[[139,5]],"drops":[]},{"flow_id":140,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[140],"route_cost":1,"offered":null,"delivered":5,"edges":[[140,5]],"drops":[]},{"flow_id":141,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[141],"route_cost":1,"offered":null,"delivered":5,"edges":[[141,5]],"drops":[]},{"flow_id":142,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[142],"route_cost":1,"offered":null,"delivered":5,"edges":[[142,5]],"drops":[]},{"flow_id":143,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[143],"route_cost":1,"offered":null,"delivered":5,"edges":[[143,5]],"drops":[]},{"flow_id":144,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[144],"route_cost":1,"offered":null,"delivered":5,"edges":[[144,5]],"drops":[]},{"flow_id":145,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[145],"route_cost":1,"offered":null,"delivered":5,"edges":[[145,5]],"drops":[]},{"flow_id":146,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[146],"route_cost":1,"offered":null,"delivered":5,"edges":[[146,5]],"drops":[]},{"flow_id":147,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[147],"route_cost":1,"offered":null,"delivered":5,"edges":[[147,5]],"drops":[]},{"flow_id":148,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[148],"route_cost":1,"offered":null,"delivered":5,"edges":[[148,5]],"drops":[]},{"flow_id":149,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[149],"route_cost":1,"offered":null,"delivered":5,"edges":[[149,5]],"drops":[]},{"flow_id":150,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[150],"route_cost":1,"offered":null,"delivered":5,"edges":[[150,5]],"drops":[]},{"flow_id":151,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[151],"route_cost":1,"offered":null,"delivered":5,"edges":[[151,5]],"drops":[]},{"flow_id":152,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[152],"route_cost":1,"offered":null,"delivered":5,"edges":[[152,5]],"drops":[]},{"flow_id":153,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[153],"route_cost":1,"offered":null,"delivered":5,"edges":[[153,5]],"drops":[]},{"flow_id":154,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[154],"route_cost":1,"offered":null,"delivered":5,"edges":[[154,5]],"drops":[]},{"flow_id":155,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[155],"route_cost":1,"offered":null,"delivered":5,"edges":[[155,5]],"drops":[]},{"flow_id":156,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[156],"route_cost":1,"offered":null,"delivered":5,"edges":[[156,5]],"drops":[]},{"flow_id":157,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[157],"route_cost":1,"offered":null,"delivered":5,"edges":[[157,5]],"drops":[]},{"flow_id":158,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[158],"route_cost":1,"offered":null,"delivered":5,"edges":[[158,5]],"drops":[]},{"flow_id":159,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[159],"route_cost":1,"offered":null,"delivered":5,"edges":[[159,5]],"drops":[]},{"flow_id":160,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[160],"route_cost":1,"offered":null,"delivered":5,"edges":[[160,5]],"drops":[]},{"flow_id":161,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[161],"route_cost":1,"offered":null,"delivered":5,"edges":[[161,5]],"drops":[]},{"flow_id":162,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[162],"route_cost":1,"offered":null,"delivered":5,"edges":[[162,5]],"drops":[]},{"flow_id":163,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[163],"route_cost":1,"offered":null,"delivered":5,"edges":[[163,5]],"drops":[]},{"flow_id":164,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[164],"route_cost":1,"offered":null,"delivered":5,"edges":[[164,5]],"drops":[]},{"flow_id":165,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[165],"route_cost":1,"offered":null,"delivered":5,"edges":[[165,5]],"drops":[]},{"flow_id":166,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[166],"route_cost":1,"offered":null,"delivered":5,"edges":[[166,5]],"drops":[]},{"flow_id":167,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[167],"route_cost":1,"offered":null,"delivered":5,"edges":[[167,5]],"drops":[]},{"flow_id":168,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[168],"route_cost":1,"offered":null,"delivered":5,"edges":[[168,5]],"drops":[]},{"flow_id":169,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[169],"route_cost":1,"offered":null,"delivered":5,"edges":[[169,5]],"drops":[]},{"flow_id":170,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[170],"route_cost":1,"offered":null,"delivered":5,"edges":[[170,5]],"drops":[]},{"flow_id":171,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[171],"route_cost":1,"offered":null,"delivered":5,"edges":[[171,5]],"drops":[]},{"flow_id":172,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[172],"route_cost":1,"offered":null,"delivered":5,"edges":[[172,5]],"drops":[]},{"flow_id":173,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[173],"route_cost":1,"offered":null,"delivered":5,"edges":[[173,5]],"drops":[]},{"flow_id":174,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[174],"route_cost":1,"offered":null,"delivered":5,"edges":[[174,5]],"drops":[]},{"flow_id":175,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[175],"route_cost":1,"offered":null,"delivered":5,"edges":[[175,5]],"drops":[]},{"flow_id":176,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[176],"route_cost":1,"offered":null,"delivered":5,"edges":[[176,5]],"drops":[]},{"flow_id":177,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[177],"route_cost":1,"offered":null,"delivered":5,"edges":[[177,5]],"drops":[]},{"flow_id":178,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[178],"route_cost":1,"offered":null,"delivered":5,"edges":[[178,5]],"drops":[]},{"flow_id":179,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[179],"route_cost":1,"offered":null,"delivered":5,"edges":[[179,5]],"drops":[]},{"flow_id":180,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[180],"route_cost":1,"offered":null,"delivered":5,"edges":[[180,5]],"drops":[]},{"flow_id":181,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[181],"route_cost":1,"offered":null,"delivered":5,"edges":[[181,5]],"drops":[]},{"flow_id":182,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[182],"route_cost":1,"offered":null,"delivered":5,"edges":[[182,5]],"drops":[]},{"flow_id":183,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[183],"route_cost":1,"offered":null,"delivered":5,"edges":[[183,5]],"drops":[]},{"flow_id":184,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[184],"route_cost":1,"offered":null,"delivered":5,"edges":[[184,5]],"drops":[]},{"flow_id":185,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[185],"route_cost":1,"offered":null,"delivered":5,"edges":[[185,5]],"drops":[]},{"flow_id":186,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[186],"route_cost":1,"offered":null,"delivered":5,"edges":[[186,5]],"drops":[]},{"flow_id":187,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[187],"route_cost":1,"offered":null,"delivered":5,"edges":[[187,5]],"drops":[]},{"flow_id":188,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[188],"route_cost":1,"offered":null,"delivered":5,"edges":[[188,5]],"drops":[]},{"flow_id":189,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[189],"route_cost":1,"offered":null,"delivered":5,"edges":[[189,5]],"drops":[]},{"flow_id":190,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[190],"route_cost":1,"offered":null,"delivered":5,"edges":[[190,5]],"drops":[]},{"flow_id":191,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[191],"route_cost":1,"offered":null,"delivered":5,"edges":[[191,5]],"drops":[]},{"flow_id":192,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[192],"route_cost":1,"offered":null,"delivered":5,"edges":[[192,5]],"drops":[]},{"flow_id":193,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[193],"route_cost":1,"offered":null,"delivered":5,"edges":[[193,5]],"drops":[]},{"flow_id":194,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[194],"route_cost":1,"offered":null,"delivered":5,"edges":[[194,5]],"drops":[]},{"flow_id":195,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[195],"route_cost":1,"offered":null,"delivered":5,"edges":[[195,5]],"drops":[]},{"flow_id":196,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[196],"route_cost":1,"offered":null,"delivered":5,"edges":[[196,5]],"drops":[]},{"flow_id":197,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[197],"route_cost":1,"offered":null,"delivered":5,"edges":[[197,5]],"drops":[]},{"flow_id":198,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[198],"route_cost":1,"offered":null,"delivered":5,"edges":[[198,5]],"drops":[]},{"flow_id":199,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[199],"route_cost":1,"offered":null,"delivered":5,"edges":[[199,5]],"drops":[]},{"flow_id":200,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[200],"route_cost":1,"offered":null,"delivered":5,"edges":[[200,5]],"drops":[]},{"flow_id":201,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[201],"route_cost":1,"offered":null,"delivered":5,"edges":[[201,5]],"drops":[]},{"flow_id":202,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[202],"route_cost":1,"offered":null,"delivered":5,"edges":[[202,5]],"drops":[]},{"flow_id":203,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[203],"route_cost":1,"offered":null,"delivered":5,"edges":[[203,5]],"drops":[]},{"flow_id":204,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[204],"route_cost":1,"offered":null,"delivered":5,"edges":[[204,5]],"drops":[]},{"flow_id":205,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[205],"route_cost":1,"offered":null,"delivered":5,"edges":[[205,5]],"drops":[]},{"flow_id":206,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[206],"route_cost":1,"offered":null,"delivered":5,"edges":[[206,5]],"drops":[]},{"flow_id":207,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[207],"route_cost":1,"offered":null,"delivered":5,"edges":[[207,5]],"drops":[]},{"flow_id":208,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[208],"route_cost":1,"offered":null,"delivered":5,"edges":[[208,5]],"drops":[]},{"flow_id":209,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[209],"route_cost":1,"offered":null,"delivered":5,"edges":[[209,5]],"drops":[]},{"flow_id":210,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[210],"route_cost":1,"offered":null,"delivered":5,"edges":[[210,5]],"drops":[]},{"flow_id":211,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[211],"route_cost":1,"offered":null,"delivered":5,"edges":[[211,5]],"drops":[]},{"flow_id":212,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[212],"route_cost":1,"offered":null,"delivered":5,"edges":[[212,5]],"drops":[]},{"flow_id":213,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[213],"route_cost":1,"offered":null,"delivered":5,"edges":[[213,5]],"drops":[]},{"flow_id":214,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[214],"route_cost":1,"offered":null,"delivered":5,"edges":[[214,5]],"drops":[]},{"flow_id":215,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[215],"route_cost":1,"offered":null,"delivered":5,"edges":[[215,5]],"drops":[]},{"flow_id":216,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[216],"route_cost":1,"offered":null,"delivered":5,"edges":[[216,5]],"drops":[]},{"flow_id":217,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[217],"route_cost":1,"offered":null,"delivered":5,"edges":[[217,5]],"drops":[]},{"flow_id":218,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[218],"route_cost":1,"offered":null,"delivered":5,"edges":[[218,5]],"drops":[]},{"flow_id":219,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[219],"route_cost":1,"offered":null,"delivered":5,"edges":[[219,5]],"drops":[]},{"flow_id":220,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[220],"route_cost":1,"offered":null,"delivered":5,"edges":[[220,5]],"drops":[]},{"flow_id":221,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[221],"route_cost":1,"offered":null,"delivered":5,"edges":[[221,5]],"drops":[]},{"flow_id":222,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[222],"route_cost":1,"offered":null,"delivered":5,"edges":[[222,5]],"drops":[]},{"flow_id":223,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[223],"route_cost":1,"offered":null,"delivered":5,"edges":[[223,5]],"drops":[]},{"flow_id":224,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[224],"route_cost":1,"offered":null,"delivered":5,"edges":[[224,5]],"drops":[]},{"flow_id":225,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[225],"route_cost":1,"offered":null,"delivered":5,"edges":[[225,5]],"drops":[]},{"flow_id":226,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[226],"route_cost":1,"offered":null,"delivered":5,"edges":[[226,5]],"drops":[]},{"flow_id":227,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[227],"route_cost":1,"offered":null,"delivered":5,"edges":[[227,5]],"drops":[]},{"flow_id":228,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[228],"route_cost":1,"offered":null,"delivered":5,"edges":[[228,5]],"drops":[]},{"flow_id":229,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[229],"route_cost":1,"offered":null,"delivered":5,"edges":[[229,5]],"drops":[]},{"flow_id":230,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[230],"route_cost":1,"offered":null,"delivered":5,"edges":[[230,5]],"drops":[]},{"flow_id":231,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[231],"route_cost":1,"offered":null,"delivered":5,"edges":[[231,5]],"drops":[]},{"flow_id":232,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[232],"route_cost":1,"offered":null,"delivered":5,"edges":[[232,5]],"drops":[]},{"flow_id":233,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[233],"route_cost":1,"offered":null,"delivered":5,"edges":[[233,5]],"drops":[]},{"flow_id":234,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[234],"route_cost":1,"offered":null,"delivered":5,"edges":[[234,5]],"drops":[]},{"flow_id":235,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[235],"route_cost":1,"offered":null,"delivered":5,"edges":[[235,5]],"drops":[]},{"flow_id":236,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[236],"route_cost":1,"offered":null,"delivered":5,"edges":[[236,5]],"drops":[]},{"flow_id":237,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[237],"route_cost":1,"offered":null,"delivered":5,"edges":[[237,5]],"drops":[]},{"flow_id":238,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[238],"route_cost":1,"offered":null,"delivered":5,"edges":[[238,5]],"drops":[]},{"flow_id":239,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[239],"route_cost":1,"offered":null,"delivered":5,"edges":[[239,5]],"drops":[]},{"flow_id":240,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[240],"route_cost":1,"offered":null,"delivered":5,"edges":[[240,5]],"drops":[]},{"flow_id":241,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[241],"route_cost":1,"offered":null,"delivered":5,"edges":[[241,5]],"drops":[]},{"flow_id":242,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[242],"route_cost":1,"offered":null,"delivered":5,"edges":[[242,5]],"drops":[]},{"flow_id":243,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[243],"route_cost":1,"offered":null,"delivered":5,"edges":[[243,5]],"drops":[]},{"flow_id":244,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[244],"route_cost":1,"offered":null,"delivered":5,"edges":[[244,5]],"drops":[]},{"flow_id":245,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[245],"route_cost":1,"offered":null,"delivered":5,"edges":[[245,5]],"drops":[]},{"flow_id":246,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[246],"route_cost":1,"offered":null,"delivered":5,"edges":[[246,5]],"drops":[]},{"flow_id":247,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[247],"route_cost":1,"offered":null,"delivered":5,"edges":[[247,5]],"drops":[]},{"flow_id":248,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[248],"route_cost":1,"offered":null,"delivered":5,"edges":[[248,5]],"drops":[]},{"flow_id":249,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[249],"route_cost":1,"offered":null,"delivered":5,"edges":[[249,5]],"drops":[]},{"flow_id":250,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[250],"route_cost":1,"offered":null,"delivered":5,"edges":[[250,5]],"drops":[]},{"flow_id":251,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[251],"route_cost":1,"offered":null,"delivered":5,"edges":[[251,5]],"drops":[]},{"flow_id":252,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[252],"route_cost":1,"offered":null,"delivered":5,"edges":[[252,5]],"drops":[]},{"flow_id":253,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[253],"route_cost":1,"offered":null,"delivered":5,"edges":[[253,5]],"drops":[]},{"flow_id":254,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[254],"route_cost":1,"offered":null,"delivered":4,"edges":[[254,4]],"drops":[]},{"flow_id":255,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[255],"route_cost":1,"offered":null,"delivered":4,"edges":[[255,4]],"drops":[]}],"next_flow_id":256,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1278,"infinite":false,"placed_demand":1278,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":5,"delivered":5,"alloc":[[0,5]],"members":[0],"weights":null},{"id":1,"route":[1],"T":5,"delivered":5,"alloc":[[1,5]],"members":[1],"weights":null},{"id":2,"route":[2],"T":5,"delivered":5,"alloc":[[2,5]],"members":[2],"weights":null},{"id":3,"route":[3],"T":5,"delivered":5,"alloc":[[3,5]],"members":[3],"weights":null},{"id":4,"route":[4],"T":5,"delivered":5,"alloc":[[4,5]],"members":[4],"weights":null},{"id":5,"route":[5],"T":5,"delivered":5,"alloc":[[5,5]],"members":[5],"weights":null},{"id":6,"route":[6],"T":5,"delivered":5,"alloc":[[6,5]],"members":[6],"weights":null},{"id":7,"route":[7],"T":5,"delivered":5,"alloc":[[7,5]],"members":[7],"weights":null},{"id":8,"route":[8],"T":5,"delivered":5,"alloc":[[8,5]],"members":[8],"weights":null},{"id":9,"route":[9],"T":5,"delivered":5,"alloc":[[9,5]],"members":[9],"weights":null},{"id":10,"route":[10],"T":5,"delivered":5,"alloc":[[10,5]],"members":[10],"weights":null},{"id":11,"route":[11],"T":5,"delivered":5,"alloc":[[11,5]],"members":[11],"weights":null},{"id":12,"route":[12],"T":5,"delivered":5,"alloc":[[12,5]],"members":[12],"weights":null},{"id":13,"route":[13],"T":5,"delivered":5,"alloc":[[13,5]],"members":[13],"weights":null},{"id":14,"route":[14],"T":5,"delivered":5,"alloc":[[14,5]],"members":[14],"weights":null},{"id":15,"route":[15],"T":5,"delivered":5,"alloc":[[15,5]],"members":[15],"weights":null},{"id":16,"route":[16],"T":5,"delivered":5,"alloc":[[16,5]],"members":[16],"weights":null},{"id":17,"route":[17],"T":5,"delivered":5,"alloc":[[17,5]],"members":[17],"weights":null},{"id":18,"route":[18],"T":5,"delivered":5,"alloc":[[18,5]],"members":[18],"weights":null},{"id":19,"route":[19],"T":5,"delivered":5,"alloc":[[19,5]],"members":[19],"weights":null},{"id":20,"route":[20],"T":5,"delivered":5,"alloc":[[20,5]],"members":[20],"weights":null},{"id":21,"route":[21],"T":5,"delivered":5,"alloc":[[21,5]],"members":[21],"weights":null},{"id":22,"route":[22],"T":5,"delivered":5,"alloc":[[22,5]],"members":[22],"weights":null},{"id":23,"route":[23],"T":5,"delivered":5,"alloc":[[23,5]],"members":[23],"weights":null},{"id":24,"route":[24],"T":5,"delivered":5,"alloc":[[24,5]],"members":[24],"weights":null},{"id":25,"route":[25],"T":5,"delivered":5,"alloc":[[25,5]],"members":[25],"weights":null},{"id":26,"route":[26],"T":5,"delivered":5,"alloc":[[26,5]],"members":[26],"weights":null},{"id":27,"route":[27],"T":5,"delivered":5,"alloc":[[27,5]],"members":[27],"weights":null},{"id":28,"route":[28],"T":5,"delivered":5,"alloc":[[28,5]],"members":[28],"weights":null},{"id":29,"route":[29],"T":5,"delivered":5,"alloc":[[29,5]],"members":[29],"weights":null},{"id":30,"route":[30],"T":5,"delivered":5,"alloc":[[30,5]],"members":[30],"weights":null},{"id":31,"route":[31],"T":5,"delivered":5,"alloc":[[31,5]],"members":[31],"weights":null},{"id":32,"route":[32],"T":5,"delivered":5,"alloc":[[32,5]],"members":[32],"weights":null},{"id":33,"route":[33],"T":5,"delivered":5,"alloc":[[33,5]],"members":[33],"weights":null},{"id":34,"route":[34],"T":5,"delivered":5,"alloc":[[34,5]],"members":[34],"weights":null},{"id":35,"route":[35],"T":5,"delivered":5,"alloc":[[35,5]],"members":[35],"weights":null},{"id":36,"route":[36],"T":5,"delivered":5,"alloc":[[36,5]],"members":[36],"weights":null},{"id":37,"route":[37],"T":5,"delivered":5,"alloc":[[37,5]],"members":[37],"weights":null},{"id":38,"route":[38],"T":5,"delivered":5,"alloc":[[38,5]],"members":[38],"weights":null},{"id":39,"route":[39],"T":5,"delivered":5,"alloc":[[39,5]],"members":[39],"weights":null},{"id":40,"route":[40],"T":5,"delivered":5,"alloc":[[40,5]],"members":[40],"weights":null},{"id":41,"route":[41],"T":5,"delivered":5,"alloc":[[41,5]],"members":[41],"weights":null},{"id":42,"route":[42],"T":5,"delivered":5,"alloc":[[42,5]],"members":[42],"weights":null},{"id":43,"route":[43],"T":5,"delivered":5,"alloc":[[43,5]],"members":[43],"weights":null},{"id":44,"route":[44],"T":5,"delivered":5,"alloc":[[44,5]],"members":[44],"weights":null},{"id":45,"route":[45],"T":5,"delivered":5,"alloc":[[45,5]],"members":[45],"weights":null},{"id":46,"route":[46],"T":5,"delivered":5,"alloc":[[46,5]],"members":[46],"weights":null},{"id":47,"route":[47],"T":5,"delivered":5,"alloc":[[47,5]],"members":[47],"weights":null},{"id":48,"route":[48],"T":5,"delivered":5,"alloc":[[48,5]],"members":[48],"weights":null},{"id":49,"route":[49],"T":5,"delivered":5,"alloc":[[49,5]],"members":[49],"weights":null},{"id":50,"route":[50],"T":5,"delivered":5,"alloc":[[50,5]],"members":[50],"weights":null},{"id":51,"route":[51],"T":5,"delivered":5,"alloc":[[51,5]],"members":[51],"weights":null},{"id":52,"route":[52],"T":5,"delivered":5,"alloc":[[52,5]],"members":[52],"weights":null},{"id":53,"route":[53],"T":5,"delivered":5,"alloc":[[53,5]],"members":[53],"weights":null},{"id":54,"route":[54],"T":5,"delivered":5,"alloc":[[54,5]],"members":[54],"weights":null},{"id":55,"route":[55],"T":5,"delivered":5,"alloc":[[55,5]],"members":[55],"weights":null},{"id":56,"route":[56],"T":5,"delivered":5,"alloc":[[56,5]],"members":[56],"weights":null},{"id":57,"route":[57],"T":5,"delivered":5,"alloc":[[57,5]],"members":[57],"weights":null},{"id":58,"route":[58],"T":5,"delivered":5,"alloc":[[58,5]],"members":[58],"weights":null},{"id":59,"route":[59],"T":5,"delivered":5,"alloc":[[59,5]],"members":[59],"weights":null},{"id":60,"route":[60],"T":5,"delivered":5,"alloc":[[60,5]],"members":[60],"weights":null},{"id":61,"route":[61],"T":5,"delivered":5,"alloc":[[61,5]],"members":[61],"weights":null},{"id":62,"route":[62],"T":5,"delivered":5,"alloc":[[62,5]],"members":[62],"weights":null},{"id":63,"route":[63],"T":5,"delivered":5,"alloc":[[63,5]],"members":[63],"weights":null},{"id":64,"route":[64],"T":5,"delivered":5,"alloc":[[64,5]],"members":[64],"weights":null},{"id":65,"route":[65],"T":5,"delivered":5,"alloc":[[65,5]],"members":[65],"weights":null},{"id":66,"route":[66],"T":5,"delivered":5,"alloc":[[66,5]],"members":[66],"weights":null},{"id":67,"route":[67],"T":5,"delivered":5,"alloc":[[67,5]],"members":[67],"weights":null},{"id":68,"route":[68],"T":5,"delivered":5,"alloc":[[68,5]],"members":[68],"weights":null},{"id":69,"route":[69],"T":5,"delivered":5,"alloc":[[69,5]],"members":[69],"weights":null},{"id":70,"route":[70],"T":5,"delivered":5,"alloc":[[70,5]],"members":[70],"weights":null},{"id":71,"route":[71],"T":5,"delivered":5,"alloc":[[71,5]],"members":[71],"weights":null},{"id":72,"route":[72],"T":5,"delivered":5,"alloc":[[72,5]],"members":[72],"weights":null},{"id":73,"route":[73],"T":5,"delivered":5,"alloc":[[73,5]],"members":[73],"weights":null},{"id":74,"route":[74],"T":5,"delivered":5,"alloc":[[74,5]],"members":[74],"weights":null},{"id":75,"route":[75],"T":5,"delivered":5,"alloc":[[75,5]],"members":[75],"weights":null},{"id":76,"route":[76],"T":5,"delivered":5,"alloc":[[76,5]],"members":[76],"weights":null},{"id":77,"route":[77],"T":5,"delivered":5,"alloc":[[77,5]],"members":[77],"weights":null},{"id":78,"route":[78],"T":5,"delivered":5,"alloc":[[78,5]],"members":[78],"weights":null},{"id":79,"route":[79],"T":5,"delivered":5,"alloc":[[79,5]],"members":[79],"weights":null},{"id":80,"route":[80],"T":5,"delivered":5,"alloc":[[80,5]],"members":[80],"weights":null},{"id":81,"route":[81],"T":5,"delivered":5,"alloc":[[81,5]],"members":[81],"weights":null},{"id":82,"route":[82],"T":5,"delivered":5,"alloc":[[82,5]],"members":[82],"weights":null},{"id":83,"route":[83],"T":5,"delivered":5,"alloc":[[83,5]],"members":[83],"weights":null},{"id":84,"route":[84],"T":5,"delivered":5,"alloc":[[84,5]],"members":[84],"weights":null},{"id":85,"route":[85],"T":5,"delivered":5,"alloc":[[85,5]],"members":[85],"weights":null},{"id":86,"route":[86],"T":5,"delivered":5,"alloc":[[86,5]],"members":[86],"weights":null},{"id":87,"route":[87],"T":5,"delivered":5,"alloc":[[87,5]],"members":[87],"weights":null},{"id":88,"route":[88],"T":5,"delivered":5,"alloc":[[88,5]],"members":[88],"weights":null},{"id":89,"route":[89],"T":5,"delivered":5,"alloc":[[89,5]],"members":[89],"weights":null},{"id":90,"route":[90],"T":5,"delivered":5,"alloc":[[90,5]],"members":[90],"weights":null},{"id":91,"route":[91],"T":5,"delivered":5,"alloc":[[91,5]],"members":[91],"weights":null},{"id":92,"route":[92],"T":5,"delivered":5,"alloc":[[92,5]],"members":[92],"weights":null},{"id":93,"route":[93],"T":5,"delivered":5,"alloc":[[93,5]],"members":[93],"weights":null},{"id":94,"route":[94],"T":5,"delivered":5,"alloc":[[94,5]],"members":[94],"weights":null},{"id":95,"route":[95],"T":5,"delivered":5,"alloc":[[95,5]],"members":[95],"weights":null},{"id":96,"route":[96],"T":5,"delivered":5,"alloc":[[96,5]],"members":[96],"weights":null},{"id":97,"route":[97],"T":5,"delivered":5,"alloc":[[97,5]],"members":[97],"weights":null},{"id":98,"route":[98],"T":5,"delivered":5,"alloc":[[98,5]],"members":[98],"weights":null},{"id":99,"route":[99],"T":5,"delivered":5,"alloc":[[99,5]],"members":[99],"weights":null},{"id":100,"route":[100],"T":5,"delivered":5,"alloc":[[100,5]],"members":[100],"weights":null},{"id":101,"route":[101],"T":5,"delivered":5,"alloc":[[101,5]],"members":[101],"weights":null},{"id":102,"route":[102],"T":5,"delivered":5,"alloc":[[102,5]],"members":[102],"weights":null},{"id":103,"route":[103],"T":5,"delivered":5,"alloc":[[103,5]],"members":[103],"weights":null},{"id":104,"route":[104],"T":5,"delivered":5,"alloc":[[104,5]],"members":[104],"weights":null},{"id":105,"route":[105],"T":5,"delivered":5,"alloc":[[105,5]],"members":[105],"weights":null},{"id":106,"route":[106],"T":5,"delivered":5,"alloc":[[106,5]],"members":[106],"weights":null},{"id":107,"route":[107],"T":5,"delivered":5,"alloc":[[107,5]],"members":[107],"weights":null},{"id":108,"route":[108],"T":5,"delivered":5,"alloc":[[108,5]],"members":[108],"weights":null},{"id":109,"route":[109],"T":5,"delivered":5,"alloc":[[109,5]],"members":[109],"weights":null},{"id":110,"route":[110],"T":5,"delivered":5,"alloc":[[110,5]],"members":[110],"weights":null},{"id":111,"route":[111],"T":5,"delivered":5,"alloc":[[111,5]],"members":[111],"weights":null},{"id":112,"route":[112],"T":5,"delivered":5,"alloc":[[112,5]],"members":[112],"weights":null},{"id":113,"route":[113],"T":5,"delivered":5,"alloc":[[113,5]],"members":[113],"weights":null},{"id":114,"route":[114],"T":5,"delivered":5,"alloc":[[114,5]],"members":[114],"weights":null},{"id":115,"route":[115],"T":5,"delivered":5,"alloc":[[115,5]],"members":[115],"weights":null},{"id":116,"route":[116],"T":5,"delivered":5,"alloc":[[116,5]],"members":[116],"weights":null},{"id":117,"route":[117],"T":5,"delivered":5,"alloc":[[117,5]],"members":[117],"weights":null},{"id":118,"route":[118],"T":5,"delivered":5,"alloc":[[118,5]],"members":[118],"weights":null},{"id":119,"route":[119],"T":5,"delivered":5,"alloc":[[119,5]],"members":[119],"weights":null},{"id":120,"route":[120],"T":5,"delivered":5,"alloc":[[120,5]],"members":[120],"weights":null},{"id":121,"route":[121],"T":5,"delivered":5,"alloc":[[121,5]],"members":[121],"weights":null},{"id":122,"route":[122],"T":5,"delivered":5,"alloc":[[122,5]],"members":[122],"weights":null},{"id":123,"route":[123],"T":5,"delivered":5,"alloc":[[123,5]],"members":[123],"weights":null},{"id":124,"route":[124],"T":5,"delivered":5,"alloc":[[124,5]],"members":[124],"weights":null},{"id":125,"route":[125],"T":5,"delivered":5,"alloc":[[125,5]],"members":[125],"weights":null},{"id":126,"route":[126],"T":5,"delivered":5,"alloc":[[126,5]],"members":[126],"weights":null},{"id":127,"route":[127],"T":5,"delivered":5,"alloc":[[127,5]],"members":[127],"weights":null},{"id":128,"route":[128],"T":5,"delivered":5,"alloc":[[128,5]],"members":[128],"weights":null},{"id":129,"route":[129],"T":5,"delivered":5,"alloc":[[129,5]],"members":[129],"weights":null},{"id":130,"route":[130],"T":5,"delivered":5,"alloc":[[130,5]],"members":[130],"weights":null},{"id":131,"route":[131],"T":5,"delivered":5,"alloc":[[131,5]],"members":[131],"weights":null},{"id":132,"route":[132],"T":5,"delivered":5,"alloc":[[132,5]],"members":[132],"weights":null},{"id":133,"route":[133],"T":5,"delivered":5,"alloc":[[133,5]],"members":[133],"weights":null},{"id":134,"route":[134],"T":5,"delivered":5,"alloc":[[134,5]],"members":[134],"weights":null},{"id":135,"route":[135],"T":5,"delivered":5,"alloc":[[135,5]],"members":[135],"weights":null},{"id":136,"route":[136],"T":5,"delivered":5,"alloc":[[136,5]],"members":[136],"weights":null},{"id":137,"route":[137],"T":5,"delivered":5,"alloc":[[137,5]],"members":[137],"weights":null},{"id":138,"route":[138],"T":5,"delivered":5,"alloc":[[138,5]],"members":[138],"weights":null},{"id":139,"route":[139],"T":5,"delivered":5,"alloc":[[139,5]],"members":[139],"weights":null},{"id":140,"route":[140],"T":5,"delivered":5,"alloc":[[140,5]],"members":[140],"weights":null},{"id":141,"route":[141],"T":5,"delivered":5,"alloc":[[141,5]],"members":[141],"weights":null},{"id":142,"route":[142],"T":5,"delivered":5,"alloc":[[142,5]],"members":[142],"weights":null},{"id":143,"route":[143],"T":5,"delivered":5,"alloc":[[143,5]],"members":[143],"weights":null},{"id":144,"route":[144],"T":5,"delivered":5,"alloc":[[144,5]],"members":[144],"weights":null},{"id":145,"route":[145],"T":5,"delivered":5,"alloc":[[145,5]],"members":[145],"weights":null},{"id":146,"route":[146],"T":5,"delivered":5,"alloc":[[146,5]],"members":[146],"weights":null},{"id":147,"route":[147],"T":5,"delivered":5,"alloc":[[147,5]],"members":[147],"weights":null},{"id":148,"route":[148],"T":5,"delivered":5,"alloc":[[148,5]],"members":[148],"weights":null},{"id":149,"route":[149],"T":5,"delivered":5,"alloc":[[149,5]],"members":[149],"weights":null},{"id":150,"route":[150],"T":5,"delivered":5,"alloc":[[150,5]],"members":[150],"weights":null},{"id":151,"route":[151],"T":5,"delivered":5,"alloc":[[151,5]],"members":[151],"weights":null},{"id":152,"route":[152],"T":5,"delivered":5,"alloc":[[152,5]],"members":[152],"weights":null},{"id":153,"route":[153],"T":5,"delivered":5,"alloc":[[153,5]],"members":[153],"weights":null},{"id":154,"route":[154],"T":5,"delivered":5,"alloc":[[154,5]],"members":[154],"weights":null},{"id":155,"route":[155],"T":5,"delivered":5,"alloc":[[155,5]],"members":[155],"weights":null},{"id":156,"route":[156],"T":5,"delivered":5,"alloc":[[156,5]],"members":[156],"weights":null},{"id":157,"route":[157],"T":5,"delivered":5,"alloc":[[157,5]],"members":[157],"weights":null},{"id":158,"route":[158],"T":5,"delivered":5,"alloc":[[158,5]],"members":[158],"weights":null},{"id":159,"route":[159],"T":5,"delivered":5,"alloc":[[159,5]],"members":[159],"weights":null},{"id":160,"route":[160],"T":5,"delivered":5,"alloc":[[160,5]],"members":[160],"weights":null},{"id":161,"route":[161],"T":5,"delivered":5,"alloc":[[161,5]],"members":[161],"weights":null},{"id":162,"route":[162],"T":5,"delivered":5,"alloc":[[162,5]],"members":[162],"weights":null},{"id":163,"route":[163],"T":5,"delivered":5,"alloc":[[163,5]],"members":[163],"weights":null},{"id":164,"route":[164],"T":5,"delivered":5,"alloc":[[164,5]],"members":[164],"weights":null},{"id":165,"route":[165],"T":5,"delivered":5,"alloc":[[165,5]],"members":[165],"weights":null},{"id":166,"route":[166],"T":5,"delivered":5,"alloc":[[166,5]],"members":[166],"weights":null},{"id":167,"route":[167],"T":5,"delivered":5,"alloc":[[167,5]],"members":[167],"weights":null},{"id":168,"route":[168],"T":5,"delivered":5,"alloc":[[168,5]],"members":[168],"weights":null},{"id":169,"route":[169],"T":5,"delivered":5,"alloc":[[169,5]],"members":[169],"weights":null},{"id":170,"route":[170],"T":5,"delivered":5,"alloc":[[170,5]],"members":[170],"weights":null},{"id":171,"route":[171],"T":5,"delivered":5,"alloc":[[171,5]],"members":[171],"weights":null},{"id":172,"route":[172],"T":5,"delivered":5,"alloc":[[172,5]],"members":[172],"weights":null},{"id":173,"route":[173],"T":5,"delivered":5,"alloc":[[173,5]],"members":[173],"weights":null},{"id":174,"route":[174],"T":5,"delivered":5,"alloc":[[174,5]],"members":[174],"weights":null},{"id":175,"route":[175],"T":5,"delivered":5,"alloc":[[175,5]],"members":[175],"weights":null},{"id":176,"route":[176],"T":5,"delivered":5,"alloc":[[176,5]],"members":[176],"weights":null},{"id":177,"route":[177],"T":5,"delivered":5,"alloc":[[177,5]],"members":[177],"weights":null},{"id":178,"route":[178],"T":5,"delivered":5,"alloc":[[178,5]],"members":[178],"weights":null},{"id":179,"route":[179],"T":5,"delivered":5,"alloc":[[179,5]],"members":[179],"weights":null},{"id":180,"route":[180],"T":5,"delivered":5,"alloc":[[180,5]],"members":[180],"weights":null},{"id":181,"route":[181],"T":5,"delivered":5,"alloc":[[181,5]],"members":[181],"weights":null},{"id":182,"route":[182],"T":5,"delivered":5,"alloc":[[182,5]],"members":[182],"weights":null},{"id":183,"route":[183],"T":5,"delivered":5,"alloc":[[183,5]],"members":[183],"weights":null},{"id":184,"route":[184],"T":5,"delivered":5,"alloc":[[184,5]],"members":[184],"weights":null},{"id":185,"route":[185],"T":5,"delivered":5,"alloc":[[185,5]],"members":[185],"weights":null},{"id":186,"route":[186],"T":5,"delivered":5,"alloc":[[186,5]],"members":[186],"weights":null},{"id":187,"route":[187],"T":5,"delivered":5,"alloc":[[187,5]],"members":[187],"weights":null},{"id":188,"route":[188],"T":5,"delivered":5,"alloc":[[188,5]],"members":[188],"weights":null},{"id":189,"route":[189],"T":5,"delivered":5,"alloc":[[189,5]],"members":[189],"weights":null},{"id":190,"route":[190],"T":5,"delivered":5,"alloc":[[190,5]],"members":[190],"weights":null},{"id":191,"route":[191],"T":5,"delivered":5,"alloc":[[191,5]],"members":[191],"weights":null},{"id":192,"route":[192],"T":5,"delivered":5,"alloc":[[192,5]],"members":[192],"weights":null},{"id":193,"route":[193],"T":5,"delivered":5,"alloc":[[193,5]],"members":[193],"weights":null},{"id":194,"route":[194],"T":5,"delivered":5,"alloc":[[194,5]],"members":[194],"weights":null},{"id":195,"route":[195],"T":5,"delivered":5,"alloc":[[195,5]],"members":[195],"weights":null},{"id":196,"route":[196],"T":5,"delivered":5,"alloc":[[196,5]],"members":[196],"weights":null},{"id":197,"route":[197],"T":5,"delivered":5,"alloc":[[197,5]],"members":[197],"weights":null},{"id":198,"route":[198],"T":5,"delivered":5,"alloc":[[198,5]],"members":[198],"weights":null},{"id":199,"route":[199],"T":5,"delivered":5,"alloc":[[199,5]],"members":[199],"weights":null},{"id":200,"route":[200],"T":5,"delivered":5,"alloc":[[200,5]],"members":[200],"weights":null},{"id":201,"route":[201],"T":5,"delivered":5,"alloc":[[201,5]],"members":[201],"weights":null},{"id":202,"route":[202],"T":5,"delivered":5,"alloc":[[202,5]],"members":[202],"weights":null},{"id":203,"route":[203],"T":5,"delivered":5,"alloc":[[203,5]],"members":[203],"weights":null},{"id":204,"route":[204],"T":5,"delivered":5,"alloc":[[204,5]],"members":[204],"weights":null},{"id":205,"route":[205],"T":5,"delivered":5,"alloc":[[205,5]],"members":[205],"weights":null},{"id":206,"route":[206],"T":5,"delivered":5,"alloc":[[206,5]],"members":[206],"weights":null},{"id":207,"route":[207],"T":5,"delivered":5,"alloc":[[207,5]],"members":[207],"weights":null},{"id":208,"route":[208],"T":5,"delivered":5,"alloc":[[208,5]],"members":[208],"weights":null},{"id":209,"route":[209],"T":5,"delivered":5,"alloc":[[209,5]],"members":[209],"weights":null},{"id":210,"route":[210],"T":5,"delivered":5,"alloc":[[210,5]],"members":[210],"weights":null},{"id":211,"route":[211],"T":5,"delivered":5,"alloc":[[211,5]],"members":[211],"weights":null},{"id":212,"route":[212],"T":5,"delivered":5,"alloc":[[212,5]],"members":[212],"weights":null},{"id":213,"route":[213],"T":5,"delivered":5,"alloc":[[213,5]],"members":[213],"weights":null},{"id":214,"route":[214],"T":5,"delivered":5,"alloc":[[214,5]],"members":[214],"weights":null},{"id":215,"route":[215],"T":5,"delivered":5,"alloc":[[215,5]],"members":[215],"weights":null},{"id":216,"route":[216],"T":5,"delivered":5,"alloc":[[216,5]],"members":[216],"weights":null},{"id":217,"route":[217],"T":5,"delivered":5,"alloc":[[217,5]],"members":[217],"weights":null},{"id":218,"route":[218],"T":5,"delivered":5,"alloc":[[218,5]],"members":[218],"weights":null},{"id":219,"route":[219],"T":5,"delivered":5,"alloc":[[219,5]],"members":[219],"weights":null},{"id":220,"route":[220],"T":5,"delivered":5,"alloc":[[220,5]],"members":[220],"weights":null},{"id":221,"route":[221],"T":5,"delivered":5,"alloc":[[221,5]],"members":[221],"weights":null},{"id":222,"route":[222],"T":5,"delivered":5,"alloc":[[222,5]],"members":[222],"weights":null},{"id":223,"route":[223],"T":5,"delivered":5,"alloc":[[223,5]],"members":[223],"weights":null},{"id":224,"route":[224],"T":5,"delivered":5,"alloc":[[224,5]],"members":[224],"weights":null},{"id":225,"route":[225],"T":5,"delivered":5,"alloc":[[225,5]],"members":[225],"weights":null},{"id":226,"route":[226],"T":5,"delivered":5,"alloc":[[226,5]],"members":[226],"weights":null},{"id":227,"route":[227],"T":5,"delivered":5,"alloc":[[227,5]],"members":[227],"weights":null},{"id":228,"route":[228],"T":5,"delivered":5,"alloc":[[228,5]],"members":[228],"weights":null},{"id":229,"route":[229],"T":5,"delivered":5,"alloc":[[229,5]],"members":[229],"weights":null},{"id":230,"route":[230],"T":5,"delivered":5,"alloc":[[230,5]],"members":[230],"weights":null},{"id":231,"route":[231],"T":5,"delivered":5,"alloc":[[231,5]],"members":[231],"weights":null},{"id":232,"route":[232],"T":5,"delivered":5,"alloc":[[232,5]],"members":[232],"weights":null},{"id":233,"route":[233],"T":5,"delivered":5,"alloc":[[233,5]],"members":[233],"weights":null},{"id":234,"route":[234],"T":5,"delivered":5,"alloc":[[234,5]],"members":[234],"weights":null},{"id":235,"route":[235],"T":5,"delivered":5,"alloc":[[235,5]],"members":[235],"weights":null},{"id":236,"route":[236],"T":5,"delivered":5,"alloc":[[236,5]],"members":[236],"weights":null},{"id":237,"route":[237],"T":5,"delivered":5,"alloc":[[237,5]],"members":[237],"weights":null},{"id":238,"route":[238],"T":5,"delivered":5,"alloc":[[238,5]],"members":[238],"weights":null},{"id":239,"route":[239],"T":5,"delivered":5,"alloc":[[239,5]],"members":[239],"weights":null},{"id":240,"route":[240],"T":5,"delivered":5,"alloc":[[240,5]],"members":[240],"weights":null},{"id":241,"route":[241],"T":5,"delivered":5,"alloc":[[241,5]],"members":[241],"weights":null},{"id":242,"route":[242],"T":5,"delivered":5,"alloc":[[242,5]],"members":[242],"weights":null},{"id":243,"route":[243],"T":5,"delivered":5,"alloc":[[243,5]],"members":[243],"weights":null},{"id":244,"route":[244],"T":5,"delivered":5,"alloc":[[244,5]],"members":[244],"weights":null},{"id":245,"route":[245],"T":5,"delivered":5,"alloc":[[245,5]],"members":[245],"weights":null},{"id":246,"route":[246],"T":5,"delivered":5,"alloc":[[246,5]],"members":[246],"weights":null},{"id":247,"route":[247],"T":5,"delivered":5,"alloc":[[247,5]],"members":[247],"weights":null},{"id":248,"route":[248],"T":5,"delivered":5,"alloc":[[248,5]],"members":[248],"weights":null},{"id":249,"route":[249],"T":5,"delivered":5,"alloc":[[249,5]],"members":[249],"weights":null},{"id":250,"route":[250],"T":5,"delivered":5,"alloc":[[250,5]],"members":[250],"weights":null},{"id":251,"route":[251],"T":5,"delivered":5,"alloc":[[251,5]],"members":[251],"weights":null},{"id":252,"route":[252],"T":5,"delivered":5,"alloc":[[252,5]],"members":[252],"weights":null},{"id":253,"route":[253],"T":5,"delivered":5,"alloc":[[253,5]],"members":[253],"weights":null},{"id":254,"route":[254],"T":4,"delivered":4,"alloc":[[254,4]],"members":[254],"weights":null},{"id":255,"route":[255],"T":4,"delivered":4,"alloc":[[255,4]],"members":[255],"weights":null}]]]}}}]},{"name":"harmful_growth_10_1","graphs":[{"n":4,"q":1.0,"edges":[[0,1,10,1],[1,3,10,1],[0,2,1,2],[2,3,1,2]],"input_index":[0,2,1,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":2,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":10,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":10,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":10},"local_outcomes":{"0":"complete"},"created":1,"sources":{"0":{"stage1":{"eb_target":10,"reservations":[{"route":[0,2],"S":[10,1,10,1],"T_bound":10,"reserved":10}],"created_cap_blocked":false},"stage3":{"T":[0,10],"bound":10,"shares":[10]},"stage4":[]}},"W_final":[0,1,0,1]},"ledger":{"allocated":[10,0,10,0],"residual":[0,1,0,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":10,"edges":[[0,10],[2,10]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":10,"infinite":false,"placed_demand":10,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":10,"delivered":10,"alloc":[[0,10],[2,10]],"members":[0,2],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage3":{"T":[10,10],"bound":10,"shares":[10]},"stage4":[{"route":[1,3],"W":[0,1,0,1],"T":10,"T_candidate":3,"bound":3,"decision":"rejected: cohort total not larger"}]}},"W_final":[0,1,0,1]},"ledger":{"allocated":[10,0,10,0],"residual":[0,1,0,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":10,"edges":[[0,10],[2,10]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":11,"infinite":false,"placed_demand":10,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":10,"delivered":10,"alloc":[[0,10],[2,10]],"members":[0,2],"weights":null}]]]}}}]},{"name":"hidden_beneficial_route","graphs":[{"n":5,"q":1.0,"edges":[[0,1,10,1],[1,4,10,1],[0,2,1,2],[2,4,1,2],[0,3,10,3],[3,4,10,3]],"input_index":[0,2,4,1,3,5]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":4,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":2,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":10,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":10,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":10},"local_outcomes":{"0":"complete"},"created":1,"sources":{"0":{"stage1":{"eb_target":10,"reservations":[{"route":[0,3],"S":[10,1,10,10,1,10],"T_bound":10,"reserved":10}],"created_cap_blocked":false},"stage3":{"T":[0,10],"bound":10,"shares":[10]},"stage4":[]}},"W_final":[0,1,10,0,1,10]},"ledger":{"allocated":[10,0,0,10,0,0],"residual":[0,1,10,0,1,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[4],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,3],"route_cost":2,"offered":null,"delivered":10,"edges":[[0,10],[3,10]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":10,"infinite":false,"placed_demand":10,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,3],"T":10,"delivered":10,"alloc":[[0,10],[3,10]],"members":[0,3],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage3":{"T":[10,10],"bound":10,"shares":[10]},"stage4":[{"route":[1,4],"W":[0,1,10,0,1,10],"T":10,"T_candidate":3,"bound":3,"decision":"rejected: cohort total not larger"}]}},"W_final":[0,1,10,0,1,10]},"ledger":{"allocated":[10,0,0,10,0,0],"residual":[0,1,10,0,1,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[4],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,3],"route_cost":2,"offered":null,"delivered":10,"edges":[[0,10],[3,10]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":11,"infinite":false,"placed_demand":10,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,3],"T":10,"delivered":10,"alloc":[[0,10],[3,10]],"members":[0,3],"weights":null}]]]}}}]},{"name":"four_calls_MaxFlow","graphs":[{"n":4,"q":1.0,"edges":[[0,1,5,1],[1,3,5,1],[0,2,5,1],[2,3,5,1]],"input_index":[0,2,1,3]},{"n":4,"q":1.0,"edges":[[0,1,1,1],[1,3,1,1],[0,2,10,1],[2,3,10,1]],"input_index":[0,2,1,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2,3]},"stage3":[{"T":[0,1],"curve":[[2,1]],"alloc":[[0,1],[2,1]]}]}},"W_final":[4,5,4,5]},"ledger":{"allocated":[1,0,1,0],"residual":[4,5,4,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[1,2],"curve":[[2,2]],"alloc":[[0,2],[2,2]]}]}},"W_final":[3,5,3,5]},"ledger":{"allocated":[2,0,2,0],"residual":[3,5,3,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,2],[2,2]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":2,"delivered":2,"alloc":[[0,2],[2,2]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[2,3],"curve":[[2,3]],"alloc":[[0,3],[2,3]]}]}},"W_final":[2,5,2,5]},"ledger":{"allocated":[3,0,3,0],"residual":[2,5,2,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":3,"edges":[[0,3],[2,3]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":3,"delivered":3,"alloc":[[0,3],[2,3]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[3,4],"curve":[[2,4]],"alloc":[[0,4],[2,4]]}]}},"W_final":[1,5,1,5]},"ledger":{"allocated":[4,0,4,0],"residual":[1,5,1,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":4,"edges":[[0,4],[2,4]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":4,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":4,"delivered":4,"alloc":[[0,4],[2,4]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[0,2],[1,3]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":2,"cohorts":[]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0,1]},"stage3":[{"T":[0,1],"curve":[[2,1]],"alloc":[[0,1],[2,1]]},{"T":[0,0],"curve":[],"alloc":[]}]}},"W_final":[4,5,4,5]},"ledger":{"allocated":[1,0,1,0],"residual":[4,5,4,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":0,"delivered":0,"alloc":[],"members":[1,3],"weights":null}]]]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[1,2],"curve":[[2,2]],"alloc":[[0,2],[2,2]]},{"T":[0,0],"curve":[],"alloc":[]}]}},"W_final":[3,5,3,5]},"ledger":{"allocated":[2,0,2,0],"residual":[3,5,3,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,2],[2,2]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":2,"delivered":2,"alloc":[[0,2],[2,2]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":0,"delivered":0,"alloc":[],"members":[1,3],"weights":null}]]]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[2,3],"curve":[[2,3]],"alloc":[[0,3],[2,3]]},{"T":[0,0],"curve":[],"alloc":[]}]}},"W_final":[2,5,2,5]},"ledger":{"allocated":[3,0,3,0],"residual":[2,5,2,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":3,"edges":[[0,3],[2,3]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":3,"delivered":3,"alloc":[[0,3],[2,3]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":0,"delivered":0,"alloc":[],"members":[1,3],"weights":null}]]]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[3,4],"curve":[[2,4]],"alloc":[[0,4],[2,4]]},{"T":[0,0],"curve":[],"alloc":[]}]}},"W_final":[1,5,1,5]},"ledger":{"allocated":[4,0,4,0],"residual":[1,5,1,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":4,"edges":[[0,4],[2,4]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":4,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":4,"delivered":4,"alloc":[[0,4],[2,4]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":0,"delivered":0,"alloc":[],"members":[1,3],"weights":null}]]]}}},{"op":"ledger","graph":1,"baseline":null,"id":2,"error":false},{"op":"policy","ledger":2,"sources":0,"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2,3]},"stage3":[{"T":[0,1],"curve":[[2,1]],"alloc":[[0,1],[2,1]]}]}},"W_final":[0,10,0,10]},"ledger":{"allocated":[1,0,1,0],"residual":[0,10,0,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[1,2],"curve":[[2,2]],"alloc":[[0,1],[1,1],[2,1],[3,1]]}]}},"W_final":[0,9,0,9]},"ledger":{"allocated":[1,1,1,1],"residual":[0,9,0,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[2,1],[3,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":2,"delivered":2,"alloc":[[0,1],[1,1],[2,1],[3,1]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[2,3],"curve":[[2,3]],"alloc":[[0,1],[1,2],[2,1],[3,2]]}]}},"W_final":[0,8,0,8]},"ledger":{"allocated":[1,2,1,2],"residual":[0,8,0,8],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":3,"edges":[[0,1],[1,2],[2,1],[3,2]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":3,"delivered":3,"alloc":[[0,1],[1,2],[2,1],[3,2]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[3,4],"curve":[[2,4]],"alloc":[[0,1],[1,3],[2,1],[3,3]]}]}},"W_final":[0,7,0,7]},"ledger":{"allocated":[1,3,1,3],"residual":[0,7,0,7],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":4,"edges":[[0,1],[1,3],[2,1],[3,3]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":4,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":4,"delivered":4,"alloc":[[0,1],[1,3],[2,1],[3,3]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"ledger","graph":1,"baseline":null,"id":3,"error":false},{"op":"policy","ledger":3,"sources":0,"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[0,2],[1,3]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":3,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":2,"cohorts":[]}}},{"op":"place","policy":3,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0,1]},"stage3":[{"T":[0,1],"curve":[[2,1]],"alloc":[[0,1],[2,1]]},{"T":[0,0],"curve":[],"alloc":[]}]}},"W_final":[0,10,0,10]},"ledger":{"allocated":[1,0,1,0],"residual":[0,10,0,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":0,"delivered":0,"alloc":[],"members":[1,3],"weights":null}]]]}}},{"op":"place","policy":3,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[1,1],"curve":[[2,1]],"alloc":[[0,1],[2,1]]},{"T":[0,1],"curve":[[2,1]],"alloc":[[1,1],[3,1]]}]}},"W_final":[0,9,0,9]},"ledger":{"allocated":[1,1,1,1],"residual":[0,9,0,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":1,"edges":[[1,1],[3,1]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":1,"delivered":1,"alloc":[[1,1],[3,1]],"members":[1,3],"weights":null}]]]}}},{"op":"place","policy":3,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[1,1],"curve":[[2,1]],"alloc":[[0,1],[2,1]]},{"T":[1,2],"curve":[[2,2]],"alloc":[[1,2],[3,2]]}]}},"W_final":[0,8,0,8]},"ledger":{"allocated":[1,2,1,2],"residual":[0,8,0,8],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[1,2],[3,2]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":2,"delivered":2,"alloc":[[1,2],[3,2]],"members":[1,3],"weights":null}]]]}}},{"op":"place","policy":3,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[1,1],"curve":[[2,1]],"alloc":[[0,1],[2,1]]},{"T":[2,3],"curve":[[2,3]],"alloc":[[1,3],[3,3]]}]}},"W_final":[0,7,0,7]},"ledger":{"allocated":[1,3,1,3],"residual":[0,7,0,7],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":3,"edges":[[1,3],[3,3]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":4,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":3,"delivered":3,"alloc":[[1,3],[3,3]],"members":[1,3],"weights":null}]]]}}}]},{"name":"four_calls_W","graphs":[{"n":4,"q":1.0,"edges":[[0,1,5,1],[1,3,5,1],[0,2,5,1],[2,3,5,1]],"input_index":[0,2,1,3]},{"n":4,"q":1.0,"edges":[[0,1,1,1],[1,3,1,1],[0,2,10,1],[2,3,10,1]],"input_index":[0,2,1,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"Weighted","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2,3]},"stage3":[{"T":[0,1],"curve":null,"alloc":[[0,1],[2,1]]}]}},"W_final":[4,5,4,5]},"ledger":{"allocated":[1,0,1,0],"residual":[4,5,4,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,1,2,3],"weights":[[0,5],[1,5],[2,5],[3,5]]}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[1,2],"curve":null,"alloc":[[0,1],[1,1],[2,1],[3,1]]}]}},"W_final":[4,4,4,4]},"ledger":{"allocated":[1,1,1,1],"residual":[4,4,4,4],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[2,1],[3,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":2,"delivered":2,"alloc":[[0,1],[1,1],[2,1],[3,1]],"members":[0,1,2,3],"weights":[[0,5],[1,5],[2,5],[3,5]]}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[2,3],"curve":null,"alloc":[[0,2],[1,1],[2,2],[3,1]]}]}},"W_final":[3,4,3,4]},"ledger":{"allocated":[2,1,2,1],"residual":[3,4,3,4],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":3,"edges":[[0,2],[1,1],[2,2],[3,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":3,"delivered":3,"alloc":[[0,2],[1,1],[2,2],[3,1]],"members":[0,1,2,3],"weights":[[0,5],[1,5],[2,5],[3,5]]}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[3,4],"curve":null,"alloc":[[0,2],[1,2],[2,2],[3,2]]}]}},"W_final":[3,3,3,3]},"ledger":{"allocated":[2,2,2,2],"residual":[3,3,3,3],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":4,"edges":[[0,2],[1,2],[2,2],[3,2]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":4,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":4,"delivered":4,"alloc":[[0,2],[1,2],[2,2],[3,2]],"members":[0,1,2,3],"weights":[[0,5],[1,5],[2,5],[3,5]]}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[0,2],[1,3]]},"placement":"Weighted","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":2,"cohorts":[]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0,1]},"stage3":[{"T":[0,1],"curve":null,"alloc":[[0,1],[2,1]]},{"T":[0,0],"curve":null,"alloc":[]}]}},"W_final":[4,5,4,5]},"ledger":{"allocated":[1,0,1,0],"residual":[4,5,4,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":[[0,5],[2,5]]},{"id":1,"route":[1,3],"T":0,"delivered":0,"alloc":[],"members":[1,3],"weights":[[1,5],[3,5]]}]]]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[1,2],"curve":null,"alloc":[[0,2],[2,2]]},{"T":[0,0],"curve":null,"alloc":[]}]}},"W_final":[3,5,3,5]},"ledger":{"allocated":[2,0,2,0],"residual":[3,5,3,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,2],[2,2]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":2,"delivered":2,"alloc":[[0,2],[2,2]],"members":[0,2],"weights":[[0,5],[2,5]]},{"id":1,"route":[1,3],"T":0,"delivered":0,"alloc":[],"members":[1,3],"weights":[[1,5],[3,5]]}]]]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[2,3],"curve":null,"alloc":[[0,3],[2,3]]},{"T":[0,0],"curve":null,"alloc":[]}]}},"W_final":[2,5,2,5]},"ledger":{"allocated":[3,0,3,0],"residual":[2,5,2,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":3,"edges":[[0,3],[2,3]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":3,"delivered":3,"alloc":[[0,3],[2,3]],"members":[0,2],"weights":[[0,5],[2,5]]},{"id":1,"route":[1,3],"T":0,"delivered":0,"alloc":[],"members":[1,3],"weights":[[1,5],[3,5]]}]]]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[3,4],"curve":null,"alloc":[[0,4],[2,4]]},{"T":[0,0],"curve":null,"alloc":[]}]}},"W_final":[1,5,1,5]},"ledger":{"allocated":[4,0,4,0],"residual":[1,5,1,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":4,"edges":[[0,4],[2,4]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":4,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":4,"delivered":4,"alloc":[[0,4],[2,4]],"members":[0,2],"weights":[[0,5],[2,5]]},{"id":1,"route":[1,3],"T":0,"delivered":0,"alloc":[],"members":[1,3],"weights":[[1,5],[3,5]]}]]]}}},{"op":"ledger","graph":1,"baseline":null,"id":2,"error":false},{"op":"policy","ledger":2,"sources":0,"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"Weighted","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2,3]},"stage3":[{"T":[0,1],"curve":null,"alloc":[[1,1],[3,1]]}]}},"W_final":[1,9,1,9]},"ledger":{"allocated":[0,1,0,1],"residual":[1,9,1,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":1,"edges":[[1,1],[3,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":1,"delivered":1,"alloc":[[1,1],[3,1]],"members":[0,1,2,3],"weights":[[0,1],[1,10],[2,1],[3,10]]}]]]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[1,2],"curve":null,"alloc":[[1,2],[3,2]]}]}},"W_final":[1,8,1,8]},"ledger":{"allocated":[0,2,0,2],"residual":[1,8,1,8],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[1,2],[3,2]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":2,"delivered":2,"alloc":[[1,2],[3,2]],"members":[0,1,2,3],"weights":[[0,1],[1,10],[2,1],[3,10]]}]]]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[2,3],"curve":null,"alloc":[[1,3],[3,3]]}]}},"W_final":[1,7,1,7]},"ledger":{"allocated":[0,3,0,3],"residual":[1,7,1,7],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":3,"edges":[[1,3],[3,3]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":3,"delivered":3,"alloc":[[1,3],[3,3]],"members":[0,1,2,3],"weights":[[0,1],[1,10],[2,1],[3,10]]}]]]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[3,4],"curve":null,"alloc":[[1,4],[3,4]]}]}},"W_final":[1,6,1,6]},"ledger":{"allocated":[0,4,0,4],"residual":[1,6,1,6],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":4,"edges":[[1,4],[3,4]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":4,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":4,"delivered":4,"alloc":[[1,4],[3,4]],"members":[0,1,2,3],"weights":[[0,1],[1,10],[2,1],[3,10]]}]]]}}},{"op":"ledger","graph":1,"baseline":null,"id":3,"error":false},{"op":"policy","ledger":3,"sources":0,"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[0,2],[1,3]]},"placement":"Weighted","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":3,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":2,"cohorts":[]}}},{"op":"place","policy":3,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0,1]},"stage3":[{"T":[0,1],"curve":null,"alloc":[[0,1],[2,1]]},{"T":[0,0],"curve":null,"alloc":[]}]}},"W_final":[0,10,0,10]},"ledger":{"allocated":[1,0,1,0],"residual":[0,10,0,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":[[0,1],[2,1]]},{"id":1,"route":[1,3],"T":0,"delivered":0,"alloc":[],"members":[1,3],"weights":[[1,10],[3,10]]}]]]}}},{"op":"place","policy":3,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[1,1],"curve":null,"alloc":[[0,1],[2,1]]},{"T":[0,1],"curve":null,"alloc":[[1,1],[3,1]]}]}},"W_final":[0,9,0,9]},"ledger":{"allocated":[1,1,1,1],"residual":[0,9,0,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":1,"edges":[[1,1],[3,1]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":[[0,1],[2,1]]},{"id":1,"route":[1,3],"T":1,"delivered":1,"alloc":[[1,1],[3,1]],"members":[1,3],"weights":[[1,10],[3,10]]}]]]}}},{"op":"place","policy":3,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[1,1],"curve":null,"alloc":[[0,1],[2,1]]},{"T":[1,2],"curve":null,"alloc":[[1,2],[3,2]]}]}},"W_final":[0,8,0,8]},"ledger":{"allocated":[1,2,1,2],"residual":[0,8,0,8],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[1,2],[3,2]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":[[0,1],[2,1]]},{"id":1,"route":[1,3],"T":2,"delivered":2,"alloc":[[1,2],[3,2]],"members":[1,3],"weights":[[1,10],[3,10]]}]]]}}},{"op":"place","policy":3,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[1,1],"curve":null,"alloc":[[0,1],[2,1]]},{"T":[2,3],"curve":null,"alloc":[[1,3],[3,3]]}]}},"W_final":[0,7,0,7]},"ledger":{"allocated":[1,3,1,3],"residual":[0,7,0,7],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":3,"edges":[[1,3],[3,3]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":4,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":[[0,1],[2,1]]},{"id":1,"route":[1,3],"T":3,"delivered":3,"alloc":[[1,3],[3,3]],"members":[1,3],"weights":[[1,10],[3,10]]}]]]}}}]},{"name":"four_calls_EB","graphs":[{"n":4,"q":1.0,"edges":[[0,1,5,1],[1,3,5,1],[0,2,5,1],[2,3,5,1]],"input_index":[0,2,1,3]},{"n":4,"q":1.0,"edges":[[0,1,1,1],[1,3,1,1],[0,2,10,1],[2,3,10,1]],"input_index":[0,2,1,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2,3]},"stage3":{"T":[0,1],"bound":10,"shares":[1]}}},"W_final":[4,5,4,5]},"ledger":{"allocated":[1,0,1,0],"residual":[4,5,4,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":{"T":[1,2],"bound":10,"shares":[2]}}},"W_final":[4,4,4,4]},"ledger":{"allocated":[1,1,1,1],"residual":[4,4,4,4],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[2,1],[3,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":2,"delivered":2,"alloc":[[0,1],[1,1],[2,1],[3,1]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":{"T":[2,3],"bound":10,"shares":[3]}}},"W_final":[3,4,3,4]},"ledger":{"allocated":[2,1,2,1],"residual":[3,4,3,4],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":3,"edges":[[0,2],[1,1],[2,2],[3,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":3,"delivered":3,"alloc":[[0,2],[1,1],[2,2],[3,1]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":{"T":[3,4],"bound":10,"shares":[4]}}},"W_final":[3,3,3,3]},"ledger":{"allocated":[2,2,2,2],"residual":[3,3,3,3],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":4,"edges":[[0,2],[1,2],[2,2],[3,2]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":4,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":4,"delivered":4,"alloc":[[0,2],[1,2],[2,2],[3,2]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[0,2],[1,3]]},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":2,"cohorts":[]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0,1]},"stage3":{"T":[0,1],"bound":10,"shares":[1,0]}}},"W_final":[4,5,4,5]},"ledger":{"allocated":[1,0,1,0],"residual":[4,5,4,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":0,"delivered":0,"alloc":[],"members":[1,3],"weights":null}]]]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":{"T":[1,2],"bound":10,"shares":[1,1]}}},"W_final":[4,4,4,4]},"ledger":{"allocated":[1,1,1,1],"residual":[4,4,4,4],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":1,"edges":[[1,1],[3,1]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":1,"delivered":1,"alloc":[[1,1],[3,1]],"members":[1,3],"weights":null}]]]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":{"T":[2,3],"bound":10,"shares":[2,1]}}},"W_final":[3,4,3,4]},"ledger":{"allocated":[2,1,2,1],"residual":[3,4,3,4],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,2],[2,2]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":1,"edges":[[1,1],[3,1]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":2,"delivered":2,"alloc":[[0,2],[2,2]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":1,"delivered":1,"alloc":[[1,1],[3,1]],"members":[1,3],"weights":null}]]]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":{"T":[3,4],"bound":10,"shares":[2,2]}}},"W_final":[3,3,3,3]},"ledger":{"allocated":[2,2,2,2],"residual":[3,3,3,3],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,2],[2,2]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[1,2],[3,2]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":4,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":2,"delivered":2,"alloc":[[0,2],[2,2]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":2,"delivered":2,"alloc":[[1,2],[3,2]],"members":[1,3],"weights":null}]]]}}},{"op":"ledger","graph":1,"baseline":null,"id":2,"error":false},{"op":"policy","ledger":2,"sources":0,"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2,3]},"stage3":{"T":[0,1],"bound":2,"shares":[1]}}},"W_final":[0,10,0,10]},"ledger":{"allocated":[1,0,1,0],"residual":[0,10,0,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":{"T":[1,2],"bound":2,"shares":[2]}}},"W_final":[0,9,0,9]},"ledger":{"allocated":[1,1,1,1],"residual":[0,9,0,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[2,1],[3,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":2,"delivered":2,"alloc":[[0,1],[1,1],[2,1],[3,1]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage3":{"T":[2,2],"bound":2,"shares":[2]}}},"W_final":[0,9,0,9]},"ledger":{"allocated":[1,1,1,1],"residual":[0,9,0,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[2,1],[3,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":2,"delivered":2,"alloc":[[0,1],[1,1],[2,1],[3,1]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage3":{"T":[2,2],"bound":2,"shares":[2]}}},"W_final":[0,9,0,9]},"ledger":{"allocated":[1,1,1,1],"residual":[0,9,0,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[2,1],[3,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":2,"delivered":2,"alloc":[[0,1],[1,1],[2,1],[3,1]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"ledger","graph":1,"baseline":null,"id":3,"error":false},{"op":"policy","ledger":3,"sources":0,"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[0,2],[1,3]]},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":3,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":2,"cohorts":[]}}},{"op":"place","policy":3,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0,1]},"stage3":{"T":[0,1],"bound":2,"shares":[1,0]}}},"W_final":[0,10,0,10]},"ledger":{"allocated":[1,0,1,0],"residual":[0,10,0,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":0,"delivered":0,"alloc":[],"members":[1,3],"weights":null}]]]}}},{"op":"place","policy":3,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":{"T":[1,2],"bound":2,"shares":[1,1]}}},"W_final":[0,9,0,9]},"ledger":{"allocated":[1,1,1,1],"residual":[0,9,0,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":1,"edges":[[1,1],[3,1]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":1,"delivered":1,"alloc":[[1,1],[3,1]],"members":[1,3],"weights":null}]]]}}},{"op":"place","policy":3,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage3":{"T":[2,2],"bound":2,"shares":[1,1]}}},"W_final":[0,9,0,9]},"ledger":{"allocated":[1,1,1,1],"residual":[0,9,0,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":1,"edges":[[1,1],[3,1]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":2,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":1,"delivered":1,"alloc":[[1,1],[3,1]],"members":[1,3],"weights":null}]]]}}},{"op":"place","policy":3,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage3":{"T":[2,2],"bound":2,"shares":[1,1]}}},"W_final":[0,9,0,9]},"ledger":{"allocated":[1,1,1,1],"residual":[0,9,0,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":1,"edges":[[1,1],[3,1]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":2,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":1,"delivered":1,"alloc":[[1,1],[3,1]],"members":[1,3],"weights":null}]]]}}}]},{"name":"four_calls_Lossy","graphs":[{"n":4,"q":1.0,"edges":[[0,1,5,1],[1,3,5,1],[0,2,5,1],[2,3,5,1]],"input_index":[0,2,1,3]},{"n":4,"q":1.0,"edges":[[0,1,1,1],[1,3,1,1],[0,2,10,1],[2,3,10,1]],"input_index":[0,2,1,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossy","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2,3]},"stage3":[{"offer":1,"offered":1,"delivered":1,"carried":[[0,1],[2,1]],"drops":[]}],"injected":1}},"W_final":[4,5,4,5]},"ledger":{"allocated":[1,0,1,0],"residual":[4,5,4,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,1,2,3],"route_cost":2,"offered":1,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":null,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"offer":1,"offered":1,"delivered":1,"carried":[[0,1],[2,1]],"drops":[]}],"injected":1}},"W_final":[3,5,3,5]},"ledger":{"allocated":[2,0,2,0],"residual":[3,5,3,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,1,2,3],"route_cost":2,"offered":2,"delivered":2,"edges":[[0,2],[2,2]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":null,"delivered":2,"alloc":[[0,2],[2,2]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"offer":1,"offered":1,"delivered":1,"carried":[[0,1],[2,1]],"drops":[]}],"injected":1}},"W_final":[2,5,2,5]},"ledger":{"allocated":[3,0,3,0],"residual":[2,5,2,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,1,2,3],"route_cost":2,"offered":3,"delivered":3,"edges":[[0,3],[2,3]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":null,"delivered":3,"alloc":[[0,3],[2,3]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"offer":1,"offered":1,"delivered":1,"carried":[[0,1],[2,1]],"drops":[]}],"injected":1}},"W_final":[1,5,1,5]},"ledger":{"allocated":[4,0,4,0],"residual":[1,5,1,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,1,2,3],"route_cost":2,"offered":4,"delivered":4,"edges":[[0,4],[2,4]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":4,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":null,"delivered":4,"alloc":[[0,4],[2,4]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[0,2],[1,3]]},"placement":"EqualBalanced","admission":"Lossy","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":2,"cohorts":[]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0,1]},"stage3":[{"offer":1,"offered":1,"delivered":1,"carried":[[0,1],[2,1]],"drops":[]},{"offer":0,"offered":0,"delivered":0,"carried":[],"drops":[]}],"injected":1}},"W_final":[4,5,4,5]},"ledger":{"allocated":[1,0,1,0],"residual":[4,5,4,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,2],"route_cost":2,"offered":1,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[1,3],"route_cost":2,"offered":0,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":null,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":null,"delivered":0,"alloc":[],"members":[1,3],"weights":null}]]]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"offer":1,"offered":1,"delivered":1,"carried":[[0,1],[2,1]],"drops":[]},{"offer":0,"offered":0,"delivered":0,"carried":[],"drops":[]}],"injected":1}},"W_final":[3,5,3,5]},"ledger":{"allocated":[2,0,2,0],"residual":[3,5,3,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,2],"route_cost":2,"offered":2,"delivered":2,"edges":[[0,2],[2,2]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[1,3],"route_cost":2,"offered":0,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":null,"delivered":2,"alloc":[[0,2],[2,2]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":null,"delivered":0,"alloc":[],"members":[1,3],"weights":null}]]]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"offer":1,"offered":1,"delivered":1,"carried":[[0,1],[2,1]],"drops":[]},{"offer":0,"offered":0,"delivered":0,"carried":[],"drops":[]}],"injected":1}},"W_final":[2,5,2,5]},"ledger":{"allocated":[3,0,3,0],"residual":[2,5,2,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,2],"route_cost":2,"offered":3,"delivered":3,"edges":[[0,3],[2,3]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[1,3],"route_cost":2,"offered":0,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":null,"delivered":3,"alloc":[[0,3],[2,3]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":null,"delivered":0,"alloc":[],"members":[1,3],"weights":null}]]]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"offer":1,"offered":1,"delivered":1,"carried":[[0,1],[2,1]],"drops":[]},{"offer":0,"offered":0,"delivered":0,"carried":[],"drops":[]}],"injected":1}},"W_final":[1,5,1,5]},"ledger":{"allocated":[4,0,4,0],"residual":[1,5,1,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,2],"route_cost":2,"offered":4,"delivered":4,"edges":[[0,4],[2,4]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[1,3],"route_cost":2,"offered":0,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":4,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":null,"delivered":4,"alloc":[[0,4],[2,4]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":null,"delivered":0,"alloc":[],"members":[1,3],"weights":null}]]]}}},{"op":"ledger","graph":1,"baseline":null,"id":2,"error":false},{"op":"policy","ledger":2,"sources":0,"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossy","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2,3]},"stage3":[{"offer":1,"offered":1,"delivered":1,"carried":[[0,1],[2,1]],"drops":[]}],"injected":1}},"W_final":[0,10,0,10]},"ledger":{"allocated":[1,0,1,0],"residual":[0,10,0,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,1,2,3],"route_cost":2,"offered":1,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":null,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[[0,1]],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"offer":1,"offered":1,"delivered":0,"carried":[],"drops":[[0,1]]}],"injected":1}},"W_final":[0,10,0,10]},"ledger":{"allocated":[1,0,1,0],"residual":[0,10,0,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,1,2,3],"route_cost":2,"offered":2,"delivered":1,"edges":[[0,1],[2,1]],"drops":[[0,1]]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":null,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[[0,1]],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"offer":1,"offered":1,"delivered":0,"carried":[],"drops":[[0,1]]}],"injected":1}},"W_final":[0,10,0,10]},"ledger":{"allocated":[1,0,1,0],"residual":[0,10,0,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,1,2,3],"route_cost":2,"offered":3,"delivered":1,"edges":[[0,1],[2,1]],"drops":[[0,2]]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":null,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[[0,1]],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"offer":1,"offered":1,"delivered":0,"carried":[],"drops":[[0,1]]}],"injected":1}},"W_final":[0,10,0,10]},"ledger":{"allocated":[1,0,1,0],"residual":[0,10,0,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,1,2,3],"route_cost":2,"offered":4,"delivered":1,"edges":[[0,1],[2,1]],"drops":[[0,3]]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":null,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"ledger","graph":1,"baseline":null,"id":3,"error":false},{"op":"policy","ledger":3,"sources":0,"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[0,2],[1,3]]},"placement":"EqualBalanced","admission":"Lossy","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":3,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":2,"cohorts":[]}}},{"op":"place","policy":3,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0,1]},"stage3":[{"offer":1,"offered":1,"delivered":1,"carried":[[0,1],[2,1]],"drops":[]},{"offer":0,"offered":0,"delivered":0,"carried":[],"drops":[]}],"injected":1}},"W_final":[0,10,0,10]},"ledger":{"allocated":[1,0,1,0],"residual":[0,10,0,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,2],"route_cost":2,"offered":1,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[1,3],"route_cost":2,"offered":0,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":null,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":null,"delivered":0,"alloc":[],"members":[1,3],"weights":null}]]]}}},{"op":"place","policy":3,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[[0,1]],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"offer":1,"offered":1,"delivered":0,"carried":[],"drops":[[0,1]]},{"offer":0,"offered":0,"delivered":0,"carried":[],"drops":[]}],"injected":1}},"W_final":[0,10,0,10]},"ledger":{"allocated":[1,0,1,0],"residual":[0,10,0,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,2],"route_cost":2,"offered":2,"delivered":1,"edges":[[0,1],[2,1]],"drops":[[0,1]]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[1,3],"route_cost":2,"offered":0,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":1,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":null,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":null,"delivered":0,"alloc":[],"members":[1,3],"weights":null}]]]}}},{"op":"place","policy":3,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[[0,1]],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"offer":1,"offered":1,"delivered":0,"carried":[],"drops":[[0,1]]},{"offer":0,"offered":0,"delivered":0,"carried":[],"drops":[]}],"injected":1}},"W_final":[0,10,0,10]},"ledger":{"allocated":[1,0,1,0],"residual":[0,10,0,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,2],"route_cost":2,"offered":3,"delivered":1,"edges":[[0,1],[2,1]],"drops":[[0,2]]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[1,3],"route_cost":2,"offered":0,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":1,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":null,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":null,"delivered":0,"alloc":[],"members":[1,3],"weights":null}]]]}}},{"op":"place","policy":3,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[[0,1]],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"offer":1,"offered":1,"delivered":0,"carried":[],"drops":[[0,1]]},{"offer":0,"offered":0,"delivered":0,"carried":[],"drops":[]}],"injected":1}},"W_final":[0,10,0,10]},"ledger":{"allocated":[1,0,1,0],"residual":[0,10,0,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,2],"route_cost":2,"offered":4,"delivered":1,"edges":[[0,1],[2,1]],"drops":[[0,3]]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[1,3],"route_cost":2,"offered":0,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":1,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":null,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":null,"delivered":0,"alloc":[],"members":[1,3],"weights":null}]]]}}}]},{"name":"eb_unequal_1_10","graphs":[{"n":4,"q":1.0,"edges":[[0,1,1,1],[1,3,1,1],[0,2,10,1],[2,3,10,1]],"input_index":[0,2,1,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2,3]},"stage3":{"T":[0,1],"bound":2,"shares":[1]}}},"W_final":[0,10,0,10]},"ledger":{"allocated":[1,0,1,0],"residual":[0,10,0,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":{"T":[1,2],"bound":2,"shares":[2]}}},"W_final":[0,9,0,9]},"ledger":{"allocated":[1,1,1,1],"residual":[0,9,0,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[2,1],[3,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":2,"delivered":2,"alloc":[[0,1],[1,1],[2,1],[3,1]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage3":{"T":[2,2],"bound":2,"shares":[2]}}},"W_final":[0,9,0,9]},"ledger":{"allocated":[1,1,1,1],"residual":[0,9,0,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[2,1],[3,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":2,"delivered":2,"alloc":[[0,1],[1,1],[2,1],[3,1]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage3":{"T":[2,2],"bound":2,"shares":[2]}}},"W_final":[0,9,0,9]},"ledger":{"allocated":[1,1,1,1],"residual":[0,9,0,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[2,1],[3,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":2,"delivered":2,"alloc":[[0,1],[1,1],[2,1],[3,1]],"members":[0,1,2,3],"weights":null}]]]}}}]},{"name":"cancellation_dag","graphs":[{"n":6,"q":1.0,"edges":[[0,1,1,1],[0,2,1,1],[1,3,1,1],[1,4,1,1],[2,3,1,1],[3,5,1,1],[4,5,1,1]],"input_index":[0,1,2,3,4,5,6]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":5,"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2,3,4,5,6]},"stage3":[{"T":[0,1],"curve":[[3,1]],"alloc":[[0,1],[2,1],[5,1]]}]}},"W_final":[0,1,0,1,1,0,1]},"ledger":{"allocated":[1,0,1,0,0,1,0],"residual":[0,1,0,1,1,0,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[5],"placement":"MaxFlow","admission":"Lossless","bundle":[0,1,2,3,4,5,6],"route_cost":3,"offered":null,"delivered":1,"edges":[[0,1],[2,1],[5,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3,4,5,6],"T":1,"delivered":1,"alloc":[[0,1],[2,1],[5,1]],"members":[0,1,2,3,4,5,6],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[1,2],"curve":[[3,2]],"alloc":[[0,1],[1,1],[3,1],[4,1],[5,1],[6,1]]}]}},"W_final":[0,0,1,0,0,0,0]},"ledger":{"allocated":[1,1,0,1,1,1,1],"residual":[0,0,1,0,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[5],"placement":"MaxFlow","admission":"Lossless","bundle":[0,1,2,3,4,5,6],"route_cost":3,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[3,1],[4,1],[5,1],[6,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3,4,5,6],"T":2,"delivered":2,"alloc":[[0,1],[1,1],[3,1],[4,1],[5,1],[6,1]],"members":[0,1,2,3,4,5,6],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":5,"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":2,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":2,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":2},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2,3,4,5,6]},"stage3":[{"T":[0,2],"curve":[[3,2]],"alloc":[[0,1],[1,1],[3,1],[4,1],[5,1],[6,1]]}]}},"W_final":[0,0,1,0,0,0,0]},"ledger":{"allocated":[1,1,0,1,1,1,1],"residual":[0,0,1,0,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[5],"placement":"MaxFlow","admission":"Lossless","bundle":[0,1,2,3,4,5,6],"route_cost":3,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[3,1],[4,1],[5,1],[6,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3,4,5,6],"T":2,"delivered":2,"alloc":[[0,1],[1,1],[3,1],[4,1],[5,1],[6,1]],"members":[0,1,2,3,4,5,6],"weights":null}]]]}}}]},{"name":"lossy_diamond_10_then_1","graphs":[{"n":4,"q":1.0,"edges":[[0,1,10,1],[1,3,1,1],[0,2,10,1],[2,3,10,1]],"input_index":[0,2,1,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossy","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":10,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":6,"remaining":4,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[[2,4]],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":10},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2,3]},"stage3":[{"offer":10,"offered":10,"delivered":6,"carried":[[0,5],[1,5],[2,1],[3,5]],"drops":[[2,4]]}],"injected":10}},"W_final":[5,5,0,5]},"ledger":{"allocated":[5,5,1,5],"residual":[5,5,0,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,1,2,3],"route_cost":2,"offered":10,"delivered":6,"edges":[[0,5],[1,5],[2,1],[3,5]],"drops":[[2,4]]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":10,"infinite":false,"placed_demand":6,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":null,"delivered":6,"alloc":[[0,5],[1,5],[2,1],[3,5]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[[2,1]],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"offer":1,"offered":1,"delivered":0,"carried":[[0,1]],"drops":[[2,1]]}],"injected":1}},"W_final":[4,5,0,5]},"ledger":{"allocated":[6,5,1,5],"residual":[4,5,0,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,1,2,3],"route_cost":2,"offered":11,"delivered":6,"edges":[[0,6],[1,5],[2,1],[3,5]],"drops":[[2,5]]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":11,"infinite":false,"placed_demand":6,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":null,"delivered":6,"alloc":[[0,6],[1,5],[2,1],[3,5]],"members":[0,1,2,3],"weights":null}]]]}}}]},{"name":"lossy_shared_ingress","graphs":[{"n":4,"q":1.0,"edges":[[0,1,2,1],[1,3,1,1],[1,2,10,1],[2,3,10,1]],"input_index":[0,2,1,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[0,2],[0,1,3]]},"placement":"EqualBalanced","admission":"Lossy","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":2,"cohorts":[]}}},{"op":"place","policy":0,"volume":2,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":2,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":2},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0,1]},"stage3":[{"offer":1,"offered":1,"delivered":1,"carried":[[0,1],[2,1]],"drops":[]},{"offer":1,"offered":1,"delivered":1,"carried":[[0,1],[1,1],[3,1]],"drops":[]}],"injected":2}},"W_final":[0,9,0,9]},"ledger":{"allocated":[2,1,1,1],"residual":[0,9,0,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,2],"route_cost":2,"offered":1,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,1,3],"route_cost":3,"offered":1,"delivered":1,"edges":[[0,1],[1,1],[3,1]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":null,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null},{"id":1,"route":[0,1,3],"T":null,"delivered":1,"alloc":[[0,1],[1,1],[3,1]],"members":[0,1,3],"weights":null}]]]}}},{"op":"place","policy":0,"volume":2,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":2,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[[0,2]],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":2},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"offer":1,"offered":1,"delivered":0,"carried":[],"drops":[[0,1]]},{"offer":1,"offered":1,"delivered":0,"carried":[],"drops":[[0,1]]}],"injected":2}},"W_final":[0,9,0,9]},"ledger":{"allocated":[2,1,1,1],"residual":[0,9,0,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,2],"route_cost":2,"offered":2,"delivered":1,"edges":[[0,1],[2,1]],"drops":[[0,1]]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,1,3],"route_cost":3,"offered":2,"delivered":1,"edges":[[0,1],[1,1],[3,1]],"drops":[[0,1]]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":2,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":null,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null},{"id":1,"route":[0,1,3],"T":null,"delivered":1,"alloc":[[0,1],[1,1],[3,1]],"members":[0,1,3],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[0,2],[0,1,3]]},"placement":"EqualBalanced","admission":"Lossy","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":2,"cohorts":[]}}},{"op":"place","policy":1,"volume":4,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":3,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[[0,2],[2,1]],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":4},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0,1]},"stage3":[{"offer":2,"offered":2,"delivered":1,"carried":[[0,2],[2,1]],"drops":[[2,1]]},{"offer":2,"offered":2,"delivered":0,"carried":[],"drops":[[0,2]]}],"injected":4}},"W_final":[0,10,0,10]},"ledger":{"allocated":[2,0,1,0],"residual":[0,10,0,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,2],"route_cost":2,"offered":2,"delivered":1,"edges":[[0,2],[2,1]],"drops":[[2,1]]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,1,3],"route_cost":3,"offered":2,"delivered":0,"edges":[],"drops":[[0,2]]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":1,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":null,"delivered":1,"alloc":[[0,2],[2,1]],"members":[0,2],"weights":null},{"id":1,"route":[0,1,3],"T":null,"delivered":0,"alloc":[],"members":[0,1,3],"weights":null}]]]}}}]},{"name":"inf_every_kernel","graphs":[{"n":4,"q":1.0,"edges":[[0,1,5,1],[1,3,5,1],[0,2,5,1],[2,3,5,1]],"input_index":[0,2,1,3]},{"n":4,"q":1.0,"edges":[[0,1,1,1],[1,3,1,1],[0,2,10,1],[2,3,10,1]],"input_index":[0,2,1,3]},{"n":4,"q":1.0,"edges":[[0,1,10,1],[1,3,1,1],[0,2,10,1],[2,3,10,1]],"input_index":[0,2,1,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":"inf","outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":10,"remaining":"inf","termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":"inf"},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2,3]},"stage3":[{"T":[0,10],"curve":[[2,10]],"alloc":[[0,5],[1,5],[2,5],[3,5]]}]}},"W_final":[0,0,0,0]},"ledger":{"allocated":[5,5,5,5],"residual":[0,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":10,"edges":[[0,5],[1,5],[2,5],[3,5]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":true,"placed_demand":10,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":10,"delivered":10,"alloc":[[0,5],[1,5],[2,5],[3,5]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"Weighted","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":"inf","outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":10,"remaining":"inf","termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":"inf"},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2,3]},"stage3":[{"T":[0,10],"curve":null,"alloc":[[0,5],[1,5],[2,5],[3,5]]}]}},"W_final":[0,0,0,0]},"ledger":{"allocated":[5,5,5,5],"residual":[0,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":10,"edges":[[0,5],[1,5],[2,5],[3,5]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":true,"placed_demand":10,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":10,"delivered":10,"alloc":[[0,5],[1,5],[2,5],[3,5]],"members":[0,1,2,3],"weights":[[0,5],[1,5],[2,5],[3,5]]}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":2,"error":false},{"op":"policy","ledger":2,"sources":0,"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":2,"volume":"inf","outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":10,"remaining":"inf","termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":"inf"},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2,3]},"stage3":{"T":[0,10],"bound":10,"shares":[10]}}},"W_final":[0,0,0,0]},"ledger":{"allocated":[5,5,5,5],"residual":[0,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":10,"edges":[[0,5],[1,5],[2,5],[3,5]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":true,"placed_demand":10,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":10,"delivered":10,"alloc":[[0,5],[1,5],[2,5],[3,5]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":3,"error":false},{"op":"policy","ledger":3,"sources":0,"destinations":3,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":null,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":3,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":3,"volume":"inf","outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":10,"remaining":"inf","termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":"inf"},"local_outcomes":{"0":"exhausted"},"created":2,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0,2],"S":[5,5,5,5],"T_bound":5,"reserved":5}],"created_cap_blocked":false},"stage3":{"T":[0,5],"bound":5,"shares":[5]},"stage4":[{"route":[1,3],"W":[0,5,0,5],"T":5,"T_candidate":10,"bound":10,"shares":[5,5],"decision":"accepted","gain":5},{"stop":"no candidate"}]}},"W_final":[0,0,0,0]},"ledger":{"allocated":[5,5,5,5],"residual":[0,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":5,"edges":[[0,5],[2,5]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":5,"edges":[[1,5],[3,5]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":true,"placed_demand":10,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":5,"delivered":5,"alloc":[[0,5],[2,5]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":5,"delivered":5,"alloc":[[1,5],[3,5]],"members":[1,3],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":4,"error":false},{"op":"policy","ledger":4,"sources":0,"destinations":3,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":null,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":4,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":4,"volume":"inf","outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":10,"remaining":"inf","termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":"inf"},"local_outcomes":{"0":"exhausted"},"created":2,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0,2],"S":[5,5,5,5],"T_bound":5,"reserved":5}],"created_cap_blocked":false},"stage3":[{"T":[0,5],"curve":[[2,5]],"alloc":[[0,5],[2,5]]}],"stage4":[{"route":[1,3],"W":[0,5,0,5],"bound":5,"fill":5,"decision":"accepted","gain":5},{"stop":"no candidate"}]}},"W_final":[0,0,0,0]},"ledger":{"allocated":[5,5,5,5],"residual":[0,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":5,"edges":[[0,5],[2,5]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":5,"edges":[[1,5],[3,5]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":true,"placed_demand":10,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":5,"delivered":5,"alloc":[[0,5],[2,5]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":5,"delivered":5,"alloc":[[1,5],[3,5]],"members":[1,3],"weights":null}]]]}}},{"op":"ledger","graph":1,"baseline":null,"id":5,"error":false},{"op":"policy","ledger":5,"sources":0,"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossy","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":5,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":5,"volume":"inf","outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":11,"remaining":"inf","termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":"inf"},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2,3]},"stage3":[{"offer":"inf","offered":11,"delivered":11,"carried":[[0,1],[1,10],[2,1],[3,10]],"drops":[]}],"injected":11}},"W_final":[0,0,0,0]},"ledger":{"allocated":[1,10,1,10],"residual":[0,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,1,2,3],"route_cost":2,"offered":11,"delivered":11,"edges":[[0,1],[1,10],[2,1],[3,10]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":true,"placed_demand":11,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":null,"delivered":11,"alloc":[[0,1],[1,10],[2,1],[3,10]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"ledger","graph":2,"baseline":null,"id":6,"error":false},{"op":"policy","ledger":6,"sources":0,"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossy","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":6,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":6,"volume":"inf","outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":11,"remaining":"inf","termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[[2,9]],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":"inf"},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2,3]},"stage3":[{"offer":"inf","offered":20,"delivered":11,"carried":[[0,10],[1,10],[2,1],[3,10]],"drops":[[2,9]]}],"injected":20}},"W_final":[0,0,0,0]},"ledger":{"allocated":[10,10,1,10],"residual":[0,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,1,2,3],"route_cost":2,"offered":20,"delivered":11,"edges":[[0,10],[1,10],[2,1],[3,10]],"drops":[[2,9]]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":true,"placed_demand":11,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":null,"delivered":11,"alloc":[[0,10],[1,10],[2,1],[3,10]],"members":[0,1,2,3],"weights":null}]]]}}}]},{"name":"retention_third_party_removal","graphs":[{"n":4,"q":1.0,"edges":[[0,1,5,1],[1,3,5,1],[0,2,5,1],[2,3,5,1]],"input_index":[0,2,1,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[0,2]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":0,"volume":2,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":2,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":2},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,2],"curve":[[2,2]],"alloc":[[0,2],[2,2]]}]}},"W_final":[3,5,3,5]},"ledger":{"allocated":[2,0,2,0],"residual":[3,5,3,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,2],[2,2]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":1,"cohorts":[[0,[{"id":0,"route":[0,2],"T":2,"delivered":2,"alloc":[[0,2],[2,2]],"members":[0,2],"weights":null}]]]}}},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"CapacityAware","flow_count":2,"max_flow_count":2,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":true,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":8,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":6,"remaining":2,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":8},"local_outcomes":{"0":"exhausted"},"created":2,"sources":{"0":{"stage1":{"eb_target":4,"reservations":[{"route":[0,2],"S":[3,5,3,5],"T_bound":3,"reserved":3},{"route":[1,3],"S":[0,5,0,5],"T_bound":5,"reserved":4}],"created_cap_blocked":false},"stage2":[],"stage3":{"T":[0,6],"bound":6,"shares":[3,3]},"stage4":[{"stop":"max_flow_count","k":2}]}},"W_final":[0,2,0,2]},"ledger":{"allocated":[5,3,5,3],"residual":[0,2,0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,2],[2,2]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":3,"edges":[[0,3],[2,3]],"drops":[]},{"flow_id":2,"owner":1,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":3,"edges":[[1,3],[3,3]],"drops":[]}],"next_flow_id":3,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":8,"infinite":false,"placed_demand":6,"usable":0,"cohorts":[[0,[{"id":1,"route":[0,2],"T":3,"delivered":3,"alloc":[[0,3],[2,3]],"members":[0,2],"weights":null},{"id":2,"route":[1,3],"T":3,"delivered":3,"alloc":[[1,3],[3,3]],"members":[1,3],"weights":null}]]]}}},{"op":"remove","policy":0,"error":false,"expect":{"ledger":{"allocated":[3,3,3,3],"residual":[2,2,2,2],"records":[{"flow_id":1,"owner":1,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":3,"edges":[[0,3],[2,3]],"drops":[]},{"flow_id":2,"owner":1,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":3,"edges":[[1,3],[3,3]],"drops":[]}],"next_flow_id":3,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage2":[{"flow":1,"T":3,"candidate":[0,2],"decision":"kept"},{"flow":2,"T":3,"candidate":[0,2],"decision":"rejected: delivers less than T_i","bound":2}],"stage3":{"T":[6,7],"bound":10,"shares":[4,3]},"stage4":[]}},"W_final":[1,2,1,2]},"ledger":{"allocated":[4,3,4,3],"residual":[1,2,1,2],"records":[{"flow_id":1,"owner":1,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":4,"edges":[[0,4],[2,4]],"drops":[]},{"flow_id":2,"owner":1,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":3,"edges":[[1,3],[3,3]],"drops":[]}],"next_flow_id":3,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":9,"infinite":false,"placed_demand":7,"usable":0,"cohorts":[[0,[{"id":1,"route":[0,2],"T":4,"delivered":4,"alloc":[[0,4],[2,4]],"members":[0,2],"weights":null},{"id":2,"route":[1,3],"T":3,"delivered":3,"alloc":[[1,3],[3,3]],"members":[1,3],"weights":null}]]]}}}]},{"name":"shared_capacity_view","graphs":[{"n":3,"q":1.0,"edges":[[0,1,10,1],[1,2,10,1],[1,2,10,1]],"input_index":[0,1,2]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":1,"destinations":2,"config":{"routing":{"kind":"Pinned","bundles":[[1]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[1],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":0,"volume":6,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":6,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[1,"complete"]]},"trace":{"offers":{"1":6},"local_outcomes":{"1":"complete"},"created":0,"sources":{"1":{"stage1":{"usable":[0]},"stage3":[{"T":[0,6],"curve":[[1,6]],"alloc":[[1,6]]}]}},"W_final":[10,4,10]},"ledger":{"allocated":[0,6,0],"residual":[10,4,10],"records":[{"flow_id":0,"owner":0,"sources":[1],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":6,"edges":[[1,6]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[1],"requested":6,"infinite":false,"placed_demand":6,"usable":1,"cohorts":[[1,[{"id":0,"route":[1],"T":6,"delivered":6,"alloc":[[1,6]],"members":[1],"weights":null}]]]}}},{"op":"policy","ledger":0,"sources":0,"destinations":2,"config":{"routing":{"kind":"CapacityAware","flow_count":2,"max_flow_count":2,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":true,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":8,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":8,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":8},"local_outcomes":{"0":"complete"},"created":2,"sources":{"0":{"stage1":{"eb_target":4,"reservations":[{"route":[0,2],"S":[10,4,10],"T_bound":10,"reserved":4},{"route":[0,2],"S":[6,4,6],"T_bound":6,"reserved":4}],"created_cap_blocked":false},"stage2":[],"stage3":{"T":[0,8],"bound":10,"shares":[4,4]},"stage4":[]}},"W_final":[2,4,2]},"ledger":{"allocated":[8,6,8],"residual":[2,4,2],"records":[{"flow_id":0,"owner":0,"sources":[1],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":6,"edges":[[1,6]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[2],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":4,"edges":[[0,4],[2,4]],"drops":[]},{"flow_id":2,"owner":1,"sources":[0],"destinations":[2],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":4,"edges":[[0,4],[2,4]],"drops":[]}],"next_flow_id":3,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":8,"infinite":false,"placed_demand":8,"usable":0,"cohorts":[[0,[{"id":1,"route":[0,2],"T":4,"delivered":4,"alloc":[[0,4],[2,4]],"members":[0,2],"weights":null},{"id":2,"route":[0,2],"T":4,"delivered":4,"alloc":[[0,4],[2,4]],"members":[0,2],"weights":null}]]]}}},{"op":"remove","policy":0,"error":false,"expect":{"ledger":{"allocated":[8,0,8],"residual":[2,10,2],"records":[{"flow_id":1,"owner":1,"sources":[0],"destinations":[2],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":4,"edges":[[0,4],[2,4]],"drops":[]},{"flow_id":2,"owner":1,"sources":[0],"destinations":[2],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":4,"edges":[[0,4],[2,4]],"drops":[]}],"next_flow_id":3,"bound":[0,1],"orphans":[]},"policy":{"roster":[1],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage2":[{"flow":1,"T":4,"candidate":[0,1],"decision":"adopted","alloc":[[0,4],[1,4]]},{"flow":2,"T":4,"candidate":[0,2],"decision":"kept"}],"stage3":{"T":[8,9],"bound":10,"shares":[5,4]},"stage4":[]}},"W_final":[1,5,6]},"ledger":{"allocated":[9,5,4],"residual":[1,5,6],"records":[{"flow_id":1,"owner":1,"sources":[0],"destinations":[2],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1],"route_cost":2,"offered":null,"delivered":5,"edges":[[0,5],[1,5]],"drops":[]},{"flow_id":2,"owner":1,"sources":[0],"destinations":[2],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":4,"edges":[[0,4],[2,4]],"drops":[]}],"next_flow_id":3,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":9,"infinite":false,"placed_demand":9,"usable":0,"cohorts":[[0,[{"id":1,"route":[0,1],"T":5,"delivered":5,"alloc":[[0,5],[1,5]],"members":[0,1],"weights":null},{"id":2,"route":[0,2],"T":4,"delivered":4,"alloc":[[0,4],[2,4]],"members":[0,2],"weights":null}]]]}}}]},{"name":"reopt_double_spend","graphs":[{"n":2,"q":1.0,"edges":[[0,1,10,1],[0,1,10,1]],"input_index":[0,1]},{"n":2,"q":1.0,"edges":[[0,1,10,1]],"input_index":[0]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":1,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":true,"diminishing":null},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":4,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":4,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":4},"local_outcomes":{"0":"complete"},"created":1,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0],"S":[10,10],"T_bound":10,"reserved":4}],"created_cap_blocked":false},"stage2":[],"stage3":[{"T":[0,4],"curve":[[1,4]],"alloc":[[0,4]]}],"stage4":[]}},"W_final":[6,10]},"ledger":{"allocated":[4,0],"residual":[6,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":4,"edges":[[0,4]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":4,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":4,"delivered":4,"alloc":[[0,4]],"members":[0],"weights":null}]]]}}},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":1,"volume":6,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":6,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":6},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,6],"curve":[[1,6]],"alloc":[[0,6]]}]}},"W_final":[0,10]},"ledger":{"allocated":[10,0],"residual":[0,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":4,"edges":[[0,4]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":6,"edges":[[0,6]],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":6,"infinite":false,"placed_demand":6,"usable":1,"cohorts":[[0,[{"id":1,"route":[0],"T":6,"delivered":6,"alloc":[[0,6]],"members":[0],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage2":[{"flow":0,"T":4,"candidate":[1],"decision":"adopted","alloc":[[1,4]]}],"stage3":[{"T":[4,5],"curve":[[1,5]],"alloc":[[1,5]]}],"stage4":[]}},"W_final":[4,5]},"ledger":{"allocated":[6,5],"residual":[4,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":5,"edges":[[1,5]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":6,"edges":[[0,6]],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":5,"infinite":false,"placed_demand":5,"usable":0,"cohorts":[[0,[{"id":0,"route":[1],"T":5,"delivered":5,"alloc":[[1,5]],"members":[1],"weights":null}]]]}}},{"op":"ledger","graph":1,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":1,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":1,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":true,"diminishing":null},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Residual"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":2,"volume":4,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":4,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":4},"local_outcomes":{"0":"complete"},"created":1,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0],"S":[10],"T_bound":10,"reserved":4}],"created_cap_blocked":false},"stage2":[],"stage3":[{"T":[0,4],"curve":[[1,4]],"alloc":[[0,4]]}],"stage4":[]}},"W_final":[6]},"ledger":{"allocated":[4],"residual":[6],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":4,"edges":[[0,4]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":4,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":4,"delivered":4,"alloc":[[0,4]],"members":[0],"weights":null}]]]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage2":[{"flow":0,"T":4,"candidate":[0],"decision":"kept"}],"stage3":[{"T":[4,5],"curve":[[1,5]],"alloc":[[0,5]]}],"stage4":[]}},"W_final":[5]},"ledger":{"allocated":[5],"residual":[5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":5,"edges":[[0,5]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":5,"infinite":false,"placed_demand":5,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":5,"delivered":5,"alloc":[[0,5]],"members":[0],"weights":null}]]]}}}]},{"name":"retention_same_structure","graphs":[{"n":2,"q":1.0,"edges":[[0,1,2,1],[0,1,2,1],[0,1,2,1]],"input_index":[0,1,2]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":null,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":true,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":1,"sources":{"0":{"stage1":{"eb_target":3,"reservations":[{"route":[0,1,2],"S":[2,2,2],"T_bound":6,"reserved":3}],"created_cap_blocked":false},"stage2":[],"stage3":{"T":[0,3],"bound":6,"shares":[3]},"stage4":[]}},"W_final":[1,1,1]},"ledger":{"allocated":[1,1,1],"residual":[1,1,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,1],[1,1],[2,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2],"T":3,"delivered":3,"alloc":[[0,1],[1,1],[2,1]],"members":[0,1,2],"weights":null}]]]}}},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[0,1]]}]}},"W_final":[0,1,1]},"ledger":{"allocated":[2,1,1],"residual":[0,1,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,1],[1,1],[2,1]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":1,"cohorts":[[0,[{"id":1,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]]]}}},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[1]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[1,1]]}]}},"W_final":[0,0,1]},"ledger":{"allocated":[2,2,1],"residual":[0,0,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,1],[1,1],[2,1]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":2,"owner":2,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]}],"next_flow_id":3,"bound":[0,1,2],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":1,"cohorts":[[0,[{"id":2,"route":[1],"T":1,"delivered":1,"alloc":[[1,1]],"members":[1],"weights":null}]]]}}},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[2]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":3,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":3,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[2,1]]}]}},"W_final":[0,0,0]},"ledger":{"allocated":[2,2,2],"residual":[0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,1],[1,1],[2,1]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":2,"owner":2,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]},{"flow_id":3,"owner":3,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[2],"route_cost":1,"offered":null,"delivered":1,"edges":[[2,1]],"drops":[]}],"next_flow_id":4,"bound":[0,1,2,3],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":1,"cohorts":[[0,[{"id":3,"route":[2],"T":1,"delivered":1,"alloc":[[2,1]],"members":[2],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage2":[{"flow":0,"T":3,"candidate":[0,1,2],"decision":"kept"}],"stage3":{"T":[3,3],"bound":3,"shares":[3]},"stage4":[{"stop":"no candidate"}]}},"W_final":[0,0,0]},"ledger":{"allocated":[2,2,2],"residual":[0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,1],[1,1],[2,1]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":2,"owner":2,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]},{"flow_id":3,"owner":3,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[2],"route_cost":1,"offered":null,"delivered":1,"edges":[[2,1]],"drops":[]}],"next_flow_id":4,"bound":[0,1,2,3],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2],"T":3,"delivered":3,"alloc":[[0,1],[1,1],[2,1]],"members":[0,1,2],"weights":null}]]]}}}]},{"name":"weighted_frozen_third_party","graphs":[{"n":4,"q":1.0,"edges":[[0,1,10,1],[1,3,1,1],[0,2,10,1],[2,3,10,1]],"input_index":[0,2,1,3]},{"n":4,"q":1.0,"edges":[[0,1,10,1],[1,3,2,1],[0,2,10,1],[2,3,10,1]],"input_index":[0,2,1,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"Weighted","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":2,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":2,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":2},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2,3]},"stage3":[{"T":[0,2],"curve":null,"alloc":[[0,1],[1,1],[2,1],[3,1]]}]}},"W_final":[9,9,0,9]},"ledger":{"allocated":[1,1,1,1],"residual":[9,9,0,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[2,1],[3,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":2,"delivered":2,"alloc":[[0,1],[1,1],[2,1],[3,1]],"members":[0,1,2,3],"weights":[[0,10],[1,10],[2,1],[3,10]]}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage3":[{"T":[2,2],"curve":null,"alloc":[[0,1],[1,1],[2,1],[3,1]]}]}},"W_final":[9,9,0,9]},"ledger":{"allocated":[1,1,1,1],"residual":[9,9,0,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[2,1],[3,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":2,"delivered":2,"alloc":[[0,1],[1,1],[2,1],[3,1]],"members":[0,1,2,3],"weights":[[0,10],[1,10],[2,1],[3,10]]}]]]}}},{"op":"ledger","graph":1,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"Weighted","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":2,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":2,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":2},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2,3]},"stage3":[{"T":[0,2],"curve":null,"alloc":[[0,1],[1,1],[2,1],[3,1]]}]}},"W_final":[9,9,1,9]},"ledger":{"allocated":[1,1,1,1],"residual":[9,9,1,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[2,1],[3,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":2,"delivered":2,"alloc":[[0,1],[1,1],[2,1],[3,1]],"members":[0,1,2,3],"weights":[[0,10],[1,10],[2,2],[3,10]]}]]]}}},{"op":"policy","ledger":1,"sources":1,"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[2]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[1],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[1,"complete"]]},"trace":{"offers":{"1":1},"local_outcomes":{"1":"complete"},"created":0,"sources":{"1":{"stage1":{"usable":[0]},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[2,1]]}]}},"W_final":[9,9,0,9]},"ledger":{"allocated":[1,1,2,1],"residual":[9,9,0,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[2,1],[3,1]],"drops":[]},{"flow_id":1,"owner":1,"sources":[1],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[2],"route_cost":1,"offered":null,"delivered":1,"edges":[[2,1]],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[1],"requested":1,"infinite":false,"placed_demand":1,"usable":1,"cohorts":[[1,[{"id":1,"route":[2],"T":1,"delivered":1,"alloc":[[2,1]],"members":[2],"weights":null}]]]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage3":[{"T":[2,2],"curve":null,"alloc":[[0,1],[1,1],[2,1],[3,1]]}]}},"W_final":[9,9,0,9]},"ledger":{"allocated":[1,1,2,1],"residual":[9,9,0,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[2,1],[3,1]],"drops":[]},{"flow_id":1,"owner":1,"sources":[1],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[2],"route_cost":1,"offered":null,"delivered":1,"edges":[[2,1]],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":2,"delivered":2,"alloc":[[0,1],[1,1],[2,1],[3,1]],"members":[0,1,2,3],"weights":[[0,10],[1,10],[2,2],[3,10]]}]]]}}},{"op":"remove","policy":2,"error":false,"expect":{"ledger":{"allocated":[1,1,1,1],"residual":[9,9,1,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[2,1],[3,1]],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[1],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[2,3],"curve":null,"alloc":[[0,2],[1,1],[2,2],[3,1]]}]}},"W_final":[8,9,0,9]},"ledger":{"allocated":[2,1,2,1],"residual":[8,9,0,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":3,"edges":[[0,2],[1,1],[2,2],[3,1]],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2,3],"T":3,"delivered":3,"alloc":[[0,2],[1,1],[2,2],[3,1]],"members":[0,1,2,3],"weights":[[0,10],[1,10],[2,2],[3,10]]}]]]}}}]},{"name":"third_party_occupancy","graphs":[{"n":2,"q":1.0,"edges":[[0,1,10,1]],"input_index":[0]}],"ops":[{"op":"ledger","graph":0,"baseline":[8],"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":4,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":4,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":4},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":[{"T":[0,4],"curve":[[1,4]],"alloc":[[0,4]]}]}},"W_final":[4]},"ledger":{"allocated":[4],"residual":[4],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":4,"edges":[[0,4]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":4,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":4,"delivered":4,"alloc":[[0,4]],"members":[0],"weights":null}]]]}}},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":1,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,3],"curve":[[1,3]],"alloc":[[0,3]]}]}},"W_final":[1]},"ledger":{"allocated":[7],"residual":[1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":4,"edges":[[0,4]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":1,"cohorts":[[0,[{"id":1,"route":[0],"T":3,"delivered":3,"alloc":[[0,3]],"members":[0],"weights":null}]]]}}},{"op":"place","policy":0,"volume":2,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":1,"termination":"exhausted","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":2},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage3":[{"T":[4,5],"curve":[[1,5]],"alloc":[[0,5]]}]}},"W_final":[0]},"ledger":{"allocated":[8],"residual":[0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":5,"edges":[[0,5]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":6,"infinite":false,"placed_demand":5,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":5,"delivered":5,"alloc":[[0,5]],"members":[0],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":[8],"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"Weighted","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":2,"volume":4,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":4,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":4},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":[{"T":[0,4],"curve":null,"alloc":[[0,4]]}]}},"W_final":[4]},"ledger":{"allocated":[4],"residual":[4],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"Weighted","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":4,"edges":[[0,4]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":4,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":4,"delivered":4,"alloc":[[0,4]],"members":[0],"weights":[[0,8]]}]]]}}},{"op":"policy","ledger":1,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":3,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":3,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,3],"curve":[[1,3]],"alloc":[[0,3]]}]}},"W_final":[1]},"ledger":{"allocated":[7],"residual":[1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"Weighted","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":4,"edges":[[0,4]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":1,"cohorts":[[0,[{"id":1,"route":[0],"T":3,"delivered":3,"alloc":[[0,3]],"members":[0],"weights":null}]]]}}},{"op":"place","policy":2,"volume":2,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":1,"termination":"exhausted","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":2},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage3":[{"T":[4,5],"curve":null,"alloc":[[0,5]]}]}},"W_final":[0]},"ledger":{"allocated":[8],"residual":[0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"Weighted","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":5,"edges":[[0,5]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":6,"infinite":false,"placed_demand":5,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":5,"delivered":5,"alloc":[[0,5]],"members":[0],"weights":[[0,8]]}]]]}}},{"op":"ledger","graph":0,"baseline":[8],"id":2,"error":false},{"op":"policy","ledger":2,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":4,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":4,"volume":4,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":4,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":4},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":{"T":[0,4],"bound":8,"shares":[4]}}},"W_final":[4]},"ledger":{"allocated":[4],"residual":[4],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":4,"edges":[[0,4]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":4,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":4,"delivered":4,"alloc":[[0,4]],"members":[0],"weights":null}]]]}}},{"op":"policy","ledger":2,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":5,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":5,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,3],"curve":[[1,3]],"alloc":[[0,3]]}]}},"W_final":[1]},"ledger":{"allocated":[7],"residual":[1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":4,"edges":[[0,4]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":1,"cohorts":[[0,[{"id":1,"route":[0],"T":3,"delivered":3,"alloc":[[0,3]],"members":[0],"weights":null}]]]}}},{"op":"place","policy":4,"volume":2,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":1,"termination":"exhausted","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":2},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage3":{"T":[4,5],"bound":5,"shares":[5]}}},"W_final":[0]},"ledger":{"allocated":[8],"residual":[0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":5,"edges":[[0,5]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":6,"infinite":false,"placed_demand":5,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":5,"delivered":5,"alloc":[[0,5]],"members":[0],"weights":null}]]]}}}]},{"name":"eb_correlated_remainders","graphs":[{"n":3,"q":1.0,"edges":[[0,1,1,1],[0,1,1,1],[0,1,1,1],[0,2,1,1]],"input_index":[0,1,2,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0,1,2]]},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":0,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":{"T":[0,3],"bound":3,"shares":[3]}}},"W_final":[0,0,0,1]},"ledger":{"allocated":[1,1,1,0],"residual":[0,0,0,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,1],[1,1],[2,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":1,"cohorts":[[0,[{"id":0,"route":[0,1,2],"T":3,"delivered":3,"alloc":[[0,1],[1,1],[2,1]],"members":[0,1,2],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0,1,2],[0,1,2,3]]},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":2,"cohorts":[]}}},{"op":"place","policy":1,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":2,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage1":{"usable":[0,1]},"stage3":{"T":[0,1],"bound":1,"shares":[1,0]}}},"W_final":[0,1,1,1]},"ledger":{"allocated":[1,0,0,0],"residual":[0,1,1,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2],"route_cost":1,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":1,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,1,2],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0,1,2],"weights":null},{"id":1,"route":[0,1,2],"T":0,"delivered":0,"alloc":[],"members":[0,1,2],"weights":null}]]]}}}]},{"name":"eb_1_plus_2_vs_3","graphs":[{"n":2,"q":1.0,"edges":[[0,1,10,1],[0,1,10,1],[0,1,10,1]],"input_index":[0,1,2]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2]},"stage3":{"T":[0,1],"bound":30,"shares":[1]}}},"W_final":[9,10,10]},"ledger":{"allocated":[1,0,0],"residual":[9,10,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0,1,2],"weights":null}]]]}}},{"op":"place","policy":0,"volume":2,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":2,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":2},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":{"T":[1,3],"bound":30,"shares":[3]}}},"W_final":[9,9,9]},"ledger":{"allocated":[1,1,1],"residual":[9,9,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,1],[1,1],[2,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2],"T":3,"delivered":3,"alloc":[[0,1],[1,1],[2,1]],"members":[0,1,2],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2]},"stage3":{"T":[0,3],"bound":30,"shares":[3]}}},"W_final":[9,9,9]},"ledger":{"allocated":[1,1,1],"residual":[9,9,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,1],[1,1],[2,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2],"T":3,"delivered":3,"alloc":[[0,1],[1,1],[2,1]],"members":[0,1,2],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":2,"error":false},{"op":"policy","ledger":2,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0],[1],[2]]},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":3,"cohorts":[]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0,1,2]},"stage3":{"T":[0,1],"bound":30,"shares":[1,0,0]}}},"W_final":[9,10,10]},"ledger":{"allocated":[1,0,0],"residual":[9,10,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":0,"edges":[],"drops":[]},{"flow_id":2,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[2],"route_cost":1,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":3,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":3,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null},{"id":1,"route":[1],"T":0,"delivered":0,"alloc":[],"members":[1],"weights":null},{"id":2,"route":[2],"T":0,"delivered":0,"alloc":[],"members":[2],"weights":null}]]]}}},{"op":"place","policy":2,"volume":2,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":2,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":2},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":{"T":[1,3],"bound":30,"shares":[1,1,1]}}},"W_final":[9,9,9]},"ledger":{"allocated":[1,1,1],"residual":[9,9,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]},{"flow_id":2,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[2],"route_cost":1,"offered":null,"delivered":1,"edges":[[2,1]],"drops":[]}],"next_flow_id":3,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":3,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null},{"id":1,"route":[1],"T":1,"delivered":1,"alloc":[[1,1]],"members":[1],"weights":null},{"id":2,"route":[2],"T":1,"delivered":1,"alloc":[[2,1]],"members":[2],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":3,"error":false},{"op":"policy","ledger":3,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0],[1],[2]]},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":3,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":3,"cohorts":[]}}},{"op":"place","policy":3,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0,1,2]},"stage3":{"T":[0,3],"bound":30,"shares":[1,1,1]}}},"W_final":[9,9,9]},"ledger":{"allocated":[1,1,1],"residual":[9,9,9],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]},{"flow_id":2,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[2],"route_cost":1,"offered":null,"delivered":1,"edges":[[2,1]],"drops":[]}],"next_flow_id":3,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":3,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null},{"id":1,"route":[1],"T":1,"delivered":1,"alloc":[[1,1]],"members":[1],"weights":null},{"id":2,"route":[2],"T":1,"delivered":1,"alloc":[[2,1]],"members":[2],"weights":null}]]]}}}]},{"name":"weighted_1_plus_5_vs_6","graphs":[{"n":2,"q":1.0,"edges":[[0,1,2,1],[0,1,2,1],[0,1,2,1]],"input_index":[0,1,2]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"Weighted","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2]},"stage3":[{"T":[0,1],"curve":null,"alloc":[[0,1]]}]}},"W_final":[1,2,2]},"ledger":{"allocated":[1,0,0],"residual":[1,2,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"Weighted","admission":"Lossless","bundle":[0,1,2],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0,1,2],"weights":[[0,2],[1,2],[2,2]]}]]]}}},{"op":"place","policy":0,"volume":5,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":5,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":5},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[1,6],"curve":null,"alloc":[[0,2],[1,2],[2,2]]}]}},"W_final":[0,0,0]},"ledger":{"allocated":[2,2,2],"residual":[0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"Weighted","admission":"Lossless","bundle":[0,1,2],"route_cost":1,"offered":null,"delivered":6,"edges":[[0,2],[1,2],[2,2]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":6,"infinite":false,"placed_demand":6,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2],"T":6,"delivered":6,"alloc":[[0,2],[1,2],[2,2]],"members":[0,1,2],"weights":[[0,2],[1,2],[2,2]]}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"Weighted","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":6,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":6,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":6},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2]},"stage3":[{"T":[0,6],"curve":null,"alloc":[[0,2],[1,2],[2,2]]}]}},"W_final":[0,0,0]},"ledger":{"allocated":[2,2,2],"residual":[0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"Weighted","admission":"Lossless","bundle":[0,1,2],"route_cost":1,"offered":null,"delivered":6,"edges":[[0,2],[1,2],[2,2]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":6,"infinite":false,"placed_demand":6,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2],"T":6,"delivered":6,"alloc":[[0,2],[1,2],[2,2]],"members":[0,1,2],"weights":[[0,2],[1,2],[2,2]]}]]]}}}]},{"name":"admission_zero_and_no_route","graphs":[{"n":4,"q":1.0,"edges":[[0,1,5,1],[1,3,5,1],[0,2,5,1],[2,3,5,1]],"input_index":[0,2,1,3]},{"n":4,"q":1.0,"edges":[[0,1,0,1],[1,3,0,1],[0,2,0,1],[2,3,0,1]],"input_index":[0,2,1,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":null,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":0,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{},"ledger":{"allocated":[0,0,0,0],"residual":[5,5,5,5],"records":[],"next_flow_id":0,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"policy","ledger":0,"sources":0,"destinations":0,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":null,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"error":true},{"op":"policy","ledger":0,"sources":[0,1],"destinations":[1,3],"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"error":true},{"op":"ledger","graph":1,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"no_route","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[]},"trace":{"roster":[]},"ledger":{"allocated":[0,0,0,0],"residual":[0,0,0,0],"records":[],"next_flow_id":0,"bound":[0],"orphans":[]},"policy":{"roster":[],"requested":1,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"ledger","graph":0,"baseline":[0,0,0,0],"id":2,"error":false},{"op":"policy","ledger":2,"sources":0,"destinations":3,"config":{"routing":{"kind":"CapacityAware","flow_count":2,"max_flow_count":null,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"no_route","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"no_route"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"no_route"},"created":0,"sources":{"0":{"stage1":{"eb_target":0,"reservations":[],"created_cap_blocked":false}}},"W_final":[0,0,0,0]},"ledger":{"allocated":[0,0,0,0],"residual":[0,0,0,0],"records":[],"next_flow_id":0,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"ledger","graph":0,"baseline":null,"id":3,"error":false},{"op":"policy","ledger":3,"sources":0,"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[0,2],[1,3]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":{"nodes":[],"edges":[0,1]},"id":3,"error":false,"expect":{"policy":{"roster":[],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":3,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"no_route","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[]},"trace":{"roster":[]},"ledger":{"allocated":[0,0,0,0],"residual":[5,5,5,5],"records":[],"next_flow_id":0,"bound":[0],"orphans":[]},"policy":{"roster":[],"requested":1,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}}]},{"name":"limits_kmaxflowspercall","graphs":[{"n":2,"q":1.0,"edges":[[0,1,1,1],[0,1,1,1],[0,1,1,1],[0,1,1,1],[0,1,1,1]],"input_index":[0,1,2,3,4]},{"n":3,"q":1.0,"edges":[[0,2,1,1],[0,2,1,1],[0,2,1,1],[0,2,1,1],[1,2,1,1],[1,2,1,1],[1,2,1,1],[1,2,1,1]],"input_index":[0,1,2,3,4,5,6,7]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":null,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":5,"outputs":null,"cap":3,"error":false,"expect":{"result":{"placed":3,"remaining":2,"termination":"limit_flows","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"limit_flows"]]},"trace":{"offers":{"0":5},"local_outcomes":{"0":"limit_flows"},"created":3,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0],"S":[1,1,1,1,1],"T_bound":1,"reserved":1}],"created_cap_blocked":false},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[0,1]]}],"stage4":[{"route":[1],"W":[0,1,1,1,1],"bound":1,"fill":1,"decision":"accepted","gain":1},{"route":[2],"W":[0,0,1,1,1],"bound":1,"fill":1,"decision":"accepted","gain":1},{"stop":"kMaxFlowsPerCall"}]}},"W_final":[0,0,0,1,1]},"ledger":{"allocated":[1,1,1,0,0],"residual":[0,0,0,1,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]},{"flow_id":2,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[2],"route_cost":1,"offered":null,"delivered":1,"edges":[[2,1]],"drops":[]}],"next_flow_id":3,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":5,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null},{"id":1,"route":[1],"T":1,"delivered":1,"alloc":[[1,1]],"members":[1],"weights":null},{"id":2,"route":[2],"T":1,"delivered":1,"alloc":[[2,1]],"members":[2],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0],[1],[2],[3],[4]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":5,"cohorts":[]}}},{"op":"place","policy":1,"volume":5,"outputs":null,"cap":3,"error":false,"expect":{"result":{"placed":5,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":5},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0,1,2,3,4]},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[0,1]]},{"T":[0,1],"curve":[[1,1]],"alloc":[[1,1]]},{"T":[0,1],"curve":[[1,1]],"alloc":[[2,1]]},{"T":[0,1],"curve":[[1,1]],"alloc":[[3,1]]},{"T":[0,1],"curve":[[1,1]],"alloc":[[4,1]]}]}},"W_final":[0,0,0,0,0]},"ledger":{"allocated":[1,1,1,1,1],"residual":[0,0,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]},{"flow_id":2,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[2],"route_cost":1,"offered":null,"delivered":1,"edges":[[2,1]],"drops":[]},{"flow_id":3,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[3],"route_cost":1,"offered":null,"delivered":1,"edges":[[3,1]],"drops":[]},{"flow_id":4,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[4],"route_cost":1,"offered":null,"delivered":1,"edges":[[4,1]],"drops":[]}],"next_flow_id":5,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":5,"infinite":false,"placed_demand":5,"usable":5,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null},{"id":1,"route":[1],"T":1,"delivered":1,"alloc":[[1,1]],"members":[1],"weights":null},{"id":2,"route":[2],"T":1,"delivered":1,"alloc":[[2,1]],"members":[2],"weights":null},{"id":3,"route":[3],"T":1,"delivered":1,"alloc":[[3,1]],"members":[3],"weights":null},{"id":4,"route":[4],"T":1,"delivered":1,"alloc":[[4,1]],"members":[4],"weights":null}]]]}}},{"op":"ledger","graph":1,"baseline":null,"id":2,"error":false},{"op":"policy","ledger":2,"sources":[0,1],"destinations":2,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":null,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Residual"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":2,"volume":8,"outputs":null,"cap":3,"error":false,"expect":{"result":{"placed":3,"remaining":5,"termination":"limit_flows","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"limit_flows"],[1,"limit_flows"]]},"trace":{"offers":{"0":4,"1":4},"local_outcomes":{"0":"limit_flows","1":"limit_flows"},"created":3,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0],"S":[1,1,1,1,1,1,1,1],"T_bound":1,"reserved":1}],"created_cap_blocked":false},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[0,1]]}],"stage4":[{"route":[1],"W":[0,1,1,1,1,1,1,1],"bound":1,"fill":1,"decision":"accepted","gain":1},{"route":[2],"W":[0,0,1,1,1,1,1,1],"bound":1,"fill":1,"decision":"accepted","gain":1},{"stop":"kMaxFlowsPerCall"}]},"1":{"stage1":{"eb_target":null,"reservations":[],"created_cap_blocked":true}}},"W_final":[0,0,0,1,1,1,1,1]},"ledger":{"allocated":[1,1,1,0,0,0,0,0],"residual":[0,0,0,1,1,1,1,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]},{"flow_id":2,"owner":0,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[2],"route_cost":1,"offered":null,"delivered":1,"edges":[[2,1]],"drops":[]}],"next_flow_id":3,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":8,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null},{"id":1,"route":[1],"T":1,"delivered":1,"alloc":[[1,1]],"members":[1],"weights":null},{"id":2,"route":[2],"T":1,"delivered":1,"alloc":[[2,1]],"members":[2],"weights":null}]]]}}}]},{"name":"limits_diminishing","graphs":[{"n":2,"q":0.000244140625,"edges":[[0,1,204800,1],[0,1,41,1],[0,1,41,1],[0,1,41,1],[0,1,41,1],[0,1,41,1],[0,1,41,1],[0,1,41,1],[0,1,41,1],[0,1,41,1],[0,1,41,1],[0,1,41,1],[0,1,41,1],[0,1,41,1],[0,1,41,1],[0,1,41,1],[0,1,41,1],[0,1,41,1],[0,1,41,1],[0,1,41,1],[0,1,41,1]],"input_index":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":null,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":[8,0.001]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":409600,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":205128,"remaining":204472,"termination":"limit_diminishing","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"limit_diminishing"]]},"trace":{"offers":{"0":409600},"local_outcomes":{"0":"limit_diminishing"},"created":9,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0],"S":[204800,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"T_bound":204800,"reserved":204800}],"created_cap_blocked":false},"stage3":[{"T":[0,204800],"curve":[[1,204800]],"alloc":[[0,204800]]}],"stage4":[{"route":[1],"W":[0,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[2],"W":[0,0,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[3],"W":[0,0,0,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[4],"W":[0,0,0,0,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[5],"W":[0,0,0,0,0,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[6],"W":[0,0,0,0,0,0,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[7],"W":[0,0,0,0,0,0,0,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[8],"W":[0,0,0,0,0,0,0,0,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"stop":"diminishing","recent":328,"threshold":"28823037615171175/70368744177664"}]}},"W_final":[0,0,0,0,0,0,0,0,0,41,41,41,41,41,41,41,41,41,41,41,41]},"ledger":{"allocated":[204800,41,41,41,41,41,41,41,41,0,0,0,0,0,0,0,0,0,0,0,0],"residual":[0,0,0,0,0,0,0,0,0,41,41,41,41,41,41,41,41,41,41,41,41],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":204800,"edges":[[0,204800]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":41,"edges":[[1,41]],"drops":[]},{"flow_id":2,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[2],"route_cost":1,"offered":null,"delivered":41,"edges":[[2,41]],"drops":[]},{"flow_id":3,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[3],"route_cost":1,"offered":null,"delivered":41,"edges":[[3,41]],"drops":[]},{"flow_id":4,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[4],"route_cost":1,"offered":null,"delivered":41,"edges":[[4,41]],"drops":[]},{"flow_id":5,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[5],"route_cost":1,"offered":null,"delivered":41,"edges":[[5,41]],"drops":[]},{"flow_id":6,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[6],"route_cost":1,"offered":null,"delivered":41,"edges":[[6,41]],"drops":[]},{"flow_id":7,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[7],"route_cost":1,"offered":null,"delivered":41,"edges":[[7,41]],"drops":[]},{"flow_id":8,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[8],"route_cost":1,"offered":null,"delivered":41,"edges":[[8,41]],"drops":[]}],"next_flow_id":9,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":409600,"infinite":false,"placed_demand":205128,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":204800,"delivered":204800,"alloc":[[0,204800]],"members":[0],"weights":null},{"id":1,"route":[1],"T":41,"delivered":41,"alloc":[[1,41]],"members":[1],"weights":null},{"id":2,"route":[2],"T":41,"delivered":41,"alloc":[[2,41]],"members":[2],"weights":null},{"id":3,"route":[3],"T":41,"delivered":41,"alloc":[[3,41]],"members":[3],"weights":null},{"id":4,"route":[4],"T":41,"delivered":41,"alloc":[[4,41]],"members":[4],"weights":null},{"id":5,"route":[5],"T":41,"delivered":41,"alloc":[[5,41]],"members":[5],"weights":null},{"id":6,"route":[6],"T":41,"delivered":41,"alloc":[[6,41]],"members":[6],"weights":null},{"id":7,"route":[7],"T":41,"delivered":41,"alloc":[[7,41]],"members":[7],"weights":null},{"id":8,"route":[8],"T":41,"delivered":41,"alloc":[[8,41]],"members":[8],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":1,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":null,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":409600,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":205620,"remaining":203980,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":409600},"local_outcomes":{"0":"exhausted"},"created":21,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0],"S":[204800,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"T_bound":204800,"reserved":204800}],"created_cap_blocked":false},"stage3":[{"T":[0,204800],"curve":[[1,204800]],"alloc":[[0,204800]]}],"stage4":[{"route":[1],"W":[0,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[2],"W":[0,0,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[3],"W":[0,0,0,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[4],"W":[0,0,0,0,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[5],"W":[0,0,0,0,0,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[6],"W":[0,0,0,0,0,0,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[7],"W":[0,0,0,0,0,0,0,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[8],"W":[0,0,0,0,0,0,0,0,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[9],"W":[0,0,0,0,0,0,0,0,0,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[10],"W":[0,0,0,0,0,0,0,0,0,0,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[11],"W":[0,0,0,0,0,0,0,0,0,0,0,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[12],"W":[0,0,0,0,0,0,0,0,0,0,0,0,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[13],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[14],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[15],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[16],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[17],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[18],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[19],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[20],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"stop":"no candidate"}]}},"W_final":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},"ledger":{"allocated":[204800,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"residual":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":204800,"edges":[[0,204800]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":41,"edges":[[1,41]],"drops":[]},{"flow_id":2,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[2],"route_cost":1,"offered":null,"delivered":41,"edges":[[2,41]],"drops":[]},{"flow_id":3,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[3],"route_cost":1,"offered":null,"delivered":41,"edges":[[3,41]],"drops":[]},{"flow_id":4,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[4],"route_cost":1,"offered":null,"delivered":41,"edges":[[4,41]],"drops":[]},{"flow_id":5,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[5],"route_cost":1,"offered":null,"delivered":41,"edges":[[5,41]],"drops":[]},{"flow_id":6,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[6],"route_cost":1,"offered":null,"delivered":41,"edges":[[6,41]],"drops":[]},{"flow_id":7,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[7],"route_cost":1,"offered":null,"delivered":41,"edges":[[7,41]],"drops":[]},{"flow_id":8,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[8],"route_cost":1,"offered":null,"delivered":41,"edges":[[8,41]],"drops":[]},{"flow_id":9,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[9],"route_cost":1,"offered":null,"delivered":41,"edges":[[9,41]],"drops":[]},{"flow_id":10,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[10],"route_cost":1,"offered":null,"delivered":41,"edges":[[10,41]],"drops":[]},{"flow_id":11,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[11],"route_cost":1,"offered":null,"delivered":41,"edges":[[11,41]],"drops":[]},{"flow_id":12,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[12],"route_cost":1,"offered":null,"delivered":41,"edges":[[12,41]],"drops":[]},{"flow_id":13,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[13],"route_cost":1,"offered":null,"delivered":41,"edges":[[13,41]],"drops":[]},{"flow_id":14,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[14],"route_cost":1,"offered":null,"delivered":41,"edges":[[14,41]],"drops":[]},{"flow_id":15,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[15],"route_cost":1,"offered":null,"delivered":41,"edges":[[15,41]],"drops":[]},{"flow_id":16,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[16],"route_cost":1,"offered":null,"delivered":41,"edges":[[16,41]],"drops":[]},{"flow_id":17,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[17],"route_cost":1,"offered":null,"delivered":41,"edges":[[17,41]],"drops":[]},{"flow_id":18,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[18],"route_cost":1,"offered":null,"delivered":41,"edges":[[18,41]],"drops":[]},{"flow_id":19,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[19],"route_cost":1,"offered":null,"delivered":41,"edges":[[19,41]],"drops":[]},{"flow_id":20,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[20],"route_cost":1,"offered":null,"delivered":41,"edges":[[20,41]],"drops":[]}],"next_flow_id":21,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":409600,"infinite":false,"placed_demand":205620,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":204800,"delivered":204800,"alloc":[[0,204800]],"members":[0],"weights":null},{"id":1,"route":[1],"T":41,"delivered":41,"alloc":[[1,41]],"members":[1],"weights":null},{"id":2,"route":[2],"T":41,"delivered":41,"alloc":[[2,41]],"members":[2],"weights":null},{"id":3,"route":[3],"T":41,"delivered":41,"alloc":[[3,41]],"members":[3],"weights":null},{"id":4,"route":[4],"T":41,"delivered":41,"alloc":[[4,41]],"members":[4],"weights":null},{"id":5,"route":[5],"T":41,"delivered":41,"alloc":[[5,41]],"members":[5],"weights":null},{"id":6,"route":[6],"T":41,"delivered":41,"alloc":[[6,41]],"members":[6],"weights":null},{"id":7,"route":[7],"T":41,"delivered":41,"alloc":[[7,41]],"members":[7],"weights":null},{"id":8,"route":[8],"T":41,"delivered":41,"alloc":[[8,41]],"members":[8],"weights":null},{"id":9,"route":[9],"T":41,"delivered":41,"alloc":[[9,41]],"members":[9],"weights":null},{"id":10,"route":[10],"T":41,"delivered":41,"alloc":[[10,41]],"members":[10],"weights":null},{"id":11,"route":[11],"T":41,"delivered":41,"alloc":[[11,41]],"members":[11],"weights":null},{"id":12,"route":[12],"T":41,"delivered":41,"alloc":[[12,41]],"members":[12],"weights":null},{"id":13,"route":[13],"T":41,"delivered":41,"alloc":[[13,41]],"members":[13],"weights":null},{"id":14,"route":[14],"T":41,"delivered":41,"alloc":[[14,41]],"members":[14],"weights":null},{"id":15,"route":[15],"T":41,"delivered":41,"alloc":[[15,41]],"members":[15],"weights":null},{"id":16,"route":[16],"T":41,"delivered":41,"alloc":[[16,41]],"members":[16],"weights":null},{"id":17,"route":[17],"T":41,"delivered":41,"alloc":[[17,41]],"members":[17],"weights":null},{"id":18,"route":[18],"T":41,"delivered":41,"alloc":[[18,41]],"members":[18],"weights":null},{"id":19,"route":[19],"T":41,"delivered":41,"alloc":[[19,41]],"members":[19],"weights":null},{"id":20,"route":[20],"T":41,"delivered":41,"alloc":[[20,41]],"members":[20],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":2,"error":false},{"op":"policy","ledger":2,"sources":0,"destinations":1,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":null,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":[8,0.001]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Residual"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":2,"volume":"inf","outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":205620,"remaining":"inf","termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":"inf"},"local_outcomes":{"0":"exhausted"},"created":21,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0],"S":[204800,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"T_bound":204800,"reserved":204800}],"created_cap_blocked":false},"stage3":[{"T":[0,204800],"curve":[[1,204800]],"alloc":[[0,204800]]}],"stage4":[{"route":[1],"W":[0,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[2],"W":[0,0,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[3],"W":[0,0,0,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[4],"W":[0,0,0,0,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[5],"W":[0,0,0,0,0,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[6],"W":[0,0,0,0,0,0,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[7],"W":[0,0,0,0,0,0,0,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[8],"W":[0,0,0,0,0,0,0,0,41,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[9],"W":[0,0,0,0,0,0,0,0,0,41,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[10],"W":[0,0,0,0,0,0,0,0,0,0,41,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[11],"W":[0,0,0,0,0,0,0,0,0,0,0,41,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[12],"W":[0,0,0,0,0,0,0,0,0,0,0,0,41,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[13],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,41,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[14],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,41,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[15],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,41,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[16],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,41,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[17],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,41,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[18],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,41,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[19],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,41,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"route":[20],"W":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,41],"bound":41,"fill":41,"decision":"accepted","gain":41},{"stop":"no candidate"}]}},"W_final":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},"ledger":{"allocated":[204800,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41,41],"residual":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":204800,"edges":[[0,204800]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":41,"edges":[[1,41]],"drops":[]},{"flow_id":2,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[2],"route_cost":1,"offered":null,"delivered":41,"edges":[[2,41]],"drops":[]},{"flow_id":3,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[3],"route_cost":1,"offered":null,"delivered":41,"edges":[[3,41]],"drops":[]},{"flow_id":4,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[4],"route_cost":1,"offered":null,"delivered":41,"edges":[[4,41]],"drops":[]},{"flow_id":5,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[5],"route_cost":1,"offered":null,"delivered":41,"edges":[[5,41]],"drops":[]},{"flow_id":6,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[6],"route_cost":1,"offered":null,"delivered":41,"edges":[[6,41]],"drops":[]},{"flow_id":7,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[7],"route_cost":1,"offered":null,"delivered":41,"edges":[[7,41]],"drops":[]},{"flow_id":8,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[8],"route_cost":1,"offered":null,"delivered":41,"edges":[[8,41]],"drops":[]},{"flow_id":9,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[9],"route_cost":1,"offered":null,"delivered":41,"edges":[[9,41]],"drops":[]},{"flow_id":10,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[10],"route_cost":1,"offered":null,"delivered":41,"edges":[[10,41]],"drops":[]},{"flow_id":11,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[11],"route_cost":1,"offered":null,"delivered":41,"edges":[[11,41]],"drops":[]},{"flow_id":12,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[12],"route_cost":1,"offered":null,"delivered":41,"edges":[[12,41]],"drops":[]},{"flow_id":13,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[13],"route_cost":1,"offered":null,"delivered":41,"edges":[[13,41]],"drops":[]},{"flow_id":14,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[14],"route_cost":1,"offered":null,"delivered":41,"edges":[[14,41]],"drops":[]},{"flow_id":15,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[15],"route_cost":1,"offered":null,"delivered":41,"edges":[[15,41]],"drops":[]},{"flow_id":16,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[16],"route_cost":1,"offered":null,"delivered":41,"edges":[[16,41]],"drops":[]},{"flow_id":17,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[17],"route_cost":1,"offered":null,"delivered":41,"edges":[[17,41]],"drops":[]},{"flow_id":18,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[18],"route_cost":1,"offered":null,"delivered":41,"edges":[[18,41]],"drops":[]},{"flow_id":19,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[19],"route_cost":1,"offered":null,"delivered":41,"edges":[[19,41]],"drops":[]},{"flow_id":20,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[20],"route_cost":1,"offered":null,"delivered":41,"edges":[[20,41]],"drops":[]}],"next_flow_id":21,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":true,"placed_demand":205620,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":204800,"delivered":204800,"alloc":[[0,204800]],"members":[0],"weights":null},{"id":1,"route":[1],"T":41,"delivered":41,"alloc":[[1,41]],"members":[1],"weights":null},{"id":2,"route":[2],"T":41,"delivered":41,"alloc":[[2,41]],"members":[2],"weights":null},{"id":3,"route":[3],"T":41,"delivered":41,"alloc":[[3,41]],"members":[3],"weights":null},{"id":4,"route":[4],"T":41,"delivered":41,"alloc":[[4,41]],"members":[4],"weights":null},{"id":5,"route":[5],"T":41,"delivered":41,"alloc":[[5,41]],"members":[5],"weights":null},{"id":6,"route":[6],"T":41,"delivered":41,"alloc":[[6,41]],"members":[6],"weights":null},{"id":7,"route":[7],"T":41,"delivered":41,"alloc":[[7,41]],"members":[7],"weights":null},{"id":8,"route":[8],"T":41,"delivered":41,"alloc":[[8,41]],"members":[8],"weights":null},{"id":9,"route":[9],"T":41,"delivered":41,"alloc":[[9,41]],"members":[9],"weights":null},{"id":10,"route":[10],"T":41,"delivered":41,"alloc":[[10,41]],"members":[10],"weights":null},{"id":11,"route":[11],"T":41,"delivered":41,"alloc":[[11,41]],"members":[11],"weights":null},{"id":12,"route":[12],"T":41,"delivered":41,"alloc":[[12,41]],"members":[12],"weights":null},{"id":13,"route":[13],"T":41,"delivered":41,"alloc":[[13,41]],"members":[13],"weights":null},{"id":14,"route":[14],"T":41,"delivered":41,"alloc":[[14,41]],"members":[14],"weights":null},{"id":15,"route":[15],"T":41,"delivered":41,"alloc":[[15,41]],"members":[15],"weights":null},{"id":16,"route":[16],"T":41,"delivered":41,"alloc":[[16,41]],"members":[16],"weights":null},{"id":17,"route":[17],"T":41,"delivered":41,"alloc":[[17,41]],"members":[17],"weights":null},{"id":18,"route":[18],"T":41,"delivered":41,"alloc":[[18,41]],"members":[18],"weights":null},{"id":19,"route":[19],"T":41,"delivered":41,"alloc":[[19,41]],"members":[19],"weights":null},{"id":20,"route":[20],"T":41,"delivered":41,"alloc":[[20,41]],"members":[20],"weights":null}]]]}}}]},{"name":"lifetime_remove_orphan_reset","graphs":[{"n":4,"q":1.0,"edges":[[0,1,5,1],[1,3,5,1],[0,2,5,1],[2,3,5,1]],"input_index":[0,2,1,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":null,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":1,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0,2],"S":[5,5,5,5],"T_bound":5,"reserved":3}],"created_cap_blocked":false},"stage3":[{"T":[0,3],"curve":[[2,3]],"alloc":[[0,3],[2,3]]}],"stage4":[]}},"W_final":[2,5,2,5]},"ledger":{"allocated":[3,0,3,0],"residual":[2,5,2,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":3,"edges":[[0,3],[2,3]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":3,"delivered":3,"alloc":[[0,3],[2,3]],"members":[0,2],"weights":null}]]]}}},{"op":"reset","ledger":0,"baseline":null,"error":true,"expect":{"ledger":{"allocated":[3,0,3,0],"residual":[2,5,2,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":3,"edges":[[0,3],[2,3]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]}}},{"op":"copy","ledger":0,"error":true,"expect":{"ledger":{"allocated":[3,0,3,0],"residual":[2,5,2,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":3,"edges":[[0,3],[2,3]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]}}},{"op":"remove","policy":0,"error":false,"expect":{"ledger":{"allocated":[0,0,0,0],"residual":[5,5,5,5],"records":[],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":2,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":2,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":2},"local_outcomes":{"0":"complete"},"created":1,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0,2],"S":[5,5,5,5],"T_bound":5,"reserved":2}],"created_cap_blocked":false},"stage3":[{"T":[0,2],"curve":[[2,2]],"alloc":[[0,2],[2,2]]}],"stage4":[]}},"W_final":[3,5,3,5]},"ledger":{"allocated":[2,0,2,0],"residual":[3,5,3,5],"records":[{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,2],[2,2]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":1,"route":[0,2],"T":2,"delivered":2,"alloc":[[0,2],[2,2]],"members":[0,2],"weights":null}]]]}}},{"op":"remove","policy":0,"error":false,"expect":{"ledger":{"allocated":[0,0,0,0],"residual":[5,5,5,5],"records":[],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"remove","policy":0,"error":false,"expect":{"ledger":{"allocated":[0,0,0,0],"residual":[5,5,5,5],"records":[],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":2,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":2,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":2},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2,3]},"stage3":{"T":[0,2],"bound":10,"shares":[2]}}},"W_final":[4,4,4,4]},"ledger":{"allocated":[1,1,1,1],"residual":[4,4,4,4],"records":[{"flow_id":2,"owner":1,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[2,1],[3,1]],"drops":[]}],"next_flow_id":3,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":2,"route":[0,1,2,3],"T":2,"delivered":2,"alloc":[[0,1],[1,1],[2,1],[3,1]],"members":[0,1,2,3],"weights":null}]]]}}},{"op":"destroy","policy":1,"error":false,"expect":{"ledger":{"allocated":[1,1,1,1],"residual":[4,4,4,4],"records":[{"flow_id":2,"owner":1,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[2,1],[3,1]],"drops":[]}],"next_flow_id":3,"bound":[0],"orphans":[1]}}},{"op":"reset","ledger":0,"baseline":null,"error":true,"expect":{"ledger":{"allocated":[1,1,1,1],"residual":[4,4,4,4],"records":[{"flow_id":2,"owner":1,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[2,1],[3,1]],"drops":[]}],"next_flow_id":3,"bound":[0],"orphans":[1]}}},{"op":"destroy","policy":0,"error":false,"expect":{"ledger":{"allocated":[1,1,1,1],"residual":[4,4,4,4],"records":[{"flow_id":2,"owner":1,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2,3],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[2,1],[3,1]],"drops":[]}],"next_flow_id":3,"bound":[],"orphans":[0,1]}}},{"op":"reset","ledger":0,"baseline":null,"error":false,"expect":{"ledger":{"allocated":[0,0,0,0],"residual":[5,5,5,5],"records":[],"next_flow_id":3,"bound":[],"orphans":[]}}}]},{"name":"pinned_kernels_5_1","graphs":[{"n":4,"q":1.0,"edges":[[0,1,5,1],[1,3,5,1],[0,2,1,1],[2,3,1,1]],"input_index":[0,2,1,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[0,2],[1,3]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":2,"cohorts":[]}}},{"op":"place","policy":0,"volume":8,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":6,"remaining":2,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":8},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage1":{"usable":[0,1]},"stage3":[{"T":[0,5],"curve":[[2,5]],"alloc":[[0,5],[2,5]]},{"T":[0,1],"curve":[[2,1]],"alloc":[[1,1],[3,1]]}]}},"W_final":[0,0,0,0]},"ledger":{"allocated":[5,1,5,1],"residual":[0,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":5,"edges":[[0,5],[2,5]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":1,"edges":[[1,1],[3,1]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":8,"infinite":false,"placed_demand":6,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":5,"delivered":5,"alloc":[[0,5],[2,5]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":1,"delivered":1,"alloc":[[1,1],[3,1]],"members":[1,3],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[0,2],[1,3]]},"placement":"Weighted","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":2,"cohorts":[]}}},{"op":"place","policy":1,"volume":8,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":6,"remaining":2,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":8},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage1":{"usable":[0,1]},"stage3":[{"T":[0,5],"curve":null,"alloc":[[0,5],[2,5]]},{"T":[0,1],"curve":null,"alloc":[[1,1],[3,1]]}]}},"W_final":[0,0,0,0]},"ledger":{"allocated":[5,1,5,1],"residual":[0,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":5,"edges":[[0,5],[2,5]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":1,"edges":[[1,1],[3,1]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":8,"infinite":false,"placed_demand":6,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":5,"delivered":5,"alloc":[[0,5],[2,5]],"members":[0,2],"weights":[[0,5],[2,5]]},{"id":1,"route":[1,3],"T":1,"delivered":1,"alloc":[[1,1],[3,1]],"members":[1,3],"weights":[[1,1],[3,1]]}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":2,"error":false},{"op":"policy","ledger":2,"sources":0,"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[0,2],[1,3]]},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":2,"cohorts":[]}}},{"op":"place","policy":2,"volume":8,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":5,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":8},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage1":{"usable":[0,1]},"stage3":{"T":[0,3],"bound":3,"shares":[2,1]}}},"W_final":[3,0,3,0]},"ledger":{"allocated":[2,1,2,1],"residual":[3,0,3,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,2],[2,2]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":1,"edges":[[1,1],[3,1]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":8,"infinite":false,"placed_demand":3,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":2,"delivered":2,"alloc":[[0,2],[2,2]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":1,"delivered":1,"alloc":[[1,1],[3,1]],"members":[1,3],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":3,"error":false},{"op":"policy","ledger":3,"sources":0,"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[0,2],[1,3]]},"placement":"EqualBalanced","admission":"Lossy","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":3,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":2,"cohorts":[]}}},{"op":"place","policy":3,"volume":8,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":5,"remaining":3,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[[1,3]],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":8},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0,1]},"stage3":[{"offer":4,"offered":4,"delivered":4,"carried":[[0,4],[2,4]],"drops":[]},{"offer":4,"offered":4,"delivered":1,"carried":[[1,1],[3,1]],"drops":[[1,3]]}],"injected":8}},"W_final":[1,0,1,0]},"ledger":{"allocated":[4,1,4,1],"residual":[1,0,1,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,2],"route_cost":2,"offered":4,"delivered":4,"edges":[[0,4],[2,4]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[1,3],"route_cost":2,"offered":4,"delivered":1,"edges":[[1,1],[3,1]],"drops":[[1,3]]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":8,"infinite":false,"placed_demand":5,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,2],"T":null,"delivered":4,"alloc":[[0,4],[2,4]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":null,"delivered":1,"alloc":[[1,1],[3,1]],"members":[1,3],"weights":null}]]]}}}]},{"name":"pinned_bundle_rules","graphs":[{"n":4,"q":1.0,"edges":[[0,1,5,1],[1,3,5,2],[0,2,5,1],[2,3,5,3],[0,3,5,9]],"input_index":[0,2,4,1,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"error":true},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[0,3],[0,3]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"error":true},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[0,1]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"error":true},{"op":"policy","ledger":0,"sources":[0,1],"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[0,3]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"error":true},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[0,3],[0,2,3]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":{"nodes":[],"edges":[2]},"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":2,"cohorts":[]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0,1]},"stage3":[{"T":[0,1],"curve":[[3,1]],"alloc":[[0,1],[3,1]]},{"T":[0,0],"curve":[],"alloc":[]}]}},"W_final":[4,5,5,4,5]},"ledger":{"allocated":[1,0,0,1,0],"residual":[4,5,5,4,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,3],"route_cost":3,"offered":null,"delivered":1,"edges":[[0,1],[3,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,3],"route_cost":3,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":2,"cohorts":[[0,[{"id":0,"route":[0,3],"T":1,"delivered":1,"alloc":[[0,1],[3,1]],"members":[0,3],"weights":null},{"id":1,"route":[0,3],"T":0,"delivered":0,"alloc":[],"members":[0,3],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[0,1,3,4]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":1,"volume":7,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":7,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":7},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,7],"curve":[[3,5],[4,2]],"alloc":[[0,5],[1,2],[3,5],[4,2]]}]}},"W_final":[0,3,5,0,3]},"ledger":{"allocated":[5,2,0,5,2],"residual":[0,3,5,0,3],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,1,3,4],"route_cost":3,"offered":null,"delivered":7,"edges":[[0,5],[1,2],[3,5],[4,2]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":7,"infinite":false,"placed_demand":7,"usable":1,"cohorts":[[0,[{"id":0,"route":[0,1,3,4],"T":7,"delivered":7,"alloc":[[0,5],[1,2],[3,5],[4,2]],"members":[0,1,3,4],"weights":null}]]]}}}]},{"name":"flag_table","graphs":[{"n":4,"q":1.0,"edges":[[0,1,5,1],[1,3,5,1],[0,2,5,1],[2,3,5,1]],"input_index":[0,2,1,3]},{"n":2,"q":1.0,"edges":[[0,1,10,1],[0,1,20,1]],"input_index":[0,1]},{"n":4,"q":1.0,"edges":[[0,1,10,1],[1,3,10,1],[0,2,1,2],[2,3,1,2]],"input_index":[0,2,1,3]},{"n":4,"q":1.0,"edges":[[0,1,5,1],[1,3,5,1],[0,2,5,1],[2,3,5,1]],"input_index":[0,2,1,3]},{"n":5,"q":1.0,"edges":[[0,1,3,1],[1,4,3,1],[0,2,2,2],[2,4,2,2],[0,3,1,3],[3,4,1,3]],"input_index":[0,2,4,1,3,5]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":1,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":5,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":5,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":5},"local_outcomes":{"0":"complete"},"created":1,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0,2],"S":[5,5,5,5],"T_bound":5,"reserved":5}],"created_cap_blocked":false},"stage3":[{"T":[0,5],"curve":[[2,5]],"alloc":[[0,5],[2,5]]}],"stage4":[]}},"W_final":[0,5,0,5]},"ledger":{"allocated":[5,0,5,0],"residual":[0,5,0,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":5,"edges":[[0,5],[2,5]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":5,"infinite":false,"placed_demand":5,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":5,"delivered":5,"alloc":[[0,5],[2,5]],"members":[0,2],"weights":null}]]]}}},{"op":"place","policy":0,"volume":0,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{},"ledger":{"allocated":[5,0,5,0],"residual":[0,5,0,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":5,"edges":[[0,5],[2,5]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":5,"infinite":false,"placed_demand":5,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":5,"delivered":5,"alloc":[[0,5],[2,5]],"members":[0,2],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage3":[{"T":[5,5],"curve":[[2,5]],"alloc":[[0,5],[2,5]]}],"stage4":[{"stop":"max_flow_count","k":1}]}},"W_final":[0,5,0,5]},"ledger":{"allocated":[5,0,5,0],"residual":[0,5,0,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":5,"edges":[[0,5],[2,5]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":6,"infinite":false,"placed_demand":5,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":5,"delivered":5,"alloc":[[0,5],[2,5]],"members":[0,2],"weights":null}]]]}}},{"op":"remove","policy":0,"error":false,"expect":{"ledger":{"allocated":[0,0,0,0],"residual":[5,5,5,5],"records":[],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":1,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0,2],"S":[5,5,5,5],"T_bound":5,"reserved":1}],"created_cap_blocked":false},"stage3":[{"T":[0,1],"curve":[[2,1]],"alloc":[[0,1],[2,1]]}],"stage4":[]}},"W_final":[4,5,4,5]},"ledger":{"allocated":[1,0,1,0],"residual":[4,5,4,5],"records":[{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":1,"route":[0,2],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null}]]]}}},{"op":"ledger","graph":1,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[1]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":1,"volume":20,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":20,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":20},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,20],"curve":[[1,20]],"alloc":[[1,20]]}]}},"W_final":[10,0]},"ledger":{"allocated":[0,20],"residual":[10,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":20,"edges":[[1,20]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":20,"infinite":false,"placed_demand":20,"usable":1,"cohorts":[[0,[{"id":0,"route":[1],"T":20,"delivered":20,"alloc":[[1,20]],"members":[1],"weights":null}]]]}}},{"op":"policy","ledger":1,"sources":0,"destinations":1,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":1,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":true,"diminishing":null},"placement":"Weighted","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":2,"volume":4,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":4,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":4},"local_outcomes":{"0":"complete"},"created":1,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0],"S":[10,0],"T_bound":10,"reserved":4}],"created_cap_blocked":false},"stage2":[],"stage3":[{"T":[0,4],"curve":null,"alloc":[[0,4]]}],"stage4":[]}},"W_final":[6,0]},"ledger":{"allocated":[4,20],"residual":[6,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":20,"edges":[[1,20]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"Weighted","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":4,"edges":[[0,4]],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":4,"usable":0,"cohorts":[[0,[{"id":1,"route":[0],"T":4,"delivered":4,"alloc":[[0,4]],"members":[0],"weights":[[0,10]]}]]]}}},{"op":"policy","ledger":1,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":3,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":3,"volume":6,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":6,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":6},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,6],"curve":[[1,6]],"alloc":[[0,6]]}]}},"W_final":[0,0]},"ledger":{"allocated":[10,20],"residual":[0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":20,"edges":[[1,20]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"Weighted","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":4,"edges":[[0,4]],"drops":[]},{"flow_id":2,"owner":2,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":6,"edges":[[0,6]],"drops":[]}],"next_flow_id":3,"bound":[0,1,2],"orphans":[]},"policy":{"roster":[0],"requested":6,"infinite":false,"placed_demand":6,"usable":1,"cohorts":[[0,[{"id":2,"route":[0],"T":6,"delivered":6,"alloc":[[0,6]],"members":[0],"weights":null}]]]}}},{"op":"remove","policy":1,"error":false,"expect":{"ledger":{"allocated":[10,0],"residual":[0,20],"records":[{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"Weighted","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":4,"edges":[[0,4]],"drops":[]},{"flow_id":2,"owner":2,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":6,"edges":[[0,6]],"drops":[]}],"next_flow_id":3,"bound":[0,1,2],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"policy","ledger":1,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[1]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":4,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":4,"volume":16,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":16,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":16},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,16],"curve":[[1,16]],"alloc":[[1,16]]}]}},"W_final":[0,4]},"ledger":{"allocated":[10,16],"residual":[0,4],"records":[{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"Weighted","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":4,"edges":[[0,4]],"drops":[]},{"flow_id":2,"owner":2,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":6,"edges":[[0,6]],"drops":[]},{"flow_id":3,"owner":3,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":16,"edges":[[1,16]],"drops":[]}],"next_flow_id":4,"bound":[0,1,2,3],"orphans":[]},"policy":{"roster":[0],"requested":16,"infinite":false,"placed_demand":16,"usable":1,"cohorts":[[0,[{"id":3,"route":[1],"T":16,"delivered":16,"alloc":[[1,16]],"members":[1],"weights":null}]]]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":true,"weights_changed":true,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage2":[{"flow":1,"T":4,"candidate":[1],"decision":"adopted","alloc":[[1,4]]}],"stage3":[{"T":[4,4],"curve":null,"alloc":[[1,4]]}],"stage4":[{"stop":"max_flow_count","k":1}]}},"W_final":[4,0]},"ledger":{"allocated":[6,20],"residual":[4,0],"records":[{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"Weighted","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":4,"edges":[[1,4]],"drops":[]},{"flow_id":2,"owner":2,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":6,"edges":[[0,6]],"drops":[]},{"flow_id":3,"owner":3,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":16,"edges":[[1,16]],"drops":[]}],"next_flow_id":4,"bound":[0,1,2,3],"orphans":[]},"policy":{"roster":[0],"requested":5,"infinite":false,"placed_demand":4,"usable":0,"cohorts":[[0,[{"id":1,"route":[1],"T":4,"delivered":4,"alloc":[[1,4]],"members":[1],"weights":[[1,20]]}]]]}}},{"op":"ledger","graph":2,"baseline":null,"id":2,"error":false},{"op":"policy","ledger":2,"sources":0,"destinations":3,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":2,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":5,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":5,"volume":10,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":10,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":10},"local_outcomes":{"0":"complete"},"created":1,"sources":{"0":{"stage1":{"eb_target":10,"reservations":[{"route":[0,2],"S":[10,1,10,1],"T_bound":10,"reserved":10}],"created_cap_blocked":false},"stage3":{"T":[0,10],"bound":10,"shares":[10]},"stage4":[]}},"W_final":[0,1,0,1]},"ledger":{"allocated":[10,0,10,0],"residual":[0,1,0,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":10,"edges":[[0,10],[2,10]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":10,"infinite":false,"placed_demand":10,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":10,"delivered":10,"alloc":[[0,10],[2,10]],"members":[0,2],"weights":null}]]]}}},{"op":"place","policy":5,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage3":{"T":[10,10],"bound":10,"shares":[10]},"stage4":[{"route":[1,3],"W":[0,1,0,1],"T":10,"T_candidate":3,"bound":3,"decision":"rejected: cohort total not larger"}]}},"W_final":[0,1,0,1]},"ledger":{"allocated":[10,0,10,0],"residual":[0,1,0,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":10,"edges":[[0,10],[2,10]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":11,"infinite":false,"placed_demand":10,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":10,"delivered":10,"alloc":[[0,10],[2,10]],"members":[0,2],"weights":null}]]]}}},{"op":"ledger","graph":3,"baseline":null,"id":3,"error":false},{"op":"policy","ledger":3,"sources":0,"destinations":3,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":2,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":6,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":6,"volume":5,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":5,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":5},"local_outcomes":{"0":"complete"},"created":1,"sources":{"0":{"stage1":{"eb_target":5,"reservations":[{"route":[0,2],"S":[5,5,5,5],"T_bound":5,"reserved":5}],"created_cap_blocked":false},"stage3":{"T":[0,5],"bound":5,"shares":[5]},"stage4":[]}},"W_final":[0,5,0,5]},"ledger":{"allocated":[5,0,5,0],"residual":[0,5,0,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":5,"edges":[[0,5],[2,5]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":5,"infinite":false,"placed_demand":5,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":5,"delivered":5,"alloc":[[0,5],[2,5]],"members":[0,2],"weights":null}]]]}}},{"op":"place","policy":6,"volume":5,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":5,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":5},"local_outcomes":{"0":"complete"},"created":1,"sources":{"0":{"stage3":{"T":[5,5],"bound":5,"shares":[5]},"stage4":[{"route":[1,3],"W":[0,5,0,5],"T":5,"T_candidate":10,"bound":10,"shares":[5,5],"decision":"accepted","gain":5}]}},"W_final":[0,0,0,0]},"ledger":{"allocated":[5,5,5,5],"residual":[0,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":5,"edges":[[0,5],[2,5]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,3],"route_cost":2,"offered":null,"delivered":5,"edges":[[1,5],[3,5]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":10,"infinite":false,"placed_demand":10,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":5,"delivered":5,"alloc":[[0,5],[2,5]],"members":[0,2],"weights":null},{"id":1,"route":[1,3],"T":5,"delivered":5,"alloc":[[1,5],[3,5]],"members":[1,3],"weights":null}]]]}}},{"op":"ledger","graph":4,"baseline":null,"id":4,"error":false},{"op":"policy","ledger":4,"sources":0,"destinations":4,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":3,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":7,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":7,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":1,"sources":{"0":{"stage1":{"eb_target":3,"reservations":[{"route":[0,3],"S":[3,2,1,3,2,1],"T_bound":3,"reserved":3}],"created_cap_blocked":false},"stage3":{"T":[0,3],"bound":3,"shares":[3]},"stage4":[]}},"W_final":[0,2,1,0,2,1]},"ledger":{"allocated":[3,0,0,3,0,0],"residual":[0,2,1,0,2,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[4],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,3],"route_cost":2,"offered":null,"delivered":3,"edges":[[0,3],[3,3]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,3],"T":3,"delivered":3,"alloc":[[0,3],[3,3]],"members":[0,3],"weights":null}]]]}}},{"op":"place","policy":7,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":2,"remaining":1,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"exhausted"},"created":1,"sources":{"0":{"stage3":{"T":[3,3],"bound":3,"shares":[3]},"stage4":[{"route":[1,4],"W":[0,2,1,0,2,1],"T":3,"T_candidate":5,"bound":5,"shares":[3,2],"decision":"accepted","gain":2},{"route":[2,5],"W":[0,0,1,0,0,1],"T":5,"T_candidate":5,"bound":5,"decision":"rejected: cohort total not larger"}]}},"W_final":[0,0,1,0,0,1]},"ledger":{"allocated":[3,2,0,3,2,0],"residual":[0,0,1,0,0,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[4],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,3],"route_cost":2,"offered":null,"delivered":3,"edges":[[0,3],[3,3]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[4],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,4],"route_cost":4,"offered":null,"delivered":2,"edges":[[1,2],[4,2]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":6,"infinite":false,"placed_demand":5,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,3],"T":3,"delivered":3,"alloc":[[0,3],[3,3]],"members":[0,3],"weights":null},{"id":1,"route":[1,4],"T":2,"delivered":2,"alloc":[[1,2],[4,2]],"members":[1,4],"weights":null}]]]}}}]},{"name":"flag_zero_delivery_creation","graphs":[{"n":2,"q":1.0,"edges":[[0,1,1,1]],"input_index":[0]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[0,1]]}]}},"W_final":[0]},"ledger":{"allocated":[1],"residual":[0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":1,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]]]}}},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":{"T":[0,0],"bound":0,"shares":[0]}}},"W_final":[0]},"ledger":{"allocated":[1],"residual":[0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[[0,[{"id":1,"route":[0],"T":0,"delivered":0,"alloc":[],"members":[0],"weights":null}]]]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage3":{"T":[0,0],"bound":0,"shares":[0]}}},"W_final":[0]},"ledger":{"allocated":[1],"residual":[0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[[0,[{"id":1,"route":[0],"T":0,"delivered":0,"alloc":[],"members":[0],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[0,1]]}]}},"W_final":[0]},"ledger":{"allocated":[1],"residual":[0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":1,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]]]}}},{"op":"policy","ledger":1,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":3,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":3,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":{"T":[0,0],"bound":0,"shares":[0]}}},"W_final":[0]},"ledger":{"allocated":[1],"residual":[0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[[0,[{"id":1,"route":[0],"T":0,"delivered":0,"alloc":[],"members":[0],"weights":null}]]]}}},{"op":"place","policy":3,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage3":{"T":[0,0],"bound":0,"shares":[0]}}},"W_final":[0]},"ledger":{"allocated":[1],"residual":[0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[[0,[{"id":1,"route":[0],"T":0,"delivered":0,"alloc":[],"members":[0],"weights":null}]]]}}}]},{"name":"sources_lossy_per_source","graphs":[{"n":6,"q":1.0,"edges":[[0,3,4,1],[1,3,4,1],[3,5,3,1],[1,4,4,2],[4,5,4,2]],"input_index":[0,1,3,2,4]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":[0,1],"destinations":5,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossy","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":8,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":5,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[[3,5]],"local_outcomes":[[0,"complete"],[1,"complete"]]},"trace":{"offers":{"0":4,"1":4},"local_outcomes":{"0":"complete","1":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0,3]},"stage3":[{"offer":4,"offered":4,"delivered":3,"carried":[[0,4],[3,3]],"drops":[[3,1]]}],"injected":4},"1":{"stage1":{"route":[1,3]},"stage3":[{"offer":4,"offered":4,"delivered":0,"carried":[[1,4]],"drops":[[3,4]]}],"injected":4}},"W_final":[0,0,4,0,4]},"ledger":{"allocated":[4,4,0,3,0],"residual":[0,0,4,0,4],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[5],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,3],"route_cost":2,"offered":4,"delivered":3,"edges":[[0,4],[3,3]],"drops":[[3,1]]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[5],"placement":"EqualBalanced","admission":"Lossy","bundle":[1,3],"route_cost":2,"offered":4,"delivered":0,"edges":[[1,4]],"drops":[[3,4]]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":8,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,3],"T":null,"delivered":3,"alloc":[[0,4],[3,3]],"members":[0,3],"weights":null}]],[1,[{"id":1,"route":[1,3],"T":null,"delivered":0,"alloc":[[1,4]],"members":[1,3],"weights":null}]]]}}}]},{"name":"sources_attribution","graphs":[{"n":4,"q":1.0,"edges":[[0,3,1,1],[1,2,10,1],[2,3,2,1]],"input_index":[0,1,2]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":[0,1],"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossy","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":20,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":17,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[[0,9],[2,8]],"local_outcomes":[[0,"complete"],[1,"complete"]]},"trace":{"offers":{"0":10,"1":10},"local_outcomes":{"0":"complete","1":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":[{"offer":10,"offered":10,"delivered":1,"carried":[[0,1]],"drops":[[0,9]]}],"injected":10},"1":{"stage1":{"route":[1,2]},"stage3":[{"offer":10,"offered":10,"delivered":2,"carried":[[1,10],[2,2]],"drops":[[2,8]]}],"injected":10}},"W_final":[0,0,0]},"ledger":{"allocated":[1,10,2],"residual":[0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0],"route_cost":1,"offered":10,"delivered":1,"edges":[[0,1]],"drops":[[0,9]]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[1,2],"route_cost":2,"offered":10,"delivered":2,"edges":[[1,10],[2,2]],"drops":[[2,8]]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":20,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":null,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]],[1,[{"id":1,"route":[1,2],"T":null,"delivered":2,"alloc":[[1,10],[2,2]],"members":[1,2],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":[0,1],"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":20,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":17,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"],[1,"exhausted"]]},"trace":{"offers":{"0":10,"1":10},"local_outcomes":{"0":"exhausted","1":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[0,1]]}]},"1":{"stage1":{"route":[1,2]},"stage3":[{"T":[0,2],"curve":[[2,2]],"alloc":[[1,2],[2,2]]}]}},"W_final":[0,8,0]},"ledger":{"allocated":[1,2,2],"residual":[0,8,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[1,2],"route_cost":2,"offered":null,"delivered":2,"edges":[[1,2],[2,2]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":20,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]],[1,[{"id":1,"route":[1,2],"T":2,"delivered":2,"alloc":[[1,2],[2,2]],"members":[1,2],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":2,"error":false},{"op":"policy","ledger":2,"sources":[0,1],"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"Weighted","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":2,"volume":20,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":17,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"],[1,"exhausted"]]},"trace":{"offers":{"0":10,"1":10},"local_outcomes":{"0":"exhausted","1":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":[{"T":[0,1],"curve":null,"alloc":[[0,1]]}]},"1":{"stage1":{"route":[1,2]},"stage3":[{"T":[0,2],"curve":null,"alloc":[[1,2],[2,2]]}]}},"W_final":[0,8,0]},"ledger":{"allocated":[1,2,2],"residual":[0,8,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[1,2],"route_cost":2,"offered":null,"delivered":2,"edges":[[1,2],[2,2]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":20,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":[[0,1]]}]],[1,[{"id":1,"route":[1,2],"T":2,"delivered":2,"alloc":[[1,2],[2,2]],"members":[1,2],"weights":[[1,10],[2,2]]}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":3,"error":false},{"op":"policy","ledger":3,"sources":[0,1],"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":3,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":3,"volume":20,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":17,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"],[1,"exhausted"]]},"trace":{"offers":{"0":10,"1":10},"local_outcomes":{"0":"exhausted","1":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":{"T":[0,1],"bound":1,"shares":[1]}},"1":{"stage1":{"route":[1,2]},"stage3":{"T":[0,2],"bound":2,"shares":[2]}}},"W_final":[0,8,0]},"ledger":{"allocated":[1,2,2],"residual":[0,8,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,2],"route_cost":2,"offered":null,"delivered":2,"edges":[[1,2],[2,2]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":20,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]],[1,[{"id":1,"route":[1,2],"T":2,"delivered":2,"alloc":[[1,2],[2,2]],"members":[1,2],"weights":null}]]]}}}]},{"name":"sources_eb_per_source","graphs":[{"n":3,"q":1.0,"edges":[[0,2,4,1],[1,2,10,2]],"input_index":[0,1]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":[0,1],"destinations":2,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":20,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":14,"remaining":6,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"],[1,"complete"]]},"trace":{"offers":{"0":10,"1":10},"local_outcomes":{"0":"exhausted","1":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":{"T":[0,4],"bound":4,"shares":[4]}},"1":{"stage1":{"route":[1]},"stage3":{"T":[0,10],"bound":10,"shares":[10]}}},"W_final":[0,0]},"ledger":{"allocated":[4,10],"residual":[0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[2],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":4,"edges":[[0,4]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[2],"placement":"EqualBalanced","admission":"Lossless","bundle":[1],"route_cost":2,"offered":null,"delivered":10,"edges":[[1,10]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":20,"infinite":false,"placed_demand":14,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":4,"delivered":4,"alloc":[[0,4]],"members":[0],"weights":null}]],[1,[{"id":1,"route":[1],"T":10,"delivered":10,"alloc":[[1,10]],"members":[1],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":[0,1],"destinations":2,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":"inf","outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":14,"remaining":"inf","termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"],[1,"exhausted"]]},"trace":{"offers":{"0":"inf","1":"inf"},"local_outcomes":{"0":"exhausted","1":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":{"T":[0,4],"bound":4,"shares":[4]}},"1":{"stage1":{"route":[1]},"stage3":{"T":[0,10],"bound":10,"shares":[10]}}},"W_final":[0,0]},"ledger":{"allocated":[4,10],"residual":[0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[2],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":4,"edges":[[0,4]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[2],"placement":"EqualBalanced","admission":"Lossless","bundle":[1],"route_cost":2,"offered":null,"delivered":10,"edges":[[1,10]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":0,"infinite":true,"placed_demand":14,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":4,"delivered":4,"alloc":[[0,4]],"members":[0],"weights":null}]],[1,[{"id":1,"route":[1],"T":10,"delivered":10,"alloc":[[1,10]],"members":[1],"weights":null}]]]}}}]},{"name":"sources_augmentation_edges","graphs":[{"n":3,"q":1.0,"edges":[[0,2,1,2],[0,1,9,1],[1,2,9,1]],"input_index":[1,0,2]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":2,"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":"inf","outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":10,"remaining":"inf","termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":"inf"},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2]},"stage3":[{"T":[0,10],"curve":[[2,10]],"alloc":[[0,9],[1,1],[2,9]]}]}},"W_final":[0,0,0]},"ledger":{"allocated":[9,1,9],"residual":[0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[0,1,2],"route_cost":2,"offered":null,"delivered":10,"edges":[[0,9],[1,1],[2,9]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":true,"placed_demand":10,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2],"T":10,"delivered":10,"alloc":[[0,9],[1,1],[2,9]],"members":[0,1,2],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":2,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":"inf","outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":"inf","termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":"inf"},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0,1,2]},"stage3":{"T":[0,3],"bound":3,"shares":[3]}}},"W_final":[7,0,7]},"ledger":{"allocated":[2,1,2],"residual":[7,0,7],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[2],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1,2],"route_cost":2,"offered":null,"delivered":3,"edges":[[0,2],[1,1],[2,2]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":true,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,2],"T":3,"delivered":3,"alloc":[[0,2],[1,1],[2,2]],"members":[0,1,2],"weights":null}]]]}}}]},{"name":"sources_inf_every_kernel","graphs":[{"n":4,"q":1.0,"edges":[[0,3,1,1],[1,2,10,1],[2,3,2,1]],"input_index":[0,1,2]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":[0,1],"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":"inf","outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":"inf","termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"],[1,"exhausted"]]},"trace":{"offers":{"0":"inf","1":"inf"},"local_outcomes":{"0":"exhausted","1":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[0,1]]}]},"1":{"stage1":{"route":[1,2]},"stage3":[{"T":[0,2],"curve":[[2,2]],"alloc":[[1,2],[2,2]]}]}},"W_final":[0,8,0]},"ledger":{"allocated":[1,2,2],"residual":[0,8,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[1,2],"route_cost":2,"offered":null,"delivered":2,"edges":[[1,2],[2,2]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":0,"infinite":true,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]],[1,[{"id":1,"route":[1,2],"T":2,"delivered":2,"alloc":[[1,2],[2,2]],"members":[1,2],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":[0,1],"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"Weighted","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":"inf","outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":"inf","termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"],[1,"exhausted"]]},"trace":{"offers":{"0":"inf","1":"inf"},"local_outcomes":{"0":"exhausted","1":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":[{"T":[0,1],"curve":null,"alloc":[[0,1]]}]},"1":{"stage1":{"route":[1,2]},"stage3":[{"T":[0,2],"curve":null,"alloc":[[1,2],[2,2]]}]}},"W_final":[0,8,0]},"ledger":{"allocated":[1,2,2],"residual":[0,8,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[3],"placement":"Weighted","admission":"Lossless","bundle":[1,2],"route_cost":2,"offered":null,"delivered":2,"edges":[[1,2],[2,2]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":0,"infinite":true,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":[[0,1]]}]],[1,[{"id":1,"route":[1,2],"T":2,"delivered":2,"alloc":[[1,2],[2,2]],"members":[1,2],"weights":[[1,10],[2,2]]}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":2,"error":false},{"op":"policy","ledger":2,"sources":[0,1],"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":2,"volume":"inf","outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":"inf","termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"],[1,"exhausted"]]},"trace":{"offers":{"0":"inf","1":"inf"},"local_outcomes":{"0":"exhausted","1":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":{"T":[0,1],"bound":1,"shares":[1]}},"1":{"stage1":{"route":[1,2]},"stage3":{"T":[0,2],"bound":2,"shares":[2]}}},"W_final":[0,8,0]},"ledger":{"allocated":[1,2,2],"residual":[0,8,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[3],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,2],"route_cost":2,"offered":null,"delivered":2,"edges":[[1,2],[2,2]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":0,"infinite":true,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]],[1,[{"id":1,"route":[1,2],"T":2,"delivered":2,"alloc":[[1,2],[2,2]],"members":[1,2],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":3,"error":false},{"op":"policy","ledger":3,"sources":[0,1],"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossy","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":3,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":3,"volume":"inf","outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":"inf","termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[[2,8]],"local_outcomes":[[0,"exhausted"],[1,"exhausted"]]},"trace":{"offers":{"0":"inf","1":"inf"},"local_outcomes":{"0":"exhausted","1":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":[{"offer":"inf","offered":1,"delivered":1,"carried":[[0,1]],"drops":[]}],"injected":1},"1":{"stage1":{"route":[1,2]},"stage3":[{"offer":"inf","offered":10,"delivered":2,"carried":[[1,10],[2,2]],"drops":[[2,8]]}],"injected":10}},"W_final":[0,0,0]},"ledger":{"allocated":[1,10,2],"residual":[0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[0],"route_cost":1,"offered":1,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[3],"placement":"EqualBalanced","admission":"Lossy","bundle":[1,2],"route_cost":2,"offered":10,"delivered":2,"edges":[[1,10],[2,2]],"drops":[[2,8]]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":0,"infinite":true,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":null,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]],[1,[{"id":1,"route":[1,2],"T":null,"delivered":2,"alloc":[[1,10],[2,2]],"members":[1,2],"weights":null}]]]}}}]},{"name":"sources_limits_and_validation","graphs":[{"n":5,"q":1.0,"edges":[[0,3,5,1],[1,3,5,1],[2,3,5,1],[4,0,1,1]],"input_index":[0,1,2,3]},{"n":3,"q":1.0,"edges":[[0,2,5,1],[2,1,5,1]],"input_index":[0,1]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":[0,1],"destinations":3,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"error":true},{"op":"policy","ledger":0,"sources":[],"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"error":true},{"op":"policy","ledger":0,"sources":[0,3],"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"error":true},{"op":"policy","ledger":0,"sources":[0,1],"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":{"nodes":[0],"edges":[0,3]},"id":0,"error":false,"expect":{"policy":{"roster":[1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":[0,1,2],"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0,1,2],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"],[1,"complete"],[2,"complete"]]},"trace":{"offers":{"0":1,"1":1,"2":1},"local_outcomes":{"0":"complete","1":"complete","2":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[0,1]]}]},"1":{"stage1":{"route":[1]},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[1,1]]}]},"2":{"stage1":{"route":[2]},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[2,1]]}]}},"W_final":[4,4,4,1]},"ledger":{"allocated":[1,1,1,0],"residual":[4,4,4,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]},{"flow_id":2,"owner":0,"sources":[2],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[2],"route_cost":1,"offered":null,"delivered":1,"edges":[[2,1]],"drops":[]}],"next_flow_id":3,"bound":[0],"orphans":[]},"policy":{"roster":[0,1,2],"requested":3,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]],[1,[{"id":1,"route":[1],"T":1,"delivered":1,"alloc":[[1,1]],"members":[1],"weights":null}]],[2,[{"id":2,"route":[2],"T":1,"delivered":1,"alloc":[[2,1]],"members":[2],"weights":null}]]]}}},{"op":"ledger","graph":1,"baseline":null,"id":2,"error":false},{"op":"policy","ledger":2,"sources":[0,1],"destinations":2,"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}}]},{"name":"sources_maxflow_vs_global","graphs":[{"n":4,"q":1.0,"edges":[[0,2,1,2],[1,2,1,1],[2,3,1,1],[0,3,1,5]],"input_index":[0,3,1,2]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":[0,1],"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":"inf","outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":"inf","termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"],[1,"exhausted"]]},"trace":{"offers":{"0":"inf","1":"inf"},"local_outcomes":{"0":"exhausted","1":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0,3]},"stage3":[{"T":[0,1],"curve":[[3,1]],"alloc":[[0,1],[3,1]]}]},"1":{"stage1":{"route":[2,3]},"stage3":[{"T":[0,0],"curve":[],"alloc":[]}]}},"W_final":[0,1,1,0]},"ledger":{"allocated":[1,0,0,1],"residual":[0,1,1,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,3],"route_cost":3,"offered":null,"delivered":1,"edges":[[0,1],[3,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[2,3],"route_cost":2,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":0,"infinite":true,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,3],"T":1,"delivered":1,"alloc":[[0,1],[3,1]],"members":[0,3],"weights":null}]],[1,[{"id":1,"route":[2,3],"T":0,"delivered":0,"alloc":[],"members":[2,3],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":[0,1],"destinations":3,"config":{"routing":{"kind":"Global"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":"inf","outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":2,"remaining":"inf","termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"],[1,"exhausted"]],"cost_distribution":[[2,1],[5,1]]},"trace":{"V":[1,1,1,1],"cap":null,"flow_core":{"placed":2,"cost_distribution":[[2,1],[5,1]],"edge_flows":[0,1,1,1],"min_cut":[1,3],"potentials":[0,0,2,5],"supply":{"0":1,"1":1}},"shares":{"0":[[1,1]],"1":[[2,1],[3,1]]}},"ledger":{"allocated":[0,1,1,1],"residual":[1,0,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":5,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[2,3],"route_cost":2,"offered":null,"delivered":1,"edges":[[2,1],[3,1]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":0,"infinite":true,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[1],"T":1,"delivered":1,"alloc":[[1,1]],"members":[1],"weights":null}]],[1,[{"id":1,"route":[2,3],"T":1,"delivered":1,"alloc":[[2,1],[3,1]],"members":[2,3],"weights":null}]]]}}}]},{"name":"sources_split_cumulative","graphs":[{"n":4,"q":1.0,"edges":[[0,3,10,1],[1,3,10,1],[2,3,10,1]],"input_index":[0,1,2]},{"n":3,"q":1.0,"edges":[[0,2,5,1],[1,2,5,1]],"input_index":[0,1]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":[0,1,2],"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0,1,2],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"],[1,"complete"],[2,"complete"]]},"trace":{"offers":{"0":1,"1":0,"2":0},"local_outcomes":{"0":"complete","1":"complete","2":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[0,1]]}]},"1":{"stage1":{"route":[1]},"stage3":[{"T":[0,0],"curve":[],"alloc":[]}]},"2":{"stage1":{"route":[2]},"stage3":[{"T":[0,0],"curve":[],"alloc":[]}]}},"W_final":[9,10,10]},"ledger":{"allocated":[1,0,0],"residual":[9,10,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":0,"edges":[],"drops":[]},{"flow_id":2,"owner":0,"sources":[2],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[2],"route_cost":1,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":3,"bound":[0],"orphans":[]},"policy":{"roster":[0,1,2],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]],[1,[{"id":1,"route":[1],"T":0,"delivered":0,"alloc":[],"members":[1],"weights":null}]],[2,[{"id":2,"route":[2],"T":0,"delivered":0,"alloc":[],"members":[2],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"],[1,"complete"],[2,"complete"]]},"trace":{"offers":{"0":0,"1":1,"2":0},"local_outcomes":{"0":"complete","1":"complete","2":"complete"},"created":0,"sources":{"0":{"stage3":[{"T":[1,1],"curve":[[1,1]],"alloc":[[0,1]]}]},"1":{"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[1,1]]}]},"2":{"stage3":[{"T":[0,0],"curve":[],"alloc":[]}]}},"W_final":[9,9,10]},"ledger":{"allocated":[1,1,0],"residual":[9,9,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]},{"flow_id":2,"owner":0,"sources":[2],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[2],"route_cost":1,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":3,"bound":[0],"orphans":[]},"policy":{"roster":[0,1,2],"requested":2,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]],[1,[{"id":1,"route":[1],"T":1,"delivered":1,"alloc":[[1,1]],"members":[1],"weights":null}]],[2,[{"id":2,"route":[2],"T":0,"delivered":0,"alloc":[],"members":[2],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":[0,1,2],"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0,1,2],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":2,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":2,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"],[1,"complete"],[2,"complete"]]},"trace":{"offers":{"0":1,"1":1,"2":0},"local_outcomes":{"0":"complete","1":"complete","2":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[0,1]]}]},"1":{"stage1":{"route":[1]},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[1,1]]}]},"2":{"stage1":{"route":[2]},"stage3":[{"T":[0,0],"curve":[],"alloc":[]}]}},"W_final":[9,9,10]},"ledger":{"allocated":[1,1,0],"residual":[9,9,10],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]},{"flow_id":2,"owner":0,"sources":[2],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[2],"route_cost":1,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":3,"bound":[0],"orphans":[]},"policy":{"roster":[0,1,2],"requested":2,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]],[1,[{"id":1,"route":[1],"T":1,"delivered":1,"alloc":[[1,1]],"members":[1],"weights":null}]],[2,[{"id":2,"route":[2],"T":0,"delivered":0,"alloc":[],"members":[2],"weights":null}]]]}}},{"op":"ledger","graph":1,"baseline":null,"id":2,"error":false},{"op":"policy","ledger":2,"sources":[0,1],"destinations":2,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossy","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"],[1,"complete"]]},"trace":{"offers":{"0":1,"1":0},"local_outcomes":{"0":"complete","1":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":[{"offer":1,"offered":1,"delivered":1,"carried":[[0,1]],"drops":[]}],"injected":1},"1":{"stage1":{"route":[1]},"stage3":[{"offer":0,"offered":0,"delivered":0,"carried":[],"drops":[]}],"injected":0}},"W_final":[4,5]},"ledger":{"allocated":[1,0],"residual":[4,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[2],"placement":"EqualBalanced","admission":"Lossy","bundle":[0],"route_cost":1,"offered":1,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[2],"placement":"EqualBalanced","admission":"Lossy","bundle":[1],"route_cost":1,"offered":0,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":null,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]],[1,[{"id":1,"route":[1],"T":null,"delivered":0,"alloc":[],"members":[1],"weights":null}]]]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"],[1,"complete"]]},"trace":{"offers":{"0":0,"1":1},"local_outcomes":{"0":"complete","1":"complete"},"created":0,"sources":{"0":{"stage3":[{"offer":0,"offered":0,"delivered":0,"carried":[],"drops":[]}],"injected":0},"1":{"stage3":[{"offer":1,"offered":1,"delivered":1,"carried":[[1,1]],"drops":[]}],"injected":1}},"W_final":[4,4]},"ledger":{"allocated":[1,1],"residual":[4,4],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[2],"placement":"EqualBalanced","admission":"Lossy","bundle":[0],"route_cost":1,"offered":1,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[2],"placement":"EqualBalanced","admission":"Lossy","bundle":[1],"route_cost":1,"offered":1,"delivered":1,"edges":[[1,1]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":2,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":null,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]],[1,[{"id":1,"route":[1],"T":null,"delivered":1,"alloc":[[1,1]],"members":[1],"weights":null}]]]}}}]},{"name":"sources_1_9_request_4","graphs":[{"n":3,"q":1.0,"edges":[[0,2,1,1],[1,2,9,1]],"input_index":[0,1]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":[0,1],"destinations":2,"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":4,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":1,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"],[1,"complete"]]},"trace":{"offers":{"0":2,"1":2},"local_outcomes":{"0":"exhausted","1":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[0,1]]}]},"1":{"stage1":{"route":[1]},"stage3":[{"T":[0,2],"curve":[[1,2]],"alloc":[[1,2]]}]}},"W_final":[0,7]},"ledger":{"allocated":[1,2],"residual":[0,7],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":2,"edges":[[1,2]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":4,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]],[1,[{"id":1,"route":[1],"T":2,"delivered":2,"alloc":[[1,2]],"members":[1],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":[0,1],"destinations":2,"config":{"routing":{"kind":"CostOnly"},"placement":"Weighted","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":4,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":1,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"],[1,"complete"]]},"trace":{"offers":{"0":2,"1":2},"local_outcomes":{"0":"exhausted","1":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":[{"T":[0,1],"curve":null,"alloc":[[0,1]]}]},"1":{"stage1":{"route":[1]},"stage3":[{"T":[0,2],"curve":null,"alloc":[[1,2]]}]}},"W_final":[0,7]},"ledger":{"allocated":[1,2],"residual":[0,7],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[2],"placement":"Weighted","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[2],"placement":"Weighted","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":2,"edges":[[1,2]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":4,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":[[0,1]]}]],[1,[{"id":1,"route":[1],"T":2,"delivered":2,"alloc":[[1,2]],"members":[1],"weights":[[1,9]]}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":2,"error":false},{"op":"policy","ledger":2,"sources":[0,1],"destinations":2,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":2,"volume":4,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":1,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"],[1,"complete"]]},"trace":{"offers":{"0":2,"1":2},"local_outcomes":{"0":"exhausted","1":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":{"T":[0,1],"bound":1,"shares":[1]}},"1":{"stage1":{"route":[1]},"stage3":{"T":[0,2],"bound":9,"shares":[2]}}},"W_final":[0,7]},"ledger":{"allocated":[1,2],"residual":[0,7],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[2],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[2],"placement":"EqualBalanced","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":2,"edges":[[1,2]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":4,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]],[1,[{"id":1,"route":[1],"T":2,"delivered":2,"alloc":[[1,2]],"members":[1],"weights":null}]]]}}}]},{"name":"sources_partition_1_1_vs_2_0","graphs":[{"n":4,"q":1.0,"edges":[[0,2,2,1],[1,2,2,1],[2,3,2,1]],"input_index":[0,1,2]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":[0,1],"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":2,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":2,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"],[1,"complete"]]},"trace":{"offers":{"0":1,"1":1},"local_outcomes":{"0":"complete","1":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0,2]},"stage3":[{"T":[0,1],"curve":[[2,1]],"alloc":[[0,1],[2,1]]}]},"1":{"stage1":{"route":[1,2]},"stage3":[{"T":[0,1],"curve":[[2,1]],"alloc":[[1,1],[2,1]]}]}},"W_final":[1,1,0]},"ledger":{"allocated":[1,1,2],"residual":[1,1,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[1,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[1,1],[2,1]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":2,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null}]],[1,[{"id":1,"route":[1,2],"T":1,"delivered":1,"alloc":[[1,1],[2,1]],"members":[1,2],"weights":null}]]]}}},{"op":"place","policy":0,"volume":2,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":2,"termination":"exhausted","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"],[1,"exhausted"]]},"trace":{"offers":{"0":1,"1":1},"local_outcomes":{"0":"exhausted","1":"exhausted"},"created":0,"sources":{"0":{"stage3":[{"T":[1,1],"curve":[[2,1]],"alloc":[[0,1],[2,1]]}]},"1":{"stage3":[{"T":[1,1],"curve":[[2,1]],"alloc":[[1,1],[2,1]]}]}},"W_final":[1,1,0]},"ledger":{"allocated":[1,1,2],"residual":[1,1,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[1,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[1,1],[2,1]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":4,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null}]],[1,[{"id":1,"route":[1,2],"T":1,"delivered":1,"alloc":[[1,1],[2,1]],"members":[1,2],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":[0,1],"destinations":3,"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":4,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":2,"remaining":2,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"],[1,"exhausted"]]},"trace":{"offers":{"0":2,"1":2},"local_outcomes":{"0":"complete","1":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0,2]},"stage3":[{"T":[0,2],"curve":[[2,2]],"alloc":[[0,2],[2,2]]}]},"1":{"stage1":{"route":[1,2]},"stage3":[{"T":[0,0],"curve":[],"alloc":[]}]}},"W_final":[0,2,0]},"ledger":{"allocated":[2,0,2],"residual":[0,2,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":2,"edges":[[0,2],[2,2]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[1,2],"route_cost":2,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":4,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2],"T":2,"delivered":2,"alloc":[[0,2],[2,2]],"members":[0,2],"weights":null}]],[1,[{"id":1,"route":[1,2],"T":0,"delivered":0,"alloc":[],"members":[1,2],"weights":null}]]]}}}]},{"name":"eb_two_flows_per_source_reservation","graphs":[{"n":3,"q":1.0,"edges":[[0,2,5,1],[0,2,5,1],[1,2,5,1],[1,2,5,1]],"input_index":[0,1,2,3]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":[0,1],"destinations":2,"config":{"routing":{"kind":"CapacityAware","flow_count":2,"max_flow_count":2,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":4,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":4,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"],[1,"complete"]]},"trace":{"offers":{"0":2,"1":2},"local_outcomes":{"0":"complete","1":"complete"},"created":4,"sources":{"0":{"stage1":{"eb_target":1,"reservations":[{"route":[0],"S":[5,5,5,5],"T_bound":5,"reserved":1},{"route":[1],"S":[4,5,5,5],"T_bound":5,"reserved":1}],"created_cap_blocked":false},"stage3":{"T":[0,2],"bound":10,"shares":[1,1]},"stage4":[]},"1":{"stage1":{"eb_target":1,"reservations":[{"route":[2],"S":[4,4,5,5],"T_bound":5,"reserved":1},{"route":[3],"S":[4,4,4,5],"T_bound":5,"reserved":1}],"created_cap_blocked":false},"stage3":{"T":[0,2],"bound":10,"shares":[1,1]},"stage4":[]}},"W_final":[4,4,4,4]},"ledger":{"allocated":[1,1,1,1],"residual":[4,4,4,4],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[2],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[2],"placement":"EqualBalanced","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]},{"flow_id":2,"owner":0,"sources":[1],"destinations":[2],"placement":"EqualBalanced","admission":"Lossless","bundle":[2],"route_cost":1,"offered":null,"delivered":1,"edges":[[2,1]],"drops":[]},{"flow_id":3,"owner":0,"sources":[1],"destinations":[2],"placement":"EqualBalanced","admission":"Lossless","bundle":[3],"route_cost":1,"offered":null,"delivered":1,"edges":[[3,1]],"drops":[]}],"next_flow_id":4,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":4,"infinite":false,"placed_demand":4,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null},{"id":1,"route":[1],"T":1,"delivered":1,"alloc":[[1,1]],"members":[1],"weights":null}]],[1,[{"id":2,"route":[2],"T":1,"delivered":1,"alloc":[[2,1]],"members":[2],"weights":null},{"id":3,"route":[3],"T":1,"delivered":1,"alloc":[[3,1]],"members":[3],"weights":null}]]]}}}]},{"name":"termination_reduction","graphs":[{"n":3,"q":1.0,"edges":[[0,2,1,1],[0,2,1,1],[0,2,1,1],[1,2,1,1]],"input_index":[0,1,2,3]},{"n":3,"q":1.0,"edges":[[0,2,1,1],[1,2,1,1]],"input_index":[0,1]},{"n":2,"q":1.0,"edges":[[0,1,1,1]],"input_index":[0]},{"n":2,"q":1.0,"edges":[[0,1,1,1],[0,1,10,1]],"input_index":[0,1]},{"n":2,"q":1.0,"edges":[[0,1,0,1]],"input_index":[0]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":[0,1],"destinations":2,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":2,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":6,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":3,"termination":"limit_flows","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"limit_flows"],[1,"exhausted"]]},"trace":{"offers":{"0":3,"1":3},"local_outcomes":{"0":"limit_flows","1":"exhausted"},"created":3,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0],"S":[1,1,1,1],"T_bound":1,"reserved":1}],"created_cap_blocked":false},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[0,1]]}],"stage4":[{"route":[1],"W":[0,1,1,1],"bound":1,"fill":1,"decision":"accepted","gain":1},{"stop":"max_flow_count","k":2}]},"1":{"stage1":{"eb_target":null,"reservations":[{"route":[3],"S":[0,0,1,1],"T_bound":1,"reserved":1}],"created_cap_blocked":false},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[3,1]]}],"stage4":[{"stop":"no candidate"}]}},"W_final":[0,0,1,0]},"ledger":{"allocated":[1,1,0,1],"residual":[0,0,1,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]},{"flow_id":2,"owner":0,"sources":[1],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[3],"route_cost":1,"offered":null,"delivered":1,"edges":[[3,1]],"drops":[]}],"next_flow_id":3,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":6,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null},{"id":1,"route":[1],"T":1,"delivered":1,"alloc":[[1,1]],"members":[1],"weights":null}]],[1,[{"id":2,"route":[3],"T":1,"delivered":1,"alloc":[[3,1]],"members":[3],"weights":null}]]]}}},{"op":"ledger","graph":1,"baseline":[0,0],"id":1,"error":false},{"op":"policy","ledger":1,"sources":[0,1],"destinations":2,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":null,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":2,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":2,"termination":"no_route","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"no_route"],[1,"no_route"]]},"trace":{"offers":{"0":1,"1":1},"local_outcomes":{"0":"no_route","1":"no_route"},"created":0,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[],"created_cap_blocked":false}},"1":{"stage1":{"eb_target":null,"reservations":[],"created_cap_blocked":false}}},"W_final":[0,0]},"ledger":{"allocated":[0,0],"residual":[0,0],"records":[],"next_flow_id":0,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":2,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"ledger","graph":1,"baseline":[1,0],"id":2,"error":false},{"op":"policy","ledger":2,"sources":[0,1],"destinations":2,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":1,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":2,"volume":4,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":3,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"],[1,"no_route"]]},"trace":{"offers":{"0":2,"1":2},"local_outcomes":{"0":"exhausted","1":"no_route"},"created":1,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0],"S":[1,0],"T_bound":1,"reserved":1}],"created_cap_blocked":false},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[0,1]]}],"stage4":[{"stop":"max_flow_count","k":1}]},"1":{"stage1":{"eb_target":null,"reservations":[],"created_cap_blocked":false}}},"W_final":[0,0]},"ledger":{"allocated":[1,0],"residual":[0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":4,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]]]}}},{"op":"ledger","graph":2,"baseline":null,"id":3,"error":false},{"op":"policy","ledger":3,"sources":0,"destinations":1,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":1,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":3,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":3,"volume":2,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":1,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":2},"local_outcomes":{"0":"exhausted"},"created":1,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0],"S":[1],"T_bound":1,"reserved":1}],"created_cap_blocked":false},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[0,1]]}],"stage4":[{"stop":"max_flow_count","k":1}]}},"W_final":[0]},"ledger":{"allocated":[1],"residual":[0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]]]}}},{"op":"ledger","graph":3,"baseline":null,"id":4,"error":false},{"op":"policy","ledger":4,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossy","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":4,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":4,"volume":4,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":1,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[[0,1]],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":4},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0,1]},"stage3":[{"offer":4,"offered":4,"delivered":3,"carried":[[0,1],[1,2]],"drops":[[0,1]]}],"injected":4}},"W_final":[0,8]},"ledger":{"allocated":[1,2],"residual":[0,8],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossy","bundle":[0,1],"route_cost":1,"offered":4,"delivered":3,"edges":[[0,1],[1,2]],"drops":[[0,1]]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":4,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1],"T":null,"delivered":3,"alloc":[[0,1],[1,2]],"members":[0,1],"weights":null}]]]}}},{"op":"ledger","graph":3,"baseline":null,"id":5,"error":false},{"op":"policy","ledger":5,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":5,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":5,"volume":0,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{},"ledger":{"allocated":[0,0],"residual":[1,10],"records":[],"next_flow_id":0,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"ledger","graph":4,"baseline":null,"id":6,"error":false},{"op":"policy","ledger":6,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":6,"error":false,"expect":{"policy":{"roster":[],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":6,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"no_route","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[]},"trace":{"roster":[]},"ledger":{"allocated":[0],"residual":[0],"records":[],"next_flow_id":0,"bound":[0],"orphans":[]},"policy":{"roster":[],"requested":1,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":6,"volume":"inf","outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":"inf","termination":"no_route","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[]},"trace":{"roster":[]},"ledger":{"allocated":[0],"residual":[0],"records":[],"next_flow_id":0,"bound":[0],"orphans":[]},"policy":{"roster":[],"requested":1,"infinite":true,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":6,"volume":1,"outputs":null,"cap":10000,"error":true,"expect":{"ledger":{"allocated":[0],"residual":[0],"records":[],"next_flow_id":0,"bound":[0],"orphans":[]}}}]},{"name":"eb_resplit_3_3_to_3_2_2","graphs":[{"n":5,"q":1.0,"edges":[[0,1,3,1],[1,4,3,1],[0,2,3,2],[2,4,3,2],[0,3,2,3],[3,4,2,3]],"input_index":[0,2,4,1,3,5]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":4,"config":{"routing":{"kind":"CapacityAware","flow_count":2,"max_flow_count":3,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":6,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":6,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":6},"local_outcomes":{"0":"complete"},"created":2,"sources":{"0":{"stage1":{"eb_target":3,"reservations":[{"route":[0,3],"S":[3,3,2,3,3,2],"T_bound":3,"reserved":3},{"route":[1,4],"S":[0,3,2,0,3,2],"T_bound":3,"reserved":3}],"created_cap_blocked":false},"stage3":{"T":[0,6],"bound":6,"shares":[3,3]},"stage4":[]}},"W_final":[0,0,2,0,0,2]},"ledger":{"allocated":[3,3,0,3,3,0],"residual":[0,0,2,0,0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[4],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,3],"route_cost":2,"offered":null,"delivered":3,"edges":[[0,3],[3,3]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[4],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,4],"route_cost":4,"offered":null,"delivered":3,"edges":[[1,3],[4,3]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":6,"infinite":false,"placed_demand":6,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,3],"T":3,"delivered":3,"alloc":[[0,3],[3,3]],"members":[0,3],"weights":null},{"id":1,"route":[1,4],"T":3,"delivered":3,"alloc":[[1,3],[4,3]],"members":[1,4],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":1,"sources":{"0":{"stage3":{"T":[6,6],"bound":6,"shares":[3,3]},"stage4":[{"route":[2,5],"W":[0,0,2,0,0,2],"T":6,"T_candidate":7,"bound":8,"shares":[3,2,2],"decision":"accepted","gain":1}]}},"W_final":[0,1,0,0,1,0]},"ledger":{"allocated":[3,2,2,3,2,2],"residual":[0,1,0,0,1,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[4],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,3],"route_cost":2,"offered":null,"delivered":3,"edges":[[0,3],[3,3]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[4],"placement":"EqualBalanced","admission":"Lossless","bundle":[1,4],"route_cost":4,"offered":null,"delivered":2,"edges":[[1,2],[4,2]],"drops":[]},{"flow_id":2,"owner":0,"sources":[0],"destinations":[4],"placement":"EqualBalanced","admission":"Lossless","bundle":[2,5],"route_cost":6,"offered":null,"delivered":2,"edges":[[2,2],[5,2]],"drops":[]}],"next_flow_id":3,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":7,"infinite":false,"placed_demand":7,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,3],"T":3,"delivered":3,"alloc":[[0,3],[3,3]],"members":[0,3],"weights":null},{"id":1,"route":[1,4],"T":2,"delivered":2,"alloc":[[1,2],[4,2]],"members":[1,4],"weights":null},{"id":2,"route":[2,5],"T":2,"delivered":2,"alloc":[[2,2],[5,2]],"members":[2,5],"weights":null}]]]}}}]},{"name":"global_chain_plus_bypasses","graphs":[{"n":4,"q":1.0,"edges":[[0,1,1,1],[1,2,1,1],[2,3,1,1],[0,2,1,3],[1,3,1,3]],"input_index":[0,3,1,4,2]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":3,"config":{"routing":{"kind":"Global"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]],"cost_distribution":[[3,1]]},"trace":{"V":[1,1,1,1,1],"cap":1,"flow_core":{"placed":1,"cost_distribution":[[3,1]],"edge_flows":[1,0,1,0,1],"min_cut":null,"potentials":[0,1,2,3],"supply":{"0":1}},"shares":{"0":[[0,1],[2,1],[4,1]]}},"ledger":{"allocated":[1,0,1,0,1],"residual":[0,1,0,1,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2,4],"route_cost":3,"offered":null,"delivered":1,"edges":[[0,1],[2,1],[4,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,2,4],"T":1,"delivered":1,"alloc":[[0,1],[2,1],[4,1]],"members":[0,2,4],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]],"cost_distribution":[[3,1],[5,1]]},"trace":{"V":[1,1,1,1,1],"cap":2,"flow_core":{"placed":2,"cost_distribution":[[3,1],[5,1]],"edge_flows":[1,1,0,1,1],"min_cut":[0,1],"potentials":[0,2,3,5],"supply":{"0":2}},"shares":{"0":[[0,1],[1,1],[3,1],[4,1]]}},"ledger":{"allocated":[1,1,0,1,1],"residual":[0,0,1,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,1,3,4],"route_cost":4,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[3,1],[4,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,3,4],"T":2,"delivered":2,"alloc":[[0,1],[1,1],[3,1],[4,1]],"members":[0,1,3,4],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":3,"config":{"routing":{"kind":"Global"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":2,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":2,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]],"cost_distribution":[[3,1],[5,1]]},"trace":{"V":[1,1,1,1,1],"cap":2,"flow_core":{"placed":2,"cost_distribution":[[3,1],[5,1]],"edge_flows":[1,1,0,1,1],"min_cut":[0,1],"potentials":[0,2,3,5],"supply":{"0":2}},"shares":{"0":[[0,1],[1,1],[3,1],[4,1]]}},"ledger":{"allocated":[1,1,0,1,1],"residual":[0,0,1,0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[3],"placement":"MaxFlow","admission":"Lossless","bundle":[0,1,3,4],"route_cost":4,"offered":null,"delivered":2,"edges":[[0,1],[1,1],[3,1],[4,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1,3,4],"T":2,"delivered":2,"alloc":[[0,1],[1,1],[3,1],[4,1]],"members":[0,1,3,4],"weights":null}]]]}}}]},{"name":"global_capped_underfill_control","graphs":[{"n":2,"q":1.0,"edges":[[0,1,3,7]],"input_index":[0]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"Global"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":2,"outputs":[false,false,true],"cap":10000,"error":false,"expect":{"result":{"placed":2,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]],"cost_distribution":[[7,2]],"bottleneck":[]},"trace":{"V":[3],"cap":2,"flow_core":{"placed":2,"cost_distribution":[[7,2]],"edge_flows":[2],"min_cut":null,"potentials":[0,7],"supply":{"0":2}},"shares":{"0":[[0,2]]}},"ledger":{"allocated":[2],"residual":[1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":7,"offered":null,"delivered":2,"edges":[[0,2]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":2,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":2,"delivered":2,"alloc":[[0,2]],"members":[0],"weights":null}]]]}}},{"op":"place","policy":0,"volume":"inf","outputs":[false,false,true],"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":"inf","termination":"exhausted","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]],"cost_distribution":[[7,3]],"bottleneck":[0],"min_cut":[0]},"trace":{"V":[3],"cap":null,"flow_core":{"placed":3,"cost_distribution":[[7,3]],"edge_flows":[3],"min_cut":[0],"potentials":[0,7],"supply":{"0":3}},"shares":{"0":[[0,3]]}},"ledger":{"allocated":[3],"residual":[0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":7,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":true,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":3,"delivered":3,"alloc":[[0,3]],"members":[0],"weights":null}]]]}}}]},{"name":"global_zero_growth_support_change","graphs":[{"n":3,"q":1.0,"edges":[[0,1,1,1],[0,1,2,3],[1,2,2,1]],"input_index":[0,1,2]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":2,"config":{"routing":{"kind":"Pinned","bundles":[[0,2]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,1],"curve":[[2,1]],"alloc":[[0,1],[2,1]]}]}},"W_final":[0,2,1]},"ledger":{"allocated":[1,0,1],"residual":[0,2,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":1,"cohorts":[[0,[{"id":0,"route":[0,2],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null}]]]}}},{"op":"policy","ledger":0,"sources":0,"destinations":2,"config":{"routing":{"kind":"Global"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]],"cost_distribution":[[4,1]]},"trace":{"V":[0,2,1],"cap":1,"flow_core":{"placed":1,"cost_distribution":[[4,1]],"edge_flows":[0,1,1],"min_cut":[2],"potentials":[0,3,4],"supply":{"0":1}},"shares":{"0":[[1,1],[2,1]]}},"ledger":{"allocated":[1,1,2],"residual":[0,1,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[1,2],"route_cost":4,"offered":null,"delivered":1,"edges":[[1,1],[2,1]],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":1,"route":[1,2],"T":1,"delivered":1,"alloc":[[1,1],[2,1]],"members":[1,2],"weights":null}]]]}}},{"op":"remove","policy":0,"error":false,"expect":{"ledger":{"allocated":[0,1,1],"residual":[1,1,1],"records":[{"flow_id":1,"owner":1,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[1,2],"route_cost":4,"offered":null,"delivered":1,"edges":[[1,1],[2,1]],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"policy","ledger":0,"sources":0,"destinations":2,"config":{"routing":{"kind":"Pinned","bundles":[[1,2]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":2,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,1],"curve":[[4,1]],"alloc":[[1,1],[2,1]]}]}},"W_final":[1,0,0]},"ledger":{"allocated":[0,2,2],"residual":[1,0,0],"records":[{"flow_id":1,"owner":1,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[1,2],"route_cost":4,"offered":null,"delivered":1,"edges":[[1,1],[2,1]],"drops":[]},{"flow_id":2,"owner":2,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[1,2],"route_cost":4,"offered":null,"delivered":1,"edges":[[1,1],[2,1]],"drops":[]}],"next_flow_id":3,"bound":[0,1,2],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":1,"cohorts":[[0,[{"id":2,"route":[1,2],"T":1,"delivered":1,"alloc":[[1,1],[2,1]],"members":[1,2],"weights":null}]]]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]],"cost_distribution":[[2,1]]},"trace":{"V":[1,1,1],"cap":2,"flow_core":{"placed":1,"cost_distribution":[[2,1]],"edge_flows":[1,0,1],"min_cut":[2],"potentials":[0,3,2],"supply":{"0":1}},"shares":{"0":[[0,1],[2,1]]}},"ledger":{"allocated":[1,1,2],"residual":[0,1,0],"records":[{"flow_id":1,"owner":1,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":2,"owner":2,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[1,2],"route_cost":4,"offered":null,"delivered":1,"edges":[[1,1],[2,1]],"drops":[]}],"next_flow_id":3,"bound":[0,1,2],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":1,"route":[0,2],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null}]]]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]],"cost_distribution":[[2,1]]},"trace":{"V":[1,1,1],"cap":2,"flow_core":{"placed":1,"cost_distribution":[[2,1]],"edge_flows":[1,0,1],"min_cut":[2],"potentials":[0,3,2],"supply":{"0":1}},"shares":{"0":[[0,1],[2,1]]}},"ledger":{"allocated":[1,1,2],"residual":[0,1,0],"records":[{"flow_id":1,"owner":1,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[0,2],"route_cost":2,"offered":null,"delivered":1,"edges":[[0,1],[2,1]],"drops":[]},{"flow_id":2,"owner":2,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[1,2],"route_cost":4,"offered":null,"delivered":1,"edges":[[1,1],[2,1]],"drops":[]}],"next_flow_id":3,"bound":[0,1,2],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":1,"route":[0,2],"T":1,"delivered":1,"alloc":[[0,1],[2,1]],"members":[0,2],"weights":null}]]]}}}]},{"name":"global_epochs","graphs":[{"n":3,"q":1.0,"edges":[[0,2,2,3],[1,2,2,1]],"input_index":[0,1]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":[0,1],"destinations":2,"config":{"routing":{"kind":"Global"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":0,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":0,"termination":"complete","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"],[1,"complete"]]},"trace":{},"ledger":{"allocated":[0,0],"residual":[2,2],"records":[],"next_flow_id":0,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"],[1,"complete"]],"cost_distribution":[[1,1]]},"trace":{"V":[2,2],"cap":1,"flow_core":{"placed":1,"cost_distribution":[[1,1]],"edge_flows":[0,1],"min_cut":null,"potentials":[0,0,1],"supply":{"0":0,"1":1}},"shares":{"0":[],"1":[[1,1]]}},"ledger":{"allocated":[0,1],"residual":[2,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[],"route_cost":null,"offered":null,"delivered":0,"edges":[],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[],"T":0,"delivered":0,"alloc":[],"members":[],"weights":null}]],[1,[{"id":1,"route":[1],"T":1,"delivered":1,"alloc":[[1,1]],"members":[1],"weights":null}]]]}}},{"op":"remove","policy":0,"error":false,"expect":{"ledger":{"allocated":[0,0],"residual":[2,2],"records":[],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"],[1,"complete"]],"cost_distribution":[[1,1]]},"trace":{"V":[2,2],"cap":1,"flow_core":{"placed":1,"cost_distribution":[[1,1]],"edge_flows":[0,1],"min_cut":null,"potentials":[0,0,1],"supply":{"0":0,"1":1}},"shares":{"0":[],"1":[[1,1]]}},"ledger":{"allocated":[0,1],"residual":[2,1],"records":[{"flow_id":2,"owner":0,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[],"route_cost":null,"offered":null,"delivered":0,"edges":[],"drops":[]},{"flow_id":3,"owner":0,"sources":[1],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]}],"next_flow_id":4,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":2,"route":[],"T":0,"delivered":0,"alloc":[],"members":[],"weights":null}]],[1,[{"id":3,"route":[1],"T":1,"delivered":1,"alloc":[[1,1]],"members":[1],"weights":null}]]]}}},{"op":"place","policy":0,"volume":"inf","outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":"inf","termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"],[1,"exhausted"]],"cost_distribution":[[1,2],[3,2]]},"trace":{"V":[2,2],"cap":null,"flow_core":{"placed":4,"cost_distribution":[[1,2],[3,2]],"edge_flows":[2,2],"min_cut":[0,1],"potentials":[0,0,3],"supply":{"0":2,"1":2}},"shares":{"0":[[0,2]],"1":[[1,2]]}},"ledger":{"allocated":[2,2],"residual":[0,0],"records":[{"flow_id":2,"owner":0,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":3,"offered":null,"delivered":2,"edges":[[0,2]],"drops":[]},{"flow_id":3,"owner":0,"sources":[1],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":2,"edges":[[1,2]],"drops":[]}],"next_flow_id":4,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":1,"infinite":true,"placed_demand":4,"usable":0,"cohorts":[[0,[{"id":2,"route":[0],"T":2,"delivered":2,"alloc":[[0,2]],"members":[0],"weights":null}]],[1,[{"id":3,"route":[1],"T":2,"delivered":2,"alloc":[[1,2]],"members":[1],"weights":null}]]]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":true,"expect":{"ledger":{"allocated":[2,2],"residual":[0,0],"records":[{"flow_id":2,"owner":0,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":3,"offered":null,"delivered":2,"edges":[[0,2]],"drops":[]},{"flow_id":3,"owner":0,"sources":[1],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":2,"edges":[[1,2]],"drops":[]}],"next_flow_id":4,"bound":[0],"orphans":[]}}},{"op":"place","policy":0,"volume":0,"outputs":null,"cap":10000,"error":true,"expect":{"ledger":{"allocated":[2,2],"residual":[0,0],"records":[{"flow_id":2,"owner":0,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":3,"offered":null,"delivered":2,"edges":[[0,2]],"drops":[]},{"flow_id":3,"owner":0,"sources":[1],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":2,"edges":[[1,2]],"drops":[]}],"next_flow_id":4,"bound":[0],"orphans":[]}}},{"op":"remove","policy":0,"error":false,"expect":{"ledger":{"allocated":[0,0],"residual":[2,2],"records":[],"next_flow_id":4,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"],[1,"complete"]],"cost_distribution":[[1,1]]},"trace":{"V":[2,2],"cap":1,"flow_core":{"placed":1,"cost_distribution":[[1,1]],"edge_flows":[0,1],"min_cut":null,"potentials":[0,0,1],"supply":{"0":0,"1":1}},"shares":{"0":[],"1":[[1,1]]}},"ledger":{"allocated":[0,1],"residual":[2,1],"records":[{"flow_id":4,"owner":0,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[],"route_cost":null,"offered":null,"delivered":0,"edges":[],"drops":[]},{"flow_id":5,"owner":0,"sources":[1],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]}],"next_flow_id":6,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":4,"route":[],"T":0,"delivered":0,"alloc":[],"members":[],"weights":null}]],[1,[{"id":5,"route":[1],"T":1,"delivered":1,"alloc":[[1,1]],"members":[1],"weights":null}]]]}}}]},{"name":"epochs_place_remove_place","graphs":[{"n":3,"q":1.0,"edges":[[0,2,5,1],[1,2,5,1]],"input_index":[0,1]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":[0,1],"destinations":2,"config":{"routing":{"kind":"CostOnly"},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"],[1,"complete"]]},"trace":{"offers":{"0":1,"1":0},"local_outcomes":{"0":"complete","1":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[0,1]]}]},"1":{"stage1":{"route":[1]},"stage3":[{"T":[0,0],"curve":[],"alloc":[]}]}},"W_final":[4,5]},"ledger":{"allocated":[1,0],"residual":[4,5],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[1],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]],[1,[{"id":1,"route":[1],"T":0,"delivered":0,"alloc":[],"members":[1],"weights":null}]]]}}},{"op":"remove","policy":0,"error":false,"expect":{"ledger":{"allocated":[0,0],"residual":[5,5],"records":[],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"],[1,"complete"]]},"trace":{"offers":{"0":1,"1":0},"local_outcomes":{"0":"complete","1":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[0,1]]}]},"1":{"stage1":{"route":[1]},"stage3":[{"T":[0,0],"curve":[],"alloc":[]}]}},"W_final":[4,5]},"ledger":{"allocated":[1,0],"residual":[4,5],"records":[{"flow_id":2,"owner":0,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":3,"owner":0,"sources":[1],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":4,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":2,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]],[1,[{"id":3,"route":[1],"T":0,"delivered":0,"alloc":[],"members":[1],"weights":null}]]]}}},{"op":"place","policy":0,"volume":"inf","outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":9,"remaining":"inf","termination":"exhausted","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"],[1,"exhausted"]]},"trace":{"offers":{"0":"inf","1":"inf"},"local_outcomes":{"0":"exhausted","1":"exhausted"},"created":0,"sources":{"0":{"stage3":[{"T":[1,5],"curve":[[1,5]],"alloc":[[0,5]]}]},"1":{"stage3":[{"T":[0,5],"curve":[[1,5]],"alloc":[[1,5]]}]}},"W_final":[0,0]},"ledger":{"allocated":[5,5],"residual":[0,0],"records":[{"flow_id":2,"owner":0,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":5,"edges":[[0,5]],"drops":[]},{"flow_id":3,"owner":0,"sources":[1],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":5,"edges":[[1,5]],"drops":[]}],"next_flow_id":4,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":1,"infinite":true,"placed_demand":10,"usable":0,"cohorts":[[0,[{"id":2,"route":[0],"T":5,"delivered":5,"alloc":[[0,5]],"members":[0],"weights":null}]],[1,[{"id":3,"route":[1],"T":5,"delivered":5,"alloc":[[1,5]],"members":[1],"weights":null}]]]}}},{"op":"remove","policy":0,"error":false,"expect":{"ledger":{"allocated":[0,0],"residual":[5,5],"records":[],"next_flow_id":4,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"],[1,"complete"]]},"trace":{"offers":{"0":1,"1":0},"local_outcomes":{"0":"complete","1":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[0,1]]}]},"1":{"stage1":{"route":[1]},"stage3":[{"T":[0,0],"curve":[],"alloc":[]}]}},"W_final":[4,5]},"ledger":{"allocated":[1,0],"residual":[4,5],"records":[{"flow_id":4,"owner":0,"sources":[0],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":5,"owner":0,"sources":[1],"destinations":[2],"placement":"MaxFlow","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":6,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":4,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]],[1,[{"id":5,"route":[1],"T":0,"delivered":0,"alloc":[],"members":[1],"weights":null}]]]}}},{"op":"remove","policy":0,"error":false,"expect":{"ledger":{"allocated":[0,0],"residual":[5,5],"records":[],"next_flow_id":6,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"remove","policy":0,"error":false,"expect":{"ledger":{"allocated":[0,0],"residual":[5,5],"records":[],"next_flow_id":6,"bound":[0],"orphans":[]},"policy":{"roster":[0,1],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}}]},{"name":"collapse_projection_every_priority","graphs":[{"n":2,"q":1.0,"edges":[[0,1,3,1],[0,1,2,1]],"input_index":[0,1]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":0,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,3],"curve":[[1,3]],"alloc":[[0,3]]}]}},"W_final":[0,2]},"ledger":{"allocated":[3,0],"residual":[0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":1,"cohorts":[[0,[{"id":0,"route":[0],"T":3,"delivered":3,"alloc":[[0,3]],"members":[0],"weights":null}]]]}}},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":{"T":[0,0],"bound":0,"shares":[0]}}},"W_final":[0,2]},"ledger":{"allocated":[3,0],"residual":[0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[[0,[{"id":1,"route":[0],"T":0,"delivered":0,"alloc":[],"members":[0],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":1,"error":false},{"op":"policy","ledger":1,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":2,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":2,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,3],"curve":[[1,3]],"alloc":[[0,3]]}]}},"W_final":[0,2]},"ledger":{"allocated":[3,0],"residual":[0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":1,"cohorts":[[0,[{"id":0,"route":[0],"T":3,"delivered":3,"alloc":[[0,3]],"members":[0],"weights":null}]]]}}},{"op":"policy","ledger":1,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"Deterministic","priority":"Capacity"}},"elig":null,"id":3,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":3,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":{"T":[0,0],"bound":0,"shares":[0]}}},"W_final":[0,2]},"ledger":{"allocated":[3,0],"residual":[0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[[0,[{"id":1,"route":[0],"T":0,"delivered":0,"alloc":[],"members":[0],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":2,"error":false},{"op":"policy","ledger":2,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":4,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":4,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,3],"curve":[[1,3]],"alloc":[[0,3]]}]}},"W_final":[0,2]},"ledger":{"allocated":[3,0],"residual":[0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":1,"cohorts":[[0,[{"id":0,"route":[0],"T":3,"delivered":3,"alloc":[[0,3]],"members":[0],"weights":null}]]]}}},{"op":"policy","ledger":2,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"Deterministic","priority":"Headroom"}},"elig":null,"id":5,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":5,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":{"T":[0,0],"bound":0,"shares":[0]}}},"W_final":[0,2]},"ledger":{"allocated":[3,0],"residual":[0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[[0,[{"id":1,"route":[0],"T":0,"delivered":0,"alloc":[],"members":[0],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":3,"error":false},{"op":"policy","ledger":3,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":6,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":6,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,3],"curve":[[1,3]],"alloc":[[0,3]]}]}},"W_final":[0,2]},"ledger":{"allocated":[3,0],"residual":[0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":1,"cohorts":[[0,[{"id":0,"route":[0],"T":3,"delivered":3,"alloc":[[0,3]],"members":[0],"weights":null}]]]}}},{"op":"policy","ledger":3,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Residual"}},"elig":null,"id":7,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":7,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[1]},"stage3":{"T":[0,1],"bound":2,"shares":[1]}}},"W_final":[0,1]},"ledger":{"allocated":[3,1],"residual":[0,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":1,"route":[1],"T":1,"delivered":1,"alloc":[[1,1]],"members":[1],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":4,"error":false},{"op":"policy","ledger":4,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":8,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":8,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,3],"curve":[[1,3]],"alloc":[[0,3]]}]}},"W_final":[0,2]},"ledger":{"allocated":[3,0],"residual":[0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":1,"cohorts":[[0,[{"id":0,"route":[0],"T":3,"delivered":3,"alloc":[[0,3]],"members":[0],"weights":null}]]]}}},{"op":"policy","ledger":4,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":9,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":9,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":{"T":[0,0],"bound":0,"shares":[0]}}},"W_final":[0,2]},"ledger":{"allocated":[3,0],"residual":[0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[[0,[{"id":1,"route":[0],"T":0,"delivered":0,"alloc":[],"members":[0],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":5,"error":false},{"op":"policy","ledger":5,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":10,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":10,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,3],"curve":[[1,3]],"alloc":[[0,3]]}]}},"W_final":[0,2]},"ledger":{"allocated":[3,0],"residual":[0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":1,"cohorts":[[0,[{"id":0,"route":[0],"T":3,"delivered":3,"alloc":[[0,3]],"members":[0],"weights":null}]]]}}},{"op":"policy","ledger":5,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Headroom"}},"elig":null,"id":11,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":11,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[1]},"stage3":{"T":[0,1],"bound":2,"shares":[1]}}},"W_final":[0,1]},"ledger":{"allocated":[3,1],"residual":[0,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":1,"route":[1],"T":1,"delivered":1,"alloc":[[1,1]],"members":[1],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":6,"error":false},{"op":"policy","ledger":6,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":12,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":12,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,3],"curve":[[1,3]],"alloc":[[0,3]]}]}},"W_final":[0,2]},"ledger":{"allocated":[3,0],"residual":[0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":1,"cohorts":[[0,[{"id":0,"route":[0],"T":3,"delivered":3,"alloc":[[0,3]],"members":[0],"weights":null}]]]}}},{"op":"policy","ledger":6,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":false,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":13,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":13,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":{"T":[0,0],"bound":0,"shares":[0]}}},"W_final":[0,2]},"ledger":{"allocated":[3,0],"residual":[0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[[0,[{"id":1,"route":[0],"T":0,"delivered":0,"alloc":[],"members":[0],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":7,"error":false},{"op":"policy","ledger":7,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":14,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":14,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,3],"curve":[[1,3]],"alloc":[[0,3]]}]}},"W_final":[0,2]},"ledger":{"allocated":[3,0],"residual":[0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":1,"cohorts":[[0,[{"id":0,"route":[0],"T":3,"delivered":3,"alloc":[[0,3]],"members":[0],"weights":null}]]]}}},{"op":"policy","ledger":7,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":false,"tie_break":"Deterministic","priority":"Capacity"}},"elig":null,"id":15,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":15,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":{"T":[0,0],"bound":0,"shares":[0]}}},"W_final":[0,2]},"ledger":{"allocated":[3,0],"residual":[0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[[0,[{"id":1,"route":[0],"T":0,"delivered":0,"alloc":[],"members":[0],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":8,"error":false},{"op":"policy","ledger":8,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":16,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":16,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,3],"curve":[[1,3]],"alloc":[[0,3]]}]}},"W_final":[0,2]},"ledger":{"allocated":[3,0],"residual":[0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":1,"cohorts":[[0,[{"id":0,"route":[0],"T":3,"delivered":3,"alloc":[[0,3]],"members":[0],"weights":null}]]]}}},{"op":"policy","ledger":8,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":false,"tie_break":"Deterministic","priority":"Headroom"}},"elig":null,"id":17,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":17,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":{"T":[0,0],"bound":0,"shares":[0]}}},"W_final":[0,2]},"ledger":{"allocated":[3,0],"residual":[0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[[0,[{"id":1,"route":[0],"T":0,"delivered":0,"alloc":[],"members":[0],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":9,"error":false},{"op":"policy","ledger":9,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":18,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":18,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,3],"curve":[[1,3]],"alloc":[[0,3]]}]}},"W_final":[0,2]},"ledger":{"allocated":[3,0],"residual":[0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":1,"cohorts":[[0,[{"id":0,"route":[0],"T":3,"delivered":3,"alloc":[[0,3]],"members":[0],"weights":null}]]]}}},{"op":"policy","ledger":9,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Residual"}},"elig":null,"id":19,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":19,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[1]},"stage3":{"T":[0,1],"bound":2,"shares":[1]}}},"W_final":[0,1]},"ledger":{"allocated":[3,1],"residual":[0,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":1,"route":[1],"T":1,"delivered":1,"alloc":[[1,1]],"members":[1],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":10,"error":false},{"op":"policy","ledger":10,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":20,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":20,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,3],"curve":[[1,3]],"alloc":[[0,3]]}]}},"W_final":[0,2]},"ledger":{"allocated":[3,0],"residual":[0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":1,"cohorts":[[0,[{"id":0,"route":[0],"T":3,"delivered":3,"alloc":[[0,3]],"members":[0],"weights":null}]]]}}},{"op":"policy","ledger":10,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":21,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":21,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"exhausted"},"created":0,"sources":{"0":{"stage1":{"route":[0]},"stage3":{"T":[0,0],"bound":0,"shares":[0]}}},"W_final":[0,2]},"ledger":{"allocated":[3,0],"residual":[0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":0,"edges":[],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[[0,[{"id":1,"route":[0],"T":0,"delivered":0,"alloc":[],"members":[0],"weights":null}]]]}}},{"op":"ledger","graph":0,"baseline":null,"id":11,"error":false},{"op":"policy","ledger":11,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":22,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":22,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,3],"curve":[[1,3]],"alloc":[[0,3]]}]}},"W_final":[0,2]},"ledger":{"allocated":[3,0],"residual":[0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":1,"cohorts":[[0,[{"id":0,"route":[0],"T":3,"delivered":3,"alloc":[[0,3]],"members":[0],"weights":null}]]]}}},{"op":"policy","ledger":11,"sources":0,"destinations":1,"config":{"routing":{"kind":"CostOnly"},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Headroom"}},"elig":null,"id":23,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":23,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"route":[1]},"stage3":{"T":[0,1],"bound":2,"shares":[1]}}},"W_final":[0,1]},"ledger":{"allocated":[3,1],"residual":[0,1],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]},{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":1,"route":[1],"T":1,"delivered":1,"alloc":[[1,1]],"members":[1],"weights":null}]]]}}}]},{"name":"eb_growth_equal_route_stop","graphs":[{"n":2,"q":1.0,"edges":[[0,1,1,1],[0,1,5,1]],"input_index":[0,1]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":null,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":100,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":4,"remaining":96,"termination":"exhausted","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"exhausted"]]},"trace":{"offers":{"0":100},"local_outcomes":{"0":"exhausted"},"created":2,"sources":{"0":{"stage1":{"eb_target":100,"reservations":[{"route":[0,1],"S":[1,5],"T_bound":2,"reserved":2}],"created_cap_blocked":false},"stage3":{"T":[0,2],"bound":2,"shares":[2]},"stage4":[{"route":[1],"W":[0,4],"T":2,"T_candidate":4,"bound":4,"shares":[2,2],"decision":"accepted","gain":2},{"stop":"candidate equals a cohort route","route":[1]}]}},"W_final":[0,2]},"ledger":{"allocated":[1,3],"residual":[0,2],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0,1],"route_cost":1,"offered":null,"delivered":2,"edges":[[0,1],[1,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":2,"edges":[[1,2]],"drops":[]}],"next_flow_id":2,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":100,"infinite":false,"placed_demand":4,"usable":0,"cohorts":[[0,[{"id":0,"route":[0,1],"T":2,"delivered":2,"alloc":[[0,1],[1,1]],"members":[0,1],"weights":null},{"id":1,"route":[1],"T":2,"delivered":2,"alloc":[[1,2]],"members":[1],"weights":null}]]]}}}]},{"name":"capacityaware_blocked_inf_remove_place","graphs":[{"n":2,"q":1.0,"edges":[[0,1,3,1]],"input_index":[0]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"Pinned","bundles":[[0]]},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":true,"multi_edge":true,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":0,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":0,"sources":{"0":{"stage1":{"usable":[0]},"stage3":[{"T":[0,3],"curve":[[1,3]],"alloc":[[0,3]]}]}},"W_final":[0]},"ledger":{"allocated":[3],"residual":[0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":1,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":1,"cohorts":[[0,[{"id":0,"route":[0],"T":3,"delivered":3,"alloc":[[0,3]],"members":[0],"weights":null}]]]}}},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":null,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"MaxFlow","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"PreferHigherPriority","priority":"Capacity"}},"elig":null,"id":1,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":"inf","outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":"inf","termination":"no_route","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"no_route"]]},"trace":{"offers":{"0":"inf"},"local_outcomes":{"0":"no_route"},"created":0,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[],"created_cap_blocked":false}}},"W_final":[0]},"ledger":{"allocated":[3],"residual":[0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":1,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":true,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":true,"expect":{"ledger":{"allocated":[3],"residual":[0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":1,"bound":[0,1],"orphans":[]}}},{"op":"remove","policy":1,"error":false,"expect":{"ledger":{"allocated":[3],"residual":[0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":1,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":0,"remaining":1,"termination":"no_route","routes_changed":false,"weights_changed":false,"drops":[],"local_outcomes":[[0,"no_route"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"no_route"},"created":0,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[],"created_cap_blocked":false}}},"W_final":[0]},"ledger":{"allocated":[3],"residual":[0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":3,"edges":[[0,3]],"drops":[]}],"next_flow_id":1,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":1,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"remove","policy":0,"error":false,"expect":{"ledger":{"allocated":[0],"residual":[3],"records":[],"next_flow_id":1,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":1,"cohorts":[]}}},{"op":"place","policy":1,"volume":1,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":1,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":1},"local_outcomes":{"0":"complete"},"created":1,"sources":{"0":{"stage1":{"eb_target":null,"reservations":[{"route":[0],"S":[3],"T_bound":3,"reserved":1}],"created_cap_blocked":false},"stage3":[{"T":[0,1],"curve":[[1,1]],"alloc":[[0,1]]}],"stage4":[]}},"W_final":[2]},"ledger":{"allocated":[1],"residual":[2],"records":[{"flow_id":1,"owner":1,"sources":[0],"destinations":[1],"placement":"MaxFlow","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]}],"next_flow_id":2,"bound":[0,1],"orphans":[]},"policy":{"roster":[0],"requested":2,"infinite":false,"placed_demand":1,"usable":0,"cohorts":[[0,[{"id":1,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null}]]]}}}]},{"name":"eb_growth_single_path_repeat","graphs":[{"n":2,"q":1.0,"edges":[[0,1,1,1],[0,1,2,1]],"input_index":[0,1]}],"ops":[{"op":"ledger","graph":0,"baseline":null,"id":0,"error":false},{"op":"policy","ledger":0,"sources":0,"destinations":1,"config":{"routing":{"kind":"CapacityAware","flow_count":1,"max_flow_count":null,"max_path_cost":null,"min_flow_bandwidth":0,"reoptimize":false,"diminishing":null},"placement":"EqualBalanced","admission":"Lossless","selection":{"multipath":false,"multi_edge":false,"tie_break":"Deterministic","priority":"Residual"}},"elig":null,"id":0,"error":false,"expect":{"policy":{"roster":[0],"requested":0,"infinite":false,"placed_demand":0,"usable":0,"cohorts":[]}}},{"op":"place","policy":0,"volume":3,"outputs":null,"cap":10000,"error":false,"expect":{"result":{"placed":3,"remaining":0,"termination":"complete","routes_changed":true,"weights_changed":false,"drops":[],"local_outcomes":[[0,"complete"]]},"trace":{"offers":{"0":3},"local_outcomes":{"0":"complete"},"created":3,"sources":{"0":{"stage1":{"eb_target":3,"reservations":[{"route":[0],"S":[1,2],"T_bound":1,"reserved":1}],"created_cap_blocked":false},"stage3":{"T":[0,1],"bound":1,"shares":[1]},"stage4":[{"route":[1],"W":[0,2],"T":1,"T_candidate":2,"bound":2,"shares":[1,1],"decision":"accepted","gain":1},{"route":[1],"W":[0,1],"T":2,"T_candidate":3,"bound":3,"shares":[1,1,1],"decision":"accepted","gain":1}]}},"W_final":[0,0]},"ledger":{"allocated":[1,2],"residual":[0,0],"records":[{"flow_id":0,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[0],"route_cost":1,"offered":null,"delivered":1,"edges":[[0,1]],"drops":[]},{"flow_id":1,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]},{"flow_id":2,"owner":0,"sources":[0],"destinations":[1],"placement":"EqualBalanced","admission":"Lossless","bundle":[1],"route_cost":1,"offered":null,"delivered":1,"edges":[[1,1]],"drops":[]}],"next_flow_id":3,"bound":[0],"orphans":[]},"policy":{"roster":[0],"requested":3,"infinite":false,"placed_demand":3,"usable":0,"cohorts":[[0,[{"id":0,"route":[0],"T":1,"delivered":1,"alloc":[[0,1]],"members":[0],"weights":null},{"id":1,"route":[1],"T":1,"delivered":1,"alloc":[[1,1]],"members":[1],"weights":null},{"id":2,"route":[1],"T":1,"delivered":1,"alloc":[[1,1]],"members":[1],"weights":null}]]]}}}]}]} diff --git a/tests/py/private_hooks.py b/tests/py/private_hooks.py new file mode 100644 index 0000000..797af25 --- /dev/null +++ b/tests/py/private_hooks.py @@ -0,0 +1,12 @@ +"""Whether the extension carries the private hooks (CMake option NETGRAPH_CORE_PRIVATE_HOOKS: ON +for the editable development build, OFF for an installed wheel). Tests that need a hook are +skipped without them, never failed.""" + +import _netgraph_core +import pytest + +HOOKS = hasattr(_netgraph_core, "_max_flows_per_call") + +needs_hooks = pytest.mark.skipif( + not HOOKS, reason="needs the private hooks (NETGRAPH_CORE_PRIVATE_HOOKS)" +) diff --git a/tests/py/test_place_global_units.py b/tests/py/test_flow_core.py similarity index 98% rename from tests/py/test_place_global_units.py rename to tests/py/test_flow_core.py index 95b8cb7..81f9c0b 100644 --- a/tests/py/test_place_global_units.py +++ b/tests/py/test_flow_core.py @@ -24,7 +24,7 @@ def build(n, edges, q=1.0): - return ngc.StrictMultiDiGraph( + return ngc.Graph( n, [e[0] for e in edges], [e[1] for e in edges], @@ -151,8 +151,8 @@ def test_release_witness_fresh_solve(): ({"view": [1, 6]}, "capacity_units"), ({"view": [1.0, 1.0]}, "view"), ({"view": [-1, 0]}, "outside"), - ({"sources": []}, "sources: the endpoint set is empty"), - ({"destinations": []}, "destinations: the endpoint set is empty"), + ({"sources": []}, "sources: the node set is empty"), + ({"destinations": []}, "destinations: the node set is empty"), ({"sources": [3]}, "out of node range"), ({"sources": [0, 2]}, "both endpoint sets"), ({"sources": [0.0]}, "sources"), diff --git a/tests/py/test_ledger.py b/tests/py/test_flow_ledger.py similarity index 66% rename from tests/py/test_ledger.py rename to tests/py/test_flow_ledger.py index 90c2da3..afd7d1a 100644 --- a/tests/py/test_ledger.py +++ b/tests/py/test_flow_ledger.py @@ -1,6 +1,7 @@ -"""The integer ledger FlowGraph through the bindings (APPROACH 8): baselines on unnarrowed -values, snapshots and records, FlowIds, bottleneck, and the lifetime table driven by the -private single-bundle Pinned entry of sub-PR 1b (``_netgraph_core._PinnedFlow``).""" +"""The integer ledger FlowLedger through the bindings (APPROACH 8): baselines on unnarrowed +values, snapshots and records, FlowIds, bottleneck, and the lifetime table driven by manual +placements: FlowPolicy with Pinned routing and one bundle (the contract's manual flow), whose +PlacementResult the tests read.""" from __future__ import annotations @@ -8,18 +9,19 @@ import gc import math -import _netgraph_core as ext import numpy as np import pytest +from private_hooks import HOOKS import netgraph_core as ngc L62 = 1 << 62 INF = math.inf +T = ngc.Termination def build(n, edges, q=1.0): - return ngc.StrictMultiDiGraph( + return ngc.Graph( n, [e[0] for e in edges], [e[1] for e in edges], @@ -44,17 +46,22 @@ def dag_of(g, edges): def pinned(fg, src, dst, edges, placement, admission=None, priority=None, elig=None): - g = fg.graph() - return ext._PinnedFlow( - fg, - src, - dst, - dag_of(g, edges), - placement, - admission if admission is not None else ngc.Admission.LOSSLESS, - priority if priority is not None else ngc.EdgePriority.RESIDUAL, - elig, + """A manual flow: a FlowPolicy with Pinned routing and one bundle (the edges' DAG).""" + cfg = ngc.FlowPolicyConfig( + routing=ngc.Pinned([dag_of(fg.graph(), edges)]), + placement=placement, + admission=admission if admission is not None else ngc.Admission.LOSSLESS, + selection=ngc.PathSelection( + ngc.EdgeTieBreak.DETERMINISTIC, + priority if priority is not None else ngc.EdgePriority.RESIDUAL, + ), ) + return ngc.FlowPolicy(fg, src, dst, cfg, elig) + + +def flow_id(policy): + recs = policy.records() + return recs[0].flow_id if recs else None EB = ngc.FlowPlacement.EQUAL_BALANCED @@ -65,7 +72,7 @@ def pinned(fg, src, dst, edges, placement, admission=None, priority=None, elig=N def test_baseline_validation_order_and_snapshots(): g = build(2, [(0, 1, 5, 1), (1, 0, 3, 1)]) - fg = ngc.FlowGraph(g) + fg = ngc.FlowLedger(g) assert list(fg.residual()) == [5, 3] assert fg.residual().dtype == np.int64 assert list(fg.allocated()) == [0, 0] @@ -82,23 +89,31 @@ def test_baseline_validation_order_and_snapshots(): (np.zeros((1, 2), dtype=np.int64), "1-D"), ): with pytest.raises(ValueError, match=msg): - ngc.FlowGraph(g, bad) - fg2 = ngc.FlowGraph(graph=g, residual=np.array([1, 2], dtype=np.uint64)) + ngc.FlowLedger(g, bad) + fg2 = ngc.FlowLedger(graph=g, residual=np.array([1, 2], dtype=np.uint64)) assert list(fg2.residual()) == [1, 2] assert fg2.uid() != fg.uid() def test_lifetime_bound_rows_and_epochs(): g = build(2, [(0, 1, 5, 1)]) - fg = ngc.FlowGraph(g) + fg = ngc.FlowLedger(g) p = pinned(fg, 0, 1, [0], EB) r = p.place(0) - assert (r["termination"], r["routes_changed"], fg.records()) == ( - "complete", + assert (r.placed, r.remaining, r.termination, r.local_outcomes) == ( + 0, + 0, + T.COMPLETE, + [(0, T.COMPLETE)], + ) + assert (r.routes_changed, r.weights_changed, r.drops, fg.records()) == ( + False, False, [], + [], ) - assert fg._state()["next_flow_id"] == 0 + if HOOKS: + assert fg._state()["next_flow_id"] == 0 with pytest.raises(ValueError, match="bound"): fg.reset() with pytest.raises(ValueError, match="bound"): @@ -106,40 +121,59 @@ def test_lifetime_bound_rows_and_epochs(): with pytest.raises(ValueError, match="bound"): copy.copy(fg) r = p.place(2) - assert (r["placed"], r["routes_changed"], p.flow_id()[1]) == (2, True, 0) + assert (r.placed, r.termination, r.routes_changed, flow_id(p)[1]) == ( + 2, + T.COMPLETE, + True, + 0, + ) r = p.place(INF) - assert (r["placed"], r["termination"], r["remaining"]) == (3, "exhausted", INF) + assert (r.placed, r.termination, r.remaining, r.local_outcomes) == ( + 3, + T.EXHAUSTED, + INF, + [(0, T.EXHAUSTED)], + ) + assert (r.routes_changed, r.weights_changed, r.cost_distribution) == ( + False, + False, + None, + ) with pytest.raises(ValueError, match=r"after \+inf"): p.place(1) with pytest.raises(ValueError, match=r"after \+inf"): p.place(0) - old = p.flow_id() + old = flow_id(p) p.remove() assert list(fg.allocated()) == [0] with pytest.raises(ValueError, match="retired or unknown"): fg.record(old) r = p.place(1) - assert (r["placed"], p.flow_id()[1]) == (1, 1) + assert (r.placed, r.routes_changed, flow_id(p)[1]) == (1, True, 1) p.remove() p.remove() - assert (p.requested(), p.infinite()) == (0, False) - assert fg._state() == {"next_flow_id": 2, "bound": [0], "orphans": []} + assert p.records() == [] + if HOOKS: + assert (p._state()["requested"], p._state()["infinite"]) == (0, False) + assert fg._state() == {"next_flow_id": 2, "bound": [0], "orphans": []} def test_orphans_from_destruction_reset_and_copy(): g = build(2, [(0, 1, 5, 1)]) - fg = ngc.FlowGraph(g) + fg = ngc.FlowLedger(g) q = pinned(fg, 0, 1, [0], EB) q.place(2) del q gc.collect() # destroyed without remove(): an orphan - assert fg._state()["orphans"] == [0] + if HOOKS: + assert fg._state()["orphans"] == [0] assert fg.records()[0].delivered == 2 p = pinned(fg, 0, 1, [0], EB) - assert p.place(5)["placed"] == 3 - p.release() - with pytest.raises(Exception, match="released"): - p.place(1) + assert p.place(5).placed == 3 + with pytest.raises(ValueError, match="bound"): + fg.copy() + del p + gc.collect() # the second orphan; the ledger has no bound policy left c = fg.copy() assert c.uid() != fg.uid() assert [r.flow_id for r in c.records()] == [(c.uid(), 0), (c.uid(), 1)] @@ -148,26 +182,32 @@ def test_orphans_from_destruction_reset_and_copy(): assert list(d.allocated()) == [5] fg.reset([4]) assert (fg.records(), list(fg.residual())) == ([], [4]) - assert fg._state() == {"next_flow_id": 2, "bound": [], "orphans": []} + if HOOKS: + assert fg._state() == {"next_flow_id": 2, "bound": [], "orphans": []} assert len(c.records()) == 2 # the copy is independent def test_policy_keeps_the_ledger_alive(): g = build(2, [(0, 1, 5, 1)]) - fg = ngc.FlowGraph(g) + fg = ngc.FlowLedger(g) p = pinned(fg, 0, 1, [0], MF) del fg gc.collect() - assert p.place(2)["placed"] == 2 + assert p.place(2).placed == 2 assert p.placed_demand() == 2 def test_records_fields_and_counters_beyond_64_bits(): g = build(4, [(0, 1, 10, 1), (0, 2, 10, 1), (1, 3, 1, 1), (2, 3, 10, 1)]) - fg = ngc.FlowGraph(g) + fg = ngc.FlowLedger(g) p = pinned(fg, 0, 3, range(4), EB, LOSSY) r = p.place(10) - assert (r["placed"], r["termination"], r["drops"]) == (6, "complete", [(2, 4)]) + assert (r.placed, r.remaining, r.termination, r.drops) == ( + 6, + 4, + T.COMPLETE, + [(2, 4)], + ) rec = fg.records()[0] assert rec.flow_id == (fg.uid(), 0) assert (rec.owner, rec.sources, rec.destinations) == (0, [0], [3]) @@ -179,7 +219,7 @@ def test_records_fields_and_counters_beyond_64_bits(): assert fg.record(rec.flow_id).delivered == 6 # Lossless records: no offered; a route without a walk at creation: route_cost None. g2 = build(2, [(0, 1, 1, 1)]) - fg2 = ngc.FlowGraph(g2) + fg2 = ngc.FlowLedger(g2) pinned(fg2, 0, 1, [0], MF).place(1) assert fg2.records()[0].offered is None # Every unit dropped on a saturated link, nine times: the counters pass 2^64. @@ -189,7 +229,8 @@ def test_records_fields_and_counters_beyond_64_bits(): lossy = fg2.records()[1] assert lossy.offered == 9 * (L62 - 1) assert lossy.drops == [(0, 9 * (L62 - 1))] - assert sat.requested() == 9 * (L62 - 1) + if HOOKS: + assert sat._state()["requested"] == 9 * (L62 - 1) assert lossy.offered > 1 << 64 @@ -198,27 +239,30 @@ def test_empty_split_graph_keeps_the_route_cost(): # record exists at zero delivery with an empty split graph; route_cost is the bundle's # minimum walk cost (APPROACH 8), not a split-graph value. g = build(2, [(0, 1, 2, 3)]) - fg = ngc.FlowGraph(g) + fg = ngc.FlowLedger(g) pinned(fg, 0, 1, [0], MF).place(2) w = pinned(fg, 0, 1, [0], W) r = w.place(1) - assert (r["placed"], r["termination"], r["routes_changed"]) == ( + assert (r.placed, r.termination, r.routes_changed, r.weights_changed) == ( 0, - "exhausted", + T.EXHAUSTED, True, + False, ) - rec = fg.record(w.flow_id()) + rec = fg.record(flow_id(w)) assert (rec.route_cost, rec.delivered, rec.edges) == (3, 0, []) - assert w.plan()["members"] == [] + if HOOKS: + flows = next(iter(w._state()["cohorts"].values())) + assert flows[0]["members"] == [] def test_flow_ids_of_other_ledgers_and_bad_shapes(): g = build(2, [(0, 1, 5, 1)]) - a, b = ngc.FlowGraph(g), ngc.FlowGraph(g) + a, b = ngc.FlowLedger(g), ngc.FlowLedger(g) p = pinned(a, 0, 1, [0], MF) p.place(1) with pytest.raises(ValueError, match="another ledger"): - b.record(p.flow_id()) + b.record(flow_id(p)) with pytest.raises(ValueError, match="tuple"): a.record(0) with pytest.raises(ValueError, match="retired or unknown"): @@ -227,7 +271,7 @@ def test_flow_ids_of_other_ledgers_and_bad_shapes(): def test_requests_are_integer_units_or_inf(): g = build(2, [(0, 1, 5, 1)]) - fg = ngc.FlowGraph(g) + fg = ngc.FlowLedger(g) p = pinned(fg, 0, 1, [0], EB) for bad, msg in ( (1.5, "integer units or"), @@ -240,14 +284,15 @@ def test_requests_are_integer_units_or_inf(): with pytest.raises(ValueError, match=msg): p.place(bad) assert fg.records() == [] - assert p.place(np.int64(2))["placed"] == 2 + assert p.place(np.int64(2)).placed == 2 def test_bottleneck_versus_min_cut(): g = build(4, [(0, 1, 1, 1), (0, 2, 2, 3), (1, 2, 1, 1), (2, 3, 2, 1)]) - fg = ngc.FlowGraph(g) + fg = ngc.FlowLedger(g) p = pinned(fg, 0, 3, range(4), MF) - assert p.place(INF)["placed"] == 2 + r = p.place(INF) + assert (r.placed, r.termination) == (2, T.EXHAUSTED) inv = [0] * 4 for e, pos in enumerate(g.input_index()): inv[pos] = e @@ -264,7 +309,7 @@ def test_bottleneck_versus_min_cut(): def test_pinned_construction_errors(): g = build(3, [(0, 1, 5, 1), (2, 1, 5, 1)]) - fg = ngc.FlowGraph(g) + fg = ngc.FlowLedger(g) with pytest.raises(ValueError, match="malformed bundle"): pinned(fg, 0, 2, [0, 1], MF) with pytest.raises(ValueError, match="Lossy"): @@ -274,15 +319,14 @@ def test_pinned_construction_errors(): with pytest.raises(ValueError, match="both endpoint sets"): pinned(fg, 1, 1, [0], MF) other = build(3, [(0, 1, 5, 1), (2, 1, 5, 1)]) + foreign = ngc.FlowPolicyConfig( + routing=ngc.Pinned([dag_of(other, [0])]), + placement=MF, + admission=ngc.Admission.LOSSLESS, + selection=ngc.PathSelection( + ngc.EdgeTieBreak.DETERMINISTIC, ngc.EdgePriority.RESIDUAL + ), + ) with pytest.raises(ValueError, match="foreign PredDAG"): - ext._PinnedFlow( - fg, - 0, - 1, - dag_of(other, [0]), - MF, - ngc.Admission.LOSSLESS, - ngc.EdgePriority.RESIDUAL, - None, - ) - assert fg._state()["bound"] == [] + ngc.FlowPolicy(fg, 0, 1, foreign) + fg.reset() # no policy is bound diff --git a/tests/py/test_flow_policy.py b/tests/py/test_flow_policy.py new file mode 100644 index 0000000..c2f1d2c --- /dev/null +++ b/tests/py/test_flow_policy.py @@ -0,0 +1,514 @@ +"""The policy engine, the DagCache and the evaluators through the bindings (APPROACH 8 to 11): +configuration objects and their structural validation, PlacementResult fields and outputs, +lifetimes, the evaluators with workers, and the cache statistics. Prototype 2's fixtures +replay in ``test_flow_policy_fixtures.py``.""" + +from __future__ import annotations + +import gc +import math + +import _netgraph_core as ext +import numpy as np +import pytest +from private_hooks import HOOKS, needs_hooks + +import netgraph_core as ngc + +INF = math.inf +L62 = 1 << 62 +T = ngc.Termination + + +def build(n, edges, q=1.0): + return ngc.Graph( + n, + [e[0] for e in edges], + [e[1] for e in edges], + [e[2] for e in edges], + [e[3] for e in edges], + q, + ) + + +def ids(g, positions): + inv = [0] * g.num_edges() + for e, pos in enumerate(g.input_index()): + inv[int(pos)] = e + return sorted(inv[p] for p in positions) + + +def dag(g, edges): + per = [[] for _ in range(g.num_nodes())] + for e in sorted(edges): + per[int(g.edge_dst()[e])].append(e) + offsets, parents, via = [0], [], [] + for v in range(g.num_nodes()): + for e in per[v]: + parents.append(int(g.edge_src()[e])) + via.append(e) + offsets.append(len(via)) + return ngc.PredDAG.from_csr(g, offsets, parents, via) + + +def sel( + tie=ngc.EdgeTieBreak.DETERMINISTIC, prio=ngc.EdgePriority.CAPACITY, mp=True, me=True +): + return ngc.PathSelection(tie, prio, mp, me) + + +def config( + routing, placement=ngc.FlowPlacement.EQUAL_BALANCED, admission=None, selection=None +): + return ngc.FlowPolicyConfig( + routing=routing, + placement=placement, + admission=admission or ngc.Admission.LOSSLESS, + selection=selection or sel(), + ) + + +GLOBAL = config(ngc.Global(), ngc.FlowPlacement.MAX_FLOW) + + +def diamond(a=5, b=3, c=1): + # input edges: 0: 0->1, 1: 1->3 (cap a, cost 1); 2: 0->2, 3: 2->3 (cap b, cost c) + return build(4, [(0, 1, a, 1), (1, 3, a, 1), (0, 2, b, c), (2, 3, b, c)]) + + +def test_configurations_are_structural_and_explicit(): + with pytest.raises(TypeError): + ngc.FlowPolicyConfig( + ngc.CostOnly(), ngc.FlowPlacement.MAX_FLOW, ngc.Admission.LOSSLESS, sel() + ) + with pytest.raises(TypeError): + ngc.FlowPolicyConfig( + routing=ngc.CostOnly(), placement=ngc.FlowPlacement.MAX_FLOW + ) + with pytest.raises(TypeError, match="incompatible"): + ngc.CostOnly(flow_count=2) # a field outside its routing variant (rule 13) + with pytest.raises(TypeError, match="incompatible"): + ngc.Global(max_flow_count=4) + with pytest.raises(TypeError, match="routing"): + config("CostOnly") + ca = ngc.CapacityAware( + flow_count=2, + max_flow_count=4, + max_path_cost=ngc.Stretch(1.5), + min_flow_bandwidth=3, + reoptimize=True, + diminishing=ngc.DiminishingReturns(2, 0.25), + ) + assert (ca.flow_count, ca.max_flow_count, ca.min_flow_bandwidth, ca.reoptimize) == ( + 2, + 4, + 3, + True, + ) + assert ca.max_path_cost == ngc.Stretch(1.5) + assert (ca.diminishing.window, ca.diminishing.fraction) == (2, 0.25) + assert ngc.CapacityAware().max_path_cost is None + assert ngc.CapacityAware( + max_path_cost=ngc.Absolute(7) + ).max_path_cost == ngc.Absolute(7) + with pytest.raises(TypeError): + ngc.CapacityAware(2) # keyword-only + for bad in (2**40, True, 1.5): + with pytest.raises(ValueError, match="flow_count"): + ngc.CapacityAware(flow_count=bad) + with pytest.raises(ValueError, match="min_flow_bandwidth"): + ngc.CapacityAware(min_flow_bandwidth=2**80) + with pytest.raises(TypeError, match="PredDAG"): + ngc.Pinned([[0, 1]]) + cfg = config(ca) + assert isinstance(cfg.routing, ngc.CapacityAware) + assert cfg.routing.flow_count == 2 + + +def test_validation_messages_before_binding(): + g = diamond() + fg = ngc.FlowLedger(g) + + def rejects(cfg, msg, src=0, dst=3): + with pytest.raises(ValueError, match=msg): + ngc.FlowPolicy(fg, src, dst, cfg) + + rejects( + config(ngc.CostOnly(), ngc.FlowPlacement.MAX_FLOW, ngc.Admission.LOSSY), "Lossy" + ) + rejects(config(ngc.CapacityAware(), admission=ngc.Admission.LOSSY), "Lossy") + rejects(config(ngc.Global()), "MaxFlow") + rejects(config(ngc.CapacityAware(flow_count=0)), "flow_count") + rejects(config(ngc.CapacityAware(flow_count=2, max_flow_count=1)), "max_flow_count") + rejects(config(ngc.CapacityAware(max_path_cost=ngc.Stretch(0.5))), "Stretch") + rejects(config(ngc.CapacityAware(max_path_cost=ngc.Stretch(math.nan))), "Stretch") + rejects( + config(ngc.CapacityAware(diminishing=ngc.DiminishingReturns(1, 1.0))), + "fraction", + ) + rejects(config(ngc.Pinned([])), "empty bundle list") + b = dag(g, ids(g, [0, 1])) + rejects(config(ngc.Pinned([b, b])), "duplicate bundle") + rejects(config(ngc.Pinned([dag(g, ids(g, [0, 3]))])), "malformed bundle") + rejects(config(ngc.Pinned([b])), "single endpoints", src=[0, 1]) + rejects(config(ngc.CostOnly()), "both endpoint sets", src=[0, 3]) + rejects(config(ngc.CostOnly()), "empty", src=[]) + rejects(config(ngc.CostOnly()), "out of node range", src=[L62]) + with pytest.raises(ValueError, match="foreign"): + ngc.FlowPolicy( + fg, 0, 3, config(ngc.CostOnly()), diamond().eligibility(excluded_edges=[0]) + ) + with pytest.raises(ValueError, match="foreign"): + ngc.FlowPolicy(fg, 0, 3, config(ngc.CostOnly()), None, ngc.DagCache(diamond())) + fg.reset() # nothing bound + + +def test_enum_values_outside_their_enumeration_are_rejected(): + # Rule 13: an enum built from an integer outside the enumeration is a domain error named + # by its field, at every constructor that takes it, so it never reaches a policy or record. + with pytest.raises( + ValueError, match="tie_break: 99 is not a value of EdgeTieBreak" + ): + ngc.PathSelection(ngc.EdgeTieBreak(99), ngc.EdgePriority.CAPACITY) + with pytest.raises(ValueError, match="priority: 99 is not a value of EdgePriority"): + ngc.PathSelection(ngc.EdgeTieBreak.DETERMINISTIC, ngc.EdgePriority(99)) + for routing in (ngc.CostOnly(), ngc.CapacityAware(), ngc.Global()): + with pytest.raises( + ValueError, match="placement: 99 is not a value of FlowPlacement" + ): + config(routing, ngc.FlowPlacement(99)) + with pytest.raises( + ValueError, match="admission: 99 is not a value of Admission" + ): + config(routing, ngc.FlowPlacement.MAX_FLOW, ngc.Admission(99)) + for valid in ngc.FlowPlacement.__members__.values(): + assert config(ngc.CostOnly(), valid).placement == valid + + +def test_placement_result_fields_and_outputs(): + g = diamond() + fg = ngc.FlowLedger(g) + pol = ngc.FlowPolicy(fg, [0], [3], config(ngc.CostOnly())) + r = pol.place(4) + assert (r.placed, r.remaining, r.termination) == (4, 0, T.COMPLETE) + assert r.local_outcomes == [(0, T.COMPLETE)] + assert (r.routes_changed, r.weights_changed, r.drops) == (True, False, []) + assert r.cost_distribution is None + assert ( + r.edge_flows is None + and r.residual is None + and r.bottleneck is None + and r.min_cut is None + ) + r = pol.place( + INF, ngc.OutputOptions(edge_flows=True, residual=True, bottleneck=True) + ) + assert (r.placed, r.remaining, r.termination) == (3, INF, T.EXHAUSTED) + assert r.edge_flows.dtype == np.int64 and r.residual.dtype == np.int64 + assert list(r.residual) == list(fg.residual()) + own = [0] * 4 + for rec in pol.records(): + for e, x in rec.edges: + own[e] += x + assert list(r.edge_flows) == own + assert r.bottleneck == fg.bottleneck(0) + assert r.min_cut is None # Global only + assert pol.placed_demand() == 7 + with pytest.raises(ValueError, match="integer units or"): + pol.place(1.5) + with pytest.raises(ValueError, match=r"after \+inf"): + pol.place(np.int64(1)) + pol.remove() + assert pol.records() == [] and list(fg.allocated()) == [0, 0, 0, 0] + assert pol.place(np.int64(2)).placed == 2 + # Global: the marginal curve; min_cut with bottleneck. + fg2 = ngc.FlowLedger(diamond(5, 3, 2)) + gl = ngc.FlowPolicy(fg2, 0, 3, GLOBAL) + r = gl.place(INF, ngc.OutputOptions(bottleneck=True)) + assert r.cost_distribution == [(2, 5), (4, 3)] + assert sorted(r.min_cut) == ids(diamond(5, 3, 2), [0, 2]) + assert [rec.delivered for rec in gl.records()] == [8] + + +def test_lifetimes_keep_alive_and_orphans(): + g = diamond() + fg = ngc.FlowLedger(g) + el = g.eligibility(excluded_edges=ids(g, [2])) + cache = ngc.DagCache(g) + pol = ngc.FlowPolicy(fg, 0, 3, config(ngc.CostOnly()), el, cache) + del fg, el, cache + gc.collect() + assert ( + pol.place(10).placed == 5 + ) # the ledger, the Eligibility and the cache are alive + fg2 = ngc.FlowLedger(g) + p = ngc.FlowPolicy(fg2, 0, 3, config(ngc.CostOnly())) + p.place(2) + del p + gc.collect() + if HOOKS: + assert fg2._state()["orphans"] == [0] + assert fg2.records()[0].delivered == 2 + with pytest.raises(ValueError, match="bound"): + q = ngc.FlowPolicy(fg2, 0, 3, config(ngc.CostOnly())) + fg2.reset() + del q + gc.collect() + fg2.reset() + assert fg2.records() == [] + + +def test_pinned_usable_bundles_and_records(): + g = diamond() + fg = ngc.FlowLedger(g) + r1, r2 = dag(g, ids(g, [0, 1])), dag(g, ids(g, [2, 3])) + el = g.eligibility(excluded_edges=ids(g, [3])) + pol = ngc.FlowPolicy(fg, 0, 3, config(ngc.Pinned([r1, r2])), el) + assert pol.usable_bundles() == 1 + r = pol.place(9) + assert (r.placed, r.termination) == (5, T.EXHAUSTED) + rec = pol.records()[0] + assert rec.bundle == r1 and pol.record(rec.flow_id).delivered == 5 + # record() reads the policy's own records only (section 8: a policy's records). + other = ngc.FlowPolicy(fg, 0, 3, config(ngc.Pinned([r1]))) + other.place(1) + with pytest.raises(ValueError, match="not a record of this policy"): + pol.record(other.records()[0].flow_id) + assert fg.record(other.records()[0].flow_id).delivered == 0 + with pytest.raises(ValueError, match="another ledger"): + pol.record((ngc.FlowLedger(g).uid(), 0)) + pol.remove() + with pytest.raises(ValueError, match="retired or unknown"): + pol.record(rec.flow_id) + all_down = ngc.FlowPolicy( + fg, 0, 3, config(ngc.Pinned([r2])), g.eligibility(excluded_edges=ids(g, [3])) + ) + assert all_down.usable_bundles() == 0 + r = all_down.place(1) + assert (r.placed, r.termination, r.local_outcomes) == (0, T.NO_ROUTE, []) + + +def test_dag_cache_statistics_and_projection(): + g = diamond() + cache = ngc.DagCache(g) + fg = ngc.FlowLedger(g) + a = ngc.FlowPolicy(fg, 0, 3, config(ngc.CostOnly()), None, cache) + b = ngc.FlowPolicy( + fg, + 0, + 3, + config( + ngc.CostOnly(), + selection=sel( + ngc.EdgeTieBreak.PREFER_HIGHER_PRIORITY, + ngc.EdgePriority.RESIDUAL, + False, + False, + ), + ), + None, + cache, + ) + s = cache.stats() + assert (s.hits, s.misses, s.entries, s.live_values) == (1, 1, 1, 1) + assert s.bytes > 0 + assert a.place(2).placed == 2 and b.place(2).placed == 2 + cache.clear() + s = cache.stats() + assert (s.hits, s.misses, s.entries, s.bytes) == (1, 1, 0, 0) + assert s.live_values == 1 # held by the policies + assert cache.graph_uid() == g.uid() + + +def test_capacity_batch_and_workers(monkeypatch): + g = diamond(5, 3) + opts = ngc.CapacityOptions(GLOBAL, outputs=ngc.OutputOptions(edge_flows=True)) + r = ngc.capacity(g, 0, 3, opts) + assert (r.placed, r.termination, r.cost_distribution) == (8, T.EXHAUSTED, [(2, 8)]) + assert int(np.sum(r.edge_flows)) == 16 + common = g.eligibility(excluded_edges=ids(g, [3])) + pairs = [(0, 3), ([0], [3]), (1, 3), (0, [1, 2])] + per = [None, g.eligibility(excluded_edges=ids(g, [0])), None, None] + want = [5, 0, 5, 8] + for workers in (1, 3, None): + got = ngc.batch_capacity( + g, pairs, ngc.CapacityOptions(GLOBAL, elig=common), per, workers + ) + assert [x.placed for x in got] == want + monkeypatch.setenv("NGRAPH_CORE_BATCH_THREADS", "4") + got = ngc.batch_capacity(g, pairs, ngc.CapacityOptions(GLOBAL, elig=common), per) + assert [x.placed for x in got] == want + assert ngc.batch_capacity(g, [], ngc.CapacityOptions(GLOBAL)) == [] + with pytest.raises(ValueError, match="node 7"): + ngc.batch_capacity( + g, [(0, 3), (0, 7), (0, 8)], ngc.CapacityOptions(GLOBAL), workers=4 + ) + with pytest.raises(ValueError, match="per_pair_elig has 1 entries"): + ngc.batch_capacity(g, pairs, ngc.CapacityOptions(GLOBAL), [None]) + with pytest.raises(ValueError, match="Pinned"): + ngc.batch_capacity( + g, pairs, ngc.CapacityOptions(config(ngc.Pinned([dag(g, ids(g, [0, 1]))]))) + ) + cache = ngc.DagCache(g) + eb = ngc.CapacityOptions(config(ngc.CostOnly()), cache=cache) + with pytest.raises(ValueError, match="workers == 1"): + ngc.batch_capacity(g, pairs, eb, workers=2) + got = ngc.batch_capacity( + g, pairs, eb + ) # None with a cache: 1 worker, even with the env set + assert [x.placed for x in got] == [7, 7, 5, 7] # EB: [4, 3] over links of 5 and 3 + with pytest.raises(ValueError, match="workers"): + ngc.batch_capacity(g, pairs, ngc.CapacityOptions(GLOBAL), workers=0) + + +def test_sensitivity_candidates_losses_and_unevaluable(): + # Global: an unsaturated critical edge appears (rule 1). + g = build(3, [(0, 1, 9, 1), (1, 2, 4, 1), (0, 2, 2, 5)]) + s = ngc.sensitivity(g, 0, 2, ngc.SensitivityOptions(GLOBAL)) + assert s.baseline.placed == 6 + pos = g.input_index() + assert {int(pos[e.edge]): e.loss for e in s.entries} == {0: 4, 1: 4, 2: 2} + assert all( + e.status == ngc.SensitivityStatus.EVALUATED and e.reason is None + for e in s.entries + ) + # A policy model: signed losses; zero-capacity edges absent. + p = build(2, [(0, 1, 1, 1), (0, 1, 10, 1), (0, 1, 0, 1)]) + opts = ngc.SensitivityOptions(config(ngc.CostOnly())) + s = ngc.sensitivity(p, 0, 1, opts, workers=2) + assert s.baseline.placed == 2 + pos = p.input_index() + assert {int(pos[e.edge]): e.loss for e in s.entries} == {0: -8, 1: 1} + with pytest.raises(ValueError, match="both endpoint sets"): + ngc.sensitivity(p, 0, 0, opts) + + +@needs_hooks +def test_sensitivity_reports_a_model_exception_as_unevaluable(): + # No valid configuration raises under a deletion; the private hook injects one (rule 1). + p = build(2, [(0, 1, 1, 1), (0, 1, 10, 1), (0, 1, 0, 1)]) + opts = ngc.SensitivityOptions(config(ngc.CostOnly())) + ext._sensitivity_fail_edge(ids(p, [1])[0]) + try: + s = ngc.sensitivity(p, 0, 1, opts) + finally: + ext._sensitivity_fail_edge(None) + bad = [e for e in s.entries if e.status == ngc.SensitivityStatus.UNEVALUABLE] + assert len(bad) == 1 and bad[0].loss is None and "injected" in bad[0].reason + + +def _state_of(policy, ledger): + return ( + policy.placed_demand(), + len(ledger.records()), + policy._state()["requested"], + [int(x) for x in ledger.allocated()], + ) + + +@needs_hooks +@pytest.mark.parametrize("routing", ["costonly", "global"]) +@pytest.mark.parametrize("traced", [False, True]) +def test_python_publication_is_all_or_nothing(routing, traced): + # review-1c F2: every Python object of a placement exists before anything is published, so + # the k-th Python allocation failing raises with the ledger and the policy unchanged, never + # crashes, and a retry places once. + cfg = GLOBAL if routing == "global" else config(ngc.CostOnly()) + g = build(2, [(0, 1, 10, 1)]) + ledger = ngc.FlowLedger(g) + want = ngc.FlowPolicy(ngc.FlowLedger(g), 0, 1, cfg).place(3).cost_distribution + reached = 0 + for k in range(400): + ledger = ngc.FlowLedger(g) + policy = ngc.FlowPolicy(ledger, 0, 1, cfg) + fn = policy._place_traced if traced else policy.place + before = _state_of(policy, ledger) + hit, _, value, error = ext._fail_python_allocation(fn, (3,), k) + if not hit: + assert error is None + r = value[0] if traced else value + assert (r.placed, r.cost_distribution) == (3, want) + break + reached += 1 + if ( + error is None + ): # a failure inside Python that the call absorbed (none expected) + continue + assert isinstance(error, (MemoryError, RuntimeError)), repr(error) + assert _state_of(policy, ledger) == before + r = fn(3) + r = r[0] if traced else r + assert r.placed == 3 and policy.placed_demand() == 3 + else: + pytest.fail("the call never completed") + assert reached >= 3 # not vacuous + + +@needs_hooks +def test_evaluator_results_survive_python_allocation_failures(): + # The evaluators publish nothing; their result objects are made through the checked path + # too, so a failed Python allocation raises instead of crashing. + g = diamond(5, 3) + calls = [ + (ngc.capacity, (g, 0, 3, ngc.CapacityOptions(GLOBAL))), + (ngc.batch_capacity, (g, [(0, 3), (1, 3)], ngc.CapacityOptions(GLOBAL))), + (ngc.sensitivity, (g, 0, 3, ngc.SensitivityOptions(GLOBAL))), + ] + for fn, args in calls: + for k in range(400): + hit, _, _, error = ext._fail_python_allocation(fn, args, k) + if not hit: + assert error is None + break + assert error is None or isinstance(error, (MemoryError, RuntimeError)) + else: + pytest.fail(f"{fn.__name__} never completed") + + +def test_batch_reports_the_lowest_failing_pair(): + # review-1c F3: the arguments first, then each pair completely in pair order, across the + # Python/C++ boundary. + g = build(2, [(0, 1, 3, 7)]) + opts = ngc.CapacityOptions(GLOBAL) + with pytest.raises(ValueError, match="node 0 is in both endpoint sets"): + ngc.batch_capacity(g, [(0, 0), (2, 1)], opts, workers=2) + with pytest.raises(ValueError, match="node 2 out of node range"): + ngc.batch_capacity(g, [(0, 1), (2, 1), (0, 0)], opts) + other = build(2, [(0, 1, 3, 7)]).eligibility() + with pytest.raises(ValueError, match=r"per_pair_elig\[0\]"): + ngc.batch_capacity(g, [(0, 1), (2, 1)], opts, per_pair_elig=[other, None]) + with pytest.raises(TypeError, match="per_pair_elig entries"): + ngc.batch_capacity(g, [(0, 1), (2, 1)], opts, per_pair_elig=[3, None]) + with pytest.raises(ValueError, match="node 2 out of node range"): + ngc.batch_capacity(g, [(0, 1), (2, 1)], opts, per_pair_elig=[None, 3]) + # An argument error comes before every pair error. + with pytest.raises(ValueError, match="workers"): + ngc.batch_capacity(g, [(0, 0)], opts, workers=0) + + +def test_batch_checks_an_explicit_empty_eligibility_list(): + # review-1c F4: a given per_pair_elig has one entry per pair, also when it is empty. + g = build(2, [(0, 1, 3, 7)]) + opts = ngc.CapacityOptions(GLOBAL) + with pytest.raises(ValueError, match="per_pair_elig has 0 entries for 1 pairs"): + ngc.batch_capacity(g, [(0, 1)], opts, per_pair_elig=[]) + assert ngc.batch_capacity(g, [], opts, per_pair_elig=[]) == [] + assert [ + r.placed for r in ngc.batch_capacity(g, [(0, 1)], opts, per_pair_elig=[None]) + ] == [3] + + +def test_global_zero_call_reports_the_cumulative_curve(): + # review-1c F5: 9.1's curve is cumulative (its amounts sum to placed_demand()); a zero call + # reports it ([] before the first placement and after remove()), changes nothing and claims + # no cut. + g = build(2, [(0, 1, 3, 7)]) + ledger = ngc.FlowLedger(g) + p = ngc.FlowPolicy(ledger, 0, 1, GLOBAL) + assert p.place(0).cost_distribution == [] + assert p.place(2).cost_distribution == [(7, 2)] + z = p.place(0, ngc.OutputOptions(edge_flows=True, residual=True, bottleneck=True)) + assert (z.placed, z.cost_distribution, z.min_cut) == (0, [(7, 2)], None) + assert p.placed_demand() == 2 and [int(x) for x in ledger.allocated()] == [2] + p.remove() + assert p.place(0).cost_distribution == [] diff --git a/tests/py/test_flow_policy_fixtures.py b/tests/py/test_flow_policy_fixtures.py new file mode 100644 index 0000000..e971418 --- /dev/null +++ b/tests/py/test_flow_policy_fixtures.py @@ -0,0 +1,342 @@ +"""Prototype 2's 59 fixtures replayed through the bindings (APPROACH 8, 9). + +``tests/data/policy_fixtures.json`` was exported from the reference, prototype 2 of the design +tree (its ``source`` names it; the design tree is untracked by design; its maintained exporter +is ``equivalence/export_policy_fixtures.py`` there): every ledger, policy +and call of every fixture in order, with the prototype's values after each operation. The +replay asserts every one of them on the build: each call's result (placed, remaining, +termination, local outcomes, flags, drops, the cost curve and the requested outputs), its +trace (prototype 2's keys: offers, created, per source the creation reservations, reroute +decisions, amounts and growth steps; Global's view, cap, flow-core payload and shares), the +ledger after it (allocations, residuals, every record field, next FlowId, owners) and the +policy state (roster, requested, +inf, placed demand, usable bundles, per source the flows' +ids, routes, T, deliveries, allocations, members and weights). An operation the prototype +rejects must raise here too (the messages differ). Normalised on both sides: the T_max probe +counts, the flow core's operation counters and ledger uids. The replay reads the trace and sets +the creation cap through the private hooks, so it is skipped without them. +""" + +from __future__ import annotations + +import gc +import json +import math +from pathlib import Path + +import _netgraph_core as ext +import pytest +from private_hooks import needs_hooks + +import netgraph_core as ngc + +DATA = Path(__file__).resolve().parents[1] / "data" / "policy_fixtures.json" +FIXTURES = json.loads(DATA.read_text())["fixtures"] + +PLACEMENT = { + "MaxFlow": ngc.FlowPlacement.MAX_FLOW, + "Weighted": ngc.FlowPlacement.WEIGHTED, + "EqualBalanced": ngc.FlowPlacement.EQUAL_BALANCED, +} +PLACEMENT_NAME = {v: k for k, v in PLACEMENT.items()} +ADMISSION = {"Lossless": ngc.Admission.LOSSLESS, "Lossy": ngc.Admission.LOSSY} +ADMISSION_NAME = {v: k for k, v in ADMISSION.items()} +TIE = { + "Deterministic": ngc.EdgeTieBreak.DETERMINISTIC, + "PreferHigherPriority": ngc.EdgeTieBreak.PREFER_HIGHER_PRIORITY, +} +PRIORITY = { + "Residual": ngc.EdgePriority.RESIDUAL, + "Capacity": ngc.EdgePriority.CAPACITY, + "Headroom": ngc.EdgePriority.HEADROOM, +} + + +def norm(x): + """String map keys; probe counts, counters and ledger uids dropped; FlowIds as ids.""" + if isinstance(x, dict): + out = {} + for k, v in x.items(): + if k in ("evals", "ledger_uid", "counters", "evaluations"): + continue + if ( + k in ("flow_id", "flow") + and isinstance(v, (list, tuple)) + and len(v) == 2 + ): + out[str(k)] = v[1] + continue + out[str(k)] = norm(v) + return out + if isinstance(x, (list, tuple)): + return [norm(v) for v in x] + return x + + +def build_graph(spec): + edges = spec["edges"] + g = ngc.Graph( + spec["n"], + [e[0] for e in edges], + [e[1] for e in edges], + [e[2] for e in edges], + [e[3] for e in edges], + spec["q"], + ) + assert list(g.input_index()) == spec["input_index"] + return g + + +def dag_of(g, edges): + per = [[] for _ in range(g.num_nodes())] + for e in sorted(edges): + per[int(g.edge_dst()[e])].append(e) + offsets, parents, via = [0], [], [] + for v in range(g.num_nodes()): + for e in per[v]: + parents.append(int(g.edge_src()[e])) + via.append(e) + offsets.append(len(via)) + return ngc.PredDAG.from_csr(g, offsets, parents, via) + + +def config_of(g, c): + r = c["routing"] + kind = r["kind"] + if kind == "CostOnly": + routing = ngc.CostOnly() + elif kind == "Global": + routing = ngc.Global() + elif kind == "Pinned": + routing = ngc.Pinned([dag_of(g, b) for b in r["bundles"]]) + else: + ceiling = None + if r["max_path_cost"] is not None: + name, value = r["max_path_cost"] + ceiling = ngc.Absolute(value) if name == "Absolute" else ngc.Stretch(value) + dim = ( + None + if r["diminishing"] is None + else ngc.DiminishingReturns(*r["diminishing"]) + ) + routing = ngc.CapacityAware( + flow_count=r["flow_count"], + max_flow_count=r["max_flow_count"], + max_path_cost=ceiling, + min_flow_bandwidth=r["min_flow_bandwidth"], + reoptimize=r["reoptimize"], + diminishing=dim, + ) + sel = c["selection"] + return ngc.FlowPolicyConfig( + routing=routing, + placement=PLACEMENT[c["placement"]], + admission=ADMISSION[c["admission"]], + selection=ngc.PathSelection( + TIE[sel["tie_break"]], + PRIORITY[sel["priority"]], + sel["multipath"], + sel["multi_edge"], + ), + ) + + +def result_json(r): + out = { + "placed": r.placed, + "remaining": "inf" if r.remaining == math.inf else r.remaining, + "termination": r.termination.name.lower(), + "routes_changed": r.routes_changed, + "weights_changed": r.weights_changed, + "drops": [[e, x] for e, x in r.drops], + "local_outcomes": [[s, o.name.lower()] for s, o in r.local_outcomes], + } + if r.cost_distribution is not None: + out["cost_distribution"] = [[c, u] for c, u in r.cost_distribution] + for name in ("edge_flows", "residual", "bottleneck", "min_cut"): + value = getattr(r, name) + if value is not None: + out[name] = [int(x) for x in value] + return out + + +def ledger_json(fg): + st = fg._state() + return { + "allocated": [int(x) for x in fg.allocated()], + "residual": [int(x) for x in fg.residual()], + "records": [ + { + "flow_id": r.flow_id[1], + "owner": r.owner, + "sources": list(r.sources), + "destinations": list(r.destinations), + "placement": PLACEMENT_NAME[r.placement], + "admission": ADMISSION_NAME[r.admission], + "bundle": sorted(int(e) for e in r.bundle.via_edges()), + "route_cost": r.route_cost, + "offered": r.offered, + "delivered": r.delivered, + "edges": [[e, x] for e, x in r.edges], + "drops": [[e, x] for e, x in r.drops], + } + for r in fg.records() + ], + "next_flow_id": st["next_flow_id"], + "bound": list(st["bound"]), + "orphans": list(st["orphans"]), + } + + +def policy_json(pol): + st = pol._state() + lossy = pol.config.admission == ngc.Admission.LOSSY + cohorts = [] + for s, flows in sorted(st["cohorts"].items()): + if not flows: + continue + out = [] + for f in flows: + members = list(f["route"] if f["members"] is None else f["members"]) + weights = None + if f["weights"] is not None: + weights = [[e, w] for e, w in zip(members, f["weights"], strict=True)] + out.append( + { + "id": f["flow_id"][1], + "route": list(f["route"]), + "T": None if lossy else f["T"], + "delivered": f["delivered"], + "alloc": [[e, x] for e, x in f["alloc"]], + "members": members, + "weights": weights, + } + ) + cohorts.append([s, out]) + return { + "roster": list(st["roster"]), + "requested": st["requested"], + "infinite": st["infinite"], + "placed_demand": st["placed_demand"], + "usable": pol.usable_bundles(), + "cohorts": cohorts, + } + + +def replay(fx): + graphs = [build_graph(spec) for spec in fx["graphs"]] + ledgers: dict[int, object] = {} + policies: dict[int, object] = {} + alive = [] + curves: dict[ + int, list + ] = {} # per policy: the cumulative Global curve of the epoch (F5) + for i, op in enumerate(fx["ops"]): + where = f"{fx['name']} op {i} ({op['op']})" + kind = op["op"] + expect = op.get("expect", {}) + if kind == "ledger": + g = graphs[op["graph"]] + if op["error"]: + with pytest.raises((ValueError, TypeError)): + ngc.FlowLedger(g, op["baseline"]) + continue + ledgers[op["id"]] = ngc.FlowLedger(g, op["baseline"]) + continue + if kind == "copy": + src = ledgers[op["ledger"]] + if op["error"]: + with pytest.raises((ValueError, TypeError)): + src.copy() + assert ledger_json(src) == expect["ledger"], where + continue + ledgers[op["id"]] = src.copy() + assert ledger_json(ledgers[op["id"]]) == expect["ledger"], where + continue + if kind == "reset": + fg = ledgers[op["ledger"]] + if op["error"]: + with pytest.raises((ValueError, TypeError)): + fg.reset(op["baseline"]) + else: + fg.reset(op["baseline"]) + assert ledger_json(fg) == expect["ledger"], where + continue + if kind == "policy": + fg = ledgers[op["ledger"]] + g = fg.graph() + + def construct(op=op, fg=fg, g=g): + elig = None + if op["elig"] is not None: + elig = g.eligibility( + excluded_nodes=op["elig"]["nodes"], + excluded_edges=op["elig"]["edges"], + ) + cfg = config_of(g, op["config"]) + return ngc.FlowPolicy(fg, op["sources"], op["destinations"], cfg, elig) + + if op["error"]: + with pytest.raises((ValueError, TypeError)): + construct() + continue + pol = construct() + alive.append(pol) + policies[op["id"]] = (pol, fg) + curves[op["id"]] = [] + assert policy_json(pol) == expect["policy"], where + continue + pol, fg = policies[op["policy"]] + if kind == "remove": + pol.remove() + curves[op["policy"]] = [] + assert ledger_json(fg) == expect["ledger"], where + assert policy_json(pol) == expect["policy"], where + continue + if kind == "destroy": + # The destruction law: dropping the last reference orphans the records. + alive[:] = [x for x in alive if x is not pol] + policies[op["policy"]] = None + del pol + gc.collect() + assert ledger_json(fg) == expect["ledger"], where + continue + assert kind == "place", where + volume = math.inf if op["volume"] == "inf" else op["volume"] + outputs = None + if op["outputs"] is not None: + e, r, b = op["outputs"] + outputs = ngc.OutputOptions(edge_flows=e, residual=r, bottleneck=b) + old = ext._max_flows_per_call(op["cap"]) + try: + if op["error"]: + with pytest.raises((ValueError, TypeError)): + pol.place(volume, outputs) + assert ledger_json(fg) == expect["ledger"], where + continue + r, trace = pol._place_traced(volume, outputs) + finally: + ext._max_flows_per_call(old) + got = result_json(r) + if isinstance(pol.config.routing, ngc.Global): + # Contract over prototype (1c NOTES P3, review-1c F5): a Global zero call reports + # the cumulative curve (9.1), which prototype 2 omits; it must equal the curve of the + # epoch's last positive call ([] before any), and only then is it set aside. + if volume == 0 and "cost_distribution" not in expect["result"]: + assert got.pop("cost_distribution") == curves[op["policy"]], where + if "cost_distribution" in expect["result"]: + curves[op["policy"]] = expect["result"]["cost_distribution"] + assert got == expect["result"], where + assert norm(trace) == expect["trace"], where + assert ledger_json(fg) == expect["ledger"], where + assert policy_json(pol) == expect["policy"], where + + +def test_the_data_holds_every_fixture(): + assert len(FIXTURES) == 59 + assert len({fx["name"] for fx in FIXTURES}) == 59 + + +@needs_hooks +@pytest.mark.parametrize("fx", FIXTURES, ids=[fx["name"] for fx in FIXTURES]) +def test_fixture(fx): + replay(fx) diff --git a/tests/py/test_graph.py b/tests/py/test_graph.py index 79a5c08..19826b9 100644 --- a/tests/py/test_graph.py +++ b/tests/py/test_graph.py @@ -1,4 +1,4 @@ -"""StrictMultiDiGraph on integer units (APPROACH 3): construction, validation order, B' ids, +"""Graph on integer units (APPROACH 3): construction, validation order, B' ids, read-only views, Eligibility.""" from __future__ import annotations @@ -25,7 +25,7 @@ def build(n, edges, q=1.0): - return ngc.StrictMultiDiGraph( + return ngc.Graph( n, [e[0] for e in edges], [e[1] for e in edges], @@ -96,7 +96,7 @@ def test_views_are_read_only_and_keep_the_graph_alive(): def test_numpy_integer_inputs_and_exact_large_units(): big = (1 << 53) + 1 - g = ngc.StrictMultiDiGraph( + g = ngc.Graph( np.int64(2), np.array([0, 0], dtype=np.int32), np.array([1, 1], dtype=np.uint8), @@ -109,10 +109,10 @@ def test_numpy_integer_inputs_and_exact_large_units(): def test_empty_and_isolated(): - g = ngc.StrictMultiDiGraph(0, [], [], [], [], 1) + g = ngc.Graph(0, [], [], [], [], 1) assert g.row_offsets().tolist() == [0] and g.in_row_offsets().tolist() == [0] assert g.max_cost() == 0 and g.cost_gcd() == 0 - h = ngc.StrictMultiDiGraph(3, [], [], [], [], 1) + h = ngc.Graph(3, [], [], [], [], 1) assert h.row_offsets().tolist() == [0, 0, 0, 0] @@ -152,26 +152,26 @@ def test_uids_are_distinct(): ) def test_validation_errors(args, pattern): with pytest.raises(ValueError, match=pattern): - ngc.StrictMultiDiGraph(*args) + ngc.Graph(*args) def test_validation_order(): # The unit values before the shapes, the shapes before the costs, the costs before the # remaining bounds (endpoints and sums). with pytest.raises(ValueError, match="capacity_units"): - ngc.StrictMultiDiGraph(2, [0, 1], [1], [-1], [1], 1) + ngc.Graph(2, [0, 1], [1], [-1], [1], 1) with pytest.raises(ValueError, match="same length"): - ngc.StrictMultiDiGraph(2, [0, 1], [1], [1], [0], 1) + ngc.Graph(2, [0, 1], [1], [1], [0], 1) with pytest.raises(ValueError, match="cost"): - ngc.StrictMultiDiGraph(2, [0], [9], [1], [0], 1) + ngc.Graph(2, [0], [9], [1], [0], 1) with pytest.raises(ValueError, match="cost"): - ngc.StrictMultiDiGraph(2, [0, 0], [1, 1], [L62 // 2, L62 // 2], [0, 1], 1) + ngc.Graph(2, [0, 0], [1, 1], [L62 // 2, L62 // 2], [0, 1], 1) with pytest.raises(ValueError, match="node range"): - ngc.StrictMultiDiGraph(2, [0, 5], [1, 1], [L62 // 2, L62 // 2], [1, 1], 1) + ngc.Graph(2, [0, 5], [1, 1], [L62 // 2, L62 // 2], [1, 1], 1) def test_bounds_just_below_are_accepted(): - g = ngc.StrictMultiDiGraph( + g = ngc.Graph( 2, [0, 0], [1, 1], [L62 // 2, L62 // 2 - 1], [L62 // 2, L62 // 2 - 1], 1 ) assert sum(int(x) for x in g.capacity_units()) == L62 - 1 @@ -251,7 +251,7 @@ def test_eligibility(): ) def test_validation_order_before_narrowing(args, pattern): with pytest.raises(ValueError, match=pattern): - ngc.StrictMultiDiGraph(*args) + ngc.Graph(*args) def test_validation_independent_of_representation(): @@ -268,15 +268,13 @@ def test_validation_independent_of_representation(): messages = [] for args in (as_lists, as_arrays): with pytest.raises(ValueError) as info: - ngc.StrictMultiDiGraph(*args) + ngc.Graph(*args) messages.append(str(info.value)) assert ( messages[0] == messages[1] == "cost[0] = 0: costs must be positive (cost >= 1)" ) with pytest.raises(ValueError, match=r"endpoint \(9223372036854775808, 1\)"): - ngc.StrictMultiDiGraph( - 2, np.array([2**63], dtype=np.uint64), [1], [1], [1], 1.0 - ) + ngc.Graph(2, np.array([2**63], dtype=np.uint64), [1], [1], [1], 1.0) def test_eligibility_ids_checked_before_narrowing(): @@ -309,7 +307,7 @@ def test_array_rank_is_a_shape_defect_before_costs(name, ndim, cost): args[ARG_INDEX[name]] = bad.reshape(1, 1) if ndim == 2 else bad pattern = rf"^{name} must be a 1-D array \(got {ndim} dimensions\)$" with pytest.raises(ValueError, match=pattern): - ngc.StrictMultiDiGraph(*args) + ngc.Graph(*args) @pytest.mark.parametrize("name", list(ARG_INDEX)) @@ -320,39 +318,37 @@ def test_nested_and_scalar_arguments_are_shape_defects(name): with pytest.raises( ValueError, match=rf"^{name} must be a 1-D sequence of integers \(entry 0" ): - ngc.StrictMultiDiGraph(*nested) + ngc.Graph(*nested) scalar = [2, [0], [1], [1], [0], 1.0] scalar[ARG_INDEX[name]] = value with pytest.raises( ValueError, match=rf"^{name} must be a 1-D sequence of integers \(got int\)$" ): - ngc.StrictMultiDiGraph(*scalar) + ngc.Graph(*scalar) def test_shape_step_keeps_its_place_in_the_order(): # The resolution and the unit values still come before the shapes, whatever the shape. with pytest.raises(ValueError, match="resolution"): - ngc.StrictMultiDiGraph(2, np.array([[0]]), [1], [1], [0], 3.0) + ngc.Graph(2, np.array([[0]]), [1], [1], [0], 3.0) with pytest.raises( ValueError, match=r"capacity_units\[0\] = 4611686018427387904 is outside" ): - ngc.StrictMultiDiGraph( - 2, [0], [1], np.array([[2**62]], dtype=np.int64), [0], 1.0 - ) + ngc.Graph(2, [0], [1], np.array([[2**62]], dtype=np.int64), [0], 1.0) with pytest.raises(ValueError, match=r"capacity_units\[0\] = -1 is outside"): - ngc.StrictMultiDiGraph(2, [0], [1], np.array(-1, dtype=np.int64), [0], 1.0) + ngc.Graph(2, [0], [1], np.array(-1, dtype=np.int64), [0], 1.0) with pytest.raises(ValueError, match=r"capacity_units\[1\] = -5 is outside"): - ngc.StrictMultiDiGraph(2, [0], [1], [[1], -5], [0], 1.0) + ngc.Graph(2, [0], [1], [[1], -5], [0], 1.0) with pytest.raises(ValueError, match=r"capacity_units = -5 is outside"): - ngc.StrictMultiDiGraph(2, [0], [1], -5, [0], 1.0) + ngc.Graph(2, [0], [1], -5, [0], 1.0) # With valid values the misshapen capacity is a shape defect, before the costs. with pytest.raises(ValueError, match="capacity_units must be a 1-D array"): - ngc.StrictMultiDiGraph(2, [0], [1], np.array([[1]]), [0], 1.0) + ngc.Graph(2, [0], [1], np.array([[1]]), [0], 1.0) # Rank before lengths. with pytest.raises(ValueError, match="dst must be a 1-D array"): - ngc.StrictMultiDiGraph(2, [0, 1], np.array([[1, 0]]), [1, 1], [1, 1], 1.0) + ngc.Graph(2, [0, 1], np.array([[1, 0]]), [1, 1], [1, 1], 1.0) # A valid graph from 1-D arrays of every integer kind still builds. - g = ngc.StrictMultiDiGraph( + g = ngc.Graph( 2, np.array([0]), np.array([1], dtype=np.uint8), np.array([7]), (3,), 1.0 ) assert g.capacity_units().tolist() == [7] diff --git a/tests/py/test_imports.py b/tests/py/test_imports.py deleted file mode 100644 index f60c017..0000000 --- a/tests/py/test_imports.py +++ /dev/null @@ -1,46 +0,0 @@ -"""The public surface of the 1b build: the graph on units, Eligibility, the conversions, the -route structures, the selection and placement axes and the ledger.""" - -import _netgraph_core - -import netgraph_core as ngc - -PUBLIC_1B = { - "StrictMultiDiGraph", - "Eligibility", - "to_units", - "as_float", - "PredDAG", - "Path", - "EdgeTieBreak", - "EdgePriority", - "PathSelection", - "FlowPlacement", - "Admission", - "FlowGraph", - "FlowRecord", -} - -# Private recorder entries: the flow core (1a), the single-bundle Pinned transaction, the -# internal search and the apportionment (1b). Never re-exported by netgraph_core. -PRIVATE_1B = { - "_place_global_units", - "_PinnedFlow", - "_spf", - "_round_robin", - "_highest_averages", -} - - -def test_public_surface(): - assert set(ngc.__all__) == PUBLIC_1B | {"__version__"} - for name in ngc.__all__: - assert hasattr(ngc, name) - assert isinstance(ngc.__version__, str) - - -def test_extension_surface_is_exactly_the_1b_set(): - names = {n for n in dir(_netgraph_core) if not n.startswith("__")} - assert names == PUBLIC_1B | PRIVATE_1B - for name in PRIVATE_1B: - assert not hasattr(ngc, name) diff --git a/tests/py/test_internal_search.py b/tests/py/test_internal_search.py index ded1a67..6685977 100644 --- a/tests/py/test_internal_search.py +++ b/tests/py/test_internal_search.py @@ -1,10 +1,11 @@ -"""The private entries of sub-PR 1b: the internal search (``_spf``, APPROACH 6) and the -apportionment (``_round_robin``, ``_highest_averages``, APPROACH 2.1).""" +"""The internal search through its private entry (``_spf``, APPROACH 6) and the apportionment +through the private hooks (``_round_robin``, ``_highest_averages``, APPROACH 2.1).""" from __future__ import annotations import _netgraph_core as ext import pytest +from private_hooks import needs_hooks import netgraph_core as ngc @@ -14,7 +15,7 @@ def build(n, edges, q=1.0): - return ngc.StrictMultiDiGraph( + return ngc.Graph( n, [e[0] for e in edges], [e[1] for e in edges], @@ -83,6 +84,7 @@ def test_search_validation(): ) +@needs_hooks def test_apportionment_entries(): assert ext._round_robin(7, 3) == [3, 2, 2] assert ext._highest_averages(10, [3, 9, 9]) == [1, 5, 4] diff --git a/tests/py/test_public_surface.py b/tests/py/test_public_surface.py new file mode 100644 index 0000000..638b980 --- /dev/null +++ b/tests/py/test_public_surface.py @@ -0,0 +1,140 @@ +"""The public surface: the graph on units, Eligibility, the conversions, the route structures, +the selection and placement axes, the ledger, the policy engine, the DagCache and the +evaluators; the extension's private entries, which netgraph_core never re-exports; and the +spellings the interface grooming retired (APPROACH 12), which exist nowhere, without aliases.""" + +import _netgraph_core +import pytest +from private_hooks import HOOKS + +import netgraph_core as ngc + +PUBLIC = { + "Graph", + "Eligibility", + "to_units", + "as_float", + "PredDAG", + "Path", + "EdgeTieBreak", + "EdgePriority", + "PathSelection", + "FlowPlacement", + "Admission", + "FlowLedger", + "FlowRecord", + "Absolute", + "Stretch", + "DiminishingReturns", + "CostOnly", + "CapacityAware", + "Global", + "Pinned", + "FlowPolicyConfig", + "OutputOptions", + "Termination", + "PlacementResult", + "FlowPolicy", + "MAX_FLOWS_PER_CALL", + "DagCache", + "DagCacheStats", + "CapacityOptions", + "SensitivityOptions", + "SensitivityStatus", + "SensitivityEntry", + "SensitivityResult", + "capacity", + "batch_capacity", + "sensitivity", +} + +# Shipped private entries: the flow core's recorder entry (APPROACH 2.2) and the internal search. +PRIVATE = {"_place_global_units", "_spf"} + +# The private hooks (NETGRAPH_CORE_PRIVATE_HOOKS: the development build only): module functions +# and class members. +HOOK_FUNCTIONS = { + "_max_flows_per_call", + "_sensitivity_fail_edge", + "_fail_python_allocation", + "_round_robin", + "_highest_averages", +} +HOOK_MEMBERS = { + "FlowLedger": {"_state"}, + "FlowPolicy": {"_place_traced", "_state", "_owner"}, +} + + +def test_public_surface(): + assert set(ngc.__all__) == PUBLIC | {"__version__"} + for name in ngc.__all__: + assert hasattr(ngc, name) + assert isinstance(ngc.__version__, str) + assert ngc.MAX_FLOWS_PER_CALL == 10000 + + +def test_extension_surface_is_exactly_the_public_and_private_sets(): + names = {n for n in dir(_netgraph_core) if not n.startswith("__")} + assert names == PUBLIC | PRIVATE | (HOOK_FUNCTIONS if HOOKS else set()) + for name in PRIVATE | HOOK_FUNCTIONS: + assert not hasattr(ngc, name) + # Every private class member is a hook (beside pybind11's own conduit); the hooks are all + # present or all absent. + for cls in PUBLIC: + obj = getattr(_netgraph_core, cls) + private = { + n + for n in dir(obj) + if n.startswith("_") + and not n.startswith("__") + and n != "_pybind11_conduit_v1_" + } + want = HOOK_MEMBERS.get(cls, set()) if HOOKS else set() + assert private == want, cls + + +# The 1a to 1c spellings renamed before 1d (APPROACH 12) and the old Python keyword: no alias. +RETIRED = { + "StrictMultiDiGraph", + "FlowGraph", + "Outputs", + "Diminishing", + "kMaxFlowsPerCall", +} + + +def test_retired_spellings_are_absent(): + for name in RETIRED: + assert not hasattr(_netgraph_core, name), name + assert not hasattr(ngc, name), name + assert ngc.Graph.__name__ == "Graph" + assert ngc.FlowLedger.__name__ == "FlowLedger" + assert ngc.OutputOptions.__name__ == "OutputOptions" + assert ngc.DiminishingReturns.__name__ == "DiminishingReturns" + + +def _config(): + return ngc.FlowPolicyConfig( + routing=ngc.CostOnly(), + placement=ngc.FlowPlacement.EQUAL_BALANCED, + admission=ngc.Admission.LOSSLESS, + selection=ngc.PathSelection( + ngc.EdgeTieBreak.DETERMINISTIC, ngc.EdgePriority.RESIDUAL + ), + ) + + +def test_policy_takes_its_ledger_by_the_ledger_keyword_only(): + g = ngc.Graph(2, [0], [1], [3], [1], 1.0) + ledger = ngc.FlowLedger(g) + p = ngc.FlowPolicy(ledger=ledger, sources=0, destinations=1, config=_config()) + assert p.place(2).placed == 2 + assert ledger.records()[0].delivered == 2 + # Positional order unchanged: the ledger first. + assert ngc.FlowPolicy(ledger, 0, 1, _config()).place(1).placed == 1 + with pytest.raises(TypeError): + ngc.FlowPolicy(flow_graph=ledger, sources=0, destinations=1, config=_config()) + with pytest.raises(ValueError, match="^ledger must be a FlowLedger$"): + ngc.FlowPolicy(None, 0, 1, _config()) + assert "ledger: _netgraph_core.FlowLedger" in ngc.FlowPolicy.__init__.__doc__ diff --git a/tests/py/test_route_structures.py b/tests/py/test_route_structures.py index 81530e3..5736486 100644 --- a/tests/py/test_route_structures.py +++ b/tests/py/test_route_structures.py @@ -15,7 +15,7 @@ def build(n, edges, q=1.0): - return ngc.StrictMultiDiGraph( + return ngc.Graph( n, [e[0] for e in edges], [e[1] for e in edges], @@ -114,7 +114,6 @@ def test_path_cost_hops_and_to_dag(): p = ngc.Path(g, [[1, 0], [3]]) assert (p.src(), p.dst(), p.cost()) == (0, 2, 2) assert p.hops() == [[1, 0], [3]] # the caller's member order is kept - assert p.nodes() == [0, 1, 2] dag = p.to_dag() assert list(dag.parent_offsets()) == [0, 0, 2, 3] assert list(dag.via_edges()) == [0, 1, 3] From a48ca9d7dd865794d86ec49d8717fc1ddf517230 Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Tue, 6 Oct 2026 14:59:38 -0700 Subject: [PATCH 06/12] Two small speed-ups, results unchanged - Weighted quota division: a 64-bit division when the dividend fits (kernel.weighted.clos 6 to 8 percent faster). - Graph constructor: stop computing the GCD once it reaches 1 (construct.clos about 4 percent faster). Both measured A/B/A against the 1c build; every result byte-identical; a test for each. Co-Authored-By: Claude Fable 5.1 --- include/netgraph/core/detail/int128.hpp | 3 ++ src/graph.cpp | 3 +- tests/cpp/graph_tests.cpp | 27 +++++++++++ tests/cpp/kernels_tests.cpp | 61 +++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/include/netgraph/core/detail/int128.hpp b/include/netgraph/core/detail/int128.hpp index 9e92826..a487ad1 100644 --- a/include/netgraph/core/detail/int128.hpp +++ b/include/netgraph/core/detail/int128.hpp @@ -39,6 +39,9 @@ inline U128 mul_u64(std::uint64_t a, std::uint64_t b) noexcept { // floor(n / d) for d > 0 and a quotient below 2^64 (n.hi < d). inline std::uint64_t div_u128(U128 n, std::uint64_t d) noexcept { + // A dividend below 2^64 (the common lower quota) takes the 64-bit division: the same quotient + // without the 128-bit division helper. + if (n.hi == 0) return n.lo / d; const unsigned __int128 x = (static_cast(n.hi) << 64) | n.lo; return static_cast(x / d); } diff --git a/src/graph.cpp b/src/graph.cpp index 3cc2595..8ba5bee 100644 --- a/src/graph.cpp +++ b/src/graph.cpp @@ -126,7 +126,8 @@ Graph::Graph(std::int32_t num_nodes, ": costs must be positive (cost >= 1)"); } max_cost = std::max(max_cost, cost[i]); - cost_gcd = std::gcd(cost_gcd, cost[i]); + // A running GCD of 1 stays 1 (gcd(1, c) = 1): the remaining edges are only checked. + if (cost_gcd != 1) cost_gcd = std::gcd(cost_gcd, cost[i]); } // The remaining bounds. for (std::size_t i = 0; i < m; ++i) { diff --git a/tests/cpp/graph_tests.cpp b/tests/cpp/graph_tests.cpp index ee30dc0..3bc9712 100644 --- a/tests/cpp/graph_tests.cpp +++ b/tests/cpp/graph_tests.cpp @@ -134,6 +134,33 @@ TEST(Graph, ShapesOfEmptyAndIsolated) { EXPECT_EQ(g.cost_gcd(), 2); } +// The cost loop after the running GCD reaches 1: every later edge is still checked for a +// positive cost (in input order: the first bad index is named) and still counts for the +// maximum; the sums are still validated; the GCD stays 1, and 0 without edges. +TEST(Graph, CostChecksAndMaximumAfterTheGcdReachesOne) { + // The GCD is 1 from the second edge on; the maximum comes later. + auto g = build(2, {0, 0, 0, 0}, {1, 1, 1, 1}, {1, 1, 1, 1}, {3, 2, 9, 4}); + EXPECT_EQ(g.cost_gcd(), 1); + EXPECT_EQ(g.max_cost(), 9); + auto late = build(2, {0, 0, 0}, {1, 1, 1}, {1, 1, 1}, {4, 6, 9}); + EXPECT_EQ(late.cost_gcd(), 1); // 1 only at the last edge + EXPECT_EQ(late.max_cost(), 9); + auto ones = build(2, {0, 0, 0}, {1, 1, 1}, {1, 1, 1}, {1, 1, L62 / 4}); + EXPECT_EQ(ones.cost_gcd(), 1); + EXPECT_EQ(ones.max_cost(), L62 / 4); + EXPECT_EQ(build(2, {0}, {1}, {1}, {6}).cost_gcd(), 6); + EXPECT_EQ(build(2, {0, 0}, {1, 1}, {1, 1}, {6, 4}).cost_gcd(), 2); + EXPECT_EQ(build(0, {}, {}, {}, {}).cost_gcd(), 0); + // A non-positive cost after the GCD reached 1 is still found, at its own index. + const auto zero = error_of([] { (void)build(2, {0, 0, 0}, {1, 1, 1}, {1, 1, 1}, {2, 3, 0}); }); + EXPECT_TRUE(contains(zero, "cost[2] = 0")) << zero; + const auto negative = error_of([] { (void)build(2, {0, 0, 0, 0}, {1, 1, 1, 1}, {1, 1, 1, 1}, {1, 5, -7, 0}); }); + EXPECT_TRUE(contains(negative, "cost[2] = -7")) << negative; + // The cost sum after the GCD reached 1. + EXPECT_TRUE(contains(error_of([] { (void)build(2, {0, 0, 0}, {1, 1, 1}, {1, 1, 1}, {1, L62 / 2, L62 / 2}); }), + "sum of all edge costs")); +} + TEST(Graph, ExactUnitsAboveTwo53) { const Amount big = (Amount{1} << 53) + 1; auto g = make_graph(2, {{0, 1, big, 1}}); diff --git a/tests/cpp/kernels_tests.cpp b/tests/cpp/kernels_tests.cpp index 81c68c2..f8124cc 100644 --- a/tests/cpp/kernels_tests.cpp +++ b/tests/cpp/kernels_tests.cpp @@ -8,12 +8,15 @@ #include #include +#include +#include #include #include #include #include #include +#include "netgraph/core/detail/int128.hpp" #include "netgraph/core/detail/kernels.hpp" #include "netgraph/core/detail/pred_dag_builder.hpp" #include "test_utils.hpp" @@ -214,6 +217,64 @@ TEST(Apportion, HighestAveragesNearTheDomainBound) { } } +// The lower-quota division floor(n / d) on both sides of 2^64: dividends with a zero high word +// (the 64-bit path) and with a nonzero one (the 128-bit path), divisors near 1, near the units +// domain bound 2^62 (the call sites divide by a weight sum below it) and near 2^64, against the +// unsigned __int128 quotient and the defining bounds q * d <= n < (q + 1) * d. +TEST(Apportion, QuotaDivisionOnBothSidesOfTheSixtyFourBitBoundary) { + using u64 = std::uint64_t; + using u128 = unsigned __int128; + constexpr u64 kMax = std::numeric_limits::max(); + const auto domain = static_cast(kDomainLimit); + int narrow = 0, wide = 0; + auto check = [&](u64 hi, u64 lo, u64 d) { + ASSERT_GT(d, 0u); + ASSERT_LT(hi, d); // the quotient fits 64 bits + const u128 n = (static_cast(hi) << 64) | lo; + const u64 q = div_u128(U128 {hi, lo}, d); + ASSERT_EQ(q, static_cast(n / d)) << hi << ":" << lo << " / " << d; + const u128 qd = static_cast(q) * d; + ASSERT_TRUE(qd <= n && n - qd < d) << hi << ":" << lo << " / " << d; + ++(hi == 0 ? narrow : wide); + }; + const std::vector divisors = {1, 2, 3, 7, domain - 2, domain - 1, + domain, domain + 1, kMax / 2, kMax - 1, kMax}; + const std::vector lows = {0, 1, 2, 3, domain - 1, domain, u64 {1} << 63, kMax - 1, kMax}; + for (u64 d : divisors) { + for (u64 lo : lows) { + check(0, lo, d); + for (u64 hi : {u64 {1}, u64 {2}, d / 2, d - 2, d - 1}) { + if (hi > 0 && hi < d) check(hi, lo, d); + } + } + // The dividends next to a multiple of d on both sides of 2^64. + for (u64 q : {u64 {1}, u64 {2}, kMax / d, kMax - 1, kMax}) { + const u128 at = static_cast(q) * d; + for (u128 n : {at - 1, at, at + 1}) { + if (n >> 64 < d) check(static_cast(n >> 64), static_cast(n), d); + } + } + } + // The call-site shape: total * w / W with total and the weights below 2^62, W the weight sum. + for (u64 total : {u64 {1}, u64 {50}, domain - 2, domain - 1}) { + for (u64 w : {u64 {1}, u64 {3}, domain / 3, domain - 1}) { + for (u64 sum : {w, w + 1, domain - 1}) { + if (sum < w) continue; + const U128 n = mul_u64(total, w); + check(n.hi, n.lo, sum); + } + } + } + std::mt19937_64 rng(20261006); + for (int i = 0; i < 20000; ++i) { + const u64 d = i % 3 == 0 ? 1 + rng() % 4096 : (i % 3 == 1 ? 1 + rng() % (domain - 1) : 1 + rng() % kMax); + const u64 hi = i % 2 == 0 ? 0 : rng() % d; + check(hi, rng(), d); + } + EXPECT_GT(narrow, 10000); + EXPECT_GT(wide, 10000); +} + TEST(Apportion, HamiltonHoleControl) { // Weights [1, 3, 3] on members with view [0, 3, 3]: highest averages is feasible on 0..4 // (T_max = 4 exactly); Hamilton's rule has the hole at 3 ({0, 1, 2, 4}). From 3e1f3ea6eebb79aeccf9e259883d6e9b943f1602 Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Thu, 8 Oct 2026 08:17:30 -0700 Subject: [PATCH 07/12] Phase 1d: the public search, KSP and the frozen DagCache One traversal core now runs every search. The public spf searches from a set of roots, forward or reverse, optionally stopping at the nearest target. ksp lists the K shortest paths in the contract's order, and resolve_to_paths unfolds a DAG into paths. The DagCache gains a frozen mode that every worker of an evaluator can share. Hits are published when the call succeeds; a call ending in DagCacheMiss counts one miss. The Python bindings cover all of it, and the old search entry points are gone. CostOnly placement builds a new flow's route, record DAG and kernel plan in one pass over the cached support, with a few smaller cuts. NetGraph's MSD loop now takes about 3 % less time than 0.12 instead of 35 % more; results are unchanged. Evidence: after the independent review's fixes, the final gates pass with hooks on and off; the timed SPF rows take 9 to 82 % less time than 0.12. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 39 +- CMakeLists.txt | 36 +- Makefile | 6 +- README.md | 27 +- bindings/python/module.cpp | 428 ++++- dev/cpp-tests.sh | 13 +- include/netgraph/core/dag_cache.hpp | 87 +- include/netgraph/core/detail/cache_access.hpp | 86 + include/netgraph/core/detail/endpoints.hpp | 14 +- include/netgraph/core/detail/enum_domain.hpp | 9 + .../netgraph/core/detail/evaluator_checks.hpp | 22 +- include/netgraph/core/detail/int128.hpp | 15 + include/netgraph/core/detail/kernels.hpp | 81 +- .../netgraph/core/detail/policy_engine.hpp | 8 + .../netgraph/core/detail/pred_dag_builder.hpp | 24 +- include/netgraph/core/detail/search.hpp | 126 -- include/netgraph/core/detail/traversal.hpp | 146 ++ include/netgraph/core/evaluators.hpp | 18 +- include/netgraph/core/flow_policy.hpp | 5 + include/netgraph/core/pred_dag.hpp | 3 + include/netgraph/core/search.hpp | 102 + python/netgraph_core/__init__.py | 35 +- python/netgraph_core/_docs.py | 261 ++- src/dag_cache.cpp | 70 +- src/evaluators.cpp | 89 +- src/flow_ledger.cpp | 40 + src/flow_policy.cpp | 247 +-- src/kernels.cpp | 408 +++- src/pred_dag.cpp | 69 +- src/search.cpp | 1650 +++++++++++++---- tests/cpp/cost_only_creation_tests.cpp | 535 ++++++ tests/cpp/dag_cache_tests.cpp | 477 +++++ tests/cpp/flow_ledger_tests.cpp | 115 ++ tests/cpp/flow_policy_fault_tests.cpp | 285 ++- tests/cpp/flow_policy_tests.cpp | 21 +- tests/cpp/kernels_tests.cpp | 19 +- tests/cpp/ksp_tests.cpp | 520 ++++++ tests/cpp/search_fault_tests.cpp | 254 +++ tests/cpp/search_reference.hpp | 206 ++ tests/cpp/search_tests.cpp | 860 +++++++-- tests/py/test_apportionment.py | 23 + tests/py/test_dag_cache.py | 357 ++++ tests/py/test_internal_search.py | 99 - tests/py/test_ksp.py | 198 ++ tests/py/test_public_surface.py | 43 +- tests/py/test_search.py | 224 +++ 46 files changed, 7275 insertions(+), 1125 deletions(-) create mode 100644 include/netgraph/core/detail/cache_access.hpp delete mode 100644 include/netgraph/core/detail/search.hpp create mode 100644 include/netgraph/core/detail/traversal.hpp create mode 100644 include/netgraph/core/search.hpp create mode 100644 tests/cpp/cost_only_creation_tests.cpp create mode 100644 tests/cpp/dag_cache_tests.cpp create mode 100644 tests/cpp/ksp_tests.cpp create mode 100644 tests/cpp/search_fault_tests.cpp create mode 100644 tests/cpp/search_reference.hpp create mode 100644 tests/py/test_apportionment.py create mode 100644 tests/py/test_dag_cache.py delete mode 100644 tests/py/test_internal_search.py create mode 100644 tests/py/test_ksp.py create mode 100644 tests/py/test_search.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 285898c..2f74b4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,9 +61,33 @@ units), one flow core, one placement engine. It replaces the 0.11 API. `make_new_instance`, which does not check `tp_alloc`: a Python allocation failure there crashes the process, Core state unchanged), and a supplied building `DagCache`, a memo that keeps the complete entries and statistics of the lookups that succeeded before a failure. -- **DagCache**: `DagCache(graph)` in building mode: keyed by the root set, the direction and - the Eligibility, its values the full static supports (immutable, shared, valid after - `clear()`), with `stats()` (`hits`, `misses`, `entries`, `bytes`, `live_values`). +- **Search**: `spf(graph, roots, SpfOptions(direction, selection, targets=None, residual=None, + min_residual=1, elig=None))` returns `SpfResult`. It searches from a root set (every root at + distance 0), `Direction.FORWARD` or `REVERSE`. Without targets it settles every reachable node + and returns `costs` (`UNREACHABLE` for the nodes not reached) and the full DAG of tight edges, + the edges on some shortest path. With targets it stops at the nearest targets' cost and + returns `target_cost` and the DAG of the walks between the roots and a nearest target (for a + reverse search, from the target to a root). With + `multipath=False` it returns the canonical single path: the one walk that minimises cost, + then the bottleneck under `PREFER_HIGHER_PRIORITY`, then hops, then the edge-id sequence. A + residual adds residual gating, which keeps only the edges with at least `min_residual` + residual units. Every search runs on one traversal core, with a bucket queue when the cost + width allows and a heap otherwise. +- **KSP and paths**: `ksp(graph, src, dst, KspOptions(k, selection, max_path_cost=None, + residual=None, elig=None))` returns the K shortest simple paths as `(cost, Path)` pairs. They + come in increasing (cost, -bottleneck under `PREFER_HIGHER_PRIORITY`, hops, edge ids) order, + truncated at `k` and at an inclusive `Absolute` or `Stretch` ceiling. The selection's + `multi_edge` lists parallel members as separate paths (`True`) or keeps one per node pair. + `resolve_to_paths(graph, dag, src, dst, split_parallel_edges=False, max_paths=None)` lists a + DAG's walks in (cost, hops, edge ids) order. +- **DagCache**: `DagCache(graph)` is keyed by the root set, the direction and the Eligibility. + Its values are the full static supports (immutable, shared, valid after `clear()`), and + `stats()` reports `hits`, `misses`, `entries`, `bytes` and `live_values`. + - **Building mode:** one writer; a miss searches and inserts. + - **Frozen mode:** `freeze()` makes the cache read-only and shareable by every worker of an + evaluator. A frozen lookup miss raises `DagCacheMiss`, an exception the evaluators pass on + unchanged, never as a status. `clear()` raises when frozen, and `thaw()` returns to + building mode after the caller has joined every reader. - **Evaluators**: `capacity(graph, sources, destinations, CapacityOptions(...))` (with Global and MaxFlow the exact maximum flow, its marginal cost curve and its minimum cut), `batch_capacity(graph, pairs, opts, per_pair_elig=None, workers=None)` (pair order; the lowest @@ -80,15 +104,18 @@ units), one flow core, one placement engine. It replaces the 0.11 API. - **C++ headers** (under `netgraph/core/`): `types.hpp`, `graph.hpp` (`Graph`, `to_units`, `as_float`; it includes `eligibility.hpp`), `eligibility.hpp`, `options.hpp` (the selection axes, `FlowPlacement`, `Admission`), `pred_dag.hpp`, `flow_record.hpp` (`FlowId`, - `Counter128`, `FlowRecord`), `flow_ledger.hpp`, `flow_policy.hpp`, `dag_cache.hpp` and - `evaluators.hpp`; each compiles alone and includes no `detail/` header. Python spells the + `Counter128`, `FlowRecord`), `flow_ledger.hpp`, `flow_policy.hpp`, `dag_cache.hpp`, + `evaluators.hpp` and `search.hpp` (`spf`, `ksp`, `resolve_to_paths` and their options; the + queue choice `SpfQueue { Auto, Heap }` is a C++-only trailing parameter of `spf`); each + compiles alone and includes no `detail/` header. Python spells the creation cap `MAX_FLOWS_PER_CALL` (C++ `kMaxFlowsPerCall`). ### Removed - The 0.11 library and its bindings: `FlowState`, the previous `FlowGraph` and `FlowPolicy` and their configuration, the backend and algorithms facade (with its `Graph` handle), the search - entry points (`shortest_paths`, `shortest_paths_to`, `spf_dag`, `k_shortest_paths`), max-flow, + entry points (`shortest_paths`, `shortest_paths_to`, `spf_dag`, `spf_to`, `k_shortest_paths` + with its `unique` flag, `PredDAG.resolve_to_paths`, `NGRAPH_CORE_SPF_QUEUE`), max-flow, batch max-flow and the previous sensitivity, the SPF memo and `StateStamp`, `kMinFlow`, `kEpsilon`, `constants.hpp`, and the profiling facility (the Python accessors, the C++ scopes and `NGRAPH_CORE_PROFILE`). diff --git a/CMakeLists.txt b/CMakeLists.txt index bb219ef..4a5183a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,7 +24,10 @@ add_executable(netgraph_core_tests tests/cpp/eligibility_tests.cpp tests/cpp/pred_dag_tests.cpp tests/cpp/search_tests.cpp + tests/cpp/ksp_tests.cpp + tests/cpp/dag_cache_tests.cpp tests/cpp/kernels_tests.cpp + tests/cpp/cost_only_creation_tests.cpp tests/cpp/flow_ledger_tests.cpp tests/cpp/flow_policy_fixture_tests.cpp tests/cpp/flow_policy_tests.cpp @@ -42,7 +45,7 @@ add_executable(netgraph_core_tests endif() # Allocation-failure tests replace the global operator new: a separate executable. add_executable(netgraph_core_fault_tests tests/cpp/flow_ledger_fault_tests.cpp tests/cpp/flow_policy_fault_tests.cpp - tests/cpp/flow_core_deletion_fault_tests.cpp) + tests/cpp/flow_core_deletion_fault_tests.cpp tests/cpp/search_fault_tests.cpp) target_link_libraries(netgraph_core_fault_tests PRIVATE netgraph_core GTest::gtest_main) target_include_directories(netgraph_core_fault_tests PRIVATE tests/cpp) if(NETGRAPH_CORE_COVERAGE AND (CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")) @@ -137,6 +140,11 @@ endif() option(NETGRAPH_CORE_COVERAGE "Enable C++ coverage instrumentation" OFF) option(NETGRAPH_CORE_SANITIZE "Enable Address/Undefined sanitizers" OFF) +option(NETGRAPH_CORE_TSAN "Enable ThreadSanitizer" OFF) +if(NETGRAPH_CORE_SANITIZE AND NETGRAPH_CORE_TSAN) + # ThreadSanitizer cannot be combined with AddressSanitizer in one binary. + message(FATAL_ERROR "NETGRAPH_CORE_SANITIZE and NETGRAPH_CORE_TSAN are mutually exclusive") +endif() option(NETGRAPH_CORE_NATIVE "Optimize for current CPU (not portable)" OFF) option(NETGRAPH_CORE_FAST_MATH "Enable fast math (may affect precision)" OFF) option(NETGRAPH_CORE_PGO_GENERATE "Instrument for PGO profile collection" OFF) @@ -144,7 +152,7 @@ option(NETGRAPH_CORE_PGO_USE "Use PGO profile data (set NETGRAPH_CORE_PGO_DIR)" set(NETGRAPH_CORE_PGO_DIR "${CMAKE_BINARY_DIR}/pgo" CACHE PATH "Directory for PGO profile data") # Only apply optimizations when not in debug/instrumentation mode -if(NOT NETGRAPH_CORE_COVERAGE AND NOT NETGRAPH_CORE_SANITIZE) +if(NOT NETGRAPH_CORE_COVERAGE AND NOT NETGRAPH_CORE_SANITIZE AND NOT NETGRAPH_CORE_TSAN) # LTO (Link Time Optimization) - enables cross-module inlining include(CheckIPOSupported) check_ipo_supported(RESULT ipo_supported OUTPUT ipo_error) @@ -273,6 +281,30 @@ if(NETGRAPH_CORE_SANITIZE) endif() endif() +if(NETGRAPH_CORE_TSAN) + message(STATUS "Enabling ThreadSanitizer (disables optimizations)") + if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + # The data-race check of the evaluators' worker threads and the shared DagCache. A CMake + # list, as SAN_FLAGS above. A report makes the process exit non-zero (TSan's exit code 66, + # or an abort under the halt_on_error=1 of dev/cpp-tests.sh), so ctest fails on any race. -O1 + # as the TSan documentation advises (the Debug build type adds -g); the instrumented + # suites at -O0 would be several times slower for no extra coverage. + set(TSAN_FLAGS -fsanitize=thread -fno-omit-frame-pointer -O1) + set(TSAN_TARGETS netgraph_core _netgraph_core) + if(TARGET netgraph_core_tests) + # The test executables link the instrumented static library: same compile and link + # flags, so they carry the TSan runtime and their own accesses are instrumented too. + list(APPEND TSAN_TARGETS netgraph_core_tests netgraph_core_fault_tests) + endif() + foreach(tgt ${TSAN_TARGETS}) + target_compile_options(${tgt} PRIVATE ${TSAN_FLAGS}) + target_link_options(${tgt} PRIVATE ${TSAN_FLAGS}) + endforeach() + else() + message(WARNING "NETGRAPH_CORE_TSAN requires GCC or Clang") + endif() +endif() + # Install install(TARGETS _netgraph_core LIBRARY DESTINATION . diff --git a/Makefile b/Makefile index cbcd14f..01d4074 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # NetGraph-Core Development Makefile -.PHONY: help venv clean-venv dev install install-native install-pgo check check-ci lint format test qt build clean check-dist publish-test publish info hooks check-python cpp-test cov sanitize-test rebuild +.PHONY: help venv clean-venv dev install install-native install-pgo check check-ci lint format test qt build clean check-dist publish-test publish info hooks check-python cpp-test cov sanitize-test tsan-test rebuild .DEFAULT_GOAL := help @@ -57,6 +57,7 @@ help: @echo " make cpp-test - Build and run C++ tests" @echo " make cov - Coverage summary + XML + single-page combined HTML" @echo " make sanitize-test - Build and run C++ tests with sanitizers" + @echo " make tsan-test - Build and run C++ tests with ThreadSanitizer" @echo "" @echo "Build & Package:" @echo " make build - Build distribution packages" @@ -278,6 +279,9 @@ cov: sanitize-test: @PATH="$(VENV_BIN):$$PATH" bash dev/cpp-tests.sh sanitize +tsan-test: + @PATH="$(VENV_BIN):$$PATH" bash dev/cpp-tests.sh tsan + # Clean + reinstall in dev mode (respects CMAKE_ARGS and MACOSX_DEPLOYMENT_TARGET) # Uses active PYTHON (venv or PATH) to avoid environment mismatches rebuild: clean diff --git a/README.md b/README.md index f2aebf7..4cb505a 100644 --- a/README.md +++ b/README.md @@ -89,12 +89,31 @@ C++ graph engine for network flow analysis, traffic engineering simulation, and supplied building `DagCache` is a memo that keeps the complete entries and statistics of the lookups that succeeded before a later failure of the call. +### Search + +`spf(graph, roots, SpfOptions(direction, selection, targets=None, residual=None, +min_residual=1, elig=None))` searches from a root set, forward or reverse. +- **Without targets:** it returns `costs` (`UNREACHABLE` for nodes not reached) and the full + DAG of tight edges, the edges that lie on some shortest path. +- **With targets:** it stops at the nearest targets and returns `target_cost` and the DAG of + the walks between the roots and the nearest targets (for a reverse search, from a target to + a root). +- **With `multipath=False`:** it returns the canonical single path, the one walk that minimises + cost, then bottleneck, then hops, then edge ids. + +`ksp(graph, src, dst, KspOptions(k, selection, max_path_cost=None, residual=None, elig=None))` +returns the K shortest simple paths as `(cost, Path)` pairs, in the contract's order and up to +an inclusive cost ceiling. `resolve_to_paths(graph, dag, src, dst, split_parallel_edges=False, +max_paths=None)` lists a DAG's walks as `Path` values. + ### DagCache -`DagCache(graph)` keeps the static shortest-path supports policies build, keyed by the root -set, the direction and the Eligibility; values are immutable and shared, and `stats()` reports -hits, misses, entries, retained bytes and live values. This build has the building mode (one -writer); the frozen mode follows. +`DagCache(graph)` keeps the static shortest-path supports that policies build, keyed by the +root set, the direction and the Eligibility. Values are immutable and shared, and `stats()` +reports hits, misses, entries, retained bytes and live values. +- **Building mode (new caches):** one writer; a miss searches and inserts. +- **Frozen mode (`freeze()`):** read-only and shared by every worker of an evaluator; a miss + raises `DagCacheMiss`. `thaw()` returns to building mode once every reader has finished. ### Evaluators diff --git a/bindings/python/module.cpp b/bindings/python/module.cpp index e9faa8d..18ca689 100644 --- a/bindings/python/module.cpp +++ b/bindings/python/module.cpp @@ -2,8 +2,8 @@ Pybind11 module `_netgraph_core`: the graph on integer units with Eligibility and the unit conversions, the route structures (PredDAG, Path), the selection and placement axes, the ledger with its records, the policy engine with its configuration and results, the DagCache - and the evaluators (capacity, batch_capacity, sensitivity); and two private entries: the flow - core's _place_global_units (the recorder's entry, APPROACH 2.2) and the internal search _spf. + , the evaluators (capacity, batch_capacity, sensitivity) and the public search (spf); and one + private entry: the flow core's _place_global_units (the recorder's entry, APPROACH 2.2). A build with the private hooks (CMake NETGRAPH_CORE_PRIVATE_HOOKS: the editable development build) adds the test controls and the parity diagnostics. @@ -39,11 +39,12 @@ #include "netgraph/core/dag_cache.hpp" #include "netgraph/core/detail/endpoints.hpp" +#include "netgraph/core/detail/enum_domain.hpp" #include "netgraph/core/detail/evaluator_checks.hpp" #include "netgraph/core/detail/flow_core.hpp" #include "netgraph/core/detail/pred_dag_builder.hpp" #include "netgraph/core/detail/private_hooks.hpp" -#include "netgraph/core/detail/search.hpp" +#include "netgraph/core/detail/traversal.hpp" #include "netgraph/core/eligibility.hpp" #include "netgraph/core/evaluators.hpp" #include "netgraph/core/flow_ledger.hpp" @@ -52,6 +53,7 @@ #include "netgraph/core/graph.hpp" #include "netgraph/core/options.hpp" #include "netgraph/core/pred_dag.hpp" +#include "netgraph/core/search.hpp" #include "netgraph/core/types.hpp" #if NETGRAPH_CORE_PRIVATE_HOOKS #include @@ -429,6 +431,29 @@ std::vector node_ids(const py::object& obj, std::int32_t num_nodes, cons return checked_ids(wide_integers(obj, name), num_nodes, name); } +// One NodeId of [0, num_nodes) (a single endpoint); the range message of detail/endpoints.hpp. +NodeId endpoint(const py::object& obj, std::int32_t num_nodes, const char* name) { + const py::object idx = python_int(obj, name); + int overflow = 0; + const long long v = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); + if (overflow != 0 || v < 0 || v >= num_nodes) { + throw py::value_error(detail::node_range_error(name, std::string(py::str(idx)), num_nodes)); + } + return static_cast(v); +} + +// A count of at least 1 (KSP's k, the resolver's max_paths): the rule-13 message below 1; a count +// above INT64_MAX means the same as INT64_MAX (no list reaches it). +std::int64_t count_of(const py::object& obj, const char* name) { + const py::object idx = python_int(obj, name); + if (PyObject_RichCompareBool(idx.ptr(), py::int_(1).ptr(), Py_LT) == 1) { + throw py::value_error(std::string(name) + " must be >= 1 (got " + std::string(py::str(idx)) + ")"); + } + int overflow = 0; + const long long v = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); + return overflow != 0 ? std::numeric_limits::max() : static_cast(v); +} + // A request: an integer number of units in [0, 2^62) or +inf (float('inf')); nullopt is +inf. std::optional request_units(const py::object& volume) { if (py::isinstance(volume) || py::isinstance(volume, py::module_::import("numpy").attr("floating"))) { @@ -503,6 +528,36 @@ struct PySensitivityOptions { OutputOptions outputs; }; +// The Python form of SpfOptions: the arguments as given (targets, residual and min_residual are +// validated at the call, in the contract's order, on their unnarrowed values); the Eligibility is +// held by reference count. +struct PySpfOptions { + Direction direction; + PathSelection selection; + py::object targets; + py::object residual; + py::object min_residual; + std::shared_ptr elig; +}; + +// The Python form of KspOptions: the arguments as given (k, max_path_cost and residual are +// validated at the call, in the contract's order, on their unnarrowed values). +struct PyKspOptions { + py::object k; + PathSelection selection; + py::object max_path_cost; + py::object residual; + std::shared_ptr elig; +}; + +// The Python form of SpfResult: every field already a Python object (made by the call through +// the checked allocations), so reading one allocates nothing. +struct PySpfResult { + py::object costs; // a read-only int64 array (full searches) or None + py::object dag; // PredDAG + py::object target_cost; // an int, or None (no target reached, or a full search) +}; + // ---------------------------------------------------------------- policies and evaluators // An optional int field of [lo, hi] (None allowed when `optional`); bools and floats rejected. @@ -606,6 +661,26 @@ py::object object_of(T value) { return std::move(obj); } +// A new Python object of the bound class T (held by std::shared_ptr) owning `value`, through the +// same checked allocation as new_object: for the values without a default constructor (PredDAG, +// Path) that a search returns. A failure raises and releases everything made so far. +template +py::object object_from(T value) { + const py::detail::type_info* tinfo = py::detail::get_type_info(typeid(T), /*throw_if_missing=*/true); + PyTypeObject* type = tinfo->type; + PyObject* raw = type->tp_alloc(type, 0); + if (raw == nullptr) throw py::error_already_set(); + auto obj = py::reinterpret_steal(raw); + auto* inst = reinterpret_cast(raw); + inst->allocate_layout(); + inst->owned = false; + auto* held = new T(std::move(value)); + py::detail::values_and_holders(inst).begin()->value_ptr() = held; + inst->owned = true; + tinfo->init_instance(inst, nullptr); + return obj; +} + // ---------------------------------------------------------------- private hooks @@ -755,6 +830,12 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { .value("CAPACITY", EdgePriority::Capacity) .value("HEADROOM", EdgePriority::Headroom); + py::enum_(m, "Direction", + "The orientation of a search: FORWARD from the roots, or REVERSE (rooted at the roots over\n" + "reversed arcs; the DAG it returns is forward-oriented, toward the roots).") + .value("FORWARD", Direction::Forward) + .value("REVERSE", Direction::Reverse); + py::class_(m, "PathSelection", "The selection of a search: tie_break and priority are explicit; multipath = True keeps\n" "every equal-cost walk (the DAG), False the one canonical single path; multi_edge = True\n" @@ -1499,21 +1580,33 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { .def_readonly("bytes", &DagCache::Stats::bytes) .def_readonly("live_values", &DagCache::Stats::live_values); + py::register_exception(m, "DagCacheMiss", PyExc_Exception); + m.attr("DagCacheMiss").attr("__doc__") = + "A lookup in a frozen DagCache whose key is absent (APPROACH 10): a dedicated exception (not a\n" + "RuntimeError or ValueError); the evaluators propagate it unchanged, never as a status."; + py::class_>( m, "DagCache", "A cache of static shortest-path supports bound to one graph, keyed (roots as a set, direction,\n" - "Eligibility uid); building mode (one writer, no concurrent readers). CostOnly policies and the\n" - "evaluators read it; selection is projected at creation, never cached. clear() empties it (the\n" - "hit and miss counters persist); held values stay valid. A supplied building cache is a memo\n" - "(APPROACH 13, limit 2): a public call that fails keeps the complete entries and statistics of the\n" - "lookups that succeeded before; never a partial entry, and a retained entry never changes a result.") + "Eligibility uid). Building mode (a new cache): one writer, no concurrent readers; a miss searches\n" + "and inserts. Frozen mode (freeze()): read-only and shareable by every worker; a miss counts one miss\n" + "and raises DagCacheMiss; clear() raises ValueError; thaw() returns to building once the caller has\n" + "joined every reader. CostOnly policies and the evaluators read it; selection is projected at\n" + "creation, never cached. clear() empties a building cache (the hit and miss counters persist); held\n" + "values stay valid. A supplied building cache is a memo (APPROACH 13, limit 2): a public call that\n" + "fails keeps the complete entries and statistics of the lookups that succeeded before; never a\n" + "partial entry, and a retained entry never changes a result. A frozen cache's statistics follow the\n" + "public call that reads it: its hits count when the call succeeds, its Python result included; a\n" + "failed call changes no statistic, except that a call raising DagCacheMiss counts one miss.") .def(py::init([](const std::shared_ptr& graph) { if (!graph) throw py::value_error("graph must be a Graph"); return std::make_shared(*graph); }), py::arg("graph")) .def("graph_uid", &DagCache::graph_uid, "uid of the graph this cache is bound to") - .def("clear", &DagCache::clear) + .def("freeze", &DagCache::freeze, "enter frozen mode (no effect when frozen)") + .def("thaw", &DagCache::thaw, "return to building mode (no effect when building); join every reader first") + .def("clear", &DagCache::clear, "empty a building cache; ValueError when frozen") .def("stats", &DagCache::stats); auto flow_policy = py::class_( @@ -1652,18 +1745,23 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { const auto s = node_ids(sources, graph->num_nodes(), "sources"); const auto d = node_ids(destinations, graph->num_nodes(), "destinations"); const CapacityOptions c {o.config, o.elig.get(), o.cache.get(), o.outputs}; - PlacementResult r; - { - py::gil_scoped_release release; - r = capacity(*graph, s, d, c); - } - return object_of(std::move(r)); + // One public call on a frozen cache's statistics, its result object included (NOTES R27). + return detail::frozen_call(c.cache, [&](detail::FrozenTally& tally) { + PlacementResult r; + { + py::gil_scoped_release release; + r = detail::capacity_in_call(*graph, s, d, c, tally); + } + return object_of(std::move(r)); + }); }, py::arg("graph"), py::arg("sources"), py::arg("destinations"), py::arg("opts"), "place(+inf) of a FlowPolicy with opts.config on a fresh ledger of the graph -> PlacementResult.\n" - "The exact maximum flow is the Global + MaxFlow configuration.\n" + "The exact maximum flow is the Global + MaxFlow configuration. A supplied frozen cache raises\n" + "DagCacheMiss for an absent key, unchanged.\n" "A failed call raises; a supplied building cache keeps the entries and statistics of the lookups\n" - "that succeeded before the failure (a memo: APPROACH 13, limit 2)."); + "that succeeded before the failure (a memo: APPROACH 13, limit 2); a supplied frozen cache's\n" + "statistics are unchanged, except one miss when the call raises DagCacheMiss."); m.def( "batch_capacity", @@ -1714,16 +1812,18 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { detail::check_batch_pair(*graph, src, dst, e, i); ps.emplace_back(std::move(src), std::move(dst)); } - std::vector rs; - { - py::gil_scoped_release release; - rs = batch_capacity(*graph, ps, c, per, w); - } - py::list out(rs.size()); - for (std::size_t i = 0; i < rs.size(); ++i) { - PyList_SET_ITEM(out.ptr(), static_cast(i), object_of(std::move(rs[i])).release().ptr()); - } - return out; + return detail::frozen_call(c.cache, [&](detail::FrozenTally& tally) { + std::vector rs; + { + py::gil_scoped_release release; + rs = detail::batch_capacity_in_call(*graph, ps, c, per, w, tally); + } + py::list out(rs.size()); + for (std::size_t i = 0; i < rs.size(); ++i) { + PyList_SET_ITEM(out.ptr(), static_cast(i), object_of(std::move(rs[i])).release().ptr()); + } + return py::object(std::move(out)); + }); }, py::arg("graph"), py::arg("pairs"), py::arg("opts"), py::arg("per_pair_elig") = py::none(), py::arg("workers") = py::none(), @@ -1732,9 +1832,14 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { "empty); workers >= 1 (None: NGRAPH_CORE_BATCH_THREADS or 1; 1 with a supplied cache).\n" "Everything is validated before a worker starts, the arguments first, then each pair\n" "completely in pair order, so the error of the lowest failing pair index is raised; Pinned is\n" - "rejected; an empty batch returns [].\n" + "rejected; an empty batch returns []. A supplied building cache needs workers == 1; a frozen cache\n" + "is shared by every worker (pass workers explicitly) and its DagCacheMiss reaches the caller\n" + "unchanged (the lowest failing pair's); a per-pair intersection that differs from both operands\n" + "has a new uid, absent from a frozen cache (pass effective per-pair values with elig None, or a\n" + "common value containing them).\n" "A failed call raises; a supplied building cache keeps the entries and statistics of the lookups\n" - "that succeeded before the failure (a memo: APPROACH 13, limit 2)."); + "that succeeded before the failure (a memo: APPROACH 13, limit 2); a supplied frozen cache's\n" + "statistics are unchanged, except one miss when the call raises DagCacheMiss."); m.def( "sensitivity", @@ -1746,12 +1851,14 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { const auto d = node_ids(destinations, graph->num_nodes(), "destinations"); const auto w = workers_of(workers, true, static_cast(o.cache)); const SensitivityOptions c {o.config, o.elig.get(), o.cache.get(), o.outputs}; - SensitivityResult r; - { - py::gil_scoped_release release; - r = sensitivity(*graph, s, d, c, w); - } - return object_of(std::move(r)); + return detail::frozen_call(c.cache, [&](detail::FrozenTally& tally) { + SensitivityResult r; + { + py::gil_scoped_release release; + r = detail::sensitivity_in_call(*graph, s, d, c, w, tally); + } + return object_of(std::move(r)); + }); }, py::arg("graph"), py::arg("sources"), py::arg("destinations"), py::arg("opts"), py::arg("workers") = py::none(), "The baseline capacity() and one entry per candidate edge (Global: the baseline's positive-flow\n" @@ -1760,63 +1867,232 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { "flow core's deletion re-solve from the baseline's final state, and CostOnly gives edges no route\n" "uses loss 0 without a placement, result-equivalent forms of that definition); a placed deletion\n" "raising a model exception is UNEVALUABLE with the message as reason.\n" - "workers as batch_capacity (NGRAPH_CORE_SENSITIVITY_THREADS).\n" + "workers as batch_capacity (NGRAPH_CORE_SENSITIVITY_THREADS); a CostOnly deletion (the one\n" + "model reading the cache) looks up a new Eligibility uid, so a frozen cache raises DagCacheMiss\n" + "there (unchanged, never UNEVALUABLE); other models read no cache.\n" "A failed call raises; a supplied building cache keeps the entries and statistics of the lookups\n" - "that succeeded before the failure (a memo: APPROACH 13, limit 2)."); + "that succeeded before the failure (a memo: APPROACH 13, limit 2); a supplied frozen cache's\n" + "statistics are unchanged, except one miss when the call raises DagCacheMiss."); + + py::class_( + m, "KspOptions", + "The options of ksp (APPROACH 6, rule 10): k (>= 1) and selection are explicit (its multi_edge is\n" + "the member rule, Expand or Collapse; multipath is ignored); max_path_cost (None, Absolute(c) with\n" + "c >= 0, or Stretch(f) with f finite in [1, inf) times the static shortest cost under the\n" + "Eligibility, residual ignored; inclusive); residual (int64 units per EdgeId, each in\n" + "[0, capacity_units]: gating at 1 unit and the priority values); elig (None: everything allowed).\n" + "The fields are validated by ksp, in the contract's order.") + .def(py::init([](const py::object& k, const PathSelection& selection, const py::object& max_path_cost, + const py::object& residual, const std::shared_ptr& elig) { + return PyKspOptions {k, selection, max_path_cost, residual, elig}; + }), + py::arg("k"), py::arg("selection"), py::kw_only(), py::arg("max_path_cost") = py::none(), + py::arg("residual") = py::none(), py::arg("elig") = nullptr) + .def_readonly("k", &PyKspOptions::k) + .def_readonly("selection", &PyKspOptions::selection) + .def_readonly("max_path_cost", &PyKspOptions::max_path_cost) + .def_readonly("residual", &PyKspOptions::residual) + .def_readonly("elig", &PyKspOptions::elig); - // ------------------------------------------------------------------ private recorder entries + m.def( + "ksp", + [](const std::shared_ptr& graph, const py::object& src, const py::object& dst, const py::object& opts) { + if (!graph) throw py::value_error("graph must be a Graph"); + const auto o = options_of(opts, "KspOptions"); + const auto n = graph->num_nodes(); + // APPROACH 6's order on unnarrowed values (Core repeats every check): the selection, src, + // dst, src == dst, k, the ceiling, the residual, then a foreign Eligibility. + detail::check_selection(o.selection); + const NodeId s = endpoint(src, n, "src"); + const NodeId t = endpoint(dst, n, "dst"); + if (s == t) throw py::value_error(detail::overlap_error(s, "src", "dst")); + KspOptions c(count_of(o.k, "k"), o.selection); + if (!o.max_path_cost.is_none()) { + if (py::isinstance(o.max_path_cost)) { + c.max_path_cost = o.max_path_cost.cast(); + } else if (py::isinstance(o.max_path_cost)) { + c.max_path_cost = o.max_path_cost.cast(); + } else { + throw py::type_error("max_path_cost must be Absolute, Stretch or None (got " + + std::string(py::str(py::type::of(o.max_path_cost).attr("__name__"))) + ")"); + } + detail::check_ceiling(c.max_path_cost); + } + std::optional> res; + if (!o.residual.is_none()) res = edge_units(o.residual, *graph, "residual"); + if (res.has_value()) c.residual = std::span(*res); + c.elig = o.elig.get(); + std::vector> paths; + { + py::gil_scoped_release release; + paths = ksp(*graph, s, t, c); + } + // Every object through a checked allocation: a failure raises. + py::list out(paths.size()); + for (std::size_t i = 0; i < paths.size(); ++i) { + py::tuple item(2); + py::int_ cost(paths[i].first); + py::object path = object_from(std::move(paths[i].second)); + PyTuple_SET_ITEM(item.ptr(), 0, cost.release().ptr()); + PyTuple_SET_ITEM(item.ptr(), 1, path.release().ptr()); + PyList_SET_ITEM(out.ptr(), static_cast(i), item.release().ptr()); + } + return out; + }, + py::arg("graph"), py::arg("src"), py::arg("dst"), py::arg("opts"), + "The K shortest simple src -> dst paths of the member graph (APPROACH 6, rule 10) -> [(cost, Path)],\n" + "in increasing (cost, -bottleneck under PREFER_HIGHER_PRIORITY, hops, edge-id sequence), the\n" + "bottleneck being the minimum priority value along a path: the minimal prefix of that order,\n" + "truncated at k and at the inclusive ceiling; each Path has single-member hops. Validated in this\n" + "order before any work (ValueError): src and dst in range, src == dst, k >= 1, the ceiling, the\n" + "residual (length, then every entry), a foreign Eligibility. An excluded endpoint, no static route\n" + "under Stretch, or no path gives []."); m.def( - "_spf", - [](const std::shared_ptr& graph, const py::object& roots, const std::string& direction, - const py::object& targets, const PathSelection& selection, const py::object& residual, - const py::object& min_residual, const std::shared_ptr& elig) { + "resolve_to_paths", + [](const std::shared_ptr& graph, const std::shared_ptr& dag, const py::object& src, + const py::object& dst, bool split_parallel_edges, const py::object& max_paths) { if (!graph) throw py::value_error("graph must be a Graph"); - if (direction != "forward" && direction != "reverse") { - throw py::value_error("direction must be 'forward' or 'reverse'"); + if (!dag) throw py::value_error("dag must be a PredDAG"); + if (dag->graph_uid() != graph->uid()) throw py::value_error("foreign PredDAG: built for another graph"); + const auto n = graph->num_nodes(); + const NodeId s = endpoint(src, n, "src"); + const NodeId t = endpoint(dst, n, "dst"); + if (s == t) throw py::value_error(detail::overlap_error(s, "src", "dst")); + std::optional mp; + if (!max_paths.is_none()) mp = count_of(max_paths, "max_paths"); + std::vector paths; + { + py::gil_scoped_release release; + paths = resolve_to_paths(*graph, *dag, s, t, split_parallel_edges, mp); } - const auto r = node_ids(roots, graph->num_nodes(), "roots"); + py::list out(paths.size()); + for (std::size_t i = 0; i < paths.size(); ++i) { + PyList_SET_ITEM(out.ptr(), static_cast(i), object_from(std::move(paths[i])).release().ptr()); + } + return out; + }, + py::arg("graph"), py::arg("dag"), py::arg("src"), py::arg("dst"), py::arg("split_parallel_edges") = false, + py::arg("max_paths") = py::none(), + "The src -> dst walks of a DAG (APPROACH 5) -> [Path], in increasing (cost, hops, edge-id sequence):\n" + "a hop costs its cheapest member and is keyed by its lowest member id. split_parallel_edges = False\n" + "keeps every parallel member of a (u, v) pair in one hop (members ascending); True gives one path\n" + "per member combination in the same order. max_paths (>= 1) truncates; None lists every walk.\n" + "ValueError: a DAG of another graph, src or dst out of range, src == dst, max_paths < 1. An\n" + "endpoint outside the DAG, or no walk, gives []."); + + // ------------------------------------------------------------------ private recorder entries + + // ------------------------------------------------------------------ the public search + + m.attr("UNREACHABLE") = py::int_(kUnreachable); + + py::class_( + m, "SpfOptions", + "The options of spf (APPROACH 6): direction and selection are explicit; targets (one NodeId or a\n" + "sequence; None: a full search) stops the search at the nearest targets (anycast); residual (int64\n" + "units per EdgeId, each in [0, capacity_units]) adds residual gating at min_residual units (an\n" + "integer >= 1; it gates a supplied residual only) and supplies the priority values; elig (None:\n" + "everything allowed). A direction outside its enumeration raises ValueError here; the other fields\n" + "are validated by spf, in the contract's order.") + .def(py::init([](Direction direction, const PathSelection& selection, const py::object& targets, + const py::object& residual, const py::object& min_residual, + const std::shared_ptr& elig) { + detail::check_member(direction, "direction"); + return PySpfOptions {direction, selection, targets, residual, min_residual, elig}; + }), + py::arg("direction"), py::arg("selection"), py::kw_only(), py::arg("targets") = py::none(), + py::arg("residual") = py::none(), py::arg("min_residual") = 1, py::arg("elig") = nullptr) + .def_readonly("direction", &PySpfOptions::direction) + .def_readonly("selection", &PySpfOptions::selection) + .def_readonly("targets", &PySpfOptions::targets) + .def_readonly("residual", &PySpfOptions::residual) + .def_readonly("min_residual", &PySpfOptions::min_residual) + .def_readonly("elig", &PySpfOptions::elig); + + py::class_(m, "SpfResult", + "The result of spf: costs (full searches: a read-only int64 array, UNREACHABLE for the\n" + "nodes not reached; None with targets), dag (PredDAG: the unique DAG of the edges tight in\n" + "the traversal direction, in their original orientation; with targets pruned to the walks\n" + "between the roots and a nearest target, from the target to a root for REVERSE, or the\n" + "canonical single path for multipath = False) and target_cost (with targets: the nearest\n" + "targets' cost, None when no target is reached; None for a full search).") + .def_property_readonly("costs", [](const PySpfResult& r) { return r.costs ? r.costs : py::object(py::none()); }) + .def_property_readonly("dag", [](const PySpfResult& r) { return r.dag; }) + .def_property_readonly("target_cost", + [](const PySpfResult& r) { return r.target_cost ? r.target_cost : py::object(py::none()); }) + .def("__repr__", [](const PySpfResult& r) { + std::string tc = r.target_cost && !r.target_cost.is_none() ? std::string(py::str(r.target_cost)) : "None"; + return "SpfResult(costs=" + std::string(r.costs ? "array" : "None") + + ", dag_entries=" + std::to_string(r.dag.cast().via_edges().size()) + + ", target_cost=" + tc + ")"; + }); + + m.def( + "spf", + [](const std::shared_ptr& graph, const py::object& roots, const py::object& opts) { + if (!graph) throw py::value_error("graph must be a Graph"); + const auto o = options_of(opts, "SpfOptions"); + const auto n = graph->num_nodes(); + // APPROACH 6's order on unnarrowed values (Core repeats every check): the enumerations, + // the roots, the targets, their overlap, a single path without targets, the residual, then + // min_residual and a foreign Eligibility. + detail::check_member(o.direction, "direction"); + detail::check_selection(o.selection); + const auto r = node_ids(roots, n, "roots"); std::optional> t; - if (!targets.is_none()) t = node_ids(targets, graph->num_nodes(), "targets"); - // Section 6's order: the endpoint checks (overlap, a single path without targets) come - // before the residual is read (review-1b R15); detail::spf repeats them. - if (t.has_value()) { - detail::check_disjoint(detail::node_set(r, graph->num_nodes(), "roots"), - detail::node_set(*t, graph->num_nodes(), "targets"), "roots", "targets"); - } else if (!selection.multipath) { + if (!o.targets.is_none()) { + t = node_ids(o.targets, n, "targets"); + detail::check_disjoint(detail::node_set(r, n, "roots"), detail::node_set(*t, n, "targets"), "roots", + "targets"); + } else if (!o.selection.multipath) { throw py::value_error("multipath = false requires targets (the single path joins a root to a target)"); } std::optional> res; - if (!residual.is_none()) res = edge_units(residual, *graph, "residual"); - const auto minr = integer_value(min_residual, 1, kDomainLimit - 1, "min_residual"); - detail::SpfOut o; + if (!o.residual.is_none()) res = edge_units(o.residual, *graph, "residual"); + const auto minr = python_int(o.min_residual, "min_residual"); + if (PyObject_RichCompareBool(minr.ptr(), py::int_(1).ptr(), Py_LT) == 1) { + throw py::value_error("min_residual must be at least 1 unit (got " + std::string(py::str(minr)) + ")"); + } + int overflow = 0; + const long long mv = PyLong_AsLongLongAndOverflow(minr.ptr(), &overflow); + SpfOptions c(o.direction, o.selection); + c.targets = std::move(t); + if (res.has_value()) c.residual = std::span(*res); + // Above INT64_MAX it gates every residual edge exactly as INT64_MAX does (residuals < 2^62). + c.min_residual = overflow != 0 ? std::numeric_limits::max() : static_cast(mv); + c.elig = o.elig.get(); + std::optional result; { py::gil_scoped_release release; - std::optional> ts; - if (t.has_value()) ts = std::span(*t); - std::optional> rs; - if (res.has_value()) rs = std::span(*res); - o = detail::spf(*graph, r, direction == "forward" ? Direction::Forward : Direction::Reverse, ts, selection, rs, - minr, elig.get()); + result.emplace(spf(*graph, r, c)); } - py::dict out; - if (o.costs.has_value()) { - py::list costs; - for (Cost c : *o.costs) costs.append(cost_or_none(c)); - out["costs"] = costs; + // The result's Python objects, each through a checked allocation: a failure raises. + auto [obj, slot] = new_object(); + if (result->costs.has_value()) { + py::array_t costs(static_cast(result->costs->size())); + std::copy(result->costs->begin(), result->costs->end(), costs.mutable_data()); + py::detail::array_proxy(costs.ptr())->flags &= ~py::detail::npy_api::NPY_ARRAY_WRITEABLE_; + slot->costs = std::move(costs); } - out["dag"] = std::make_shared(detail::PredDAGBuilder::of_edges(*graph, o.edges)); - if (t.has_value()) { - out["target_cost"] = o.target_cost.has_value() ? py::object(py::int_(*o.target_cost)) : py::object(py::none()); + slot->dag = object_from(std::move(result->dag)); + if (result->target_cost.has_value() && *result->target_cost != kUnreachable) { + slot->target_cost = py::int_(*result->target_cost); } - if (o.path.has_value()) out["path"] = o.path->walk; - return out; + return std::move(obj); }, - py::arg("graph"), py::arg("roots"), py::arg("direction"), py::arg("targets"), py::arg("selection"), - py::arg("residual") = py::none(), py::arg("min_residual") = 1, py::arg("elig") = nullptr, - "private: the internal search of APPROACH 6 (the recorder's entry; the public spf replaces it).\n" - "Returns {costs (full search), dag, target_cost (with targets; None when unreachable), path (the\n" - "single path's walk)}."); + py::arg("graph"), py::arg("roots"), py::arg("opts"), + "Shortest paths from the roots (one NodeId or a sequence; every root at distance 0), FORWARD or\n" + "REVERSE (APPROACH 6) -> SpfResult. Without targets every reachable node is settled: costs and the\n" + "full DAG of tight edges. With targets the search stops at the nearest targets' cost: target_cost\n" + "and the DAG pruned to the walks between the roots and a nearest target (anycast; for REVERSE, from\n" + "the target to a root in the edges' orientation), or for multipath = False\n" + "the canonical single path, the walk minimising (cost, -bottleneck under PREFER_HIGHER_PRIORITY,\n" + "hops, edge-id sequence). Validated in this order before any work (ValueError): the roots (non-\n" + "empty, ids in range), the targets (the same), a vertex in both sets, multipath = False without\n" + "targets, the residual (length, then every entry in [0, capacity_units]), min_residual >= 1, a\n" + "foreign Eligibility. Excluded roots and targets are skipped; all roots excluded gives an empty\n" + "result."); // ------------------------------------------------------------------ private hooks diff --git a/dev/cpp-tests.sh b/dev/cpp-tests.sh index ff28da7..fdf03df 100644 --- a/dev/cpp-tests.sh +++ b/dev/cpp-tests.sh @@ -4,10 +4,14 @@ set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/.." jobs=${CMAKE_BUILD_PARALLEL_LEVEL:-4} case "$jobs" in ''|*[!0-9]*|0) echo 'CMAKE_BUILD_PARALLEL_LEVEL must be positive.' >&2; exit 2 ;; esac +tsan=OFF case "${1:-release}" in release) build_dir=build/cpp-tests; build_type=Release; sanitize=OFF ;; sanitize) build_dir=build/cpp-sanitize; build_type=Debug; sanitize=ON ;; - *) echo 'Expected release or sanitize.' >&2; exit 2 ;; + # ThreadSanitizer (data races of the worker threads and the shared DagCache), its own build + # directory; the longest instrumented test takes about 30 s, inside the common timeout. + tsan) build_dir=build/cpp-tsan; build_type=Debug; sanitize=OFF; tsan=ON ;; + *) echo 'Expected release, sanitize or tsan.' >&2; exit 2 ;; esac if [[ "$(uname -s)" == Darwin ]]; then export CC="$(xcrun --find clang)" @@ -18,10 +22,15 @@ else leak_detection=1 fi cmake -S . -B "$build_dir" -DNETGRAPH_CORE_BUILD_TESTS=ON -DNETGRAPH_CORE_PRIVATE_HOOKS=ON \ - -DNETGRAPH_CORE_SANITIZE="$sanitize" -DCMAKE_BUILD_TYPE="$build_type" + -DNETGRAPH_CORE_SANITIZE="$sanitize" -DNETGRAPH_CORE_TSAN="$tsan" -DCMAKE_BUILD_TYPE="$build_type" cmake --build "$build_dir" --config "$build_type" --parallel "$jobs" if [[ "$sanitize" == ON ]]; then export ASAN_OPTIONS="${ASAN_OPTIONS:-detect_leaks=$leak_detection}" fi +if [[ "$tsan" == ON ]]; then + # A report aborts the test (halt_on_error=1; TSan's own default is exit code 66 at exit): + # ctest fails on any race. + export TSAN_OPTIONS="${TSAN_OPTIONS:-halt_on_error=1 second_deadlock_stack=1}" +fi ctest --test-dir "$build_dir" --build-config "$build_type" \ --output-on-failure --parallel "$jobs" --timeout 120 --no-tests=error diff --git a/include/netgraph/core/dag_cache.hpp b/include/netgraph/core/dag_cache.hpp index f021220..01b8e6c 100644 --- a/include/netgraph/core/dag_cache.hpp +++ b/include/netgraph/core/dag_cache.hpp @@ -1,11 +1,15 @@ -/* The DagCache of APPROACH 10 in building mode. */ +/* The DagCache of APPROACH 10: building and frozen modes, the dedicated frozen-miss exception. */ #pragma once +#include #include +#include #include +#include #include #include #include +#include #include #include @@ -16,6 +20,27 @@ namespace netgraph::core { +namespace detail { +struct DagCacheAccess; +class FrozenTally; +} // namespace detail + +// A lookup in a frozen DagCache whose key is absent (APPROACH 10): a dedicated exception, not a +// std::runtime_error, so no evaluator converts it into a status (a sensitivity entry's +// `unevaluable` comes from a std::runtime_error); every evaluator propagates it unchanged. Its +// message is shared, so copying it never allocates. what() is defined in dag_cache.cpp: as the +// key function it gives the type one vtable and one type_info. An all-inline class gets a weak +// copy per translation unit, and the LTO build of the extension kept two, so the binding's catch +// of DagCacheMiss missed the thrown one (NOTES R26). +class DagCacheMiss : public std::exception { +public: + explicit DagCacheMiss(const std::string& what) : what_(std::make_shared(what)) {} + [[nodiscard]] const char* what() const noexcept override; + +private: + std::shared_ptr what_; +}; + // A cache of shortest-path supports bound to one graph (by uid). The key is (the roots as a // sorted set, the direction, the Eligibility uid; no Eligibility is uid 0); the value is the full // static support of that search (multipath = true, multi_edge = true, no residual: every tight @@ -23,14 +48,26 @@ namespace netgraph::core { // reverse search) as an immutable shared PredDAG, valid after clear() and after the cache is // destroyed. Selection is never cached: a policy projects it at creation (APPROACH 9.1, 10). // -// Building mode: one writer, no concurrent readers. A hit returns the value; a miss runs the -// search, inserts and returns (a failed search counts one miss and inserts nothing); an invalid -// query (a graph or Eligibility of another graph, an empty root set, a root out of range) throws -// std::invalid_argument and counts nothing. clear() empties the cache; the hit and miss counters -// persist. Statistics are atomic per field, not a consistent snapshot: `bytes` is the retained -// payload of the entries (the DAGs' stored arrays), `live_values` the values alive anywhere, -// entries included. A supplied cache whose public call fails later keeps the complete entries -// and statistics of its successful lookups (APPROACH 13, limit 2). +// Building mode (a new cache): one writer, no concurrent readers. A hit returns the value; a miss +// runs the search, inserts and returns (a failed search counts one miss and inserts nothing); an +// invalid query (a graph or Eligibility of another graph, an empty root set, a root out of range, +// a direction outside its enumeration) throws std::invalid_argument and counts nothing. clear() +// empties the cache; the hit and miss counters persist. +// +// Frozen mode (after freeze()): read-only and shareable; lookups from several threads at once are +// safe. A hit returns the value; a miss counts one miss and throws DagCacheMiss (nothing is +// inserted); clear() throws std::invalid_argument; freeze() again has no effect; thaw() returns to +// building mode once the caller has joined every reader (no lookup may run concurrently with +// freeze(), thaw() or destruction). +// +// Statistics are atomic per field, not a consistent snapshot: `bytes` is the retained payload of +// the entries (the DAGs' stored arrays), `live_values` the values alive anywhere, entries +// included. A supplied building cache whose public call fails later keeps the complete entries and +// statistics of its successful lookups (APPROACH 13, limit 2). A frozen cache's statistics follow +// the public call that reads it (a policy constructor, an evaluator; NOTES R27): its hits are +// counted when the call succeeds, its Python result included, and a failed call changes no +// statistic, except that a call raising DagCacheMiss counts one miss. A direct lookup() is such a +// call: a hit counts one hit, a miss one miss. class DagCache { public: struct Stats { @@ -49,13 +86,41 @@ class DagCache { [[nodiscard]] std::uint64_t graph_uid() const noexcept { return graph_uid_; } [[nodiscard]] std::shared_ptr lookup(const Graph& g, std::span roots, Direction direction, const Eligibility* elig); - void clear() noexcept; + void freeze() noexcept; + void thaw() noexcept; + void clear(); [[nodiscard]] Stats stats() const noexcept; private: + friend struct detail::DagCacheAccess; + // lookup() inside a public call: with a tally of this cache, a frozen hit is recorded in it and a + // frozen miss counts nothing here (the call's boundary counts it). + std::shared_ptr lookup_in_call(const Graph& g, std::span roots, Direction direction, + const Eligibility* elig, detail::FrozenTally* tally); + bool frozen_ {false}; using Key = std::tuple, Direction, std::uint64_t>; + // A lookup's key over borrowed roots (a validated sorted, duplicate-free span): a hit needs no copy. + using KeyView = std::tuple, Direction, std::uint64_t>; + // The order of Key (lexicographic roots, then direction, then Eligibility uid), also between a Key + // and a KeyView. + struct KeyLess { + using is_transparent = void; + static bool less(std::span ra, Direction da, std::uint64_t ua, std::span rb, + Direction db, std::uint64_t ub) noexcept { + const std::size_t n = std::min(ra.size(), rb.size()); + for (std::size_t i = 0; i < n; ++i) { + if (ra[i] != rb[i]) return ra[i] < rb[i]; + } + if (ra.size() != rb.size()) return ra.size() < rb.size(); + return da != db ? da < db : ua < ub; + } + template + bool operator()(const A& a, const B& b) const noexcept { + return less(std::get<0>(a), std::get<1>(a), std::get<2>(a), std::get<0>(b), std::get<1>(b), std::get<2>(b)); + } + }; std::uint64_t graph_uid_ {0}; - std::map> entries_ {}; + std::map, KeyLess> entries_ {}; std::atomic hits_ {0}; std::atomic misses_ {0}; std::atomic num_entries_ {0}; diff --git a/include/netgraph/core/detail/cache_access.hpp b/include/netgraph/core/detail/cache_access.hpp new file mode 100644 index 0000000..152e2ad --- /dev/null +++ b/include/netgraph/core/detail/cache_access.hpp @@ -0,0 +1,86 @@ +/* Internal access to a DagCache: its mode (the evaluators' rule for a supplied cache) and the + frozen statistics of one public call (APPROACH 13; NOTES R27). */ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/dag_cache.hpp" + +namespace netgraph::core::detail { + +class FrozenTally; + +struct DagCacheAccess { + // Whether the cache is frozen (APPROACH 10: a frozen cache may be shared by every worker). + [[nodiscard]] static bool frozen(const DagCache& cache) noexcept { return cache.frozen_; } + // A lookup inside a public call (FrozenTally): a frozen hit is recorded in `tally` instead of + // the cache, a frozen miss is counted by the call's boundary (frozen_call); a building cache + // counts at once, as DagCache::lookup does. + [[nodiscard]] static std::shared_ptr lookup(DagCache& cache, const Graph& g, + std::span roots, Direction direction, + const Eligibility* elig, FrozenTally& tally) { + return cache.lookup_in_call(g, roots, direction, elig, &tally); + } + static void add_hits(DagCache& cache, std::int64_t n) noexcept { + cache.hits_.fetch_add(n, std::memory_order_relaxed); + } + static void add_miss(DagCache& cache) noexcept { cache.misses_.fetch_add(1, std::memory_order_relaxed); } +}; + +// The frozen-cache statistics of one public call that reads a supplied cache (NOTES R27). A +// frozen cache's counters are observable state under the all-or-nothing rule (APPROACH 13; only +// a supplied building cache, a memo, keeps the statistics of a failed call: limit 2). So inside +// the call a frozen hit is recorded here, and the call's boundary (frozen_call) publishes the +// hits after the call's last failing step, its Python result included; a failed call drops +// them. A call that raises DagCacheMiss counts that one miss (R20), whatever its workers did. All +// workers of the call record into the one atomic count; every call has its own tally, so the +// increments of concurrent successful calls are all kept. +class FrozenTally { +public: + explicit FrozenTally(DagCache* cache) noexcept : cache_(cache) {} + FrozenTally(const FrozenTally&) = delete; + FrozenTally& operator=(const FrozenTally&) = delete; + ~FrozenTally() = default; + + [[nodiscard]] const DagCache* cache() const noexcept { return cache_; } + void hit() noexcept { hits_.fetch_add(1, std::memory_order_relaxed); } + // The call succeeded: its hits reach the cache (one atomic addition, cannot fail). + void publish() noexcept { + const auto n = hits_.exchange(0, std::memory_order_relaxed); + if (cache_ != nullptr && n != 0) DagCacheAccess::add_hits(*cache_, n); + } + // The call raises DagCacheMiss: one miss; its hits are dropped. + void miss() noexcept { + if (cache_ != nullptr) DagCacheAccess::add_miss(*cache_); + } + +private: + DagCache* cache_; + std::atomic hits_ {0}; +}; + +// Runs body(tally) as one public call on `cache` (the supplied cache, or null): publishes the +// recorded hits once body has returned (its last failing step: body builds the call's result, +// the Python object included) and returns body's value, which then moves without failing; when +// body throws, nothing is published, except one miss for a DagCacheMiss. +template +auto frozen_call(DagCache* cache, Body&& body) -> decltype(std::forward(body)(std::declval())) { + using Result = decltype(std::forward(body)(std::declval())); + static_assert(std::is_nothrow_move_constructible_v, "the result moves out after the publication"); + FrozenTally tally(cache); + try { + Result result = std::forward(body)(tally); + tally.publish(); + return result; + } catch (const DagCacheMiss&) { + tally.miss(); + throw; + } +} + +} // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/endpoints.hpp b/include/netgraph/core/detail/endpoints.hpp index 1db481c..6045146 100644 --- a/include/netgraph/core/detail/endpoints.hpp +++ b/include/netgraph/core/detail/endpoints.hpp @@ -25,14 +25,20 @@ inline std::string overlap_error(NodeId v, const char* a, const char* b) { return "node " + std::to_string(v) + " is in both endpoint sets: " + a + " and " + b + " must not overlap"; } -// The sorted, duplicate-free node set; std::invalid_argument for an empty set or an id out of -// range (`name` labels the message). -[[nodiscard]] inline std::vector node_set(std::span nodes, std::int32_t num_nodes, - const char* name) { +// node_set's checks without the set: std::invalid_argument for an empty set or an id out of range +// (the first one, in input order; `name` labels the message). +inline void check_range(std::span nodes, std::int32_t num_nodes, const char* name) { if (nodes.empty()) throw std::invalid_argument(empty_set_error(name)); for (NodeId v : nodes) { if (v < 0 || v >= num_nodes) throw std::invalid_argument(node_range_error(name, std::to_string(v), num_nodes)); } +} + +// The sorted, duplicate-free node set; std::invalid_argument for an empty set or an id out of +// range (`name` labels the message). +[[nodiscard]] inline std::vector node_set(std::span nodes, std::int32_t num_nodes, + const char* name) { + check_range(nodes, num_nodes, name); std::vector out(nodes.begin(), nodes.end()); std::sort(out.begin(), out.end()); out.erase(std::unique(out.begin(), out.end()), out.end()); diff --git a/include/netgraph/core/detail/enum_domain.hpp b/include/netgraph/core/detail/enum_domain.hpp index 0fa3c5e..c88b6f0 100644 --- a/include/netgraph/core/detail/enum_domain.hpp +++ b/include/netgraph/core/detail/enum_domain.hpp @@ -8,6 +8,7 @@ #include #include "netgraph/core/options.hpp" +#include "netgraph/core/search.hpp" namespace netgraph::core::detail { @@ -57,6 +58,14 @@ inline void check_member(Direction v, const char* field) { not_a_member(field, static_cast(v), "Direction"); } +inline void check_member(SpfQueue v, const char* field) { + switch (v) { + case SpfQueue::Auto: + case SpfQueue::Heap: return; + } + not_a_member(field, static_cast(v), "SpfQueue"); +} + // The two enum-typed fields of a selection. inline void check_selection(const PathSelection& selection) { check_member(selection.tie_break, "selection.tie_break"); diff --git a/include/netgraph/core/detail/evaluator_checks.hpp b/include/netgraph/core/detail/evaluator_checks.hpp index 6d8b361..b50bfa9 100644 --- a/include/netgraph/core/detail/evaluator_checks.hpp +++ b/include/netgraph/core/detail/evaluator_checks.hpp @@ -1,11 +1,15 @@ -/* The evaluators' argument checks and worker default (internal), shared with the Python - binding so that both report the same first error (APPROACH 4 and 11). */ +/* The evaluators' argument checks, worker default and in-call forms (internal), shared with the + Python binding so that both report the same first error (APPROACH 4 and 11) and publish a frozen + cache's statistics at the same boundary (NOTES R27). */ #pragma once #include #include #include +#include +#include +#include "netgraph/core/detail/cache_access.hpp" #include "netgraph/core/evaluators.hpp" #include "netgraph/core/graph.hpp" #include "netgraph/core/types.hpp" @@ -25,4 +29,18 @@ void check_batch_pair(const Graph& g, std::span sources, // (APPROACH 11). std::int32_t default_workers(bool sensitivity) noexcept; +// The evaluators inside a public call: as capacity, batch_capacity and sensitivity, with a +// supplied frozen cache's hits recorded in `tally` and nothing published. The public evaluators +// are frozen_call over these; the Python binding runs frozen_call over these and the construction +// of its result object, so the hits count only once that object exists. +PlacementResult capacity_in_call(const Graph& g, std::span sources, + std::span destinations, const CapacityOptions& opts, FrozenTally& tally); +std::vector batch_capacity_in_call( + const Graph& g, const std::vector, std::vector>>& pairs, + const CapacityOptions& opts, std::span per_pair_elig, std::int32_t workers, + FrozenTally& tally); +SensitivityResult sensitivity_in_call(const Graph& g, std::span sources, + std::span destinations, const SensitivityOptions& opts, + std::int32_t workers, FrozenTally& tally); + } // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/int128.hpp b/include/netgraph/core/detail/int128.hpp index a487ad1..d001c60 100644 --- a/include/netgraph/core/detail/int128.hpp +++ b/include/netgraph/core/detail/int128.hpp @@ -133,4 +133,19 @@ inline Dyadic dyadic(double f) noexcept { return d; } +// cost <= f * base, exactly (the Stretch ceiling of APPROACH 6 and 9.1): f is the dyadic rational +// it is (finite, f >= 1), cost >= 0 and base >= 1 below 2^63. f = m * 2^x with m odd below 2^53; +// bit lengths are compared before any shift, so no product exceeds 128 bits. +inline bool stretch_le(std::int64_t cost, std::int64_t base, double f) noexcept { + const auto [m, x] = dyadic(f); + const U128 rhs = mul_u64(static_cast(base), m); // below 2^116 + if (x >= 0) { + if (bits(rhs) + x > 64) return true; // rhs * 2^x >= 2^64 > cost + return !(shl(rhs, x) < U128 {0, static_cast(cost)}); + } + // f >= 1 with m < 2^53: -x < 53, so cost * 2^-x < 2^116. + const U128 lhs = shl(U128 {0, static_cast(cost)}, -x); + return !(rhs < lhs); +} + } // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/kernels.hpp b/include/netgraph/core/detail/kernels.hpp index 25e4c7a..9f4d97a 100644 --- a/include/netgraph/core/detail/kernels.hpp +++ b/include/netgraph/core/detail/kernels.hpp @@ -8,12 +8,15 @@ */ #pragma once +#include #include +#include #include #include #include #include +#include "netgraph/core/detail/traversal.hpp" #include "netgraph/core/graph.hpp" #include "netgraph/core/options.hpp" #include "netgraph/core/pred_dag.hpp" @@ -67,21 +70,47 @@ struct KernelPlan { FlowPlacement placement {FlowPlacement::MaxFlow}; Admission admission {Admission::Lossless}; NodeId src {0}; - std::vector dsts {}; // ascending std::vector route {}; // ascending: the flow's route (its record's bundle) std::vector members {}; // ascending: the split graph std::vector weights {}; // Weighted: per member (positive), frozen; else empty - // The split graph's nodes in a topological order (the source first), with per position its - // out-members members[out_begin[p] .. out_end[p]) (consecutive in ascending EdgeId: B' ids - // rank edges by source first) and per member the position of its head. Empty when the split - // graph is empty. - std::vector topo {}; - std::vector out_begin {}; - std::vector out_end {}; - std::vector head_pos {}; - std::vector terminal {}; + // The destinations (ascending) and the split graph's nodes in a topological order (the source + // first), with per position its out-members members[out_begin[p] .. out_end[p]) (consecutive in + // ascending EdgeId: B' ids rank edges by source first) and per member the position of its head; + // the structure is empty when the split graph is empty. Views of one block the plan owns + // (layout()), so a plan costs one allocation for all of them. + std::span dsts {}; + std::span topo {}; + std::span out_begin {}; + std::span out_end {}; + std::span head_pos {}; + std::span terminal {}; std::int32_t src_out_begin {0}; std::int32_t src_out_end {0}; + + KernelPlan() = default; + KernelPlan(const KernelPlan& other); + KernelPlan(KernelPlan&& other) noexcept; + KernelPlan& operator=(const KernelPlan& other); + KernelPlan& operator=(KernelPlan&& other) noexcept; + ~KernelPlan() = default; + + // The writable views of a new block: the destinations copied in, the structure for `positions` + // nodes and `num_members` members zeroed. One allocation, made before anything changes (a + // failure leaves the plan as it was); the previous block is released. + struct Layout { + std::span topo; + std::span out_begin; + std::span out_end; + std::span head_pos; + std::span terminal; + }; + Layout layout(std::span destinations, std::size_t positions, std::size_t num_members); + +private: + // Points the views into store_ for these sizes (the layout's order: dsts, topo, out_begin, + // out_end, head_pos, then terminal as bytes). + Layout point(std::size_t num_dsts, std::size_t positions, std::size_t num_members) noexcept; + std::vector store_ {}; }; // The edges of `dag` that lie on some walk from `src` to the destination set (edges leaving a @@ -120,6 +149,38 @@ struct KernelPlan { EdgePriority priority, NodeId src, std::span dsts, const PredDAG& bundle, const Eligibility* elig, Cost* cost = nullptr); +// ---------------------------------------------------------------- CostOnly creation (9.1, 10) + +// std::lower_bound(a, x) on an ascending `a`, found from position `hint` (the creation walk's +// previous range): a galloping probe toward x (1, 2, 4, ... away from the hint), then a binary +// search inside the last step. The answer is std::lower_bound's whatever the hint (the first +// position holding a value >= x, a.size() when none); only the probes differ. +[[nodiscard]] std::size_t lower_bound_from(std::span a, std::size_t hint, EdgeId x) noexcept; + +// The creation products of a CostOnly flow: its record bundle, its plan and its route cost. +struct CreationProducts { + std::shared_ptr bundle {}; + KernelPlan plan {}; + Cost cost {kUnreachable}; +}; + +// The products of a CostOnly flow of `src` created on its policy's static support (`support`: a +// reverse multipath support rooted at the destination set `dsts`) in one pass over its route: +// support_route's route for `selection` (Collapse on `view`), its record bundle (`support` itself +// when the route is the whole support, else PredDAGBuilder::of_edges of the route), and make_plan's +// plan and cost on that bundle, identical to those functions' for every input it accepts. It +// accepts a multipath selection with a kernel of static membership (every kernel but Weighted with +// Residual priority) and checks, while it builds, that the plan's members are the whole route: +// every route edge a member candidate, none leaving a destination, every route node reaching a +// destination, the source first in the topological order, no cycle, the members of a tail +// consecutive. Otherwise, and when src reaches no destination, it returns nullopt, and the caller +// takes those functions' path. Nothing proportional to the graph's size is allocated per call. +[[nodiscard]] std::optional cost_only_creation(const Graph& g, + const std::shared_ptr& support, + NodeId src, std::span dsts, + const PathSelection& selection, PriorityFn view, + FlowPlacement placement, Admission admission); + // ---------------------------------------------------------------- allocation (16.2, 16.3) // The cumulative allocation alloc(T) of a Weighted or EqualBalanced plan, member-aligned: diff --git a/include/netgraph/core/detail/policy_engine.hpp b/include/netgraph/core/detail/policy_engine.hpp index 9991cd9..d9bab86 100644 --- a/include/netgraph/core/detail/policy_engine.hpp +++ b/include/netgraph/core/detail/policy_engine.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -71,6 +72,13 @@ struct PolicyEngine { struct PolicyAccess { [[nodiscard]] static const PolicyEngine& engine(const FlowPolicy& policy) noexcept { return *policy.engine_; } [[nodiscard]] static PolicyEngine& engine(FlowPolicy& policy) noexcept { return *policy.engine_; } + // A policy constructed inside another public call, whose tally records a supplied frozen + // cache's hits (NOTES R27); returned as a prvalue (FlowPolicy does not move). + [[nodiscard]] static FlowPolicy make(FlowLedger& ledger, std::span sources, + std::span destinations, FlowPolicyConfig config, + const Eligibility* elig, DagCache* cache, FrozenTally& tally) { + return FlowPolicy(ledger, sources, destinations, std::move(config), elig, cache, tally); + } }; // The configuration checks of FlowPolicy (APPROACH 9; enumeration values first, rule 13), diff --git a/include/netgraph/core/detail/pred_dag_builder.hpp b/include/netgraph/core/detail/pred_dag_builder.hpp index 648b9fa..1e3dc35 100644 --- a/include/netgraph/core/detail/pred_dag_builder.hpp +++ b/include/netgraph/core/detail/pred_dag_builder.hpp @@ -14,10 +14,19 @@ namespace netgraph::core::detail { struct PredDAGBuilder { // The DAG of an edge set known to be acyclic and duplicate-free: per node its predecessor // entries (edge_src, e) in ascending EdgeId. Not validated beyond what the callers guarantee. - // O(|edges| + N) for ascending input (every caller's), O(|edges| log |edges| + N) otherwise - // (a sort first); the grouping by child is a stable counting sort over a temporary N + 1 - // array, and nothing retained is proportional to the graph's size. + // A small set (8 |edges| < N) is grouped by sorting, O(|edges| log |edges|), with nothing + // proportional to the graph's size; a larger one by a stable counting sort over a temporary + // N + 1 array, O(|edges| + N) (a sort first when the input is not ascending). Nothing retained + // is proportional to the graph's size. [[nodiscard]] static PredDAG of_edges(const Graph& g, std::span edges); + // The DAG without entries. + [[nodiscard]] static PredDAG empty(const Graph& g); + // A DAG from its finished parts (the searches' emission): `nodes` the children with entries + // (ascending), `offsets` their entry ranges (nodes.size() + 1, from 0), `parents` and `via` the + // entries (ascending via within a child), `ascending` the via in ascending EdgeId. + [[nodiscard]] static PredDAG of_parts(const Graph& g, std::vector nodes, std::vector offsets, + std::vector parents, std::vector via, + std::vector ascending); // A Path's DAG: hop h's members (ascending) are the entries of child nodes[h + 1], all with // the parent nodes[h]; the nodes are distinct. [[nodiscard]] static PredDAG of_path(std::uint64_t graph_uid, std::int32_t num_nodes, @@ -31,4 +40,13 @@ struct PredDAGBuilder { [[nodiscard]] static std::size_t payload_bytes(const PredDAG& dag) noexcept; }; +// Internal construction of Path values from trusted walks (the searches' results): the checks of +// Path's constructor hold by construction, so none is repeated (no per-path O(E) scan). +struct PathBuilder { + // A walk of single-member hops [[e1], [e2], ...]. + [[nodiscard]] static Path of_walk(const Graph& g, std::span walk); + // Contiguous hops of parallel members (each hop's members share (u, v); no node repeats). + [[nodiscard]] static Path of_hops(const Graph& g, std::vector> hops); +}; + } // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/search.hpp b/include/netgraph/core/detail/search.hpp deleted file mode 100644 index f0a4eb0..0000000 --- a/include/netgraph/core/detail/search.hpp +++ /dev/null @@ -1,126 +0,0 @@ -/* The search semantics of APPROACH 6 as internal routines (the DagCache's supports, the - policies' routes and the private _spf entry of the bindings). - - Positive costs make the shortest-path DAG from a root set unique (2.4): the tight edges - d(u) + cost(e) = d(v) of the member graph. With targets the DAG is pruned to the walks that - end in a nearest target (anycast), the same edge set for a forward search from the roots - and a reverse search rooted at the targets. The single path is the staged canonical walk: - (1) the tight DAG of the query, (2) under PreferHigherPriority the best attainable - bottleneck B* (a max-min DP on the DAG) and the DAG restricted to priority >= B*, (3) one - reverse hop count h from the targets and a greedy walk taking the smallest tight edge whose - head has h(v) = h(u) - 1. -*/ -#pragma once - -#include -#include -#include -#include - -#include "netgraph/core/graph.hpp" -#include "netgraph/core/options.hpp" -#include "netgraph/core/pred_dag.hpp" -#include "netgraph/core/types.hpp" - -namespace netgraph::core::detail { - -// An exact priority value: num / den with den >= 1 (den == 1 for Residual and Capacity, the -// capacity for Headroom); `infinite` above every finite value (the bottleneck of an empty walk). -struct Priority { - Amount num {0}; - Amount den {1}; - bool infinite {false}; -}; -[[nodiscard]] int compare(const Priority& a, const Priority& b) noexcept; // -1, 0, 1 - -// The priority value of an edge with `residual` units left of `capacity`: the residual units -// (Residual), the capacity units (Capacity) or the rational residual / capacity (Headroom). -[[nodiscard]] Priority priority_value(EdgePriority priority, Amount residual, Amount capacity) noexcept; - -// The per-edge priority values a search reads: `residual` empty (no residual) reads the static -// capacity for every priority; else priority_value on the residual. -struct PriorityValues { - const Graph* g {nullptr}; - EdgePriority priority {EdgePriority::Capacity}; - std::span residual {}; - [[nodiscard]] Priority of(EdgeId e) const noexcept; -}; - -// select_adjacency (APPROACH 6): whether member `e` replaces `cur`, the choice so far among the -// eligible members of one (u, v) pair seen in ascending EdgeId (cur < e): the lower cost, then -// under PreferHigherPriority (`prefer`) the higher priority value; on a tie the lower EdgeId -// (cur) stays. `value(e)` reads the priority value of an edge on the view of the selection. -template -[[nodiscard]] bool replaces(const Graph& g, EdgeId e, EdgeId cur, bool prefer, const Value& value) { - const auto cost = g.cost(); - const Cost ce = cost[static_cast(e)]; - const Cost cc = cost[static_cast(cur)]; - if (ce != cc) return ce < cc; - return prefer && compare(value(e), value(cur)) > 0; -} - -// The member graph (flags per edge): statically eligible, capacity >= 1 unit, residual >= -// min_residual when a residual is given; with multi_edge = false one member per (u, v) by -// select_adjacency. -[[nodiscard]] std::vector member_mask(const Graph& g, const Eligibility* elig, - const PathSelection& selection, - std::span residual, Amount min_residual); - -// Dijkstra over the members from (Forward) or toward (Reverse) the root set; kUnreachable for -// the nodes it does not reach. -[[nodiscard]] std::vector distances(const Graph& g, std::span member, - std::span roots, Direction direction); - -// The tight edges among the reached nodes of a full search (forward-oriented), ascending. -[[nodiscard]] std::vector full_dag(const Graph& g, std::span member, - std::span dist, Direction direction); - -// The edges on the minimum-cost walks from the source set to the destination set (the walks -// of cost d* = min over destinations of the distance), ascending; cost kUnreachable and no -// edge when no destination is reachable. -struct TightDag { - Cost cost {0}; - std::vector edges {}; -}; -[[nodiscard]] TightDag tight_dag(const Graph& g, std::span member, - std::span sources, std::span destinations); - -// The staged canonical single path from the source set to the destination set over the -// members; `priority` null for Deterministic. -struct SinglePath { - std::vector walk {}; // in walk order - Cost cost {0}; -}; -[[nodiscard]] std::optional canonical_path(const Graph& g, - std::span member, - std::span sources, - std::span destinations, - const PriorityValues* priority); - -// The search of APPROACH 6: validates (std::invalid_argument) the direction and the selection -// (enumeration values), the root set (non-empty, ids in range; duplicates removed), the targets -// when given (non-empty, ids in range, disjoint from the roots), multipath = false without -// targets, the residual (length E, entries in [0, capacity_units]), min_residual >= 1 and a -// foreign Eligibility, before any work. Excluded roots and targets are skipped (all excluded: -// an empty result). Without targets: `costs` for every node and the full DAG; with targets: the -// anycast-pruned DAG (or the canonical single path for multipath = false) and `target_cost` -// (nullopt when no target is reachable). -struct SpfOut { - std::optional> costs {}; - std::vector edges {}; - std::optional target_cost {}; - std::optional path {}; -}; -[[nodiscard]] SpfOut spf(const Graph& g, std::span roots, Direction direction, - std::optional> targets, const PathSelection& selection, - std::optional> residual, Amount min_residual, - const Eligibility* elig); - -// The full static support of a search from (Forward) or toward (Reverse) the root set: every -// tight edge of the statically eligible member graph (multipath, multi_edge, no residual) over -// the nodes it reaches, forward-oriented (APPROACH 10: the DagCache's value, and a CostOnly -// policy's DAG when it has no cache). -[[nodiscard]] PredDAG static_support(const Graph& g, std::span roots, Direction direction, - const Eligibility* elig); - -} // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/traversal.hpp b/include/netgraph/core/detail/traversal.hpp new file mode 100644 index 0000000..4ad0be6 --- /dev/null +++ b/include/netgraph/core/detail/traversal.hpp @@ -0,0 +1,146 @@ +/* The one traversal core of every search (internal; APPROACH 6 and 14): public spf, the + policies' route searches, the DagCache's supports and KSP's spur searches. + + One Dijkstra loop, parametrised by the direction (Forward scans a node's out-edge id range, + Reverse its in-edge slots) and the queue (a binary heap, or monotone cyclic buckets when the + graph's costs fit 65,536 buckets), both popping in (distance, node) order. Positive costs make + the set of tight edges d(u) + cost(e) = d(v) of a fixed member graph unique (2.4), whatever the + pop order among equal distances; the loop records them in per-node lists while it runs. An edge + is a candidate when it passes static eligibility, capacity >= 1 unit and the residual gate; + Collapse (multi_edge = false) keeps one member per (u, v) pair by select_adjacency when the + tight edges are emitted (it never changes a distance). With targets the loop stops at the first + target popped: every node nearer than that distance d* is settled by then, so every target at + d* has its final distance, and the walks back from the targets at d* are the anycast-pruned DAG. + The canonical single path is extracted from that DAG in stages (section 6) with the search's + own distances as the topological order: no second traversal. + + Every call leases the thread's workspace (node arrays reset through touched lists); the lease + restores it on every exit, exceptions included, and overlapping leases are refused. +*/ +#pragma once + +#include +#include +#include +#include + +#include "netgraph/core/graph.hpp" +#include "netgraph/core/options.hpp" +#include "netgraph/core/pred_dag.hpp" +#include "netgraph/core/search.hpp" +#include "netgraph/core/types.hpp" + +namespace netgraph::core::detail { + +// An exact priority value: num / den with den >= 1 (den == 1 for Residual and Capacity, the +// capacity for Headroom); `infinite` above every finite value (the bottleneck of an empty walk). +struct Priority { + Amount num {0}; + Amount den {1}; + bool infinite {false}; +}; +[[nodiscard]] int compare(const Priority& a, const Priority& b) noexcept; // -1, 0, 1 + +// The priority value of an edge with `residual` units left of `capacity`: the residual units +// (Residual), the capacity units (Capacity) or the rational residual / capacity (Headroom). +[[nodiscard]] Priority priority_value(EdgePriority priority, Amount residual, Amount capacity) noexcept; + +// The per-edge priority values a search reads: `residual` empty (no residual) reads the static +// capacity for every priority (APPROACH 10); else priority_value on the residual. +struct PriorityValues { + const Graph* g {nullptr}; + EdgePriority priority {EdgePriority::Capacity}; + std::span residual {}; + [[nodiscard]] Priority of(EdgeId e) const noexcept; +}; + +// Any source of per-edge priority values (a search's PriorityValues, a policy's working view), +// without a virtual call or an allocation. +struct PriorityFn { + const void* ctx {nullptr}; + Priority (*fn)(const void*, EdgeId) noexcept {nullptr}; + [[nodiscard]] Priority operator()(EdgeId e) const noexcept { return fn(ctx, e); } + [[nodiscard]] static PriorityFn of(const PriorityValues& values) noexcept; +}; + +// select_adjacency (APPROACH 6): whether member `e` replaces `cur`, the choice so far among the +// eligible members of one (u, v) pair seen in ascending EdgeId (cur < e): the lower cost, then +// under PreferHigherPriority (`prefer`) the higher priority value; on a tie the lower EdgeId +// (cur) stays. `value(e)` reads the priority value of an edge on the view of the selection. +template +[[nodiscard]] bool replaces(const Graph& g, EdgeId e, EdgeId cur, bool prefer, const Value& value) { + const auto cost = g.cost(); + const Cost ce = cost[static_cast(e)]; + const Cost cc = cost[static_cast(cur)]; + if (ce != cc) return ce < cc; + return prefer && compare(value(e), value(cur)) > 0; +} + +// ---------------------------------------------------------------- queue dispatch + +// The bucket queue's limit (an engineering cap, not a proven crossover): Auto takes the buckets +// when W = max_cost / max(1, cost_gcd) + 1 <= kMaxBucketWidth. +inline constexpr std::int64_t kMaxBucketWidth = std::int64_t {1} << 16; +// W of g (1 on a graph without edges: max_cost and cost_gcd are 0 there). +[[nodiscard]] std::int64_t bucket_width(const Graph& g) noexcept; +// Whether a search on g with `queue` runs on the buckets (Heap never does). +[[nodiscard]] bool uses_buckets(const Graph& g, SpfQueue queue) noexcept; + +// ---------------------------------------------------------------- searches of validated inputs + +// The candidates of a search beyond capacity >= 1 unit (validated by the caller): the static +// Eligibility (null: everything allowed) and a residual gate (`residual` empty: none; else E +// entries, an edge needs residual >= min_residual >= 1). +struct Gate { + const Eligibility* elig {nullptr}; + std::span residual {}; + Amount min_residual {1}; +}; + +// A policy's route: the edges in ascending EdgeId and the cost of its walks. +struct SearchRoute { + std::vector edges {}; + Cost cost {kUnreachable}; +}; + +// CapacityAware's search (APPROACH 9.1): forward from `src` toward the destination set (allowed +// ones; disjoint from src) over the candidates of `gate`, Collapse and the staged single path by +// `selection` with the priority values on `gate.residual`; nullopt when no destination is reached. +[[nodiscard]] std::optional route_search(const Graph& g, const Gate& gate, const PathSelection& selection, + NodeId src, std::span dests); + +// CostOnly's projection of a cached full static support (APPROACH 9.1, 10): the support edges +// reachable from `src` (ascending `support`: a reverse multipath DAG rooted at the destination +// set), Collapse by `selection` on the priority values `view` (no residual gating), and for +// multipath = false the staged canonical single path on that part, its distances derived from the +// support itself. An empty route when src reaches no destination of `dests` in the support. +[[nodiscard]] SearchRoute support_route(const Graph& g, std::span support, NodeId src, + std::span dests, const PathSelection& selection, PriorityFn view); + +// The full static support of a search from (Forward) or toward (Reverse) the root set (every root +// in range; excluded roots skipped): every tight edge of the statically eligible member graph +// (multipath, multi_edge, no residual) over the nodes it reaches, forward-oriented (the DagCache's +// value, and a CostOnly policy's DAG when it has no cache). +[[nodiscard]] PredDAG static_support(const Graph& g, std::span roots, Direction direction, + const Eligibility* elig); + +// The static cost from each of `sources` to the nearest of `dests` (allowed ones) over the +// statically eligible edges of at least 1 unit, kUnreachable where there is no walk (the +// CapacityAware and Global rosters and the Stretch baselines). +[[nodiscard]] std::vector static_costs(const Graph& g, const Eligibility* elig, std::span dests, + std::span sources); + +// The rule-13 check of a cost ceiling (APPROACH 6; KSP and CapacityAware): Absolute's cost >= 0, +// Stretch's factor finite and in [1, inf); std::invalid_argument naming max_path_cost. +void check_ceiling(const std::optional& ceiling); + +#if NETGRAPH_CORE_PRIVATE_HOOKS +// Test evidence (C++ tests, hooks builds only): the queue of every search this thread ran. +struct QueueCounts { + std::int64_t heap {0}; + std::int64_t buckets {0}; +}; +QueueCounts& thread_queue_counts() noexcept; +#endif + +} // namespace netgraph::core::detail diff --git a/include/netgraph/core/evaluators.hpp b/include/netgraph/core/evaluators.hpp index dca860f..3fe7d0e 100644 --- a/include/netgraph/core/evaluators.hpp +++ b/include/netgraph/core/evaluators.hpp @@ -49,6 +49,10 @@ struct SensitivityResult { std::vector entries {}; // ascending EdgeId; an absent edge has loss 0 }; +// Each evaluator is one public call on a supplied frozen cache's statistics (dag_cache.hpp): its +// hits count when it returns; a failed call changes no statistic, except one miss when it raises +// DagCacheMiss. + // place(+inf) of a FlowPolicy with opts.cfg on a fresh ledger of g (its capacities). [[nodiscard]] PlacementResult capacity(const Graph& g, std::span sources, std::span destinations, const CapacityOptions& opts); @@ -56,13 +60,17 @@ struct SensitivityResult { // capacity() per pair, in pair order, with opts.elig intersected with per_pair_elig[i] (null // entries allowed; an empty span means none: the Python binding checks the length of every // sequence it is given). Validated before any worker starts, the arguments first (workers >= 1, -// opts.elig's and the cache's graph, a supplied cache only with workers == 1: a building cache -// has one writer; Pinned rejected: bundles bind endpoints; the per_pair_elig length; the +// opts.elig's and the cache's graph, a supplied building cache only with workers == 1: it has +// one writer, while a frozen cache is shared by every worker; Pinned rejected: bundles bind +// endpoints; the per_pair_elig length; the // configuration), then each pair completely in pair order (its endpoint sets, their overlap, // its Eligibility's graph), so the lowest failing pair index is reported. Without a supplied // cache each worker owns a private building cache, allocated before any thread starts. A worker // exception aborts the batch and the exception of the lowest pair index is raised; an empty -// batch returns an empty list. +// batch returns an empty list. A frozen cache's DagCacheMiss propagates unchanged; a per-pair +// intersection that differs from both operands has a fresh uid, absent from a frozen cache +// (APPROACH 10: already effective per-pair values with a null common value, or a common value +// containing them, reuse the prepopulated operand). [[nodiscard]] std::vector batch_capacity( const Graph& g, const std::vector, std::vector>>& pairs, const CapacityOptions& opts, std::span per_pair_elig, std::int32_t workers); @@ -81,7 +89,9 @@ struct SensitivityResult { // the entry unevaluable with its message as the reason. Every other exception propagates (the // lowest candidate's). Workers and the cache // rule as batch_capacity; a worker's private cache is cleared per candidate (fresh Eligibility -// uids never hit). +// uids never hit). CostOnly is the one model reading the cache: with a frozen cache a +// placed deletion raises DagCacheMiss (a fresh uid), which propagates unchanged (it is not a +// std::runtime_error, so never `unevaluable`); the other models read no cache. [[nodiscard]] SensitivityResult sensitivity(const Graph& g, std::span sources, std::span destinations, const SensitivityOptions& opts, std::int32_t workers); diff --git a/include/netgraph/core/flow_policy.hpp b/include/netgraph/core/flow_policy.hpp index 588bba2..f72734f 100644 --- a/include/netgraph/core/flow_policy.hpp +++ b/include/netgraph/core/flow_policy.hpp @@ -22,6 +22,7 @@ namespace netgraph::core { namespace detail { struct PolicyEngine; struct PolicyAccess; +class FrozenTally; } // namespace detail // CapacityAware creations per call (stage 1 and accepted growth, across sources) before the @@ -154,6 +155,10 @@ class FlowPolicy { private: friend struct detail::PolicyAccess; + // Construction inside another public call (an evaluator): a supplied frozen cache's hits are + // recorded in that call's tally (NOTES R27). + FlowPolicy(FlowLedger& ledger, std::span sources, std::span destinations, + FlowPolicyConfig config, const Eligibility* elig, DagCache* cache, detail::FrozenTally& tally); std::unique_ptr engine_; }; diff --git a/include/netgraph/core/pred_dag.hpp b/include/netgraph/core/pred_dag.hpp index 2660201..233f510 100644 --- a/include/netgraph/core/pred_dag.hpp +++ b/include/netgraph/core/pred_dag.hpp @@ -12,6 +12,7 @@ namespace netgraph::core { namespace detail { struct PredDAGBuilder; +struct PathBuilder; } // namespace detail // An immutable, graph-bound DAG in predecessor CSR form: the predecessor entries of node v are @@ -81,6 +82,8 @@ class Path { } private: + friend struct detail::PathBuilder; + Path() = default; std::uint64_t graph_uid_ {0}; std::int32_t num_nodes_ {0}; std::vector> hops_ {}; diff --git a/include/netgraph/core/search.hpp b/include/netgraph/core/search.hpp new file mode 100644 index 0000000..d1dd2c8 --- /dev/null +++ b/include/netgraph/core/search.hpp @@ -0,0 +1,102 @@ +/* The public search (APPROACH 5 and 6): spf from a set of roots, forward or reverse, with an + optional set of targets (anycast); ksp, the K shortest paths in the canonical order; and + resolve_to_paths, a DAG unfolded into explicit paths. */ +#pragma once + +#include +#include +#include +#include +#include + +#include "netgraph/core/graph.hpp" +#include "netgraph/core/options.hpp" +#include "netgraph/core/pred_dag.hpp" +#include "netgraph/core/types.hpp" + +namespace netgraph::core { + +// The frontier queue of a search (C++ only; the results never depend on it). Auto takes the +// monotone bucket queue when the graph's costs fit max_cost / max(1, cost_gcd) + 1 <= 65,536 +// buckets, else the binary heap; Heap always takes the heap. Both pop in (distance, node) order +// and give identical results. +enum class SpfQueue { Auto, Heap }; + +// The options of spf (APPROACH 6). direction and selection are explicit; targets absent is a +// full search (every reachable node settled), present a search that stops at the nearest +// targets (anycast). A supplied residual (int64[E] in EdgeId order, each entry in +// [0, capacity_units]) is borrowed for the call; it adds residual gating at min_residual units +// and supplies the residual values of the selected priority. min_residual (an integer >= 1) gates +// a supplied residual only: static eligibility (the Eligibility, capacity >= 1 unit) is +// unconditional. The constructor rejects a direction outside its enumeration +// (std::invalid_argument naming the field); the fields stay assignable, so spf checks them again. +struct SpfOptions { + SpfOptions(Direction direction_, PathSelection selection_); + Direction direction; + std::optional> targets {}; + PathSelection selection; + std::optional> residual {}; + Amount min_residual {1}; + const Eligibility* elig {nullptr}; +}; + +// The result of spf. costs: full searches only (no targets), one entry per node, kUnreachable +// for the nodes the search did not reach. dag: the unique DAG of the member graph's edges that +// are tight in the traversal direction, returned in their original orientation u -> v (d the +// search's distance: d(u) + cost = d(v) for a forward search, d(v) + cost = d(u) for a reverse +// one); with targets, pruned to the walks between the roots and a nearest target (forward: from a +// root to the target; reverse: from the target to a root), or the canonical single path for +// multipath = false. target_cost: searches with targets only, the nearest targets' cost, or +// kUnreachable when no target is reached. +struct SpfResult { + std::optional> costs; + PredDAG dag; + std::optional target_cost; +}; + +// Validates in this order before any work (std::invalid_argument): the enumerations (direction, +// the selection, the queue), the root set (non-empty, ids in range; duplicates removed), the +// target set when given (the same), a vertex in both sets, multipath = false without targets, +// the residual (length E, then every entry in [0, capacity_units]), min_residual >= 1, a foreign +// Eligibility. A root or target excluded by the Eligibility is skipped; all roots excluded gives +// an empty result (no error). +[[nodiscard]] SpfResult spf(const Graph& g, std::span roots, const SpfOptions& opts, + SpfQueue queue = SpfQueue::Auto); + +// The options of ksp (APPROACH 6, rule 10). k (>= 1) and selection are explicit; the selection's +// multi_edge is the member rule (Expand keeps every eligible parallel member, Collapse one per +// (u, v) pair by select_adjacency) and multipath is ignored. max_path_cost: an inclusive ceiling, +// Absolute(c) (c >= 0) or Stretch(f) (f finite in [1, inf), read as the exact dyadic rational it +// is) times the static shortest cost of the member graph under the Eligibility, residual ignored. +// A supplied residual (validated as for spf, borrowed for the call) gates at 1 unit and supplies +// the priority values. The fields stay assignable; ksp checks them. +struct KspOptions { + KspOptions(std::int64_t k_, PathSelection selection_); + std::int64_t k; + std::optional max_path_cost {}; + PathSelection selection; + std::optional> residual {}; + const Eligibility* elig {nullptr}; +}; + +// The K shortest simple src -> dst paths of the member graph in increasing (cost, -bottleneck +// under PreferHigherPriority, hops, edge-id sequence), the bottleneck being the minimum priority +// value along a path: the minimal prefix of that order, truncated at k and at the ceiling. Each +// path is a Path of single-member hops with its cost. Validates in this order before any work +// (std::invalid_argument): the selection's enumerations, src and dst in range, src == dst, k >= 1, +// the ceiling, the residual, a foreign Eligibility. An excluded endpoint, no static route under a +// Stretch ceiling, or no path gives an empty list. +[[nodiscard]] std::vector> ksp(const Graph& g, NodeId src, NodeId dst, const KspOptions& opts); + +// The src -> dst walks of a DAG (APPROACH 5) in increasing (cost, hops, edge-id sequence): a hop's +// cost is its cheapest member's, and a grouped hop is represented in the key by its lowest member +// id. split_parallel_edges = false keeps every parallel member of the DAG's (u, v) pair in one hop +// (members ascending); true gives one path per member combination, in the same order. max_paths +// (>= 1) truncates the sorted list; absent, every walk. Validates (std::invalid_argument): a DAG of +// another graph, src and dst in range, src == dst, max_paths >= 1. An endpoint outside the DAG, or +// no walk, gives an empty list. +[[nodiscard]] std::vector resolve_to_paths(const Graph& g, const PredDAG& dag, NodeId src, NodeId dst, + bool split_parallel_edges = false, + std::optional max_paths = std::nullopt); + +} // namespace netgraph::core diff --git a/python/netgraph_core/__init__.py b/python/netgraph_core/__init__.py index 2b9ee44..8cca271 100644 --- a/python/netgraph_core/__init__.py +++ b/python/netgraph_core/__init__.py @@ -11,10 +11,10 @@ with its ``FlowRecord`` snapshots), the policy engine (``FlowPolicy`` with ``FlowPolicyConfig`` over the routing variants ``CostOnly``, ``CapacityAware``, ``Global``, ``Pinned``; ``PlacementResult``, ``OutputOptions``, ``Termination``), the -``DagCache`` (building mode) and the evaluators (``capacity``, ``batch_capacity``, -``sensitivity``). The public search API (shortest paths, k shortest paths, path -resolution) and the frozen cache are not part of this build; ``_netgraph_core`` also holds the private entries -of the flow core and the internal search, which are never re-exported here. +``DagCache`` (building and frozen modes, ``DagCacheMiss``) and the evaluators (``capacity``, ``batch_capacity``, +``sensitivity``) and the public search (``spf`` with ``Direction``, ``SpfOptions``, +``SpfResult`` and the ``UNREACHABLE`` sentinel; ``ksp`` with ``KspOptions``; ``resolve_to_paths``). ``_netgraph_core`` also holds the private +entry of the flow core, which is never re-exported here. """ # pyright: reportMissingImports=false @@ -24,14 +24,17 @@ from _netgraph_core import ( MAX_FLOWS_PER_CALL, + UNREACHABLE, Absolute, Admission, CapacityAware, CapacityOptions, CostOnly, DagCache, + DagCacheMiss, DagCacheStats, DiminishingReturns, + Direction, EdgePriority, EdgeTieBreak, Eligibility, @@ -42,6 +45,7 @@ FlowRecord, Global, Graph, + KspOptions, OutputOptions, Path, PathSelection, @@ -52,12 +56,17 @@ SensitivityOptions, SensitivityResult, SensitivityStatus, + SpfOptions, + SpfResult, Stretch, Termination, as_float, batch_capacity, capacity, + ksp, + resolve_to_paths, sensitivity, + spf, to_units, ) @@ -71,14 +80,17 @@ if TYPE_CHECKING: # pragma: no cover - typing-only from ._docs import ( # noqa: I001 MAX_FLOWS_PER_CALL as MAX_FLOWS_PER_CALL, + UNREACHABLE as UNREACHABLE, Absolute as Absolute, Admission as Admission, CapacityAware as CapacityAware, CapacityOptions as CapacityOptions, CostOnly as CostOnly, DagCache as DagCache, + DagCacheMiss as DagCacheMiss, DagCacheStats as DagCacheStats, DiminishingReturns as DiminishingReturns, + Direction as Direction, EdgePriority as EdgePriority, EdgeTieBreak as EdgeTieBreak, Eligibility as Eligibility, @@ -89,6 +101,7 @@ FlowRecord as FlowRecord, Global as Global, Graph as Graph, + KspOptions as KspOptions, OutputOptions as OutputOptions, Path as Path, PathSelection as PathSelection, @@ -99,12 +112,17 @@ SensitivityOptions as SensitivityOptions, SensitivityResult as SensitivityResult, SensitivityStatus as SensitivityStatus, + SpfOptions as SpfOptions, + SpfResult as SpfResult, Stretch as Stretch, Termination as Termination, as_float as as_float, batch_capacity as batch_capacity, capacity as capacity, + ksp as ksp, + resolve_to_paths as resolve_to_paths, sensitivity as sensitivity, + spf as spf, to_units as to_units, ) @@ -137,6 +155,7 @@ "FlowPolicy", "MAX_FLOWS_PER_CALL", "DagCache", + "DagCacheMiss", "DagCacheStats", "CapacityOptions", "SensitivityOptions", @@ -146,4 +165,12 @@ "capacity", "batch_capacity", "sensitivity", + "Direction", + "SpfOptions", + "SpfResult", + "UNREACHABLE", + "spf", + "KspOptions", + "ksp", + "resolve_to_paths", ] diff --git a/python/netgraph_core/_docs.py b/python/netgraph_core/_docs.py index 1ca4f57..6422e47 100644 --- a/python/netgraph_core/_docs.py +++ b/python/netgraph_core/_docs.py @@ -9,10 +9,12 @@ the staged calls, allocates through pybind11's ``make_new_instance``, which does not check the result of ``tp_alloc``, so a Python allocation failure at that point crashes the process, with Core state unchanged; (2) a supplied building DagCache is a memo: it keeps the complete entries -and statistics of the lookups that succeeded before a later failure of the call. +and statistics of the lookups that succeeded before a later failure of the call. A frozen +DagCache's statistics are part of the guarantee: a failed call changes none of them, except one +miss when it raises ``DagCacheMiss``. -Generated ``.pyi`` stubs (pybind11-stubgen, APPROACH 13) arrive with the public search (PLAN, -sub-PR 1d); this hand-written shim is deleted in Phase 5. +This hand-written shim is the package's type declaration until Phase 5, which replaces it by +generated ``.pyi`` stubs (pybind11-stubgen, APPROACH 13) with their override file and drift check. """ from __future__ import annotations @@ -200,7 +202,8 @@ def eligibility( class EdgeTieBreak: - """Ties between equal-cost candidates, at both tie levels (parallel members and walks). + """Ties between equal-cost candidates, at both tie levels (parallel members and walks; + APPROACH 6). ``DETERMINISTIC`` takes the lowest EdgeId and ignores capacities; ``PREFER_HIGHER_PRIORITY`` prefers the higher ``priority`` value first. @@ -221,7 +224,7 @@ def value(self) -> int: ... class EdgePriority: """The per-edge value ``PREFER_HIGHER_PRIORITY`` reads, and the Weighted kernel's - membership and weights: ``RESIDUAL`` (residual units), ``CAPACITY`` (static capacity + membership and weights (APPROACH 6, 7): ``RESIDUAL`` (residual units), ``CAPACITY`` (static capacity units), ``HEADROOM`` (the exact rational residual / capacity; rejected with WEIGHTED). A search given no residual reads static capacities for every priority.""" @@ -239,6 +242,24 @@ def name(self) -> str: ... def value(self) -> int: ... +class Direction: + """The orientation of a search (APPROACH 6): ``FORWARD`` from the roots, or ``REVERSE`` + rooted at the roots over reversed arcs. A reverse search returns the forward-oriented DAG + of the walks toward its roots.""" + + FORWARD: ClassVar["Direction"] + REVERSE: ClassVar["Direction"] + __members__: ClassVar[dict[str, "Direction"]] + + def __init__(self, value: int) -> None: ... + def __int__(self) -> int: ... + def __index__(self) -> int: ... + @property + def name(self) -> str: ... + @property + def value(self) -> int: ... + + class FlowPlacement: """The kernel of one flow (APPROACH 7). @@ -720,22 +741,57 @@ def bytes(self) -> int: ... def live_values(self) -> int: ... +class DagCacheMiss(Exception): + """A lookup in a frozen DagCache whose key is absent (APPROACH 10). + + A dedicated exception (neither a RuntimeError nor a ValueError): every evaluator + propagates it unchanged, never as a sensitivity status. A frozen cache never searches, + so a miss means the caller did not prepopulate that (roots, direction, Eligibility uid). + """ + + class DagCache: """A cache of static shortest-path supports bound to one graph (APPROACH 10). Keyed ``(roots as a set, direction, Eligibility uid)``; the value is the full static support (``multipath``, ``multi_edge``, no residual) as an immutable shared value, valid - after ``clear()``. Selection is projected at creation, never cached. Building mode: one - writer, no concurrent readers (a supplied cache needs ``workers == 1``). A supplied - building cache is a memo (APPROACH 13, limit 2): a public call that fails keeps the - complete entries and statistics of the lookups that succeeded before; it never holds a - partial entry, held values stay valid, and a retained entry never changes a result. + after ``clear()`` and after the cache is gone. Selection is projected at creation, never + cached. + + Building mode (a new cache): one writer, no concurrent readers; a miss searches and + inserts; a supplied building cache needs ``workers == 1``. Frozen mode (``freeze()``): + read-only, shareable by every worker; a miss counts one miss and raises + ``DagCacheMiss``; ``clear()`` raises ValueError; ``freeze()`` again has no effect; + ``thaw()`` returns to building mode once the caller has joined every reader. A per-pair + or per-candidate Eligibility built inside a batch or a sensitivity has a new uid, so + its key is absent from a frozen cache unless the intersection returned a prepopulated + operand. + + A supplied building cache is a memo (APPROACH 13, limit 2): a public call that fails + keeps the complete entries and statistics of the lookups that succeeded before; it never + holds a partial entry, held values stay valid, and a retained entry never changes a + result. A frozen cache's statistics follow the public call that reads it (a policy + constructor, an evaluator): its hits count when the call succeeds, its Python result + included; a failed call changes no statistic, except that a call raising + ``DagCacheMiss`` counts one miss. """ def __init__(self, graph: Graph) -> None: ... - def graph_uid(self) -> int: ... + def graph_uid(self) -> int: + """uid of the graph this cache is bound to.""" + ... + + def freeze(self) -> None: + """Enter frozen mode (no effect when already frozen).""" + ... + + def thaw(self) -> None: + """Return to building mode (no effect when building); join every reader first.""" + ... + def clear(self) -> None: - """Empties the cache; the hit and miss counters persist; held values stay valid.""" + """Empty a building cache (the hit and miss counters persist; held values stay + valid); ValueError when frozen.""" ... def stats(self) -> DagCacheStats: ... @@ -903,9 +959,11 @@ def capacity( """``place(+inf)`` of a FlowPolicy with ``opts.config`` on a fresh ledger of the graph. The exact maximum flow is the Global + MaxFlow configuration (the minimum-cost maximum - flow; ``cost_distribution`` its marginal curve). A failed call raises and returns no + flow; ``cost_distribution`` its marginal curve). A supplied frozen cache raises + ``DagCacheMiss`` for an absent key, unchanged. A failed call raises and returns no result; a supplied building cache keeps the entries and statistics of the lookups that - succeeded before the failure (a memo: APPROACH 13, limit 2). + succeeded before the failure (a memo: APPROACH 13, limit 2); a supplied frozen + cache's statistics are unchanged, except one miss when the call raises ``DagCacheMiss``. """ ... @@ -925,10 +983,16 @@ def batch_capacity( rejected; the ``per_pair_elig`` length), then each pair completely in pair order (its endpoints, their overlap, its Eligibility), so the error of the lowest failing pair index is raised; an empty batch returns ``[]``. ``workers``: an explicit int >= 1 is honoured; ``None`` takes - ``NGRAPH_CORE_BATCH_THREADS`` (or 1), and 1 when a cache is supplied (a supplied - building cache is rejected unless ``workers == 1``). A failed call raises and returns + ``NGRAPH_CORE_BATCH_THREADS`` (or 1), and 1 when a cache is supplied. A supplied + building cache is rejected unless ``workers == 1``; a frozen cache is shared by every + worker (pass ``workers`` explicitly), and its ``DagCacheMiss`` for an absent key reaches + the caller unchanged (the lowest failing pair index's). A per-pair intersection that + differs from both operands has a new uid, absent from a frozen cache: pass already + effective per-pair values with ``opts.elig`` ``None``, or a common value containing them, + so the intersection returns the prepopulated operand. A failed call raises and returns no result; a supplied building cache keeps the entries and statistics of the lookups - that succeeded before the failure (a memo: APPROACH 13, limit 2). + that succeeded before the failure (a memo: APPROACH 13, limit 2); a supplied frozen + cache's statistics are unchanged, except one miss when the call raises ``DagCacheMiss``. """ ... @@ -951,8 +1015,169 @@ def sensitivity( source's reachable part of the static support loss 0 without a placement, a result-equivalent predicate; every other deletion is placed on a fresh ledger, where a model exception makes the entry ``UNEVALUABLE`` with the message as reason. ``workers`` as ``batch_capacity`` (``NGRAPH_CORE_SENSITIVITY_THREADS``). + The cache: a supplied building cache needs ``workers == 1``, a frozen one is shared by + every worker; CostOnly is the one model reading the cache, and its deletion looks up a + new Eligibility (a new uid), so with a frozen cache it raises ``DagCacheMiss`` + (unchanged, never ``UNEVALUABLE``); the other models read no cache. A failed call raises and returns no result; a supplied building cache keeps the entries and statistics of the lookups that succeeded before the failure (a memo: - APPROACH 13, limit 2). + APPROACH 13, limit 2); a supplied frozen cache's statistics are unchanged, except one + miss when the call raises ``DagCacheMiss``. + """ + ... + + +# ---------------------------------------------------------------- the public search (APPROACH 6) + +UNREACHABLE: int +"""The path-cost sentinel of an unreachable node (``2**63 - 1``) in ``SpfResult.costs``; the +result accessors that return a single cost give ``None`` instead.""" + + +class SpfOptions: + """The options of ``spf`` (APPROACH 6). + + ``direction`` and ``selection`` are explicit. ``targets`` (one NodeId or a sequence; + ``None``: a full search) stops the search at the nearest targets (anycast). ``residual`` + (int64 units per EdgeId, each in ``[0, capacity_units]``) adds residual gating at + ``min_residual`` units and supplies the priority values of the selection; ``min_residual`` + (an integer ``>= 1``) gates a supplied residual only (static eligibility, the Eligibility + and capacity ``>= 1`` unit, is unconditional). ``elig``: ``None`` allows everything. + A direction outside its enumeration raises ValueError here; every other field is + validated by ``spf``, in the contract's order. + """ + + def __init__( + self, + direction: Direction, + selection: PathSelection, + *, + targets: "int | Sequence[int] | None" = None, + residual: "Sequence[int] | np.ndarray | None" = None, + min_residual: int = 1, + elig: Eligibility | None = None, + ) -> None: ... + @property + def direction(self) -> Direction: ... + @property + def selection(self) -> PathSelection: ... + @property + def targets(self) -> "int | Sequence[int] | None": ... + @property + def residual(self) -> "Sequence[int] | np.ndarray | None": ... + @property + def min_residual(self) -> int: ... + @property + def elig(self) -> Eligibility | None: ... + + +class SpfResult: + """The result of ``spf`` (APPROACH 6). + + ``costs``: full searches only, a read-only int64 array with one entry per node and + ``UNREACHABLE`` for the nodes the search did not reach (``None`` with targets). + ``dag``: the unique DAG of the member graph's edges that are tight in the traversal + direction, returned in their original orientation ``u -> v`` (``d`` the search's distance: + ``d(u) + cost = d(v)`` for a forward search, ``d(v) + cost = d(u)`` for a reverse one); + with targets, pruned to the walks between the roots and a nearest target (forward: from a + root to the target; reverse: from the target to a root), or the canonical single path for + ``multipath = False``. ``target_cost``: with targets, the nearest targets' cost, ``None`` + when no target is reached (and ``None`` for a full search). + """ + + @property + def costs(self) -> "np.ndarray | None": ... + @property + def dag(self) -> PredDAG: ... + @property + def target_cost(self) -> int | None: ... + + +def spf(graph: Graph, roots: "int | Sequence[int]", opts: SpfOptions) -> SpfResult: + """Shortest paths from the roots (every root at distance 0), forward or reverse (APPROACH 6). + + The member graph is every statically eligible edge of at least 1 unit (and, with a + residual, at least ``min_residual`` residual units); ``multi_edge = False`` keeps one + member per ``(u, v)`` pair (minimum cost, then the higher priority under + PREFER_HIGHER_PRIORITY, then the lowest EdgeId). Without targets every reachable node is + settled (``costs`` and the full DAG). With targets the search stops at the nearest + targets' cost (``target_cost``) and the DAG keeps only the walks between the roots and a + nearest target (for a reverse search, from the target to a root); with ``multipath = False`` it is the canonical single path, the walk minimising + ``(cost, -bottleneck under PREFER_HIGHER_PRIORITY, hops, edge-id sequence)`` (the same + walk from both directions with the roles swapped). Validated in this order before any + work (ValueError): the roots (non-empty, ids in range), the targets (the same), a vertex + in both sets, ``multipath = False`` without targets, the residual (its length, then every + entry in ``[0, capacity_units]``), ``min_residual >= 1``, a foreign Eligibility. A root or + target excluded by the Eligibility is skipped; all roots excluded gives an empty result. + """ + ... + + +class KspOptions: + """The options of ``ksp`` (APPROACH 6, rule 10). + + ``k`` (an integer ``>= 1``) and ``selection`` are explicit; the selection's ``multi_edge`` + is the member rule (Expand keeps every eligible parallel member, Collapse one per + ``(u, v)`` pair by ``select_adjacency``) and ``multipath`` is ignored. ``max_path_cost``: + ``None``, ``Absolute(c)`` (``c >= 0``) or ``Stretch(f)`` (``f`` finite in ``[1, inf)``, the + exact dyadic rational it is, times the static shortest cost of the member graph under the + Eligibility, residual ignored); the ceiling is inclusive. ``residual`` (int64 units per + EdgeId, each in ``[0, capacity_units]``) gates at 1 unit and supplies the priority values. + Every field is validated by ``ksp``, in the contract's order. + """ + + def __init__( + self, + k: int, + selection: PathSelection, + *, + max_path_cost: "Absolute | Stretch | None" = None, + residual: "Sequence[int] | np.ndarray | None" = None, + elig: Eligibility | None = None, + ) -> None: ... + @property + def k(self) -> int: ... + @property + def selection(self) -> PathSelection: ... + @property + def max_path_cost(self) -> "Absolute | Stretch | None": ... + @property + def residual(self) -> "Sequence[int] | np.ndarray | None": ... + @property + def elig(self) -> Eligibility | None: ... + + +def ksp(graph: Graph, src: int, dst: int, opts: KspOptions) -> "list[tuple[int, Path]]": + """The K shortest simple ``src -> dst`` paths of the member graph (APPROACH 6, rule 10). + + In increasing ``(cost, -bottleneck under PREFER_HIGHER_PRIORITY, hops, edge-id sequence)``, + the bottleneck being the minimum priority value along a path: the minimal prefix of that + order, truncated at ``k`` and at the inclusive ceiling. Each Path has single-member hops. + Built by Yen deviations whose first path and spur paths are the canonical single path of + ``spf`` (the spur's bottleneck target capped by the root prefix's bottleneck), so ``k = 1`` + gives the single path of a forward or reverse ``spf``. Validated in this order before any + work (ValueError): ``src`` and ``dst`` in range, ``src == dst``, ``k >= 1``, the ceiling, the + residual (its length, then every entry), a foreign Eligibility. An excluded endpoint, no + static route under ``Stretch``, or no path gives ``[]``. + """ + ... + + +def resolve_to_paths( + graph: Graph, + dag: PredDAG, + src: int, + dst: int, + split_parallel_edges: bool = False, + max_paths: int | None = None, +) -> "list[Path]": + """The ``src -> dst`` walks of a DAG (APPROACH 5) in increasing ``(cost, hops, edge-id + sequence)``: a hop costs its cheapest member and is keyed by its lowest member id. + + ``split_parallel_edges = False`` keeps every parallel member of a ``(u, v)`` pair in one + hop (members ascending); ``True`` gives one path per member combination, in the same + order. ``max_paths`` (``>= 1``) truncates the sorted list; ``None`` lists every walk. + ValueError: a DAG of another graph, ``src`` or ``dst`` out of range, ``src == dst``, + ``max_paths < 1``. An endpoint outside the DAG, or no walk, gives ``[]``. """ ... diff --git a/src/dag_cache.cpp b/src/dag_cache.cpp index aa62cc6..e409516 100644 --- a/src/dag_cache.cpp +++ b/src/dag_cache.cpp @@ -1,17 +1,20 @@ /* - The DagCache of APPROACH 10 in building mode: a map from (roots, direction, Eligibility uid) - to the full static support of that search, with atomic statistics. + The DagCache of APPROACH 10: a map from (roots, direction, Eligibility uid) to the full static + support of that search, with atomic statistics; building mode (a miss searches and inserts) and + frozen mode (read-only: concurrent hits read the map and bump atomic counters, a miss throws). */ #include "netgraph/core/dag_cache.hpp" +#include #include #include #include +#include "netgraph/core/detail/cache_access.hpp" #include "netgraph/core/detail/endpoints.hpp" #include "netgraph/core/detail/enum_domain.hpp" #include "netgraph/core/detail/pred_dag_builder.hpp" -#include "netgraph/core/detail/search.hpp" +#include "netgraph/core/detail/traversal.hpp" namespace netgraph::core { @@ -28,25 +31,66 @@ struct LiveDeleter { } // namespace +// The key function of DagCacheMiss (dag_cache.hpp): its vtable and type_info live here only. +const char* DagCacheMiss::what() const noexcept { return what_->c_str(); } + DagCache::DagCache(const Graph& graph) : graph_uid_(graph.uid()) {} std::shared_ptr DagCache::lookup(const Graph& g, std::span roots, Direction direction, const Eligibility* elig) { + return lookup_in_call(g, roots, direction, elig, nullptr); +} + +std::shared_ptr DagCache::lookup_in_call(const Graph& g, std::span roots, + Direction direction, const Eligibility* elig, + detail::FrozenTally* tally) { + // Inside a public call a frozen cache's statistics wait for the call's boundary (NOTES R27). + const bool staged = frozen_ && tally != nullptr && tally->cache() == this; // Invalid queries throw before anything is counted. if (g.uid() != graph_uid_) throw std::invalid_argument("DagCache: the graph is not the cache's graph (foreign graph)"); if (elig != nullptr && elig->graph_uid() != g.uid()) { throw std::invalid_argument("foreign Eligibility: built for another graph"); } - auto key_roots = detail::node_set(roots, g.num_nodes(), "roots"); + // The roots as a sorted set: borrowed when the caller's span already is one (a hit then copies + // nothing), else normalised (node_set's checks either way, in its order). + std::vector normalised; + std::span key_roots = roots; + if (roots.empty() || !std::is_sorted(roots.begin(), roots.end()) || + std::adjacent_find(roots.begin(), roots.end()) != roots.end()) { + normalised = detail::node_set(roots, g.num_nodes(), "roots"); + key_roots = normalised; + } else { + detail::check_range(roots, g.num_nodes(), "roots"); + } detail::check_member(direction, "direction"); - Key key {std::move(key_roots), direction, elig != nullptr ? elig->uid() : 0}; - const auto it = entries_.find(key); + const std::uint64_t elig_uid = elig != nullptr ? elig->uid() : 0; + const auto it = entries_.find(KeyView {key_roots, direction, elig_uid}); if (it != entries_.end()) { - hits_.fetch_add(1, std::memory_order_relaxed); + if (staged) { + tally->hit(); + } else { + hits_.fetch_add(1, std::memory_order_relaxed); + } return it->second; } + if (frozen_) { + // A frozen miss counts one miss (the lookup found no entry; inside a call, the boundary + // counts it) and inserts nothing; the exception is built first, so a failed allocation there + // counts nothing. + std::string roots_text; + for (NodeId r : key_roots) roots_text += (roots_text.empty() ? "" : ", ") + std::to_string(r); + const DagCacheMiss miss("DagCache: the frozen cache has no entry for roots {" + roots_text + "}, direction " + + (direction == Direction::Forward ? "Forward" : "Reverse") + ", Eligibility uid " + + std::to_string(elig_uid) + " (a frozen cache never searches)"); + if (!staged) misses_.fetch_add(1, std::memory_order_relaxed); + throw miss; + } + // The entry's owned key first (the borrowed roots copied): a failed copy counts nothing, like a + // failed normalisation. Then a failed search (or a failed insertion) inserts nothing and the miss + // stays counted. + Key key {normalised.empty() ? std::vector(key_roots.begin(), key_roots.end()) : std::move(normalised), + direction, elig_uid}; misses_.fetch_add(1, std::memory_order_relaxed); - // A failed search (or a failed insertion) inserts nothing; the miss stays counted. auto value = std::make_unique(detail::static_support(g, std::get<0>(key), direction, elig)); const auto bytes = static_cast(detail::PredDAGBuilder::payload_bytes(*value)); live_->fetch_add(1, std::memory_order_relaxed); // the deleter runs even if the control block fails @@ -57,7 +101,15 @@ std::shared_ptr DagCache::lookup(const Graph& g, std::span #include +#include "netgraph/core/detail/cache_access.hpp" #include "netgraph/core/detail/endpoints.hpp" #include "netgraph/core/detail/evaluator_checks.hpp" #include "netgraph/core/detail/flow_core.hpp" @@ -36,11 +37,12 @@ std::shared_ptr borrowed(const Graph& g) { return std::shared_ptr(std::shared_ptr {}, &g); } +// One evaluation inside a public call: a supplied frozen cache's hit goes to the call's tally. PlacementResult evaluate(const Graph& g, std::span sources, std::span destinations, const FlowPolicyConfig& cfg, const Eligibility* elig, - DagCache* cache, const OutputOptions& outputs) { + DagCache* cache, const OutputOptions& outputs, detail::FrozenTally& tally) { FlowLedger fg(borrowed(g)); - FlowPolicy policy(fg, sources, destinations, cfg, elig, cache); + FlowPolicy policy = detail::PolicyAccess::make(fg, sources, destinations, cfg, elig, cache, tally); return policy.place(std::nullopt, outputs); } @@ -52,10 +54,10 @@ void check_common(const Graph& g, const Eligibility* elig, const DagCache* cache if (cache != nullptr && cache->graph_uid() != g.uid()) { throw std::invalid_argument("foreign DagCache: bound to another graph"); } - if (cache != nullptr && workers != 1) { + if (cache != nullptr && workers != 1 && !detail::DagCacheAccess::frozen(*cache)) { throw std::invalid_argument( "a supplied building DagCache has one writer: it is rejected unless workers == 1 (got workers = " + - std::to_string(workers) + ")"); + std::to_string(workers) + "; a frozen cache may be shared by every worker)"); } } @@ -65,8 +67,8 @@ std::size_t worker_threads(std::size_t n, std::int32_t workers) { } // Runs task(i, cache, t) for i in [0, n) on worker_threads(n, workers) threads (t the worker's -// index), each with its own private building cache unless `shared` is given (workers == 1 -// then). Indices are taken in increasing +// index), each with its own private building cache unless `shared` is given (a supplied cache: +// building with workers == 1, or frozen and shared by every worker). Indices are taken in increasing // order and a taken index always runs, so after an exception every lower index has run: the // lowest failing index is the one a sequential run would report, and its exception is rethrown. // Everything a worker needs (the result slots, the private caches) is allocated here, before @@ -173,32 +175,22 @@ void check_batch_pair(const Graph& g, std::span sources, } } -std::int32_t default_workers(bool sensitivity) noexcept { - const char* v = sensitivity ? std::getenv("NGRAPH_CORE_SENSITIVITY_THREADS") : std::getenv("NGRAPH_CORE_BATCH_THREADS"); - if (v == nullptr) return 1; - char* end = nullptr; - const long n = std::strtol(v, &end, 10); - if (end == v || *end != '\0' || n < 1 || n > 4096) return 1; - return static_cast(n); +PlacementResult capacity_in_call(const Graph& g, std::span sources, + std::span destinations, const CapacityOptions& opts, FrozenTally& tally) { + return evaluate(g, sources, destinations, opts.cfg, opts.elig, opts.cache, opts.outputs, tally); } -} // namespace detail - -PlacementResult capacity(const Graph& g, std::span sources, - std::span destinations, const CapacityOptions& opts) { - return evaluate(g, sources, destinations, opts.cfg, opts.elig, opts.cache, opts.outputs); -} - -std::vector batch_capacity( +std::vector batch_capacity_in_call( const Graph& g, const std::vector, std::vector>>& pairs, - const CapacityOptions& opts, std::span per_pair_elig, std::int32_t workers) { - detail::check_batch_arguments(g, opts, workers); + const CapacityOptions& opts, std::span per_pair_elig, std::int32_t workers, + FrozenTally& tally) { + check_batch_arguments(g, opts, workers); if (!per_pair_elig.empty() && per_pair_elig.size() != pairs.size()) { throw std::invalid_argument("per_pair_elig has " + std::to_string(per_pair_elig.size()) + " entries for " + std::to_string(pairs.size()) + " pairs"); } for (std::size_t i = 0; i < pairs.size(); ++i) { - detail::check_batch_pair(g, pairs[i].first, pairs[i].second, per_pair_elig.empty() ? nullptr : per_pair_elig[i], i); + check_batch_pair(g, pairs[i].first, pairs[i].second, per_pair_elig.empty() ? nullptr : per_pair_elig[i], i); } std::vector> results(pairs.size()); run_indexed(pairs.size(), workers, g, opts.cache, [&](std::size_t i, DagCache& cache, std::size_t /*t*/) { @@ -213,7 +205,7 @@ std::vector batch_capacity( elig = &*both; } } - results[i] = evaluate(g, pairs[i].first, pairs[i].second, opts.cfg, elig, &cache, opts.outputs); + results[i] = evaluate(g, pairs[i].first, pairs[i].second, opts.cfg, elig, &cache, opts.outputs, tally); }); std::vector out; out.reserve(results.size()); @@ -221,9 +213,9 @@ std::vector batch_capacity( return out; } -SensitivityResult sensitivity(const Graph& g, std::span sources, - std::span destinations, const SensitivityOptions& opts, - std::int32_t workers) { +SensitivityResult sensitivity_in_call(const Graph& g, std::span sources, + std::span destinations, const SensitivityOptions& opts, + std::int32_t workers, FrozenTally& tally) { check_common(g, opts.elig, opts.cache, workers); const bool global = std::holds_alternative(opts.cfg.routing); const bool cost_only = std::holds_alternative(opts.cfg.routing); @@ -238,8 +230,8 @@ SensitivityResult sensitivity(const Graph& g, std::span sources, cache = own.get(); } FlowLedger fg(borrowed(g)); - FlowPolicy policy(fg, sources, destinations, opts.cfg, opts.elig, cache); - auto& pe = detail::PolicyAccess::engine(policy); + FlowPolicy policy = PolicyAccess::make(fg, sources, destinations, opts.cfg, opts.elig, cache, tally); + auto& pe = PolicyAccess::engine(policy); if (global) pe.capture = &capture; out.baseline = policy.place(std::nullopt, opts.outputs); pe.capture = nullptr; @@ -298,7 +290,7 @@ SensitivityResult sensitivity(const Graph& g, std::span sources, const EdgeId one[1] = {e}; Eligibility deleted = g.eligibility({}, one); if (opts.elig != nullptr) deleted = Eligibility::intersect(*opts.elig, deleted); - const auto r = evaluate(g, sources, destinations, opts.cfg, &deleted, &cache, OutputOptions {}); + const auto r = evaluate(g, sources, destinations, opts.cfg, &deleted, &cache, OutputOptions {}, tally); entry.loss = base - r.placed; } entry.status = SensitivityStatus::Evaluated; @@ -312,4 +304,39 @@ SensitivityResult sensitivity(const Graph& g, std::span sources, return out; } +std::int32_t default_workers(bool sensitivity) noexcept { + const char* v = sensitivity ? std::getenv("NGRAPH_CORE_SENSITIVITY_THREADS") : std::getenv("NGRAPH_CORE_BATCH_THREADS"); + if (v == nullptr) return 1; + char* end = nullptr; + const long n = std::strtol(v, &end, 10); + if (end == v || *end != '\0' || n < 1 || n > 4096) return 1; + return static_cast(n); +} + +} // namespace detail + +// Each evaluator is one public call on a supplied frozen cache's statistics (NOTES R27). +PlacementResult capacity(const Graph& g, std::span sources, + std::span destinations, const CapacityOptions& opts) { + return detail::frozen_call(opts.cache, [&](detail::FrozenTally& tally) { + return detail::capacity_in_call(g, sources, destinations, opts, tally); + }); +} + +std::vector batch_capacity( + const Graph& g, const std::vector, std::vector>>& pairs, + const CapacityOptions& opts, std::span per_pair_elig, std::int32_t workers) { + return detail::frozen_call(opts.cache, [&](detail::FrozenTally& tally) { + return detail::batch_capacity_in_call(g, pairs, opts, per_pair_elig, workers, tally); + }); +} + +SensitivityResult sensitivity(const Graph& g, std::span sources, + std::span destinations, const SensitivityOptions& opts, + std::int32_t workers) { + return detail::frozen_call(opts.cache, [&](detail::FrozenTally& tally) { + return detail::sensitivity_in_call(g, sources, destinations, opts, workers, tally); + }); +} + } // namespace netgraph::core diff --git a/src/flow_ledger.cpp b/src/flow_ledger.cpp index 754e0d2..99d4f62 100644 --- a/src/flow_ledger.cpp +++ b/src/flow_ledger.cpp @@ -328,6 +328,46 @@ std::vector LedgerAccess::commit(FlowLedger& fg, std::int64_t owner, std // The drafts carry exactly the owner's live records plus new ones. const auto owned = fg.owner_ids_.find(owner); const std::size_t num_current = owned == fg.owner_ids_.end() ? 0 : owned->second.size(); + if (owned == fg.owner_ids_.end() && drafts.size() == 1 && !drafts[0].id.has_value() && + std::adjacent_find(drafts[0].record.edges.begin(), drafts[0].record.edges.end(), + [](const auto& a, const auto& b) { return a.first >= b.first; }) == + drafts[0].record.edges.end()) { + // One new record for an owner without live records (a fresh placement), its edges strictly + // ascending: each touched edge's delta is the draft's own amount, touched in the draft's order, + // so the checks below run on the draft itself, in the general path's order and with its + // messages (negative amounts first, then feasibility edge by edge); the staging and the + // publication are the general path's for one new record. + Draft& d = drafts[0]; + for (const auto& [e, x] : d.record.edges) { + if (x < 0) throw std::logic_error("ledger: a negative allocation in a commit"); + } + for (const auto& [e, x] : d.record.edges) { + const auto ei = static_cast(e); + const Amount next = fg.allocated_[ei] + x; + if (next < 0 || next > fg.baseline_[ei]) { + throw std::logic_error("ledger: infeasible commit on edge " + std::to_string(e) + " (" + + std::to_string(next) + " of " + std::to_string(fg.baseline_[ei]) + ")"); + } + } + std::vector created; + created.reserve(1); + std::vector live; + live.reserve(1); + const std::int64_t id = fg.next_id_; + std::map fresh; + const auto node = fresh.emplace_hint(fresh.end(), id, record_of(fg.uid_, id, owner, d)); + created.emplace_back(fg.uid_, id); + live.push_back(id); + std::map> index_node; + index_node.emplace(owner, std::move(live)); + // Publication: nothing below allocates or throws (the record's dropped zero entries add + // nothing). + for (const auto& [e, x] : node->second.edges) fg.allocated_[static_cast(e)] += x; + fg.records_.merge(fresh); + fg.owner_ids_.merge(index_node); + fg.next_id_ = id + 1; + return created; + } std::vector kept; std::size_t num_new = 0; for (const auto& d : drafts) { diff --git a/src/flow_policy.cpp b/src/flow_policy.cpp index 7c6a247..e238b28 100644 --- a/src/flow_policy.cpp +++ b/src/flow_policy.cpp @@ -30,6 +30,7 @@ #include #include +#include "netgraph/core/detail/cache_access.hpp" #include "netgraph/core/detail/endpoints.hpp" #include "netgraph/core/detail/enum_domain.hpp" #include "netgraph/core/detail/flow_core.hpp" @@ -40,8 +41,8 @@ #include "netgraph/core/detail/policy_engine.hpp" #include "netgraph/core/detail/pred_dag_builder.hpp" #include "netgraph/core/detail/private_hooks.hpp" -#include "netgraph/core/detail/search.hpp" #include "netgraph/core/detail/trace.hpp" +#include "netgraph/core/detail/traversal.hpp" namespace netgraph::core { @@ -174,20 +175,6 @@ int rank(Termination t) noexcept { Trace amount_or_inf(std::optional v) { return v.has_value() ? Trace::integer(*v) : Trace::str("inf"); } -// cost <= f * base, exactly: f is the dyadic rational it is (f >= 1, finite), base >= 1. -// f = m * 2^x with m odd below 2^53; products stay within 128 bits (APPROACH 6). -bool stretch_le(Cost cost, Cost base, double f) { - const auto [m, x] = dyadic(f); - const U128 rhs = mul_u64(static_cast(base), m); // below 2^115 - if (x >= 0) { - if (bits(rhs) + x > 64) return true; // rhs * 2^x >= 2^64 > cost - return !(shl(rhs, x) < U128 {0, static_cast(cost)}); - } - // f >= 1 with m < 2^53: -x < 53, so cost * 2^-x < 2^116. - const U128 lhs = shl(U128 {0, static_cast(cost)}, -x); - return !(rhs < lhs); -} - // The member-aligned amounts of a record's (EdgeId, amount) pairs (both ascending). template void align(std::span members, const std::vector>& pairs, std::vector& out) { @@ -203,20 +190,17 @@ void align(std::span members, const std::vector cumulative_offers(const Counter128& requested, Amount v, std::size_t k) { +// cumulative request goes from R to R + v over k sources (APPROACH 9.3), offer(i, increment) for +// i in [0, k). It depends only on r = R mod k: floor((r + v) / k) + [i < (r + v) mod k] - [i < r]. +template +void cumulative_offers(const Counter128& requested, Amount v, std::size_t k, Offer&& offer) { const auto kk = static_cast(k); const std::uint64_t two64 = ((std::numeric_limits::max() % kk) + 1) % kk; const std::uint64_t r = ((requested.hi % kk) * two64 % kk + requested.lo % kk) % kk; const auto total = static_cast(v) + r; const auto base = static_cast(total / kk); const std::uint64_t extra = total % kk; - std::vector out(k); - for (std::size_t i = 0; i < k; ++i) { - out[i] = base + (i < extra ? 1 : 0) - (i < r ? 1 : 0); - } - return out; + for (std::size_t i = 0; i < k; ++i) offer(i, base + (i < extra ? 1 : 0) - (i < r ? 1 : 0)); } // ================================================================ working flows @@ -298,87 +282,51 @@ std::shared_ptr bundle_of(const Graph& g, std::span return std::make_shared(PredDAGBuilder::of_edges(g, route)); } -// CostOnly: the static support restricted to the walks from src (a forward reachability over the -// support's edges: B' ids rank edges by source, so a node's support out-edges are one range of -// the ascending edge list), then the selection projected on the creation view W (APPROACH 9.1, -// 10): Collapse by (cost, priority under PreferHigherPriority, lowest EdgeId), and the staged -// canonical single path for multipath = false. +// CostOnly: the static support restricted to the walks from src, then the selection projected on +// the creation view W (APPROACH 9.1, 10): Collapse by (cost, priority under PreferHigherPriority, +// lowest EdgeId), and the staged canonical single path for multipath = false, both reading W's +// priority values and never gating on them (the traversal core's support projection). Route cost_only_route(const Call& c, NodeId src) { const auto& g = c.g; const auto& pe = c.pe; const auto& sel = pe.config.selection; - const auto support = PredDAGBuilder::ascending(*pe.support); - const auto row = g.row_offsets(); - const auto edge_dst = g.edge_dst(); - const auto edge_src = g.edge_src(); - std::vector route; - { - // Visited nodes in the per-thread node map (released before any routine that uses it). - NodeMap& map = NodeMap::local(); - map.reset(static_cast(g.num_nodes())); - std::vector stack {src}; - (void)map.add(src); - while (!stack.empty()) { - const NodeId u = stack.back(); - stack.pop_back(); - auto it = std::lower_bound(support.begin(), support.end(), row[static_cast(u)]); - for (; it != support.end() && *it < row[static_cast(u) + 1]; ++it) { - route.push_back(*it); - const NodeId v = edge_dst[static_cast(*it)]; - if (map.of(v) < 0) { - (void)map.add(v); - stack.push_back(v); - } - } - } - std::sort(route.begin(), route.end()); - } + const auto capacity = g.capacity_units(); + struct View { + const Working* W; + std::span capacity; + EdgePriority priority; + }; + const View view {&c.W, capacity, sel.priority}; + const PriorityFn on_w {&view, [](const void* ctx, EdgeId e) noexcept { + const auto& v = *static_cast(ctx); + return priority_value(v.priority, v.W->at(e), v.capacity[static_cast(e)]); + }}; + auto r = support_route(g, PredDAGBuilder::ascending(*pe.support), src, pe.dests_ok, sel, on_w); Route out; - if (!sel.multi_edge) { - // One member per (u, v) by select_adjacency on the creation view W (the support has no - // residual gating, so the projection reads W's priority values, never gates on them): the - // members of a pair are consecutive in id order (B' ranks by (src, cost, dst, input)). - const bool prefer = sel.tie_break == EdgeTieBreak::PreferHigherPriority; - const auto capacity = g.capacity_units(); - auto value = [&](EdgeId e) { return priority_value(sel.priority, c.W.at(e), capacity[static_cast(e)]); }; - std::vector kept; - for (std::size_t i = 0; i < route.size();) { - const auto ui = static_cast(route[i]); - EdgeId best = route[i]; - std::size_t j = i + 1; - for (; j < route.size() && edge_src[static_cast(route[j])] == edge_src[ui] && - edge_dst[static_cast(route[j])] == edge_dst[ui]; - ++j) { - if (replaces(g, route[j], best, prefer, value)) best = route[j]; - } - kept.push_back(best); - i = j; - } - route = std::move(kept); - } - if (sel.multipath) { - out.edges = std::move(route); - return out; - } - // The staged canonical single path over the (collapsed) support of the source. - std::vector member(static_cast(g.num_edges()), 0); - for (EdgeId e : route) member[static_cast(e)] = 1; - std::vector wd; - std::optional prio; - if (sel.tie_break == EdgeTieBreak::PreferHigherPriority) { - c.W.dense(wd); - prio = PriorityValues {&g, sel.priority, wd}; - } - const NodeId s[1] = {src}; - auto path = canonical_path(g, member, s, pe.dests_ok, prio ? &*prio : nullptr); - if (path.has_value()) { - out.edges = path->walk; - std::sort(out.edges.begin(), out.edges.end()); - out.cost = path->cost; - } + out.edges = std::move(r.edges); + if (!sel.multipath && !out.edges.empty()) out.cost = r.cost; return out; } +// CostOnly's creation products in one pass (kernels' cost_only_creation, identical to +// cost_only_route, bundle_of and plan_on where it applies); nullopt elsewhere. +std::optional creation_products(const Call& c, NodeId src) { + const auto& pe = c.pe; + const auto& sel = pe.config.selection; + struct View { + const Working* W; + std::span capacity; + EdgePriority priority; + }; + const View view {&c.W, c.g.capacity_units(), sel.priority}; + const PriorityFn on_w {&view, [](const void* ctx, EdgeId e) noexcept { + const auto& v = *static_cast(ctx); + return priority_value(v.priority, v.W->at(e), v.capacity[static_cast(e)]); + }}; + return cost_only_creation(c.g, pe.support, src, pe.dests_ok, sel, on_w, pe.config.placement, + pe.config.admission); +} + // CapacityAware: the canonical route of section 6 on the selection view `view` (dense): static // eligibility, residual >= max(1, min_flow_bandwidth), Collapse on the view, the tight DAG or the // staged single path toward the allowed destinations, then the max_path_cost ceiling. @@ -387,24 +335,12 @@ std::optional search(const Call& c, std::size_t ri, std::span(pe.config.routing); const auto& sel = pe.config.selection; - const Amount threshold = std::max(1, ca.min_flow_bandwidth); - const auto member = member_mask(g, pe.elig, sel, view, threshold); - const NodeId s[1] = {pe.roster[ri]}; + const Gate gate {pe.elig, view, std::max(1, ca.min_flow_bandwidth)}; + auto found = route_search(g, gate, sel, pe.roster[ri], pe.dests_ok); + if (!found.has_value()) return std::nullopt; Route out; - if (sel.multipath) { - auto tight = tight_dag(g, member, s, pe.dests_ok); - if (tight.cost == kUnreachable) return std::nullopt; - out.edges = std::move(tight.edges); - out.cost = tight.cost; - } else { - const PriorityValues prio {&g, sel.priority, view}; - const bool prefer = sel.tie_break == EdgeTieBreak::PreferHigherPriority; - auto path = canonical_path(g, member, s, pe.dests_ok, prefer ? &prio : nullptr); - if (!path.has_value()) return std::nullopt; - out.edges = std::move(path->walk); - std::sort(out.edges.begin(), out.edges.end()); - out.cost = path->cost; - } + out.edges = std::move(found->edges); + out.cost = found->cost; if (ca.max_path_cost.has_value()) { if (const auto* a = std::get_if(&*ca.max_path_cost)) { if (out.cost > a->cost) return std::nullopt; @@ -615,9 +551,17 @@ void stage1(Call& c, SourceRun& st) { auto& pe = c.pe; Trace tr = Trace::map(); if (is(pe)) { - const Route route = cost_only_route(c, st.src); - if (kTraced && c.trace) tr.set("route", route_trace(route.edges)); - WorkFlow f = new_flow(c, st.src, bundle_of(c.g, route.edges, pe.support), route.cost, nullptr); + WorkFlow f; + if (auto made = creation_products(c, st.src)) { + // The route, its bundle and its plan in one pass (multipath, static membership). + if (kTraced && c.trace) tr.set("route", route_trace(made->plan.route)); + f = new_flow(c, st.src, std::move(made->bundle), made->cost, nullptr, + std::make_shared(std::move(made->plan))); + } else { + const Route route = cost_only_route(c, st.src); + if (kTraced && c.trace) tr.set("route", route_trace(route.edges)); + f = new_flow(c, st.src, bundle_of(c.g, route.edges, pe.support), route.cost, nullptr); + } f.seq = ++c.seq; st.flows.push_back(std::move(f)); } else if (is(pe)) { @@ -1119,17 +1063,18 @@ Draft draft_of(const PolicyEngine& pe, const WorkFlow& f) { r.bundle = f.bundle; r.route_cost = f.cost; const auto members = f.members(); - r.edges.reserve(static_cast(std::count_if(f.alloc.begin(), f.alloc.end(), [](Amount x) { return x != 0; }))); - for (std::size_t m = 0; m < members.size(); ++m) { - if (f.alloc[m] != 0) r.edges.emplace_back(members[m], f.alloc[m]); + // Exactly sized, written by index (the nonzero members in member order). + r.edges.resize(static_cast(std::count_if(f.alloc.begin(), f.alloc.end(), [](Amount x) { return x != 0; }))); + for (std::size_t m = 0, k = 0; m < members.size(); ++m) { + if (f.alloc[m] != 0) r.edges[k++] = {members[m], f.alloc[m]}; } if (pe.config.admission == Admission::Lossy) { r.offered = f.offered; r.delivered = f.delivered; - r.drops.reserve(static_cast( + r.drops.resize(static_cast( std::count_if(f.drops.begin(), f.drops.end(), [](const Counter128& x) { return !x.is_zero(); }))); - for (std::size_t m = 0; m < members.size(); ++m) { - if (!f.drops[m].is_zero()) r.drops.emplace_back(members[m], f.drops[m]); + for (std::size_t m = 0, k = 0; m < members.size(); ++m) { + if (!f.drops[m].is_zero()) r.drops[k++] = {members[m], f.drops[m]}; } } else { r.delivered = f.T; @@ -1177,8 +1122,7 @@ PlacementResult place_flows(PolicyEngine& pe, std::optional v, const Out if (is(pe)) { runs[0].offer = v; } else if (v.has_value()) { - const auto inc = cumulative_offers(pe.requested, *v, k); - for (std::size_t i = 0; i < k; ++i) runs[i].offer = inc[i]; + cumulative_offers(pe.requested, *v, k, [&](std::size_t i, Amount x) { runs[i].offer = x; }); } Trace sources_tr = Trace::map(); for (std::size_t i = 0; i < k; ++i) { @@ -1463,14 +1407,6 @@ PlacementResult place_global(PolicyEngine& pe, std::optional v, const Ou // ---------------------------------------------------------------- construction -// The static reverse distances toward the allowed destinations over the statically eligible -// member graph (capacity >= 1 unit, no residual). -std::vector static_distances(const Graph& g, const Eligibility* elig, std::span dests) { - const PathSelection full(EdgeTieBreak::Deterministic, EdgePriority::Capacity, true, true); - const auto member = member_mask(g, elig, full, {}, 1); - return distances(g, member, dests, Direction::Reverse); -} - } // namespace void validate_config(const FlowPolicyConfig& cfg, std::size_t num_sources, std::size_t num_destinations) { @@ -1497,16 +1433,7 @@ void validate_config(const FlowPolicyConfig& cfg, std::size_t num_sources, std:: if (ca->min_flow_bandwidth < 0 || ca->min_flow_bandwidth >= kDomainLimit) { invalid("CapacityAware min_flow_bandwidth must be in [0, 2^62) units (got " + str(ca->min_flow_bandwidth) + ")"); } - if (ca->max_path_cost.has_value()) { - if (const auto* a = std::get_if(&*ca->max_path_cost)) { - if (a->cost < 0) invalid("max_path_cost Absolute cost must be >= 0 (got " + str(a->cost) + ")"); - } else { - const double f = std::get(*ca->max_path_cost).factor; - if (!(f >= 1.0) || !std::isfinite(f)) { - invalid("max_path_cost Stretch factor must be finite and in [1, inf) (got " + std::to_string(f) + ")"); - } - } - } + check_ceiling(ca->max_path_cost); if (ca->diminishing.has_value()) { const auto& d = *ca->diminishing; if (d.window < 1) invalid("diminishing window must be >= 1 (got " + str(d.window) + ")"); @@ -1538,8 +1465,14 @@ FlowPolicyConfig::FlowPolicyConfig(Routing routing_, FlowPlacement placement_, A using detail::LedgerAccess; using detail::PolicyEngine; -FlowPolicy::FlowPolicy(FlowLedger& ledger, std::span sources, std::span destinations, - FlowPolicyConfig config, const Eligibility* elig, DagCache* cache) { +namespace { + +// The engine of a new policy, bound to its ledger as the last step. A supplied frozen cache's hit +// is recorded in `tally` (NOTES R27): the public constructor publishes it once construction has +// succeeded, an evaluator once its whole call has. +std::unique_ptr make_engine(FlowLedger& ledger, std::span sources, + std::span destinations, FlowPolicyConfig config, + const Eligibility* elig, DagCache* cache, detail::FrozenTally& tally) { auto pe = std::make_unique(ledger, std::move(config)); pe->graph = ledger.graph(); const auto& g = *pe->graph; @@ -1590,7 +1523,7 @@ FlowPolicy::FlowPolicy(FlowLedger& ledger, std::span sources, std: if (!pe->usable.empty()) pe->roster.push_back(src); } else if (detail::is(*pe)) { pe->support = cache != nullptr - ? cache->lookup(g, pe->destinations, Direction::Reverse, elig) + ? detail::DagCacheAccess::lookup(*cache, g, pe->destinations, Direction::Reverse, elig, tally) : std::make_shared(detail::static_support(g, pe->destinations, Direction::Reverse, elig)); const auto edges = detail::PredDAGBuilder::ascending(*pe->support); const auto row = g.row_offsets(); @@ -1600,18 +1533,32 @@ FlowPolicy::FlowPolicy(FlowLedger& ledger, std::span sources, std: if (it != edges.end() && *it < row[static_cast(s) + 1]) pe->roster.push_back(s); } } else { - const auto dist = detail::static_distances(g, elig, pe->dests_ok); - for (NodeId s : pe->sources) { - if (!node_ok(s) || dist[static_cast(s)] == kUnreachable) continue; + const auto dist = detail::static_costs(g, elig, pe->dests_ok, pe->sources); + for (std::size_t i = 0; i < pe->sources.size(); ++i) { + const NodeId s = pe->sources[i]; + if (!node_ok(s) || dist[i] == kUnreachable) continue; pe->roster.push_back(s); - pe->baseline.push_back(dist[static_cast(s)]); + pe->baseline.push_back(dist[i]); } } pe->cohorts.resize(pe->roster.size()); pe->owner = LedgerAccess::bind(ledger); // last: nothing is bound when construction fails - engine_ = std::move(pe); + return pe; } +} // namespace + +// A public call of its own: a supplied frozen cache counts the hit once the policy exists. +FlowPolicy::FlowPolicy(FlowLedger& ledger, std::span sources, std::span destinations, + FlowPolicyConfig config, const Eligibility* elig, DagCache* cache) + : engine_(detail::frozen_call(cache, [&](detail::FrozenTally& tally) { + return make_engine(ledger, sources, destinations, std::move(config), elig, cache, tally); + })) {} + +FlowPolicy::FlowPolicy(FlowLedger& ledger, std::span sources, std::span destinations, + FlowPolicyConfig config, const Eligibility* elig, DagCache* cache, detail::FrozenTally& tally) + : engine_(make_engine(ledger, sources, destinations, std::move(config), elig, cache, tally)) {} + // The destruction law (section 8): the records stay in the ledger as orphans. FlowPolicy::~FlowPolicy() { LedgerAccess::release(engine_->fg, engine_->owner); } diff --git a/src/kernels.cpp b/src/kernels.cpp index 3043847..899bc83 100644 --- a/src/kernels.cpp +++ b/src/kernels.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -94,6 +95,81 @@ void check_kernel(FlowPlacement placement, Admission admission, EdgePriority pri } } +KernelPlan::Layout KernelPlan::point(std::size_t num_dsts, std::size_t positions, std::size_t num_members) noexcept { + std::int32_t* at = store_.data(); + dsts = std::span(at, num_dsts); + at += num_dsts; + Layout w; + w.topo = std::span(at, positions); + at += positions; + w.out_begin = std::span(at, positions); + at += positions; + w.out_end = std::span(at, positions); + at += positions; + w.head_pos = std::span(at, num_members); + at += num_members; + w.terminal = std::span(reinterpret_cast(at), positions); + topo = w.topo; + out_begin = w.out_begin; + out_end = w.out_end; + head_pos = w.head_pos; + terminal = w.terminal; + return w; +} + +KernelPlan::Layout KernelPlan::layout(std::span destinations, std::size_t positions, + std::size_t num_members) { + const std::size_t words = destinations.size() + 3 * positions + num_members + (positions + 3) / 4; + std::vector store(words, 0); // the one allocation + store_.swap(store); + const Layout w = point(destinations.size(), positions, num_members); + std::copy(destinations.begin(), destinations.end(), store_.begin()); + return w; +} + +KernelPlan::KernelPlan(const KernelPlan& other) + : placement(other.placement), admission(other.admission), src(other.src), route(other.route), + members(other.members), weights(other.weights), src_out_begin(other.src_out_begin), + src_out_end(other.src_out_end), store_(other.store_) { + (void)point(other.dsts.size(), other.topo.size(), other.head_pos.size()); +} + +KernelPlan::KernelPlan(KernelPlan&& other) noexcept + : placement(other.placement), admission(other.admission), src(other.src), route(std::move(other.route)), + members(std::move(other.members)), weights(std::move(other.weights)), src_out_begin(other.src_out_begin), + src_out_end(other.src_out_end), store_(std::move(other.store_)) { + (void)point(other.dsts.size(), other.topo.size(), other.head_pos.size()); + (void)other.point(0, 0, 0); +} + +KernelPlan& KernelPlan::operator=(const KernelPlan& other) { + if (this != &other) { + KernelPlan copy(other); + *this = std::move(copy); + } + return *this; +} + +KernelPlan& KernelPlan::operator=(KernelPlan&& other) noexcept { + if (this != &other) { + placement = other.placement; + admission = other.admission; + src = other.src; + route = std::move(other.route); + members = std::move(other.members); + weights = std::move(other.weights); + src_out_begin = other.src_out_begin; + src_out_end = other.src_out_end; + const std::size_t nd = other.dsts.size(); + const std::size_t np = other.topo.size(); + const std::size_t nm = other.head_pos.size(); + store_ = std::move(other.store_); + (void)point(nd, np, nm); + (void)other.point(0, 0, 0); + } + return *this; +} + namespace { // Per-thread per-node state of build_split, indexed by NodeId and valid for a node only @@ -242,11 +318,6 @@ void build_split(const Graph& g, const PredDAG& dag, NodeId src, std::span(num_pos); - plan.topo.resize(P); - plan.terminal.resize(P); - plan.out_begin.resize(P); - plan.out_end.resize(P); + const KernelPlan::Layout w = plan.layout(dsts, P, num_members); std::size_t pos = 0; for (NodeId v : s.order) { const auto& nv = s.at(v); if (nv.fwd == 0) continue; - plan.topo[pos] = v; - plan.terminal[pos] = nv.target; + w.topo[pos] = v; + w.terminal[pos] = nv.target; const std::int32_t b = nv.count == 0 ? 0 : nv.first; - plan.out_begin[pos] = b; - plan.out_end[pos] = b + nv.count; + w.out_begin[pos] = b; + w.out_end[pos] = b + nv.count; ++pos; } - plan.head_pos.resize(num_members); for (std::size_t m = 0; m < num_members; ++m) { - plan.head_pos[m] = s.at(s.member_head[m]).position; + w.head_pos[m] = s.at(s.member_head[m]).position; } plan.src_out_begin = plan.out_begin[0]; plan.src_out_end = plan.out_end[0]; @@ -346,7 +416,6 @@ KernelPlan make_plan(const Graph& g, FlowPlacement placement, Admission admissio plan.placement = placement; plan.admission = admission; plan.src = src; - plan.dsts.assign(dsts.begin(), dsts.end()); plan.route.assign(edges.begin(), edges.end()); // The creation view of a route edge (the route ascends: a binary search). auto creation = [&](EdgeId e) { @@ -377,7 +446,6 @@ KernelPlan make_route_plan(const Graph& g, FlowPlacement placement, Admission ad plan.placement = placement; plan.admission = admission; plan.src = src; - plan.dsts.assign(dsts.begin(), dsts.end()); auto usable = [&](EdgeId e) { return capacity[static_cast(e)] >= 1 && (elig == nullptr || elig->edge_ok(e)); }; @@ -390,6 +458,310 @@ KernelPlan make_route_plan(const Graph& g, FlowPlacement placement, Admission ad return plan; } +// ---------------------------------------------------------------- CostOnly creation + +namespace { + +// Per-thread state of cost_only_creation. Per node (by NodeId) a slot valid only under the +// current epoch (DagScratch's pattern: nothing is cleared between calls, a call that throws leaves +// stale slots behind an old epoch, and the one array of slots grows whole or not at all); every +// other buffer is indexed by the reached nodes' local indices (their rank in ascending NodeId) or +// by route position, and is rewritten before it is read. +struct CreationScratch { + struct Node { + std::uint32_t stamp {0}; + std::uint8_t reached {0}; + std::uint8_t target {0}; + std::int32_t local {0}; // rank among the reached nodes (ascending NodeId) + std::int32_t lo {0}; // the node's support out-edges: support[lo, hi) + std::int32_t hi {0}; + }; + std::uint32_t epoch {0}; + std::vector node; + std::vector reached; // the walk's nodes (its queue), then ascending + std::vector route; // ascending + std::vector tail; // per route edge: its tail's local index + std::vector head; // per route edge: its head's local index + std::vector parent; // per entry: its parent's local index + std::vector entry; // per local node + 1: its entries (by child) + std::vector fill; + std::vector via; // the entries: by child, ascending via + std::vector state; // per local node: DFS 1 open, 2 done + std::vector> dfs; // (local node, next entry) + std::vector order; // local nodes, every one after its parents + std::vector position; // per local node + std::vector first; // per local node: its first member + std::vector count; // per local node: its number of members + std::vector dist; // per local node + + void begin(std::size_t n) { + if (node.size() < n) node.resize(n); // strong guarantee; new slots carry stamp 0 + if (++epoch == 0) { + for (auto& v : node) v.stamp = 0; + epoch = 1; + } + } + Node& at(NodeId v) noexcept { return node[static_cast(v)]; } + // The slot of v, reset on its first use in this call. + Node& touch(NodeId v) noexcept { + auto& x = at(v); + if (x.stamp != epoch) { + x = Node {}; + x.stamp = epoch; + } + return x; + } +}; + +CreationScratch& creation_scratch() { + thread_local CreationScratch s; + return s; +} + +} // namespace + +std::size_t lower_bound_from(std::span a, std::size_t hint, EdgeId x) noexcept { + const std::size_t n = a.size(); + std::size_t lo = 0; + std::size_t hi = 0; + if (hint < n && a[hint] < x) { + // The answer lies in (hint, n]: a[lo - 1] < x throughout. + lo = hint + 1; + hi = lo; + for (std::size_t step = 1; hi < n && a[hi] < x; step <<= 1) { + lo = hi + 1; + hi = lo + step; + } + hi = std::min(hi, n); + } else { + // The answer lies in [0, hint] (hint == n, or a[hint] >= x): a[hi] >= x or hi == n throughout. + hi = std::min(hint, n); + for (std::size_t step = 1;; step <<= 1) { + if (hi < step) { + lo = 0; + break; + } + const std::size_t probe = hi - step; + if (a[probe] < x) { + lo = probe + 1; + break; + } + hi = probe; + } + } + return static_cast(std::lower_bound(a.begin() + static_cast(lo), + a.begin() + static_cast(hi), x) - + a.begin()); +} + +std::optional cost_only_creation(const Graph& g, const std::shared_ptr& support, + NodeId src, std::span dsts, + const PathSelection& selection, PriorityFn view, + FlowPlacement placement, Admission admission) { + const EdgePriority priority = selection.priority; + if (!selection.multipath || (placement == FlowPlacement::Weighted && priority == EdgePriority::Residual)) { + return std::nullopt; + } + check_kernel(placement, admission, priority); + const auto sup = PredDAGBuilder::ascending(*support); + const auto row = g.row_offsets(); + const auto edst = g.edge_dst(); + const auto edge_cost = g.cost(); + const auto capacity = g.capacity_units(); + CreationScratch& s = creation_scratch(); + s.begin(static_cast(g.num_nodes())); + for (NodeId d : dsts) s.touch(d).target = 1; + // support_route's walk over the support from src: every reached node keeps its support + // out-edges as one range (B' ids rank edges by source first: one tail's edges are consecutive). + s.reached.clear(); + s.touch(src).reached = 1; + s.reached.push_back(src); + bool any_target = false; + std::size_t m = 0; + std::size_t hint = 0; + for (std::size_t q = 0; q < s.reached.size(); ++q) { // the reached list is the walk's queue + const NodeId u = s.reached[q]; + const auto ui = static_cast(u); + const std::size_t lo = lower_bound_from(sup, hint, row[ui]); + std::size_t hi = lo; + for (; hi < sup.size() && sup[hi] < row[ui + 1]; ++hi) { + const NodeId v = edst[static_cast(sup[hi])]; + auto& nv = s.touch(v); + if (nv.reached != 0) continue; + nv.reached = 1; + s.reached.push_back(v); + } + auto& nu = s.at(u); + any_target = any_target || nu.target != 0; + nu.lo = static_cast(lo); + nu.hi = static_cast(hi); + m += hi - lo; + hint = lo; + } + if (!any_target || m == 0) return std::nullopt; + // The route in ascending EdgeId: the reached nodes' ranges in ascending NodeId (ascending in the + // support, checked), each with Collapse when multi_edge is false (support_route's: one member per + // (u, v) by select_adjacency on the view), every route edge with its tail's local index. A + // destination with route edges, or a route edge that is no member candidate, means build_split + // would not keep the whole route: the caller's path then runs. + std::sort(s.reached.begin(), s.reached.end()); + const std::size_t r = s.reached.size(); + for (std::size_t i = 0; i < r; ++i) s.at(s.reached[i]).local = static_cast(i); + s.route.resize(m); + s.tail.resize(m); + s.first.resize(r); + s.count.resize(r); + const bool collapse = !selection.multi_edge; + const bool prefer = selection.tie_break == EdgeTieBreak::PreferHigherPriority; + std::size_t w = 0; + std::int32_t prev_hi = 0; + for (std::size_t i = 0; i < r; ++i) { + const auto& nu = s.at(s.reached[i]); + s.first[i] = static_cast(w); + if (nu.hi > nu.lo) { + if (nu.lo < prev_hi || nu.target != 0) return std::nullopt; + prev_hi = nu.hi; + } + for (auto k = nu.lo; k < nu.hi;) { + EdgeId best = sup[static_cast(k)]; + auto j = k + 1; + if (collapse) { + const NodeId v = edst[static_cast(best)]; + for (; j < nu.hi && edst[static_cast(sup[static_cast(j)])] == v; ++j) { + if (replaces(g, sup[static_cast(j)], best, prefer, view)) best = sup[static_cast(j)]; + } + } + if (placement != FlowPlacement::MaxFlow && capacity[static_cast(best)] < 1) return std::nullopt; + s.route[w] = best; + s.tail[w] = static_cast(i); + ++w; + k = j; + } + s.count[i] = static_cast(w) - s.first[i]; + } + const std::span route(s.route.data(), w); + // The entries grouped by child (ascending NodeId), ascending via within a child: a counting pass + // over the heads' local indices, the route scattered in ascending order with each entry's parent. + s.head.resize(w); + s.entry.assign(r + 1, 0); + for (std::size_t k = 0; k < w; ++k) { + const auto h = s.at(edst[static_cast(route[k])]).local; + s.head[k] = h; + ++s.entry[static_cast(h) + 1]; + } + std::size_t children = 0; + for (std::size_t i = 0; i < r; ++i) { + if (s.entry[i + 1] != 0) ++children; + s.entry[i + 1] += s.entry[i]; + } + s.fill.assign(s.entry.begin(), s.entry.end() - 1); + s.via.resize(w); + s.parent.resize(w); + for (std::size_t k = 0; k < w; ++k) { + const auto at = static_cast(s.fill[static_cast(s.head[k])]++); + s.via[at] = route[k]; + s.parent[at] = s.tail[k]; + } + // build_split's order: a backward DFS from the destinations, in their order, over the entries, + // listing every node after its parents (postorder). A destination the walk did not reach has no + // entry and no position there, so it is skipped. + s.state.assign(r, 0); + s.order.clear(); + for (NodeId d : dsts) { + const auto& nd = s.at(d); + if (nd.reached == 0) continue; + const auto di = nd.local; + if (s.state[static_cast(di)] != 0) continue; + s.state[static_cast(di)] = 1; + s.dfs.clear(); + s.dfs.emplace_back(di, s.entry[static_cast(di)]); + while (!s.dfs.empty()) { + const auto [v, k] = s.dfs.back(); + if (k < s.entry[static_cast(v) + 1]) { + s.dfs.back().second = k + 1; + const auto u = s.parent[static_cast(k)]; + const auto su = s.state[static_cast(u)]; + if (su == 0) { + s.state[static_cast(u)] = 1; + s.dfs.emplace_back(u, s.entry[static_cast(u)]); + } else if (su == 1) { + return std::nullopt; // a cycle (build_split throws) + } + } else { + s.state[static_cast(v)] = 2; + s.order.push_back(v); + s.dfs.pop_back(); + } + } + } + const auto src_local = s.at(src).local; + if (s.order.size() != r || s.order.front() != src_local) return std::nullopt; + s.position.resize(r); + for (std::size_t p = 0; p < r; ++p) s.position[static_cast(s.order[p])] = static_cast(p); + // make_plan's cost: the minimum walk cost from src to a destination over the members, by the + // order (every member is a route edge, so route_cost is not called). + s.dist.resize(r); + Cost best = kUnreachable; + for (const auto v : s.order) { + const auto vi = static_cast(v); + Cost dv = 0; + if (v != src_local) { + dv = kUnreachable; + for (auto k = s.entry[vi]; k < s.entry[vi + 1]; ++k) { + const auto ki = static_cast(k); + dv = std::min(dv, s.dist[static_cast(s.parent[ki])] + edge_cost[static_cast(s.via[ki])]); + } + } + s.dist[vi] = dv; + if (s.at(s.reached[vi]).target != 0) best = std::min(best, dv); + } + CreationProducts out; + out.cost = best; + KernelPlan& plan = out.plan; + plan.placement = placement; + plan.admission = admission; + plan.src = src; + plan.route.assign(route.begin(), route.end()); + plan.members.assign(route.begin(), route.end()); + if (placement == FlowPlacement::Weighted) { + plan.weights.resize(route.size()); + for (std::size_t k = 0; k < route.size(); ++k) plan.weights[k] = capacity[static_cast(route[k])]; + } + const KernelPlan::Layout lay = plan.layout(dsts, r, route.size()); + for (std::size_t p = 0; p < r; ++p) { + const auto v = static_cast(s.order[p]); + const NodeId x = s.reached[v]; + lay.topo[p] = x; + lay.terminal[p] = s.at(x).target; + const std::int32_t b = s.count[v] == 0 ? 0 : s.first[v]; + lay.out_begin[p] = b; + lay.out_end[p] = b + s.count[v]; + } + for (std::size_t k = 0; k < route.size(); ++k) lay.head_pos[k] = s.position[static_cast(s.head[k])]; + plan.src_out_begin = plan.out_begin[0]; + plan.src_out_end = plan.out_end[0]; + // The record bundle: the support itself when the route is all of it (bundle_of's share). + if (route.size() == sup.size()) { + out.bundle = support; + } else { + std::vector nodes(children); + std::vector offsets(children + 1); + offsets[0] = 0; + for (std::size_t i = 0, c = 0; i < r; ++i) { + if (s.entry[i + 1] == s.entry[i]) continue; + nodes[c] = s.reached[i]; + offsets[++c] = s.entry[i + 1]; + } + std::vector via(s.via.begin(), s.via.begin() + static_cast(w)); + std::vector parents(w); + for (std::size_t k = 0; k < w; ++k) parents[k] = s.reached[static_cast(s.parent[k])]; + std::vector asc(route.begin(), route.end()); + out.bundle = std::make_shared(PredDAGBuilder::of_parts( + g, std::move(nodes), std::move(offsets), std::move(parents), std::move(via), std::move(asc))); + } + return out; +} + // ---------------------------------------------------------------- allocation bool allocate(const KernelPlan& plan, Amount total, std::span out, std::span limit) { diff --git a/src/pred_dag.cpp b/src/pred_dag.cpp index 67d18a1..8f5aac5 100644 --- a/src/pred_dag.cpp +++ b/src/pred_dag.cpp @@ -157,14 +157,22 @@ PredDAG PredDAGBuilder::of_edges(const Graph& g, std::span edges) out.num_nodes_ = g.num_nodes(); std::vector sorted(edges.begin(), edges.end()); if (!std::is_sorted(sorted.begin(), sorted.end())) std::sort(sorted.begin(), sorted.end()); - // Entries grouped by child (ascending NodeId), ascending via within a child: a stable - // counting sort of the ascending edges by their child. - std::vector slot(static_cast(out.num_nodes_) + 1, 0); - for (EdgeId e : sorted) ++slot[static_cast(edge_dst[static_cast(e)]) + 1]; - for (std::size_t v = 1; v < slot.size(); ++v) slot[v] += slot[v - 1]; + // Entries grouped by child (ascending NodeId), ascending via within a child: a small set by + // sorting (child, via) pairs, a larger one by a stable counting sort of the ascending edges by + // their child over an N + 1 temporary. std::vector by_child(sorted.size()); - for (EdgeId e : sorted) { - by_child[static_cast(slot[static_cast(edge_dst[static_cast(e)])]++)] = e; + if (sorted.size() * 8 < static_cast(out.num_nodes_)) { + std::vector> keyed(sorted.size()); + for (std::size_t k = 0; k < sorted.size(); ++k) keyed[k] = {edge_dst[static_cast(sorted[k])], sorted[k]}; + std::sort(keyed.begin(), keyed.end()); + for (std::size_t k = 0; k < keyed.size(); ++k) by_child[k] = keyed[k].second; + } else { + std::vector slot(static_cast(out.num_nodes_) + 1, 0); + for (EdgeId e : sorted) ++slot[static_cast(edge_dst[static_cast(e)]) + 1]; + for (std::size_t v = 1; v < slot.size(); ++v) slot[v] += slot[v - 1]; + for (EdgeId e : sorted) { + by_child[static_cast(slot[static_cast(edge_dst[static_cast(e)])]++)] = e; + } } out.parents_.resize(by_child.size()); out.via_.resize(by_child.size()); @@ -183,6 +191,27 @@ PredDAG PredDAGBuilder::of_edges(const Graph& g, std::span edges) return out; } +PredDAG PredDAGBuilder::empty(const Graph& g) { + PredDAG out; + out.graph_uid_ = g.uid(); + out.num_nodes_ = g.num_nodes(); + return out; +} + +PredDAG PredDAGBuilder::of_parts(const Graph& g, std::vector nodes, std::vector offsets, + std::vector parents, std::vector via, + std::vector ascending) { + PredDAG out; + out.graph_uid_ = g.uid(); + out.num_nodes_ = g.num_nodes(); + out.nodes_ = std::move(nodes); + out.node_offsets_ = std::move(offsets); + out.parents_ = std::move(parents); + out.via_ = std::move(via); + out.edges_ = std::move(ascending); + return out; +} + PredDAG PredDAGBuilder::of_path(std::uint64_t graph_uid, std::int32_t num_nodes, const std::vector>& hops, const std::vector& nodes) { // Hop h's child is nodes[h + 1]; the children are distinct, so ordering the hops by child @@ -208,6 +237,32 @@ PredDAG PredDAGBuilder::of_path(std::uint64_t graph_uid, std::int32_t num_nodes, return out; } +Path PathBuilder::of_walk(const Graph& g, std::span walk) { + std::vector> hops; + hops.reserve(walk.size()); + for (EdgeId e : walk) hops.push_back({e}); + return of_hops(g, std::move(hops)); +} + +Path PathBuilder::of_hops(const Graph& g, std::vector> hops) { + const auto edge_src = g.edge_src(); + const auto edge_dst = g.edge_dst(); + const auto cost = g.cost(); + Path out; + out.nodes_.reserve(hops.size() + 1); + out.nodes_.push_back(edge_src[static_cast(hops.front().front())]); + for (const auto& hop : hops) { + out.nodes_.push_back(edge_dst[static_cast(hop.front())]); + Cost best = cost[static_cast(hop.front())]; + for (EdgeId e : hop) best = std::min(best, cost[static_cast(e)]); + out.cost_ += best; + } + out.graph_uid_ = g.uid(); + out.num_nodes_ = g.num_nodes(); + out.hops_ = std::move(hops); + return out; +} + std::span PredDAGBuilder::ascending(const PredDAG& dag) noexcept { return dag.edges_; } std::span PredDAGBuilder::member_nodes(const PredDAG& dag) noexcept { return dag.nodes_; } diff --git a/src/search.cpp b/src/search.cpp index 230fde4..0fbfcac 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -1,17 +1,24 @@ /* - The search semantics of APPROACH 6 as internal routines, and the constructor of their - PathSelection. + The one traversal core of every search (detail/traversal.hpp; APPROACH 6 and 14), the public + spf, and the constructors of the search options. - Distances are int64 path costs: every cost is positive and the sum of all costs is below - 2^62, so no simple path cost overflows and kUnreachable (INT64_MAX) never collides. Dijkstra - uses a binary heap of (distance, node); distances are unique, so the heap's order among - equal keys changes no output. + Distances are int64 path costs: every cost is positive and the sum of all costs is below 2^62, + so no simple path cost overflows and kUnreachable (INT64_MAX) never collides. + + The queues pop in (distance, node) order: the heap by its key, the buckets because each bucket + holds one distance at a time (pending distances lie in (d, d + max_cost] for the distance d + popped last, all multiples of the cost gcd) and the active bucket is sorted by node. A node is + pushed only when its distance strictly improves, so a bucket holds a node at most once. Pop + order among equal distances never changes a result: positive costs make every tight + predecessor of a node strictly nearer, hence settled before it. */ -#include "netgraph/core/detail/search.hpp" +#include "netgraph/core/search.hpp" #include +#include +#include #include -#include +#include #include #include #include @@ -20,11 +27,13 @@ #include "netgraph/core/detail/enum_domain.hpp" #include "netgraph/core/detail/int128.hpp" #include "netgraph/core/detail/pred_dag_builder.hpp" +#include "netgraph/core/detail/traversal.hpp" namespace netgraph::core::detail { int compare(const Priority& a, const Priority& b) noexcept { if (a.infinite || b.infinite) return a.infinite == b.infinite ? 0 : (a.infinite ? 1 : -1); + if (a.den == 1 && b.den == 1) return a.num == b.num ? 0 : (a.num < b.num ? -1 : 1); const U128 left = mul_u64(static_cast(a.num), static_cast(b.den)); const U128 right = mul_u64(static_cast(b.num), static_cast(a.den)); if (left == right) return 0; @@ -44,384 +53,1187 @@ Priority PriorityValues::of(EdgeId e) const noexcept { return priority_value(priority, residual[ei], cap); } -std::vector member_mask(const Graph& g, const Eligibility* elig, - const PathSelection& selection, std::span residual, - Amount min_residual) { - const auto m = static_cast(g.num_edges()); - const auto capacity = g.capacity_units(); - std::vector member(m, 0); - for (std::size_t e = 0; e < m; ++e) { - if (elig != nullptr && !elig->edge_ok(static_cast(e))) continue; - if (capacity[e] < 1) continue; - if (!residual.empty() && residual[e] < min_residual) continue; - member[e] = 1; - } - if (selection.multi_edge) return member; - // Collapse: one member per (u, v) by select_adjacency. - const PriorityValues prio {&g, selection.priority, residual}; - const auto value = [&prio](EdgeId e) { return prio.of(e); }; - const bool prefer = selection.tie_break == EdgeTieBreak::PreferHigherPriority; - const auto edge_dst = g.edge_dst(); - const auto row = g.row_offsets(); - std::vector best(static_cast(g.num_nodes()), -1); - std::vector touched; - for (std::int32_t u = 0; u < g.num_nodes(); ++u) { - touched.clear(); - for (auto e = row[static_cast(u)]; e < row[static_cast(u) + 1]; ++e) { - const auto ei = static_cast(e); - if (member[ei] == 0) continue; - const auto v = static_cast(edge_dst[ei]); - const EdgeId cur = best[v]; - if (cur < 0) { - best[v] = e; - touched.push_back(static_cast(v)); - continue; +PriorityFn PriorityFn::of(const PriorityValues& values) noexcept { + return {&values, [](const void* ctx, EdgeId e) noexcept { return static_cast(ctx)->of(e); }}; +} + +std::int64_t bucket_width(const Graph& g) noexcept { + const Cost stride = std::max(1, g.cost_gcd()); + return g.max_cost() / stride + 1; // max_cost < 2^62: no overflow +} + +bool uses_buckets(const Graph& g, SpfQueue queue) noexcept { + return queue == SpfQueue::Auto && bucket_width(g) <= kMaxBucketWidth; +} + +#if NETGRAPH_CORE_PRIVATE_HOOKS +QueueCounts& thread_queue_counts() noexcept { + thread_local QueueCounts counts; + return counts; +} +#endif + +namespace { + +constexpr Cost kInf = kUnreachable; + +// Node marks of the workspace (one byte per node, zero between leases). +enum : std::uint8_t { + kTarget = 1, // a live target of the current search, or a destination of a support route + kExcluded = 2, // a KSP spur's excluded prefix node + kKeep = 4, // a node of the pruned DAG (the walk back from the targets) or of a support route + kBannedHead = 8, // a KSP Collapse ban: the spur node's transition to this node +}; + +// Releases a buffer whose retained capacity exceeds a small multiple of what the last use +// needed, so a thread's workspace does not keep the high-water mark of every graph it saw. +template +void trim(Vec& v, std::size_t used) noexcept { + if (v.capacity() > 4 * used + 65536) { + Vec fresh; + v.swap(fresh); + } +} + +// The reference queue: a binary heap on (distance, node). +class HeapQueue { +public: + void begin(std::span roots) { + h_.clear(); + peak_ = 0; + for (NodeId r : roots) push(0, r); + } + void push(Cost c, NodeId v) { + h_.emplace_back(c, v); // strong guarantee: a failed growth leaves the heap as it was + std::push_heap(h_.begin(), h_.end(), std::greater<> {}); + if (h_.size() > peak_) peak_ = h_.size(); + } + bool pop(Cost& c, NodeId& v) noexcept { + if (h_.empty()) return false; + std::pop_heap(h_.begin(), h_.end(), std::greater<> {}); + c = h_.back().first; + v = h_.back().second; + h_.pop_back(); + return true; + } + void end() noexcept { + h_.clear(); + trim(h_, peak_); + } + +private: + std::vector> h_ {}; + std::size_t peak_ {0}; +}; + +// Monotone cyclic buckets (see the file comment): W' = bit_ceil(W) >= 64 buckets, each an +// intrusive list into one item pool, an occupancy bit per bucket and the words set during the +// search; the active bucket is copied out and sorted by node. A frontier of exactly one item is +// held inline (chain-like stretches pay no bucket bookkeeping); a second push promotes it. +// Between searches every head is -1 and every occupancy bit 0. +class BucketQueue { +public: + void begin(std::int64_t width, Cost stride, std::size_t num_nodes, std::span roots) { + const std::size_t w2 = std::max(64, std::bit_ceil(static_cast(width))); + const std::size_t words = w2 / 64; + // Each growth keeps the invariant of the existing part (heads -1, bits 0), and none can leave + // the queue unusable: every later use reads only up to the sizes reached. + if (head_.size() < w2) head_.resize(w2, -1); + if (occ_.size() < words) occ_.resize(words, 0); + if (dirty_flag_.size() < words) dirty_flag_.resize(words, 0); + if (dirty_.capacity() < words) dirty_.reserve(words); // one entry per word: never grows + if (active_.capacity() < num_nodes) active_.reserve(num_nodes); // a bucket holds a node once + mask_ = w2 - 1; + words_ = words; + stride_ = stride; + shift_ = std::has_single_bit(static_cast(stride)) ? std::countr_zero(static_cast(stride)) : -1; + cur_ = 0; + cur_b_ = 0; + pending_ = 0; + held_ = false; + items_.clear(); + peak_ = 0; + active_.assign(roots.begin(), roots.end()); // distance 0, sorted; within the reserved capacity + pos_ = 0; + } + void push(Cost c, NodeId v) { + if (!held_ && pending_ == 0 && pos_ == active_.size()) { + held_ = true; + held_cost_ = c; + held_node_ = v; + return; + } + if (held_) { + put(held_cost_, held_node_); + held_ = false; + } + put(c, v); + } + bool pop(Cost& c, NodeId& v) noexcept { + if (pos_ < active_.size()) { + c = cur_; + v = active_[pos_++]; + return true; + } + if (held_) { + held_ = false; + cur_ = held_cost_; + cur_b_ = index(cur_); + c = cur_; + v = held_node_; + return true; + } + if (pending_ == 0) return false; + activate(); + c = cur_; + v = active_[pos_++]; + return true; + } + void end() noexcept { + for (const std::uint32_t w : dirty_) { + std::uint64_t word = occ_[w]; + while (word != 0) { + head_[(static_cast(w) << 6) + static_cast(std::countr_zero(word))] = -1; + word &= word - 1; } - if (replaces(g, e, cur, prefer, value)) best[v] = e; + occ_[w] = 0; + dirty_flag_[w] = 0; } - for (std::size_t e = static_cast(row[static_cast(u)]); - e < static_cast(row[static_cast(u) + 1]); ++e) { - if (member[e] != 0 && best[static_cast(edge_dst[e])] != static_cast(e)) member[e] = 0; + dirty_.clear(); + items_.clear(); + active_.clear(); + pos_ = 0; + pending_ = 0; + held_ = false; + trim(items_, peak_); + } + +private: + struct Item { + NodeId node; + std::int32_t next; + }; + [[nodiscard]] std::size_t index(Cost c) const noexcept { + const Cost q = shift_ >= 0 ? (c >> shift_) : c / stride_; + return static_cast(q) & mask_; + } + void put(Cost c, NodeId v) { + const std::size_t b = index(c); + items_.push_back({v, head_[b]}); // the only allocation; a failure changes nothing + head_[b] = static_cast(items_.size() - 1); + const std::size_t w = b >> 6; + if (dirty_flag_[w] == 0) { + dirty_flag_[w] = 1; + dirty_.push_back(static_cast(w)); // reserved for every word } - for (NodeId v : touched) best[static_cast(v)] = -1; + occ_[w] |= std::uint64_t {1} << (b & 63); + ++pending_; + if (items_.size() > peak_) peak_ = items_.size(); + } + // The next non-empty bucket after the active one, cyclically (pending_ > 0): its items become + // the active bucket, sorted by node. + void activate() noexcept { + std::size_t i = (cur_b_ + 1) & mask_; + std::size_t w = i >> 6; + std::uint64_t word = occ_[w] & (~std::uint64_t {0} << (i & 63)); + while (word == 0) { + w = w + 1 == words_ ? 0 : w + 1; + word = occ_[w]; + } + const std::size_t f = (w << 6) + static_cast(std::countr_zero(word)); + cur_ += static_cast((f - cur_b_) & mask_) * stride_; + cur_b_ = f; + active_.clear(); + pos_ = 0; + for (std::int32_t k = head_[f]; k >= 0; k = items_[static_cast(k)].next) { + active_.push_back(items_[static_cast(k)].node); // within the reserved capacity + } + head_[f] = -1; + occ_[w] &= ~(std::uint64_t {1} << (f & 63)); + pending_ -= active_.size(); + if (active_.size() > 1) std::sort(active_.begin(), active_.end()); } - return member; -} -namespace { + std::vector head_ {}; + std::vector occ_ {}; + std::vector dirty_flag_ {}; + std::vector dirty_ {}; + std::vector items_ {}; + std::vector active_ {}; + std::size_t pos_ {0}; + std::size_t mask_ {0}; + std::size_t words_ {0}; + Cost stride_ {1}; + int shift_ {0}; + Cost cur_ {0}; + std::size_t cur_b_ {0}; + std::size_t pending_ {0}; + std::size_t peak_ {0}; + bool held_ {false}; + Cost held_cost_ {0}; + NodeId held_node_ {0}; +}; -using HeapEntry = std::pair; -using MinHeap = std::priority_queue, std::greater<>>; +struct PoolEntry { + EdgeId edge; + std::int32_t next; +}; -} // namespace +// The per-thread search workspace. Between leases: dist == kInf, head == -1, loc == -1 and +// flags == 0 for every node; touched, marked, the pool and both queues empty. touched and marked +// have the capacity of the node arrays, so recording a node never allocates; scratch buffers are +// rewritten before every use. +struct Workspace { + std::vector dist {}; + std::vector head {}; + std::vector loc {}; + std::vector flags {}; + std::vector touched {}; // the nodes whose dist (and head) a lease wrote + std::vector marked {}; // the nodes whose flags (and loc) a lease wrote + std::vector pool {}; // the tight-edge lists + std::size_t pool_peak {0}; + HeapQueue heap {}; + BucketQueue buckets {}; + // scratch + std::vector list {}; + std::vector pruned {}; + std::vector kept {}; + std::vector stack {}; + std::vector off_out {}; + std::vector off_in {}; + std::vector idx_out {}; + std::vector idx_in {}; + std::vector hops {}; + std::vector keep_edge {}; + std::vector has_best {}; + std::vector best {}; + bool leased {false}; +}; -std::vector distances(const Graph& g, std::span member, - std::span roots, Direction direction) { - const auto n = static_cast(g.num_nodes()); - std::vector dist(n, kUnreachable); - const auto cost = g.cost(); - const auto edge_dst = g.edge_dst(); - const auto edge_src = g.edge_src(); - const auto row = g.row_offsets(); - const auto in_row = g.in_row_offsets(); - const auto in_adj = g.in_adj_edge_index(); - MinHeap heap; - for (NodeId r : roots) { - dist[static_cast(r)] = 0; - heap.emplace(0, r); - } - while (!heap.empty()) { - const auto [d, u] = heap.top(); - heap.pop(); +thread_local Workspace tls_workspace; + +// The lease of the thread's workspace for one search (and its extraction): sizes the node arrays +// for the graph, refuses an overlapping lease, and restores the workspace's invariant on every +// exit, exceptions included. A failed acquisition changes nothing (the arrays are replaced +// together or not at all). The workspace holds no pointer into a graph, a residual or an +// Eligibility between leases. +class Lease { +public: + explicit Lease(std::size_t n) : ws_(tls_workspace) { + if (ws_.leased) throw std::logic_error("search: the thread's workspace is already leased (overlapping searches)"); + if (ws_.dist.size() < n || ws_.dist.size() > 4 * n + 4096) { + std::vector dist(n, kInf); + std::vector head(n, -1); + std::vector loc(n, -1); + std::vector flags(n, 0); + std::vector touched; + std::vector marked; + touched.reserve(n); + marked.reserve(n); + ws_.dist.swap(dist); + ws_.head.swap(head); + ws_.loc.swap(loc); + ws_.flags.swap(flags); + ws_.touched.swap(touched); + ws_.marked.swap(marked); + } + ws_.leased = true; + } + Lease(const Lease&) = delete; + Lease& operator=(const Lease&) = delete; + ~Lease() { release(); } + [[nodiscard]] Workspace& ws() noexcept { return ws_; } + +private: + void release() noexcept { + auto& w = ws_; + const std::size_t n = w.dist.size(); + if (w.touched.size() * 4 >= n) { + std::fill(w.dist.begin(), w.dist.end(), kInf); + std::fill(w.head.begin(), w.head.end(), -1); + } else { + for (NodeId v : w.touched) { + w.dist[static_cast(v)] = kInf; + w.head[static_cast(v)] = -1; + } + } + if (w.marked.size() * 4 >= n) { + std::fill(w.flags.begin(), w.flags.end(), std::uint8_t {0}); + std::fill(w.loc.begin(), w.loc.end(), -1); + } else { + for (NodeId v : w.marked) { + w.flags[static_cast(v)] = 0; + w.loc[static_cast(v)] = -1; + } + } + w.touched.clear(); + w.marked.clear(); + w.pool_peak = std::max(w.pool_peak, w.pool.size()); + w.pool.clear(); + trim(w.pool, w.pool_peak); + w.pool_peak = 0; + w.heap.end(); + w.buckets.end(); + w.leased = false; + } + Workspace& ws_; +}; + +void mark(Workspace& ws, NodeId v, std::uint8_t bit) noexcept { + auto& f = ws.flags[static_cast(v)]; + if (f == 0) ws.marked.push_back(v); // within the reserved capacity + f = static_cast(f | bit); +} + +// A KSP spur's rules (forward searches only): the spur node, its banned out-edges (Expand) or +// the banned heads of its transitions (Collapse: the heads carry kBannedHead), and the excluded +// prefix nodes (kExcluded). +struct Spur { + NodeId node {-1}; + std::span banned_edges {}; + bool ban_heads {false}; +}; + +// One search over validated inputs. `roots`: allowed, sorted, unique, non-empty; with `targets` +// the live targets carry kTarget and the search stops at the first one popped. +struct SearchIn { + const Graph* g {nullptr}; + Gate gate {}; + Direction direction {Direction::Forward}; + SpfQueue queue {SpfQueue::Auto}; + std::span roots {}; + bool targets {false}; + bool lists {true}; + const Spur* spur {nullptr}; +}; + +template +Cost loop(Workspace& ws, Q& q, const SearchIn& in) { + const Graph& g = *in.g; + const Cost* const cost = g.cost().data(); + const Amount* const cap = g.capacity_units().data(); + const NodeId* const esrc = g.edge_src().data(); + const NodeId* const edst = g.edge_dst().data(); + const std::int32_t* const row = g.row_offsets().data(); + const std::int32_t* const in_row = g.in_row_offsets().data(); + const EdgeId* const in_adj = g.in_adj_edge_index().data(); + const std::uint8_t* const allowed = in.gate.elig != nullptr ? in.gate.elig->edge_allowed().data() : nullptr; + const Amount* const res = in.gate.residual.empty() ? nullptr : in.gate.residual.data(); + const Amount minr = in.gate.min_residual; + // The node arrays are not resized during a lease: raw pointers keep the compiler from + // reloading the vector bases around every store into the thread-local workspace. + Cost* const dist = ws.dist.data(); + std::int32_t* const head = ws.head.data(); + const std::uint8_t* const flags = ws.flags.data(); + const bool targets = in.targets; + const bool lists = in.lists; + const Spur* const spur = in.spur; + auto candidate = [&](std::size_t e) noexcept { + return (allowed == nullptr || allowed[e] != 0) && cap[e] >= 1 && (res == nullptr || res[e] >= minr); + }; + // Records a node in `touched` before its first write (never allocates) and a tight edge in the + // pool before it is linked (a failed growth changes nothing). + auto relax = [&](NodeId x, Cost nd, EdgeId e) { + const auto xi = static_cast(x); + const Cost dx = dist[xi]; + if (nd < dx) { + if (lists) ws.pool.push_back({e, -1}); + if (dx == kInf) ws.touched.push_back(x); + if (lists) head[xi] = static_cast(ws.pool.size() - 1); + dist[xi] = nd; + q.push(nd, x); + } else if (lists && nd == dx) { + ws.pool.push_back({e, head[xi]}); + head[xi] = static_cast(ws.pool.size() - 1); + } + }; + Cost d = 0; + NodeId u = 0; + while (q.pop(d, u)) { const auto ui = static_cast(u); - if (d != dist[ui]) continue; - if (direction == Direction::Forward) { - for (auto e = row[ui]; e < row[ui + 1]; ++e) { - const auto ei = static_cast(e); - if (member[ei] == 0) continue; - const auto v = static_cast(edge_dst[ei]); - const Cost nd = d + cost[ei]; - if (nd < dist[v]) { - dist[v] = nd; - heap.emplace(nd, static_cast(v)); + if (d != dist[ui]) continue; // a stale entry: the node improved after this push + if (targets && (flags[ui] & kTarget) != 0) return d; // the nearest targets' distance + if constexpr (D == Direction::Forward) { + const bool at_spur = spur != nullptr && u == spur->node; + for (auto e = static_cast(row[ui]); e < static_cast(row[ui + 1]); ++e) { + if (!candidate(e)) continue; + const NodeId v = edst[e]; + if (spur != nullptr) { + const std::uint8_t fv = flags[static_cast(v)]; + if ((fv & kExcluded) != 0) continue; + if (at_spur) { + if (spur->ban_heads ? (fv & kBannedHead) != 0 + : std::find(spur->banned_edges.begin(), spur->banned_edges.end(), + static_cast(e)) != spur->banned_edges.end()) { + continue; + } + } } + relax(v, d + cost[e], static_cast(e)); } } else { - for (auto s = in_row[ui]; s < in_row[ui + 1]; ++s) { - const auto ei = static_cast(in_adj[static_cast(s)]); - if (member[ei] == 0) continue; - const auto v = static_cast(edge_src[ei]); - const Cost nd = d + cost[ei]; - if (nd < dist[v]) { - dist[v] = nd; - heap.emplace(nd, static_cast(v)); - } + for (auto s = static_cast(in_row[ui]); s < static_cast(in_row[ui + 1]); ++s) { + const auto e = static_cast(in_adj[s]); + if (!candidate(e)) continue; + relax(esrc[e], d + cost[e], static_cast(e)); } } } - return dist; + return kInf; } -std::vector full_dag(const Graph& g, std::span member, - std::span dist, Direction direction) { - const auto cost = g.cost(); - const auto edge_src = g.edge_src(); - const auto edge_dst = g.edge_dst(); - std::vector out; - for (std::size_t e = 0; e < member.size(); ++e) { - if (member[e] == 0) continue; - const Cost du = dist[static_cast(edge_src[e])]; - const Cost dv = dist[static_cast(edge_dst[e])]; - if (du == kUnreachable || dv == kUnreachable) continue; - const bool tight = direction == Direction::Forward ? du + cost[e] == dv : dv + cost[e] == du; - if (tight) out.push_back(static_cast(e)); +// Runs one search into the leased workspace; returns the nearest targets' distance d* (kInf +// without targets or when none is reached). +Cost run(Workspace& ws, const SearchIn& in) { + for (NodeId r : in.roots) { + ws.touched.push_back(r); // before the write; within the reserved capacity + ws.dist[static_cast(r)] = 0; } - return out; + const Graph& g = *in.g; + const bool forward = in.direction == Direction::Forward; + if (uses_buckets(g, in.queue)) { +#if NETGRAPH_CORE_PRIVATE_HOOKS + ++thread_queue_counts().buckets; +#endif + ws.buckets.begin(bucket_width(g), std::max(1, g.cost_gcd()), ws.dist.size(), in.roots); + return forward ? loop(ws, ws.buckets, in) : loop(ws, ws.buckets, in); + } +#if NETGRAPH_CORE_PRIVATE_HOOKS + ++thread_queue_counts().heap; +#endif + ws.heap.begin(in.roots); + return forward ? loop(ws, ws.heap, in) : loop(ws, ws.heap, in); +} + +// How a list's entries are read after a search: the list of a node holds its tight in-edges +// (Forward: the other endpoint is the edge's source) or its tight out-edges toward the roots +// (Reverse: the other endpoint is the edge's destination); Collapse keeps one member per pair. +struct Emit { + const Graph* g {nullptr}; + bool forward {true}; + bool collapse {false}; + bool prefer {false}; + PriorityFn prio {}; +}; + +// Appends node x's list to `out` with Collapse applied. The entries of one pair are consecutive +// in the list (each node's scan inserts them one after another; they are the pair's cheapest +// candidates, consecutive in both adjacency orders), so one pass groups them by the other endpoint. +void read_list(const Workspace& ws, const Emit& em, NodeId x, std::vector& out) { + const NodeId* const other = em.forward ? em.g->edge_src().data() : em.g->edge_dst().data(); + std::int32_t k = ws.head[static_cast(x)]; + if (!em.collapse) { + for (; k >= 0; k = ws.pool[static_cast(k)].next) out.push_back(ws.pool[static_cast(k)].edge); + return; + } + while (k >= 0) { + EdgeId best = ws.pool[static_cast(k)].edge; + const NodeId y = other[static_cast(best)]; + k = ws.pool[static_cast(k)].next; + for (; k >= 0; k = ws.pool[static_cast(k)].next) { + const EdgeId e = ws.pool[static_cast(k)].edge; + if (other[static_cast(e)] != y) break; + if (em.prefer) { + const int c = compare(em.prio(e), em.prio(best)); + if (c > 0 || (c == 0 && e < best)) best = e; + } else if (e < best) { + best = e; + } + } + out.push_back(best); + } +} + +// Sorts a short run ascending (a node's list). +void sort_small(std::vector& v, std::size_t from) { + if (v.size() - from > 1) std::sort(v.begin() + static_cast(from), v.end()); } -TightDag tight_dag(const Graph& g, std::span member, std::span sources, - std::span destinations) { +// The full search's DAG from the lists (one dense pass over the nodes; a full search costs O(N) +// for its costs anyway): Forward lists are per child, so the entries come grouped by child and +// the ascending order by a counting pass over the parents; Reverse lists are per parent, so the +// ascending order comes directly and the grouping by a counting pass over the children. +PredDAG emit_full(Workspace& ws, const Emit& em) { + const Graph& g = *em.g; const auto n = static_cast(g.num_nodes()); - const auto cost = g.cost(); - const auto edge_dst = g.edge_dst(); - const auto edge_src = g.edge_src(); - const auto row = g.row_offsets(); - const auto in_row = g.in_row_offsets(); - const auto in_adj = g.in_adj_edge_index(); - std::vector is_dst(n, 0); - for (NodeId t : destinations) is_dst[static_cast(t)] = 1; - // Forward Dijkstra from the sources until every node at distance <= d* is final. - std::vector dist(n, kUnreachable); - std::vector settled(n, 0); - MinHeap heap; - for (NodeId s : sources) { - dist[static_cast(s)] = 0; - heap.emplace(0, s); - } - Cost dstar = kUnreachable; - while (!heap.empty()) { - const auto [d, u] = heap.top(); - if (d > dstar) break; - heap.pop(); - const auto ui = static_cast(u); - if (d != dist[ui] || settled[ui] != 0) continue; - settled[ui] = 1; - if (is_dst[ui] != 0) { - dstar = std::min(dstar, d); - continue; // destinations are terminal - } - for (auto e = row[ui]; e < row[ui + 1]; ++e) { - const auto ei = static_cast(e); - if (member[ei] == 0) continue; - const auto v = static_cast(edge_dst[ei]); - const Cost nd = d + cost[ei]; - if (nd < dist[v]) { - dist[v] = nd; - heap.emplace(nd, static_cast(v)); + const auto esrc = g.edge_src(); + const auto edst = g.edge_dst(); + std::vector nodes; + std::vector offsets {0}; + std::vector parents; + std::vector via; + std::vector asc; + auto& list = ws.list; + list.clear(); + if (em.forward) { + for (std::size_t v = 0; v < n; ++v) { + if (ws.head[v] < 0) continue; + const std::size_t from = list.size(); + read_list(ws, em, static_cast(v), list); + sort_small(list, from); + nodes.push_back(static_cast(v)); + offsets.push_back(static_cast(list.size())); + } + via = list; + parents.resize(via.size()); + for (std::size_t k = 0; k < via.size(); ++k) parents[k] = esrc[static_cast(via[k])]; + // Ascending EdgeId = grouped by source (B' ids rank by source first), ascending within one. + std::vector slot(n + 1, 0); + for (NodeId p : parents) ++slot[static_cast(p) + 1]; + for (std::size_t v = 1; v <= n; ++v) slot[v] += slot[v - 1]; + asc.resize(via.size()); + for (std::size_t k = 0; k < via.size(); ++k) { + asc[static_cast(slot[static_cast(parents[k])]++)] = via[k]; + } + for (std::size_t v = 0, from = 0; v < n; ++v) { + const auto to = static_cast(slot[v]); + if (to - from > 1) std::sort(asc.begin() + static_cast(from), asc.begin() + static_cast(to)); + from = to; + } + } else { + const auto row = g.row_offsets(); + for (std::size_t u = 0; u < n; ++u) { + if (ws.head[u] < 0) continue; + if (!em.collapse && row[u + 1] - row[u] <= 64) { + // The list holds distinct out-edges of u, ids in [row[u], row[u + 1]): one bit per id gives + // them in ascending order without a sort. + std::uint64_t bits = 0; + for (std::int32_t k = ws.head[u]; k >= 0; k = ws.pool[static_cast(k)].next) { + bits |= std::uint64_t {1} << (ws.pool[static_cast(k)].edge - row[u]); + } + for (; bits != 0; bits &= bits - 1) list.push_back(row[u] + std::countr_zero(bits)); + continue; + } + const std::size_t from = list.size(); + read_list(ws, em, static_cast(u), list); + sort_small(list, from); + } + asc = list; // grouped by source in ascending NodeId, ascending within each: ascending EdgeId + std::vector slot(n + 1, 0); + for (EdgeId e : asc) ++slot[static_cast(edst[static_cast(e)]) + 1]; + for (std::size_t v = 1; v <= n; ++v) slot[v] += slot[v - 1]; + via.resize(asc.size()); + for (EdgeId e : asc) via[static_cast(slot[static_cast(edst[static_cast(e)])]++)] = e; + parents.resize(via.size()); + for (std::size_t k = 0; k < via.size(); ++k) parents[k] = esrc[static_cast(via[k])]; + for (std::size_t v = 0, from = 0; v < n; ++v) { + const auto to = static_cast(slot[v]); // the end of child v's entries + if (to > from) { + nodes.push_back(static_cast(v)); + offsets.push_back(static_cast(to)); } + from = to; } } - TightDag out; - if (dstar == kUnreachable) { - out.cost = kUnreachable; - return out; + return PredDAGBuilder::of_parts(g, std::move(nodes), std::move(offsets), std::move(parents), std::move(via), + std::move(asc)); +} + +// The walk back from the targets at d* along the lists (anycast pruning): every tight edge on a +// walk from a root to a nearest target, Collapse applied, into ws.pruned (any order); the nodes on +// those walks carry kKeep and are listed in ws.kept. +void prune(Workspace& ws, const Emit& em, std::span targets, Cost dstar) { + const NodeId* const other = em.forward ? em.g->edge_src().data() : em.g->edge_dst().data(); + ws.pruned.clear(); + ws.kept.clear(); + ws.stack.clear(); + for (NodeId t : targets) { + if (ws.dist[static_cast(t)] != dstar) continue; + mark(ws, t, kKeep); + ws.kept.push_back(t); + ws.stack.push_back(t); } - out.cost = dstar; - // Back-prune from the destinations at d* over tight in-edges whose tail is settled below d*. - std::vector keep(n, 0); - std::vector stack; - for (NodeId t : destinations) { - const auto ti = static_cast(t); - if (dist[ti] == dstar && keep[ti] == 0) { - keep[ti] = 1; - stack.push_back(t); - } - } - while (!stack.empty()) { - const auto v = static_cast(stack.back()); - stack.pop_back(); - for (auto s = in_row[v]; s < in_row[v + 1]; ++s) { - const auto ei = static_cast(in_adj[static_cast(s)]); - if (member[ei] == 0) continue; - const auto u = static_cast(edge_src[ei]); - if (settled[u] == 0 || is_dst[u] != 0 || dist[u] >= dstar || dist[u] + cost[ei] != dist[v]) continue; - out.edges.push_back(static_cast(ei)); - if (keep[u] == 0) { - keep[u] = 1; - stack.push_back(static_cast(u)); - } + while (!ws.stack.empty()) { + const NodeId x = ws.stack.back(); + ws.stack.pop_back(); + const std::size_t from = ws.pruned.size(); + read_list(ws, em, x, ws.pruned); + for (std::size_t k = from; k < ws.pruned.size(); ++k) { + const NodeId y = other[static_cast(ws.pruned[k])]; + if ((ws.flags[static_cast(y)] & kKeep) != 0) continue; + mark(ws, y, kKeep); + ws.kept.push_back(y); + ws.stack.push_back(y); } } - std::sort(out.edges.begin(), out.edges.end()); - return out; } -std::optional canonical_path(const Graph& g, std::span member, - std::span sources, std::span destinations, - const PriorityValues* priority) { - TightDag tight = tight_dag(g, member, sources, destinations); - if (tight.cost == kUnreachable) return std::nullopt; - const auto n = static_cast(g.num_nodes()); +// The staged canonical single path (APPROACH 6) on a tight DAG: `edges` (ws.pruned, any order) and +// its nodes `nodes` (ws.kept, each with kKeep), `pot(v)` the potential of a node (its forward +// distance from the walks' starts: 0 at the starts, `total` at the ends; a topological order). +// Stage 2 under PreferHigherPriority (`prio`): B*, the max-min priority over the DAG's walks, +// capped by `cap` (a KSP root prefix's bottleneck), and the DAG restricted to priority >= B*. +// Stage 3: the reverse hop count h from the ends and the greedy walk from the starts of minimum +// h, each step the smallest edge whose head has h(v) = h(u) - 1 (prefix-monotone, so the walk is +// the lexicographic minimum and simple). +struct Walk { + std::vector edges {}; // in walk order + Cost cost {0}; + Priority bottleneck {0, 1, true}; // the minimum priority along the walk (prio given) +}; + +template +Walk extract(Workspace& ws, const Graph& g, Pot pot, Cost total, const PriorityFn* prio, const Priority* cap) { + auto& nodes = ws.kept; + auto& edges = ws.pruned; + const auto esrc = g.edge_src(); + const auto edst = g.edge_dst(); const auto cost = g.cost(); - const auto edge_src = g.edge_src(); - const auto edge_dst = g.edge_dst(); - std::vector is_dst(n, 0); - for (NodeId t : destinations) is_dst[static_cast(t)] = 1; - std::vector dag = std::move(tight.edges); - SinglePath out; - out.cost = tight.cost; - if (priority != nullptr) { - // Stage 2: B*, the max-min bottleneck over the walks of the tight DAG. Distances from the - // sources inside the DAG order its nodes topologically. - std::vector dag_member(member.size(), 0); - for (EdgeId e : dag) dag_member[static_cast(e)] = 1; - const auto dist = distances(g, dag_member, sources, Direction::Forward); - std::vector by_tail(dag); - std::sort(by_tail.begin(), by_tail.end(), [&](EdgeId a, EdgeId b) { - const Cost da = dist[static_cast(edge_src[static_cast(a)])]; - const Cost db = dist[static_cast(edge_src[static_cast(b)])]; - return da != db ? da < db : a < b; - }); - std::vector> best(n); - for (NodeId s : sources) best[static_cast(s)] = Priority {0, 1, true}; - for (EdgeId e : by_tail) { - const auto u = static_cast(edge_src[static_cast(e)]); - const auto v = static_cast(edge_dst[static_cast(e)]); - if (!best[u].has_value()) continue; - Priority cand = priority->of(e); - if (compare(*best[u], cand) < 0) cand = *best[u]; - if (!best[v].has_value() || compare(cand, *best[v]) > 0) best[v] = cand; + std::sort(nodes.begin(), nodes.end(), [&](NodeId a, NodeId b) { + const Cost pa = pot(a); + const Cost pb = pot(b); + return pa != pb ? pa < pb : a < b; + }); + std::sort(edges.begin(), edges.end()); + const std::size_t k = nodes.size(); + for (std::size_t i = 0; i < k; ++i) ws.loc[static_cast(nodes[i])] = static_cast(i); + auto local = [&](NodeId v) { return static_cast(ws.loc[static_cast(v)]); }; + // Local CSRs over the edges in ascending EdgeId: out-lists by tail, in-lists by head. + ws.off_out.assign(k + 1, 0); + ws.off_in.assign(k + 1, 0); + for (EdgeId e : edges) { + ++ws.off_out[local(esrc[static_cast(e)]) + 1]; + ++ws.off_in[local(edst[static_cast(e)]) + 1]; + } + for (std::size_t i = 1; i <= k; ++i) { + ws.off_out[i] += ws.off_out[i - 1]; + ws.off_in[i] += ws.off_in[i - 1]; + } + ws.idx_out.resize(edges.size()); + ws.idx_in.resize(edges.size()); + ws.hops.assign(k, 0); // reused as the fill cursors, then as h + for (std::size_t p = 0; p < edges.size(); ++p) { + const std::size_t t = local(esrc[static_cast(edges[p])]); + ws.idx_out[static_cast(ws.off_out[t] + ws.hops[t]++)] = static_cast(p); + } + std::fill(ws.hops.begin(), ws.hops.end(), 0); + for (std::size_t p = 0; p < edges.size(); ++p) { + const std::size_t h = local(edst[static_cast(edges[p])]); + ws.idx_in[static_cast(ws.off_in[h] + ws.hops[h]++)] = static_cast(p); + } + ws.keep_edge.assign(edges.size(), 1); + if (prio != nullptr) { + ws.best.assign(k, Priority {}); + ws.has_best.assign(k, 0); + for (std::size_t i = 0; i < k && pot(nodes[i]) == 0; ++i) { + ws.best[i] = Priority {0, 1, true}; + ws.has_best[i] = 1; + } + for (std::size_t i = 0; i < k; ++i) { + if (ws.has_best[i] == 0) continue; + for (auto s = ws.off_out[i]; s < ws.off_out[i + 1]; ++s) { + const EdgeId e = edges[static_cast(ws.idx_out[static_cast(s)])]; + Priority c = (*prio)(e); + if (compare(ws.best[i], c) < 0) c = ws.best[i]; + const std::size_t j = local(edst[static_cast(e)]); + if (ws.has_best[j] == 0 || compare(c, ws.best[j]) > 0) { + ws.best[j] = c; + ws.has_best[j] = 1; + } + } } std::optional b_star; - for (NodeId t : destinations) { - const auto ti = static_cast(t); - if (dist[ti] != tight.cost || !best[ti].has_value()) continue; - if (!b_star.has_value() || compare(*best[ti], *b_star) > 0) b_star = best[ti]; - } - if (!b_star.has_value()) return std::nullopt; - std::vector kept; - for (EdgeId e : dag) { - if (compare(priority->of(e), *b_star) >= 0) kept.push_back(e); - } - dag = std::move(kept); - } - // Stage 3: hop counts toward the destinations inside the (restricted) DAG, then the greedy - // walk by the smallest tight edge whose head is one hop closer. - constexpr std::int32_t kNone = -1; - std::vector h(n, kNone); - std::vector> in_dag(n), out_dag(n); - for (EdgeId e : dag) { // ascending: out_dag lists stay ascending - in_dag[static_cast(edge_dst[static_cast(e)])].push_back(e); - out_dag[static_cast(edge_src[static_cast(e)])].push_back(e); - } - std::vector queue; - for (NodeId t : destinations) { - h[static_cast(t)] = 0; - queue.push_back(t); + for (std::size_t i = k; i-- > 0 && pot(nodes[i]) == total;) { + if (ws.has_best[i] != 0 && (!b_star.has_value() || compare(ws.best[i], *b_star) > 0)) b_star = ws.best[i]; + } + if (!b_star.has_value()) throw std::logic_error("canonical path: no walk reaches an end of the tight DAG"); + if (cap != nullptr && compare(*cap, *b_star) < 0) b_star = *cap; + for (std::size_t p = 0; p < edges.size(); ++p) ws.keep_edge[p] = compare((*prio)(edges[p]), *b_star) >= 0 ? 1 : 0; + } + // h: the hop count to the nearest end inside the (restricted) DAG; -1 where none is reached. + auto& h = ws.hops; + std::fill(h.begin(), h.end(), -1); + auto& queue = ws.stack; + queue.clear(); + for (std::size_t i = k; i-- > 0 && pot(nodes[i]) == total;) { + h[i] = 0; + queue.push_back(nodes[i]); } for (std::size_t qi = 0; qi < queue.size(); ++qi) { - const auto v = static_cast(queue[qi]); - for (EdgeId e : in_dag[v]) { - const auto u = static_cast(edge_src[static_cast(e)]); - if (h[u] == kNone) { - h[u] = h[v] + 1; - queue.push_back(static_cast(u)); + const std::size_t x = local(queue[qi]); + for (auto s = ws.off_in[x]; s < ws.off_in[x + 1]; ++s) { + const auto p = static_cast(ws.idx_in[static_cast(s)]); + if (ws.keep_edge[p] == 0) continue; + const std::size_t y = local(esrc[static_cast(edges[p])]); + if (h[y] < 0) { + h[y] = h[x] + 1; + queue.push_back(nodes[y]); } } } - std::int32_t hmin = kNone; - for (NodeId s : sources) { - const std::int32_t hs = h[static_cast(s)]; - if (hs != kNone && (hmin == kNone || hs < hmin)) hmin = hs; - } - if (hmin == kNone) return std::nullopt; - std::vector frontier; - for (NodeId s : sources) { - if (h[static_cast(s)] == hmin) frontier.push_back(s); - } - while (true) { - EdgeId pick = -1; - for (NodeId x : frontier) { - const auto xi = static_cast(x); - for (EdgeId e : out_dag[xi]) { - const auto v = static_cast(edge_dst[static_cast(e)]); - if (h[v] != kNone && h[v] == h[xi] - 1) { - if (pick < 0 || e < pick) pick = e; - break; // ascending: the first qualifying edge of x is its smallest - } - } + // The first qualifying out-edge of a node (out-lists ascending: its smallest), or -1. + auto next_edge = [&](std::size_t x) -> EdgeId { + for (auto s = ws.off_out[x]; s < ws.off_out[x + 1]; ++s) { + const auto p = static_cast(ws.idx_out[static_cast(s)]); + if (ws.keep_edge[p] == 0) continue; + const EdgeId e = edges[p]; + if (h[local(edst[static_cast(e)])] == h[x] - 1) return e; } - if (pick < 0) break; - out.walk.push_back(pick); - const NodeId u = edge_dst[static_cast(pick)]; - if (is_dst[static_cast(u)] != 0) break; - frontier.assign(1, u); + return -1; + }; + std::int32_t hmin = -1; + for (std::size_t i = 0; i < k && pot(nodes[i]) == 0; ++i) { + if (h[i] >= 0 && (hmin < 0 || h[i] < hmin)) hmin = h[i]; + } + if (hmin <= 0) throw std::logic_error("canonical path: no start reaches an end of the restricted DAG"); + EdgeId pick = -1; + for (std::size_t i = 0; i < k && pot(nodes[i]) == 0; ++i) { + if (h[i] != hmin) continue; + const EdgeId e = next_edge(i); + if (e >= 0 && (pick < 0 || e < pick)) pick = e; + } + Walk out; + out.edges.reserve(static_cast(hmin)); + while (pick >= 0) { + out.edges.push_back(pick); + out.cost += cost[static_cast(pick)]; + if (prio != nullptr) { + const Priority p = (*prio)(pick); + if (compare(p, out.bottleneck) < 0) out.bottleneck = p; + } + const std::size_t v = local(edst[static_cast(pick)]); + if (h[v] == 0) break; + pick = next_edge(v); + } + if (out.edges.size() != static_cast(hmin) || out.cost != total) { + throw std::logic_error("canonical path: the greedy walk is not a shortest walk of the DAG"); } - if (out.walk.empty()) return std::nullopt; - Cost walk_cost = 0; - for (EdgeId e : out.walk) walk_cost += cost[static_cast(e)]; - if (walk_cost != out.cost) throw std::logic_error("canonical path: the walk is not tight"); return out; } -SpfOut spf(const Graph& g, std::span roots, Direction direction, - std::optional> targets, const PathSelection& selection, - std::optional> residual_arg, Amount min_residual, const Eligibility* elig) { - check_member(direction, "direction"); - check_selection(selection); - const auto root_set = node_set(roots, g.num_nodes(), "roots"); - std::vector target_set; - if (targets.has_value()) { - target_set = node_set(*targets, g.num_nodes(), "targets"); - check_disjoint(root_set, target_set, "roots", "targets"); - } else if (!selection.multipath) { - throw std::invalid_argument("multipath = false requires targets (the single path joins a root to a target)"); +// The live (allowed) members of a sorted node set. +std::vector live(std::span set, const Eligibility* elig) { + std::vector out; + out.reserve(set.size()); + for (NodeId v : set) { + if (elig == nullptr || elig->node_ok(v)) out.push_back(v); } + return out; +} + +Emit emit_of(const Graph& g, Direction direction, const PathSelection& selection, const PriorityFn& prio) { + Emit em; + em.g = &g; + em.forward = direction == Direction::Forward; + em.collapse = !selection.multi_edge; + em.prefer = selection.tie_break == EdgeTieBreak::PreferHigherPriority; + em.prio = prio; + return em; +} + +void check_residual(const Graph& g, std::span residual) { const auto m = static_cast(g.num_edges()); - const std::span residual = residual_arg.value_or(std::span {}); - if (residual_arg.has_value() && residual.size() != m) { + if (residual.size() != m) { throw std::invalid_argument("residual has length " + std::to_string(residual.size()) + ", expected " + std::to_string(m) + " (one entry per edge)"); } const auto capacity = g.capacity_units(); - for (std::size_t e = 0; e < residual.size(); ++e) { + for (std::size_t e = 0; e < m; ++e) { if (residual[e] < 0 || residual[e] > capacity[e]) { throw std::invalid_argument("residual[" + std::to_string(e) + "] = " + std::to_string(residual[e]) + " is outside [0, capacity_units[" + std::to_string(e) + "] = " + std::to_string(capacity[e]) + "]"); } } - if (min_residual < 1) { - throw std::invalid_argument("min_residual must be at least 1 unit (got " + std::to_string(min_residual) + ")"); +} + +// One KSP spur search (APPROACH 6, rule 10): the canonical walk from `spur` to `dst` over the +// candidates of `gate`, without the excluded prefix nodes, with the spur node's banned +// transitions (Expand: the banned edges; Collapse: every edge to a banned head, no re-selection), +// B* capped by `cap` (the root prefix's bottleneck). nullopt when dst is unreachable. +std::optional spur_walk(const Graph& g, const Gate& gate, const PathSelection& selection, const PriorityFn& prio, + NodeId spur, NodeId dst, std::span excluded, std::span banned, + const Priority* cap) { + Lease lease(static_cast(g.num_nodes())); + Workspace& ws = lease.ws(); + mark(ws, dst, kTarget); + for (NodeId v : excluded) mark(ws, v, kExcluded); + Spur rule; + rule.node = spur; + if (selection.multi_edge) { + rule.banned_edges = banned; + } else { + rule.ban_heads = true; + for (EdgeId e : banned) mark(ws, g.edge_dst()[static_cast(e)], kBannedHead); } - if (elig != nullptr && elig->graph_uid() != g.uid()) { - throw std::invalid_argument("foreign Eligibility: built for another graph"); + const NodeId roots[1] = {spur}; + SearchIn in; + in.g = &g; + in.gate = gate; + in.roots = roots; + in.targets = true; + in.spur = &rule; + const Cost dstar = run(ws, in); + if (dstar == kInf) return std::nullopt; + const Emit em = emit_of(g, Direction::Forward, selection, prio); + const NodeId targets[1] = {dst}; + prune(ws, em, targets, dstar); + const bool prefer = selection.tie_break == EdgeTieBreak::PreferHigherPriority; + return extract(ws, g, [&](NodeId v) { return ws.dist[static_cast(v)]; }, dstar, + prefer ? &prio : nullptr, cap); +} + + +// The walks of resolve_to_paths (validated inputs): see the public function. +std::vector enumerate_paths(const Graph& g, const PredDAG& dag, NodeId src, NodeId dst, bool split, + std::optional max_paths) { + const auto children = PredDAGBuilder::member_nodes(dag); + const auto offsets = PredDAGBuilder::member_offsets(dag); + const auto parents = dag.parents(); + const auto via = dag.via_edges(); + const auto cost = g.cost(); + std::vector out; + // The entries of a child: [offsets[i], offsets[i + 1]) for children[i], none for another node. + auto entries_of = [&](NodeId v) -> std::pair { + const auto it = std::lower_bound(children.begin(), children.end(), v); + if (it == children.end() || *it != v) return {0, 0}; + const auto i = static_cast(it - children.begin()); + return {offsets[i], offsets[i + 1]}; + }; + Lease lease(static_cast(g.num_nodes())); + Workspace& ws = lease.ws(); + // The nodes that reach dst inside the DAG (the walk back over the entries), local index by + // discovery. + auto& kept = ws.kept; + kept.clear(); + ws.stack.clear(); + mark(ws, dst, kKeep); + kept.push_back(dst); + ws.stack.push_back(dst); + while (!ws.stack.empty()) { + const NodeId v = ws.stack.back(); + ws.stack.pop_back(); + const auto [lo, hi] = entries_of(v); + for (auto k = lo; k < hi; ++k) { + const NodeId u = parents[static_cast(k)]; + if ((ws.flags[static_cast(u)] & kKeep) != 0) continue; + mark(ws, u, kKeep); + kept.push_back(u); + ws.stack.push_back(u); + } } - const auto member = member_mask(g, elig, selection, residual, min_residual); - std::vector live_roots; - for (NodeId r : root_set) { - if (elig == nullptr || elig->node_ok(r)) live_roots.push_back(r); + if ((ws.flags[static_cast(src)] & kKeep) == 0) return out; // src is not on a walk to dst + for (std::size_t i = 0; i < kept.size(); ++i) ws.loc[static_cast(kept[i])] = static_cast(i); + auto local = [&](NodeId v) { return static_cast(ws.loc[static_cast(v)]); }; + // The hops: per child, its entries come in ascending via, so grouped by parent (B' ids rank by + // source first); a hop is one parent's run (its members ascending, its cost the cheapest, its + // key the lowest id), or one member when split. + struct Hop { + std::int32_t tail; + std::int32_t head; + std::int32_t first; // the members are via[first .. first + count) + std::int32_t count; + Cost cost; + EdgeId key; + }; + std::vector hops; + for (NodeId v : kept) { + const auto [lo, hi] = entries_of(v); + for (auto k = lo; k < hi;) { + const NodeId u = parents[static_cast(k)]; + auto end = k + 1; + while (end < hi && parents[static_cast(end)] == u) ++end; + if (split) { + for (auto m = k; m < end; ++m) { + const EdgeId e = via[static_cast(m)]; + hops.push_back({static_cast(local(u)), static_cast(local(v)), m, 1, + cost[static_cast(e)], e}); + } + } else { + Cost c = cost[static_cast(via[static_cast(k)])]; + for (auto m = k + 1; m < end; ++m) c = std::min(c, cost[static_cast(via[static_cast(m)])]); + hops.push_back({static_cast(local(u)), static_cast(local(v)), k, end - k, c, + via[static_cast(k)]}); + } + k = end; + } } - SpfOut out; - if (!targets.has_value()) { - auto dist = distances(g, member, live_roots, direction); - out.edges = full_dag(g, member, dist, direction); - out.costs = std::move(dist); - return out; + const std::size_t n = kept.size(); + std::vector out_off(n + 1, 0); + for (const auto& h : hops) ++out_off[static_cast(h.tail) + 1]; + for (std::size_t i = 1; i <= n; ++i) out_off[i] += out_off[i - 1]; + std::vector out_hop(hops.size()); + { + std::vector fill(out_off.begin(), out_off.end() - 1); + for (std::size_t h = 0; h < hops.size(); ++h) { + out_hop[static_cast(fill[static_cast(hops[h].tail)]++)] = static_cast(h); + } } - std::vector live_targets; - for (NodeId t : target_set) { - if (elig == nullptr || elig->node_ok(t)) live_targets.push_back(t); + // Per node: its completions found so far (sorted) and the heap of the next completion per hop. + struct Elem { + Cost cost; + std::int64_t hops; + std::int32_t hop; // -1: the empty completion of dst + std::int64_t rank; // the completion of the hop's head this one extends + }; + struct Next { + Cost cost; + std::int64_t hops; + EdgeId key; + std::int32_t hop; + std::int64_t rank; + }; + auto after = [](const Next& a, const Next& b) { // a pops after b: (cost, hops, key) ascending + if (a.cost != b.cost) return a.cost > b.cost; + if (a.hops != b.hops) return a.hops > b.hops; + return a.key > b.key; + }; + std::vector> list(n); + std::vector> heap(n); + std::vector ready(n, 0); + const std::size_t t = local(dst); + list[t].push_back({0, 0, -1, -1}); + ready[t] = 1; // a walk ends at dst: its out-hops never extend a completion + auto offer = [&](std::size_t x, std::int32_t h, std::int64_t rank) { + const auto& s = list[static_cast(hops[static_cast(h)].head)][static_cast(rank)]; + heap[x].push_back({hops[static_cast(h)].cost + s.cost, s.hops + 1, hops[static_cast(h)].key, h, rank}); + std::push_heap(heap[x].begin(), heap[x].end(), after); + }; + // get(x, i): whether node x has an i-th completion (computing it if needed), with an explicit + // frame stack. Stages: 0 entry, 1 the next hop to initialise, 2 back from initialising one, + // 3 pop the next completion, 4 back from extending the popped hop. + struct Frame { + std::size_t x; + std::int64_t i; + int stage; + std::int32_t k; + std::int64_t r; + }; + std::vector frames; + auto get = [&](std::size_t x0, std::int64_t i0) { + frames.assign(1, Frame {x0, i0, 0, 0, 0}); + bool ret = false; + while (!frames.empty()) { + const std::size_t top = frames.size() - 1; + Frame f = frames[top]; + switch (f.stage) { + case 0: + if (static_cast(list[f.x].size()) > f.i) { + ret = true; + frames.pop_back(); + } else if (ready[f.x] == 0) { + frames[top].stage = 1; + frames[top].k = out_off[f.x]; + } else { + frames[top].stage = 3; + } + break; + case 1: + if (f.k == out_off[f.x + 1]) { + ready[f.x] = 1; + frames[top].stage = 3; + } else { + frames[top].stage = 2; + frames.push_back({static_cast(hops[static_cast(out_hop[static_cast(f.k)])].head), 0, + 0, 0, 0}); + } + break; + case 2: + if (ret) offer(f.x, out_hop[static_cast(f.k)], 0); + frames[top].k = f.k + 1; + frames[top].stage = 1; + break; + case 3: { + auto& hx = heap[f.x]; + if (hx.empty()) { + ret = false; + frames.pop_back(); + break; + } + std::pop_heap(hx.begin(), hx.end(), after); + const Next c = hx.back(); + hx.pop_back(); + list[f.x].push_back({c.cost, c.hops, c.hop, c.rank}); + frames[top].stage = 4; + frames[top].k = c.hop; + frames[top].r = c.rank + 1; + frames.push_back({static_cast(hops[static_cast(c.hop)].head), c.rank + 1, 0, 0, 0}); + break; + } + default: // 4 + if (ret) offer(f.x, f.k, f.r); + frames[top].stage = 0; + break; + } + } + return ret; + }; + const std::size_t s = local(src); + for (std::int64_t j = 0; !max_paths.has_value() || j < *max_paths; ++j) { + if (!get(s, j)) break; + std::vector> walk; + std::size_t x = s; + std::int64_t idx = j; + while (x != t) { + const Elem& e = list[x][static_cast(idx)]; + const Hop& h = hops[static_cast(e.hop)]; + walk.emplace_back(via.begin() + h.first, via.begin() + h.first + h.count); + x = static_cast(h.head); + idx = e.rank; + } + out.push_back(PathBuilder::of_hops(g, std::move(walk))); } - out.target_cost = std::nullopt; - if (live_roots.empty() || live_targets.empty()) return out; - const auto& src = direction == Direction::Forward ? live_roots : live_targets; - const auto& dst = direction == Direction::Forward ? live_targets : live_roots; + return out; +} + +} // namespace + +std::optional route_search(const Graph& g, const Gate& gate, const PathSelection& selection, NodeId src, + std::span dests) { + if (dests.empty()) return std::nullopt; + const PriorityValues values {&g, selection.priority, gate.residual}; + const PriorityFn prio = PriorityFn::of(values); + Lease lease(static_cast(g.num_nodes())); + Workspace& ws = lease.ws(); + for (NodeId t : dests) mark(ws, t, kTarget); + const NodeId roots[1] = {src}; + SearchIn in; + in.g = &g; + in.gate = gate; + in.roots = roots; + in.targets = true; + const Cost dstar = run(ws, in); + if (dstar == kInf) return std::nullopt; + const Emit em = emit_of(g, Direction::Forward, selection, prio); + prune(ws, em, dests, dstar); + SearchRoute out; + out.cost = dstar; if (selection.multipath) { - auto tight = tight_dag(g, member, src, dst); - if (tight.cost != kUnreachable) { - out.target_cost = tight.cost; - out.edges = std::move(tight.edges); + out.edges = ws.pruned; + } else { + const bool prefer = selection.tie_break == EdgeTieBreak::PreferHigherPriority; + auto walk = extract(ws, g, [&](NodeId v) { return ws.dist[static_cast(v)]; }, dstar, + prefer ? &prio : nullptr, nullptr); + out.edges = std::move(walk.edges); + } + std::sort(out.edges.begin(), out.edges.end()); + return out; +} + +SearchRoute support_route(const Graph& g, std::span support, NodeId src, std::span dests, + const PathSelection& selection, PriorityFn view) { + const auto row = g.row_offsets(); + const auto edst = g.edge_dst(); + const auto esrc = g.edge_src(); + const auto cost = g.cost(); + Lease lease(static_cast(g.num_nodes())); + Workspace& ws = lease.ws(); + for (NodeId t : dests) mark(ws, t, kTarget); + // The part of the support reachable from src: every walk of a support from one node has one + // cost to each node it reaches (the support is a reverse multipath DAG), so the order of the + // walk assigns each node its distance from src exactly. + auto& route = ws.pruned; + route.clear(); + ws.kept.clear(); + ws.stack.clear(); + mark(ws, src, kKeep); + ws.kept.push_back(src); + ws.touched.push_back(src); + ws.dist[static_cast(src)] = 0; + ws.stack.push_back(src); + Cost total = kInf; + while (!ws.stack.empty()) { + const NodeId u = ws.stack.back(); + ws.stack.pop_back(); + const auto ui = static_cast(u); + if ((ws.flags[ui] & kTarget) != 0) total = ws.dist[ui]; + auto it = std::lower_bound(support.begin(), support.end(), row[ui]); + for (; it != support.end() && *it < row[ui + 1]; ++it) { + route.push_back(*it); + const NodeId v = edst[static_cast(*it)]; + const auto vi = static_cast(v); + if ((ws.flags[vi] & kKeep) != 0) continue; + mark(ws, v, kKeep); + ws.kept.push_back(v); + ws.touched.push_back(v); + ws.dist[vi] = ws.dist[ui] + cost[static_cast(*it)]; + ws.stack.push_back(v); } - return out; } - const PriorityValues prio {&g, selection.priority, residual}; + SearchRoute out; + if (total == kInf) return out; // no destination reached (the roster excludes such a source) + std::sort(route.begin(), route.end()); const bool prefer = selection.tie_break == EdgeTieBreak::PreferHigherPriority; - auto path = canonical_path(g, member, src, dst, prefer ? &prio : nullptr); - if (path.has_value()) { - out.target_cost = path->cost; - out.edges = path->walk; - std::sort(out.edges.begin(), out.edges.end()); - out.path = std::move(path); + if (!selection.multi_edge) { + // One member per (u, v) by select_adjacency on the view (no gating: the support's members are + // all statically eligible). The members of a pair in the support have one cost, so they are + // consecutive in id order (B' ranks by (src, cost, dst, input)). + std::size_t w = 0; + for (std::size_t i = 0; i < route.size();) { + const auto ei = static_cast(route[i]); + EdgeId best = route[i]; + std::size_t j = i + 1; + for (; j < route.size() && esrc[static_cast(route[j])] == esrc[ei] && + edst[static_cast(route[j])] == edst[ei]; + ++j) { + if (replaces(g, route[j], best, prefer, view)) best = route[j]; + } + route[w++] = best; + i = j; + } + route.resize(w); } + out.cost = total; + if (selection.multipath) { + out.edges = route; + return out; + } + auto walk = extract(ws, g, [&](NodeId v) { return ws.dist[static_cast(v)]; }, total, + prefer ? &view : nullptr, nullptr); + out.edges = std::move(walk.edges); + std::sort(out.edges.begin(), out.edges.end()); return out; } -PredDAG static_support(const Graph& g, std::span roots, Direction direction, - const Eligibility* elig) { - const PathSelection full(EdgeTieBreak::Deterministic, EdgePriority::Capacity, true, true); - const auto out = spf(g, roots, direction, std::nullopt, full, std::nullopt, 1, elig); - return PredDAGBuilder::of_edges(g, out.edges); +PredDAG static_support(const Graph& g, std::span roots, Direction direction, const Eligibility* elig) { + const auto root_set = node_set(roots, g.num_nodes(), "roots"); + const auto live_roots = live(root_set, elig); + if (live_roots.empty()) return PredDAGBuilder::empty(g); + Lease lease(static_cast(g.num_nodes())); + Workspace& ws = lease.ws(); + SearchIn in; + in.g = &g; + in.gate.elig = elig; + in.direction = direction; + in.roots = live_roots; + (void)run(ws, in); + Emit em; + em.g = &g; + em.forward = direction == Direction::Forward; + return emit_full(ws, em); +} + +std::vector static_costs(const Graph& g, const Eligibility* elig, std::span dests, + std::span sources) { + std::vector out(sources.size(), kInf); + std::vector roots(dests.begin(), dests.end()); + std::sort(roots.begin(), roots.end()); + roots.erase(std::unique(roots.begin(), roots.end()), roots.end()); + roots = live(roots, elig); + if (roots.empty()) return out; + Lease lease(static_cast(g.num_nodes())); + Workspace& ws = lease.ws(); + SearchIn in; + in.g = &g; + in.gate.elig = elig; + in.direction = Direction::Reverse; + in.roots = roots; + in.lists = false; + (void)run(ws, in); + for (std::size_t i = 0; i < sources.size(); ++i) out[i] = ws.dist[static_cast(sources[i])]; + return out; +} + +void check_ceiling(const std::optional& ceiling) { + if (!ceiling.has_value()) return; + if (const auto* a = std::get_if(&*ceiling)) { + if (a->cost < 0) throw std::invalid_argument("max_path_cost Absolute cost must be >= 0 (got " + std::to_string(a->cost) + ")"); + return; + } + const double f = std::get(*ceiling).factor; + if (!(f >= 1.0) || !std::isfinite(f)) { + throw std::invalid_argument("max_path_cost Stretch factor must be finite and in [1, inf) (got " + std::to_string(f) + ")"); + } } } // namespace netgraph::core::detail @@ -435,4 +1247,204 @@ PathSelection::PathSelection(EdgeTieBreak tie_break_, EdgePriority priority_, bo detail::check_member(priority, "priority"); } +SpfOptions::SpfOptions(Direction direction_, PathSelection selection_) : direction(direction_), selection(selection_) { + detail::check_member(direction, "direction"); +} + +SpfResult spf(const Graph& g, std::span roots, const SpfOptions& opts, SpfQueue queue) { + using namespace detail; + check_member(opts.direction, "direction"); + check_selection(opts.selection); + check_member(queue, "queue"); + const auto root_set = node_set(roots, g.num_nodes(), "roots"); + std::vector target_set; + if (opts.targets.has_value()) { + target_set = node_set(*opts.targets, g.num_nodes(), "targets"); + check_disjoint(root_set, target_set, "roots", "targets"); + } else if (!opts.selection.multipath) { + throw std::invalid_argument("multipath = false requires targets (the single path joins a root to a target)"); + } + const std::span residual = opts.residual.value_or(std::span {}); + if (opts.residual.has_value()) check_residual(g, residual); + if (opts.min_residual < 1) { + throw std::invalid_argument("min_residual must be at least 1 unit (got " + std::to_string(opts.min_residual) + ")"); + } + if (opts.elig != nullptr && opts.elig->graph_uid() != g.uid()) { + throw std::invalid_argument("foreign Eligibility: built for another graph"); + } + const PriorityValues values {&g, opts.selection.priority, residual}; + const PriorityFn prio = PriorityFn::of(values); + const Emit em = emit_of(g, opts.direction, opts.selection, prio); + const auto live_roots = live(root_set, opts.elig); + SearchIn in; + in.g = &g; + in.gate = Gate {opts.elig, residual, opts.min_residual}; + in.direction = opts.direction; + in.queue = queue; + in.roots = live_roots; + if (!opts.targets.has_value()) { + const auto n = static_cast(g.num_nodes()); + if (live_roots.empty()) { + return SpfResult {std::vector(n, kUnreachable), PredDAGBuilder::empty(g), std::nullopt}; + } + Lease lease(n); + Workspace& ws = lease.ws(); + (void)run(ws, in); + std::vector costs(ws.dist.begin(), ws.dist.begin() + static_cast(n)); + PredDAG dag = emit_full(ws, em); + return SpfResult {std::move(costs), std::move(dag), std::nullopt}; + } + const auto live_targets = live(target_set, opts.elig); + if (live_roots.empty() || live_targets.empty()) return SpfResult {std::nullopt, PredDAGBuilder::empty(g), kUnreachable}; + Lease lease(static_cast(g.num_nodes())); + Workspace& ws = lease.ws(); + for (NodeId t : live_targets) mark(ws, t, kTarget); + in.targets = true; + const Cost dstar = run(ws, in); + if (dstar == kInf) return SpfResult {std::nullopt, PredDAGBuilder::empty(g), kUnreachable}; + prune(ws, em, live_targets, dstar); + if (opts.selection.multipath) return SpfResult {std::nullopt, PredDAGBuilder::of_edges(g, ws.pruned), dstar}; + // The walks run from the walk starts (Forward: the roots; Reverse: the targets) to the ends; + // the potential is the forward distance from the starts (Reverse: d* - the distance to the roots). + const bool prefer = opts.selection.tie_break == EdgeTieBreak::PreferHigherPriority; + Walk walk; + if (opts.direction == Direction::Forward) { + walk = extract(ws, g, [&](NodeId v) { return ws.dist[static_cast(v)]; }, dstar, + prefer ? &prio : nullptr, nullptr); + } else { + walk = extract(ws, g, [&](NodeId v) { return dstar - ws.dist[static_cast(v)]; }, dstar, + prefer ? &prio : nullptr, nullptr); + } + return SpfResult {std::nullopt, PredDAGBuilder::of_edges(g, walk.edges), dstar}; +} + + +KspOptions::KspOptions(std::int64_t k_, PathSelection selection_) : k(k_), selection(selection_) {} + +std::vector> ksp(const Graph& g, NodeId src, NodeId dst, const KspOptions& opts) { + using namespace detail; + check_selection(opts.selection); + const NodeId s1[1] = {src}; + const NodeId t1[1] = {dst}; + (void)node_set(s1, g.num_nodes(), "src"); + (void)node_set(t1, g.num_nodes(), "dst"); + check_disjoint(s1, t1, "src", "dst"); + if (opts.k < 1) throw std::invalid_argument("k must be >= 1 (got " + std::to_string(opts.k) + ")"); + check_ceiling(opts.max_path_cost); + const std::span residual = opts.residual.value_or(std::span {}); + if (opts.residual.has_value()) check_residual(g, residual); + if (opts.elig != nullptr && opts.elig->graph_uid() != g.uid()) { + throw std::invalid_argument("foreign Eligibility: built for another graph"); + } + std::vector> out; + if (opts.elig != nullptr && (!opts.elig->node_ok(src) || !opts.elig->node_ok(dst))) return out; + // The ceiling: Absolute(c), or Stretch(f) times the static shortest cost of the member graph + // under the Eligibility (residual ignored: the policy ceiling's baseline of 9.1); no static route + // gives []. + const auto* absolute = opts.max_path_cost.has_value() ? std::get_if(&*opts.max_path_cost) : nullptr; + const auto* stretch = opts.max_path_cost.has_value() ? std::get_if(&*opts.max_path_cost) : nullptr; + Cost base = 0; + if (stretch != nullptr) { + base = static_costs(g, opts.elig, t1, s1)[0]; + if (base == kUnreachable) return out; + } + auto within = [&](Cost c) { + if (absolute != nullptr) return c <= absolute->cost; + if (stretch != nullptr) return stretch_le(c, base, stretch->factor); + return true; + }; + const Gate gate {opts.elig, residual, 1}; // KSP gates a supplied residual at 1 unit + const PriorityValues values {&g, opts.selection.priority, residual}; + const PriorityFn prio = PriorityFn::of(values); + const bool prefer = opts.selection.tie_break == EdgeTieBreak::PreferHigherPriority; + // Yen deviations (APPROACH 6): the first path and every spur path are the canonical walk from + // the spur node under the spur's exclusions; roots are matched by edge prefix; a deviation bans + // the next edge of every accepted path with that prefix (Expand) or that transition (Collapse); + // the prefix's vertices are excluded; candidates live in an ordered set keyed by the tuple + // (structural deduplication). + struct Cand { + Cost cost {0}; + Priority bottleneck {0, 1, true}; + std::vector edges {}; + }; + struct CandLess { + bool prefer; + bool operator()(const Cand& a, const Cand& b) const { + if (a.cost != b.cost) return a.cost < b.cost; + if (prefer) { + const int c = compare(a.bottleneck, b.bottleneck); + if (c != 0) return c > 0; + } + if (a.edges.size() != b.edges.size()) return a.edges.size() < b.edges.size(); + return a.edges < b.edges; + } + }; + auto first = spur_walk(g, gate, opts.selection, prio, src, dst, {}, {}, nullptr); + if (!first.has_value() || !within(first->cost)) return out; + std::vector accepted; + accepted.push_back(Cand {first->cost, first->bottleneck, std::move(first->edges)}); + std::set candidates(CandLess {prefer}); + const auto edst = g.edge_dst(); + const auto cost = g.cost(); + std::vector nodes; + std::vector banned; + while (static_cast(accepted.size()) < opts.k) { + const std::vector last = accepted.back().edges; + nodes.assign(1, src); + for (EdgeId e : last) nodes.push_back(edst[static_cast(e)]); + Cost prefix_cost = 0; + Priority prefix_bottleneck {0, 1, true}; + for (std::size_t j = 0; j < last.size(); ++j) { + banned.clear(); + for (const auto& p : accepted) { + if (p.edges.size() > j && std::equal(last.begin(), last.begin() + static_cast(j), p.edges.begin())) { + banned.push_back(p.edges[j]); + } + } + const std::span excluded(nodes.data(), j); + auto sp = spur_walk(g, gate, opts.selection, prio, nodes[j], dst, excluded, banned, + prefer && j > 0 ? &prefix_bottleneck : nullptr); + if (sp.has_value()) { + Cand c; + c.cost = prefix_cost + sp->cost; + c.bottleneck = compare(sp->bottleneck, prefix_bottleneck) < 0 ? sp->bottleneck : prefix_bottleneck; + c.edges.assign(last.begin(), last.begin() + static_cast(j)); + c.edges.insert(c.edges.end(), sp->edges.begin(), sp->edges.end()); + // An accepted path never returns (its next edge after this prefix is banned); checked + // anyway, as the prototype does. + const bool known = std::any_of(accepted.begin(), accepted.end(), [&](const Cand& a) { return a.edges == c.edges; }); + if (!known) candidates.insert(std::move(c)); + } + prefix_cost += cost[static_cast(last[j])]; + if (prefer) { + const Priority pj = prio(last[j]); + if (compare(pj, prefix_bottleneck) < 0) prefix_bottleneck = pj; + } + } + if (candidates.empty()) break; + auto best = candidates.begin(); + if (!within(best->cost)) break; // every later path costs at least as much + accepted.push_back(*best); + candidates.erase(best); + } + out.reserve(accepted.size()); + for (auto& p : accepted) out.emplace_back(p.cost, PathBuilder::of_walk(g, p.edges)); + return out; +} + +std::vector resolve_to_paths(const Graph& g, const PredDAG& dag, NodeId src, NodeId dst, bool split_parallel_edges, + std::optional max_paths) { + using namespace detail; + if (dag.graph_uid() != g.uid()) throw std::invalid_argument("foreign PredDAG: built for another graph"); + const NodeId s1[1] = {src}; + const NodeId t1[1] = {dst}; + (void)node_set(s1, g.num_nodes(), "src"); + (void)node_set(t1, g.num_nodes(), "dst"); + check_disjoint(s1, t1, "src", "dst"); + if (max_paths.has_value() && *max_paths < 1) { + throw std::invalid_argument("max_paths must be >= 1 (got " + std::to_string(*max_paths) + ")"); + } + return enumerate_paths(g, dag, src, dst, split_parallel_edges, max_paths); +} + } // namespace netgraph::core diff --git a/tests/cpp/cost_only_creation_tests.cpp b/tests/cpp/cost_only_creation_tests.cpp new file mode 100644 index 0000000..0f0a89c --- /dev/null +++ b/tests/cpp/cost_only_creation_tests.cpp @@ -0,0 +1,535 @@ +// The CostOnly creation products in one pass (APPROACH 9.1, 10; task 1d-msd): cost_only_creation +// against the functions it replaces on the policy's path, kept as the reference (support_route's +// route, the record bundle of bundle_of, make_plan's plan and cost on that bundle), on random +// graphs for every source, selection and kernel: it answers exactly where that path's members are +// the whole route, with the same bundle (the support itself when the route is all of it), plan and +// cost. On crafted supports that break its preconditions (edges leaving a destination, dead ends, +// members below one unit, a cycle) it answers nothing or the reference's own result. Also +// lower_bound_from against std::lower_bound from every hint, and the plan's one block under copy +// and move. +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/detail/kernels.hpp" +#include "netgraph/core/detail/pred_dag_builder.hpp" +#include "netgraph/core/detail/traversal.hpp" +#include "test_utils.hpp" + +using namespace netgraph::core; +using namespace netgraph::core::test; +using namespace netgraph::core::detail; + +namespace { + +template +std::vector> vec(std::span v) { + return std::vector>(v.begin(), v.end()); +} + +void expect_same_plan(const KernelPlan& got, const KernelPlan& want, const std::string& where) { + EXPECT_EQ(got.placement, want.placement) << where; + EXPECT_EQ(got.admission, want.admission) << where; + EXPECT_EQ(got.src, want.src) << where; + EXPECT_EQ(vec(got.dsts), vec(want.dsts)) << where; + EXPECT_EQ(got.route, want.route) << where; + EXPECT_EQ(got.members, want.members) << where; + EXPECT_EQ(got.weights, want.weights) << where; + EXPECT_EQ(vec(got.topo), vec(want.topo)) << where; + EXPECT_EQ(vec(got.out_begin), vec(want.out_begin)) << where; + EXPECT_EQ(vec(got.out_end), vec(want.out_end)) << where; + EXPECT_EQ(vec(got.head_pos), vec(want.head_pos)) << where; + EXPECT_EQ(vec(got.terminal), vec(want.terminal)) << where; + EXPECT_EQ(got.src_out_begin, want.src_out_begin) << where; + EXPECT_EQ(got.src_out_end, want.src_out_end) << where; +} + +// The policy's CostOnly creation before task 1d-msd (flow_policy.cpp: cost_only_route, bundle_of, +// new_flow's plan_on): the reference the one-pass products must equal. +struct Reference { + std::vector route; + std::shared_ptr bundle; + KernelPlan plan; + Cost cost {kUnreachable}; +}; + +Reference reference(const Graph& g, const std::shared_ptr& support, NodeId src, + const std::vector& dsts, const PathSelection& sel, PriorityFn view, + const std::vector& W, FlowPlacement placement, Admission admission) { + Reference out; + auto r = support_route(g, PredDAGBuilder::ascending(*support), src, dsts, sel, view); + out.route = r.edges; + Cost cost = !sel.multipath && !r.edges.empty() ? r.cost : kUnreachable; + out.bundle = PredDAGBuilder::ascending(*support).size() == r.edges.size() + ? support + : std::make_shared(PredDAGBuilder::of_edges(g, r.edges)); + std::vector creation; + if (placement == FlowPlacement::Weighted && sel.priority == EdgePriority::Residual) { + for (EdgeId e : PredDAGBuilder::ascending(*out.bundle)) creation.push_back(W[static_cast(e)]); + } + const bool computed = cost == kUnreachable; + out.plan = make_plan(g, placement, admission, sel.priority, src, dsts, *out.bundle, creation, + computed ? &cost : nullptr); + out.cost = cost; + return out; +} + +struct Kernel { + FlowPlacement placement; + Admission admission; +}; + +const Kernel kKernels[] = {{FlowPlacement::MaxFlow, Admission::Lossless}, + {FlowPlacement::EqualBalanced, Admission::Lossless}, + {FlowPlacement::EqualBalanced, Admission::Lossy}, + {FlowPlacement::Weighted, Admission::Lossless}, + {FlowPlacement::Weighted, Admission::Lossy}}; + +std::vector selections() { + std::vector out; + for (auto tie : {EdgeTieBreak::Deterministic, EdgeTieBreak::PreferHigherPriority}) { + for (auto prio : {EdgePriority::Capacity, EdgePriority::Residual, EdgePriority::Headroom}) { + for (bool mp : {true, false}) { + for (bool me : {true, false}) out.emplace_back(tie, prio, mp, me); + } + } + } + return out; +} + +// Random edges with many ties (costs 1 to 3, times `factor`), parallel bursts, capacities 0 to 4 +// units. +Graph random_graph(std::mt19937& rng, int n, int m, Cost factor = 1) { + std::vector edges; + while (static_cast(edges.size()) < m) { + const auto u = static_cast(rng() % static_cast(n)); + const auto v = static_cast(rng() % static_cast(n)); + const int copies = rng() % 4 == 0 ? 1 + static_cast(rng() % 3) : 1; + for (int c = 0; c < copies; ++c) { + edges.push_back({u, v, static_cast(rng() % 5), static_cast(1 + rng() % 3) * factor}); + } + } + return make_graph(n, edges); +} + +std::vector live(std::vector set, const Eligibility* elig) { + std::sort(set.begin(), set.end()); + set.erase(std::unique(set.begin(), set.end()), set.end()); + std::vector out; + for (NodeId v : set) { + if (elig == nullptr || elig->node_ok(v)) out.push_back(v); + } + return out; +} + +// The comparison on `trials` random graphs whose edge costs are 1 to 3 times `factor`: for every +// source, every one of the 24 selections and every legal kernel, the one pass answers exactly where +// the reference's members are its whole multipath route, with the same bundle (the support itself +// when the route is all of it), every plan array and the exact cost. +struct Tally { + long made {0}; + long shared {0}; + long collapsed {0}; + long weighted {0}; + long anycast {0}; + long declined {0}; + long beyond_int32 {0}; // answers whose route cost exceeds INT32_MAX +}; + +Tally compare_with_reference(std::mt19937& rng, int trials, Cost factor) { + const auto sels = selections(); + Tally t; + for (int trial = 0; trial < trials; ++trial) { + const int n = 2 + static_cast(rng() % 10); + const Graph g = random_graph(rng, n, static_cast(rng() % 40), factor); + std::optional elig; + if (rng() % 3 == 0) { + std::vector xn; + std::vector xe; + if (rng() % 2 == 0) xn.push_back(static_cast(rng() % static_cast(n))); + for (int k = 0; k < 2 && g.num_edges() > 0; ++k) xe.push_back(static_cast(rng() % static_cast(g.num_edges()))); + elig = g.eligibility(xn, xe); + } + const Eligibility* el = elig ? &*elig : nullptr; + std::vector D; + for (std::size_t k = 1 + rng() % 3; k > 0; --k) D.push_back(static_cast(rng() % static_cast(n))); + const auto dset = live(D, nullptr); + const auto dsts = live(D, el); + if (dsts.empty()) continue; + const auto support = std::make_shared(static_support(g, dset, Direction::Reverse, el)); + std::vector W(static_cast(g.num_edges())); + for (std::size_t e = 0; e < W.size(); ++e) { + const Amount cap = g.capacity_units()[e]; + W[e] = cap == 0 ? 0 : static_cast(rng() % static_cast(cap + 1)); + } + for (NodeId src = 0; src < n; ++src) { + if (std::binary_search(dset.begin(), dset.end(), src)) continue; + for (const auto& sel : sels) { + const PriorityValues values {&g, sel.priority, W}; + const PriorityFn view = PriorityFn::of(values); + for (const auto& k : kKernels) { + if (k.placement == FlowPlacement::Weighted && sel.priority == EdgePriority::Headroom) continue; + const std::string where = "factor " + std::to_string(factor) + " trial " + std::to_string(trial) + " src " + std::to_string(src) + " kernel " + + std::to_string(static_cast(k.placement)) + "/" + + std::to_string(static_cast(k.admission)) + " sel " + + std::to_string(static_cast(sel.tie_break)) + + std::to_string(static_cast(sel.priority)) + std::to_string(sel.multipath) + + std::to_string(sel.multi_edge); + const auto want = reference(g, support, src, dsts, sel, view, W, k.placement, k.admission); + const auto got = cost_only_creation(g, support, src, dsts, sel, view, k.placement, k.admission); + // It answers exactly where the reference's members are its whole multipath route under a + // kernel of static membership (on a valid support that is every route that exists). + const bool applies = sel.multipath && + !(k.placement == FlowPlacement::Weighted && sel.priority == EdgePriority::Residual) && + !want.route.empty(); + EXPECT_EQ(got.has_value(), applies) << where; + if (got.has_value() != applies) return t; + if (!got.has_value()) { + ++t.declined; + continue; + } + EXPECT_EQ(want.plan.members, want.route) << where; // the precondition the shortcut checks + EXPECT_TRUE(got->bundle) << where; + if (!got->bundle) return t; + EXPECT_TRUE(*got->bundle == *want.bundle) << where; + EXPECT_EQ(vec(PredDAGBuilder::ascending(*got->bundle)), want.route) << where; + EXPECT_EQ(got->bundle == support, want.bundle == support) << where; // the same shared value + expect_same_plan(got->plan, want.plan, where); + EXPECT_EQ(got->cost, want.cost) << where; + ++t.made; + if (want.bundle == support) ++t.shared; + if (!sel.multi_edge && want.route.size() < PredDAGBuilder::ascending(*support).size()) ++t.collapsed; + if (k.placement == FlowPlacement::Weighted) ++t.weighted; + if (dsts.size() > 1) ++t.anycast; + if (want.cost > std::numeric_limits::max()) ++t.beyond_int32; + if (::testing::Test::HasFailure()) return t; + } + } + } + } + return t; +} + +} // namespace + +TEST(CostOnlyCreation, LowerBoundFromEveryHintIsStdLowerBound) { + std::mt19937 rng(20261007); + int checked = 0; + for (int trial = 0; trial < 400; ++trial) { + const auto n = static_cast(rng() % 140); + std::vector a(n); + EdgeId x = static_cast(rng() % 3); + for (auto& v : a) { + v = x; + x += 1 + static_cast(rng() % 4); + } + const EdgeId top = n == 0 ? 3 : a.back() + 3; + for (EdgeId key = -2; key <= top; ++key) { + const auto want = static_cast(std::lower_bound(a.begin(), a.end(), key) - a.begin()); + for (std::size_t hint = 0; hint <= n + 3; ++hint) { + ASSERT_EQ(lower_bound_from(a, hint, key), want) << "n " << n << " key " << key << " hint " << hint; + ++checked; + } + } + } + EXPECT_GT(checked, 100000); +} + +TEST(CostOnlyCreation, EqualsTheReferenceForEverySourceSelectionAndKernel) { + std::mt19937 rng(1007); + const Tally t = compare_with_reference(rng, 300, 1); + // Not vacuous: every branch of the one pass was compared, and the declined inputs too. + EXPECT_GT(t.made, 20000); + EXPECT_GT(t.shared, 500); + EXPECT_GT(t.collapsed, 2000); + EXPECT_GT(t.weighted, 4000); + EXPECT_GT(t.anycast, 4000); + EXPECT_GT(t.declined, 20000); +} + +TEST(CostOnlyCreation, WideCostsEqualTheReference) { + // Repeat the reference comparison with factors 3, 64 and 2^35. These cases catch the + // int32 output truncation; with 2^35 every answered cost exceeds INT32_MAX. + // They do not cover every narrower representation: a 48-bit output mask survives. + for (const Cost factor : {Cost {3}, Cost {64}, Cost {1} << 35}) { + std::mt19937 rng(static_cast(2000 + factor % 997)); + const Tally t = compare_with_reference(rng, 120, factor); + if (::testing::Test::HasFailure()) return; + EXPECT_GT(t.made, 7000) << "factor " << factor; + EXPECT_GT(t.shared, 150) << "factor " << factor; + EXPECT_GT(t.collapsed, 700) << "factor " << factor; + EXPECT_GT(t.declined, 7000) << "factor " << factor; + if (factor == (Cost {1} << 35)) EXPECT_EQ(t.beyond_int32, t.made); + } +} + +TEST(CostOnlyCreation, ChainCostsBeyondInt32AreExact) { + // The review's witness (0 -> 1 -> 2, two edges of 2^35: the cost 2^36 = 68,719,476,736), a chain + // whose total 2,500,000,000 exceeds INT32_MAX without being a power of two, a diamond of 2^35 + // edges with a parallel pair, and a full-width chain of two edges of 2^60 + 1 (the cost + // 2^61 + 2, below the graph's 2^62 bound; a 48-bit output mask gives 2): the exact cost and every + // array, for every legal kernel, both multi_edge settings. + struct Chain { + std::int32_t n; + std::vector edges; + NodeId dst; + Cost cost; + }; + const Cost big = Cost {1} << 35; + const std::vector chains = { + {3, {{0, 1, 3, big}, {1, 2, 3, big}}, 2, Cost {68719476736}}, + {3, {{0, 1, 5, 1500000000}, {1, 2, 5, 1000000000}}, 2, Cost {2500000000}}, + {4, {{0, 1, 4, big}, {0, 2, 3, big}, {1, 3, 2, big}, {2, 3, 1, big}, {2, 3, 2, big}}, 3, 2 * big}, + {3, {{0, 1, 2, (Cost {1} << 60) + 1}, {1, 2, 2, (Cost {1} << 60) + 1}}, 2, Cost {2305843009213693954}}, + }; + int checked = 0; + for (const auto& c : chains) { + ASSERT_GT(c.cost, std::numeric_limits::max()); + const Graph g = make_graph(c.n, c.edges); + const std::vector dsts = {c.dst}; + const auto support = std::make_shared(static_support(g, dsts, Direction::Reverse, nullptr)); + const std::vector W(g.capacity_units().begin(), g.capacity_units().end()); + for (bool me : {true, false}) { + const PathSelection sel(EdgeTieBreak::PreferHigherPriority, EdgePriority::Capacity, true, me); + const PriorityValues values {&g, sel.priority, W}; + for (const auto& k : kKernels) { + const std::string where = "chain cost " + std::to_string(c.cost) + " kernel " + + std::to_string(static_cast(k.placement)) + "/" + + std::to_string(static_cast(k.admission)) + " multi_edge " + std::to_string(me); + const auto want = reference(g, support, 0, dsts, sel, PriorityFn::of(values), W, k.placement, k.admission); + const auto got = + cost_only_creation(g, support, 0, dsts, sel, PriorityFn::of(values), k.placement, k.admission); + ASSERT_TRUE(got.has_value()) << where; + EXPECT_EQ(want.cost, c.cost) << where; + EXPECT_EQ(got->cost, c.cost) << where; + EXPECT_TRUE(*got->bundle == *want.bundle) << where; + EXPECT_EQ(vec(PredDAGBuilder::ascending(*got->bundle)), want.route) << where; + EXPECT_EQ(got->bundle == support, want.bundle == support) << where; + expect_same_plan(got->plan, want.plan, where); + ++checked; + } + } + } + EXPECT_EQ(checked, 4 * 2 * 5); +} + +TEST(CostOnlyCreation, LargerGraphsAndTheSupportItself) { + // Larger fabrics (Clos and random) where the walk crosses many ranges: every source to one and + // to two destinations, the EB and MaxFlow kernels, with and without Collapse. + std::mt19937 rng(7); + long made = 0; + for (int shape = 0; shape < 4; ++shape) { + Graph g = [&] { + if (shape < 2) { + std::vector edges; + const int leaves = shape == 0 ? 12 : 20; + const int spines = shape == 0 ? 6 : 8; + for (int u = 0; u < leaves; ++u) { + for (int s = 0; s < spines; ++s) { + const Amount cap = static_cast(1 + rng() % 9); + for (int copy = 0; copy < (rng() % 5 == 0 ? 2 : 1); ++copy) { + edges.push_back({u, leaves + s, cap, 1}); + edges.push_back({leaves + s, u, cap, 1}); + } + } + } + return make_graph(leaves + spines, edges); + } + return random_graph(rng, 200, 2400); + }(); + const int n = g.num_nodes(); + std::vector W(g.capacity_units().begin(), g.capacity_units().end()); + for (int q = 0; q < 6; ++q) { + std::vector D = {static_cast(rng() % static_cast(n))}; + if (q % 2 == 1) D.push_back(static_cast(rng() % static_cast(n))); + const auto dsts = live(D, nullptr); + const auto support = std::make_shared(static_support(g, dsts, Direction::Reverse, nullptr)); + for (NodeId src = 0; src < n; src += (n > 50 ? 7 : 1)) { + if (std::binary_search(dsts.begin(), dsts.end(), src)) continue; + for (bool me : {true, false}) { + const PathSelection sel(EdgeTieBreak::PreferHigherPriority, EdgePriority::Capacity, true, me); + const PriorityValues values {&g, sel.priority, W}; + for (auto placement : {FlowPlacement::EqualBalanced, FlowPlacement::MaxFlow}) { + const auto want = reference(g, support, src, dsts, sel, PriorityFn::of(values), W, placement, + Admission::Lossless); + const auto got = cost_only_creation(g, support, src, dsts, sel, PriorityFn::of(values), placement, + Admission::Lossless); + ASSERT_EQ(got.has_value(), !want.route.empty()); + if (!got) continue; + EXPECT_TRUE(*got->bundle == *want.bundle); + EXPECT_EQ(got->bundle == support, want.bundle == support); + expect_same_plan(got->plan, want.plan, "shape " + std::to_string(shape) + " src " + std::to_string(src)); + EXPECT_EQ(got->cost, want.cost); + ++made; + } + } + } + } + } + EXPECT_GT(made, 300); +} + +TEST(CostOnlyCreation, CraftedSupportsGiveNothingOrTheReference) { + // Acyclic edge sets that are no static support: edges leaving a destination, nodes that reach no + // destination, members below one unit. The one pass declines them or equals the reference. + std::mt19937 rng(99); + long made = 0; + long declined = 0; + for (int trial = 0; trial < 1500; ++trial) { + const int n = 3 + static_cast(rng() % 8); + const Graph g = random_graph(rng, n, 4 + static_cast(rng() % 30)); + std::vector perm(static_cast(n)); + std::iota(perm.begin(), perm.end(), 0); + std::shuffle(perm.begin(), perm.end(), rng); + std::vector rank(static_cast(n)); + for (int i = 0; i < n; ++i) rank[static_cast(perm[static_cast(i)])] = i; + std::vector chosen; + for (EdgeId e = 0; e < g.num_edges(); ++e) { + const auto u = g.edge_src()[static_cast(e)]; + const auto v = g.edge_dst()[static_cast(e)]; + if (rank[static_cast(u)] < rank[static_cast(v)] && rng() % 4 != 0) chosen.push_back(e); + } + const auto support = std::make_shared(PredDAGBuilder::of_edges(g, chosen)); + const NodeId src = perm.front(); + std::vector dsts = {perm.back()}; + if (n > 4 && rng() % 2 == 0) dsts.push_back(perm[static_cast(n / 2)]); // may have out-edges + std::sort(dsts.begin(), dsts.end()); + std::vector W(g.capacity_units().begin(), g.capacity_units().end()); + for (bool me : {true, false}) { + const PathSelection sel(EdgeTieBreak::Deterministic, EdgePriority::Capacity, true, me); + const PriorityValues values {&g, sel.priority, W}; + for (const auto& k : kKernels) { + const auto want = reference(g, support, src, dsts, sel, PriorityFn::of(values), W, k.placement, k.admission); + const auto got = + cost_only_creation(g, support, src, dsts, sel, PriorityFn::of(values), k.placement, k.admission); + const std::string where = "trial " + std::to_string(trial); + if (!got.has_value()) { + ++declined; + continue; + } + EXPECT_TRUE(*got->bundle == *want.bundle) << where; + EXPECT_EQ(got->bundle == support, want.bundle == support) << where; + expect_same_plan(got->plan, want.plan, where); + EXPECT_EQ(got->cost, want.cost) << where; + ++made; + if (::testing::Test::HasFailure()) return; + } + } + } + EXPECT_GT(made, 1000); + EXPECT_GT(declined, 1000); +} + +TEST(CostOnlyCreation, DeclinesEachBrokenPrecondition) { + // 0 -> 1 -> 3 and 0 -> 2 -> 3, destination 3: the reference keeps the whole route, and so does + // the one pass. Each variant breaks one precondition; the one pass then answers nothing (the + // reference's members differ from its route, or it throws). + const Graph g = make_graph(6, {{0, 1, 2, 1}, {0, 2, 2, 1}, {1, 3, 2, 1}, {2, 3, 2, 1}, {3, 4, 2, 1}, + {1, 5, 2, 1}, {2, 1, 2, 1}, {1, 2, 0, 1}}); + const auto inv = id_of_input(g); + const std::vector dsts = {3}; + const std::vector W(g.capacity_units().begin(), g.capacity_units().end()); + const PathSelection sel(EdgeTieBreak::Deterministic, EdgePriority::Capacity, true, true); + const PriorityValues values {&g, sel.priority, W}; + auto run = [&](std::vector edges, FlowPlacement placement) { + for (auto& e : edges) e = inv[static_cast(e)]; + std::sort(edges.begin(), edges.end()); + const auto support = std::make_shared(PredDAGBuilder::of_edges(g, edges)); + return std::make_pair(support, cost_only_creation(g, support, 0, dsts, sel, PriorityFn::of(values), placement, + Admission::Lossless)); + }; + { + const auto [support, got] = run({0, 1, 2, 3}, FlowPlacement::EqualBalanced); + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(got->bundle, support); // the route is the whole support + EXPECT_EQ(got->cost, 2); + EXPECT_EQ(vec(got->plan.topo), (std::vector {0, 1, 2, 3})); + } + // An edge leaving the destination (3 -> 4). + EXPECT_FALSE(run({0, 1, 2, 3, 4}, FlowPlacement::EqualBalanced).second.has_value()); + // A node that reaches no destination (1 -> 5). + EXPECT_FALSE(run({0, 1, 2, 3, 5}, FlowPlacement::EqualBalanced).second.has_value()); + // A member below one unit (1 -> 2 has no capacity): declined for EB, kept by MaxFlow. + EXPECT_FALSE(run({0, 1, 2, 3, 7}, FlowPlacement::EqualBalanced).second.has_value()); + EXPECT_TRUE(run({0, 1, 2, 3, 7}, FlowPlacement::MaxFlow).second.has_value()); + // A cycle (1 -> 2 -> 1): declined, and the reference throws. + { + const auto [support, got] = run({0, 1, 2, 3, 6, 7}, FlowPlacement::MaxFlow); + EXPECT_FALSE(got.has_value()); + EXPECT_THROW((void)reference(g, support, 0, dsts, sel, PriorityFn::of(values), W, FlowPlacement::MaxFlow, + Admission::Lossless), + std::logic_error); + } + // Not applicable: multipath = false, Weighted with Residual priority, no destination reached. + { + std::vector edges = {inv[0], inv[1], inv[2], inv[3]}; + std::sort(edges.begin(), edges.end()); + const auto support = std::make_shared(PredDAGBuilder::of_edges(g, edges)); + const PathSelection single(EdgeTieBreak::Deterministic, EdgePriority::Capacity, false, true); + EXPECT_FALSE(cost_only_creation(g, support, 0, dsts, single, PriorityFn::of(values), FlowPlacement::EqualBalanced, + Admission::Lossless) + .has_value()); + const PathSelection residual(EdgeTieBreak::Deterministic, EdgePriority::Residual, true, true); + EXPECT_FALSE(cost_only_creation(g, support, 0, dsts, residual, PriorityFn::of(values), FlowPlacement::Weighted, + Admission::Lossless) + .has_value()); + EXPECT_FALSE(cost_only_creation(g, support, 0, std::vector {5}, sel, PriorityFn::of(values), + FlowPlacement::EqualBalanced, Admission::Lossless) + .has_value()); + } +} + +TEST(KernelPlanBlock, CopiesAndMovesKeepTheirOwnViews) { + const Graph g = make_graph(4, {{0, 1, 3, 1}, {0, 2, 2, 1}, {1, 3, 3, 1}, {2, 3, 2, 1}}); + std::vector all(4); + std::iota(all.begin(), all.end(), 0); + const auto dag = PredDAGBuilder::of_edges(g, all); + const std::vector dsts = {3}; + const KernelPlan original = + make_plan(g, FlowPlacement::Weighted, Admission::Lossless, EdgePriority::Capacity, 0, dsts, dag, {}); + ASSERT_EQ(original.topo.size(), 4U); + ASSERT_EQ(original.head_pos.size(), 4U); + ASSERT_EQ(vec(original.dsts), dsts); + // A copy's views point into its own block (none into the source's), and stay valid once the + // source is gone. + auto own_block = [](const KernelPlan& a, const KernelPlan& b) { + return a.dsts.data() != b.dsts.data() && a.topo.data() != b.topo.data() && + a.out_begin.data() != b.out_begin.data() && a.out_end.data() != b.out_end.data() && + a.head_pos.data() != b.head_pos.data() && a.terminal.data() != b.terminal.data(); + }; + std::optional source(original); + const KernelPlan copy(*source); + EXPECT_TRUE(own_block(copy, *source)); + source.reset(); + expect_same_plan(copy, original, "copy"); + KernelPlan donor(copy); + KernelPlan moved(std::move(donor)); + expect_same_plan(moved, original, "move"); + EXPECT_TRUE(donor.topo.empty() && donor.dsts.empty() && donor.head_pos.empty() && donor.terminal.empty()); + KernelPlan assigned; + assigned = copy; + expect_same_plan(assigned, original, "copy assignment"); + EXPECT_TRUE(own_block(assigned, copy)); + KernelPlan target = make_plan(g, FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Capacity, 1, dsts, + dag, {}); + target = std::move(moved); + expect_same_plan(target, original, "move assignment"); + EXPECT_TRUE(moved.topo.empty() && moved.dsts.empty() && moved.head_pos.empty() && moved.terminal.empty()); + const KernelPlan& self = target; + target = self; + expect_same_plan(target, original, "self assignment"); + const KernelPlan empty; + const KernelPlan empty_copy(empty); + EXPECT_TRUE(empty_copy.dsts.empty() && empty_copy.topo.empty() && empty_copy.members.empty()); +} diff --git a/tests/cpp/dag_cache_tests.cpp b/tests/cpp/dag_cache_tests.cpp new file mode 100644 index 0000000..acd1148 --- /dev/null +++ b/tests/cpp/dag_cache_tests.cpp @@ -0,0 +1,477 @@ +// The DagCache's modes (APPROACH 10): the transition table (building and frozen lookups, freeze, +// clear, thaw, destruction), the statistics (a frozen miss counts one miss), DagCacheMiss as a +// dedicated exception, frozen hits from several threads, and the evaluators' sharing rules +// (section 11): a frozen cache shared by every worker, a building one rejected with two or more, +// DagCacheMiss propagated unchanged through batch_capacity and sensitivity, the reuse of a +// prepopulated Eligibility through an unchanged intersection, and the deletion-mode sensitivity +// with a supplied frozen cache. The thread cases are named DagCacheFrozen* (the TSan filter). +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/dag_cache.hpp" +#include "netgraph/core/evaluators.hpp" +#include "netgraph/core/flow_policy.hpp" +#include "test_utils.hpp" + +using namespace netgraph::core; +using namespace netgraph::core::test; + +static_assert(!std::is_base_of_v); +static_assert(!std::is_base_of_v); +static_assert(std::is_base_of_v); +static_assert(std::is_nothrow_copy_constructible_v); + +namespace { + +// A small two-tier fabric: leaves 0..5, spines 6..8, every leaf joined to every spine both ways. +std::shared_ptr fabric() { + std::vector e; + for (NodeId l = 0; l < 6; ++l) { + for (NodeId s = 6; s < 9; ++s) { + e.push_back({l, s, 4, 1}); + e.push_back({s, l, 4, 1}); + } + } + return std::make_shared(make_graph(9, e)); +} + +std::vector v(std::initializer_list x) { return x; } + +FlowPolicyConfig costonly() { + return FlowPolicyConfig {CostOnly {}, FlowPlacement::EqualBalanced, Admission::Lossless, + PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity)}; +} + +FlowPolicyConfig global() { + return FlowPolicyConfig {Global {}, FlowPlacement::MaxFlow, Admission::Lossless, + PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity)}; +} + +std::string miss_of(const std::function& fn) { + try { + fn(); + } catch (const DagCacheMiss& e) { + return e.what(); + } + return ""; +} + +} // namespace + +TEST(DagCacheModes, TransitionTableAndStatistics) { + const auto g = fabric(); + DagCache cache(*g); + // Building: a miss searches and inserts, a hit returns the same value. + const auto a = cache.lookup(*g, v({1, 0}), Direction::Reverse, nullptr); + const auto a2 = cache.lookup(*g, v({0, 1}), Direction::Reverse, nullptr); + EXPECT_EQ(a.get(), a2.get()); + auto st = cache.stats(); + EXPECT_EQ(st.misses, 1); + EXPECT_EQ(st.hits, 1); + EXPECT_EQ(st.entries, 1); + // Frozen: hits return the value; a miss counts one miss, inserts nothing, throws DagCacheMiss. + cache.freeze(); + cache.freeze(); // idempotent + EXPECT_EQ(cache.lookup(*g, v({0, 1}), Direction::Reverse, nullptr).get(), a.get()); + const auto msg = miss_of([&] { (void)cache.lookup(*g, v({2}), Direction::Forward, nullptr); }); + EXPECT_EQ(msg, "DagCache: the frozen cache has no entry for roots {2}, direction Forward, Eligibility uid 0 (a frozen " + "cache never searches)"); + st = cache.stats(); + EXPECT_EQ(st.misses, 2); + EXPECT_EQ(st.hits, 2); + EXPECT_EQ(st.entries, 1); + // Invalid queries throw std::invalid_argument and count nothing, in frozen mode too. + const auto other = fabric(); + EXPECT_THROW((void)cache.lookup(*other, v({0}), Direction::Reverse, nullptr), std::invalid_argument); + EXPECT_THROW((void)cache.lookup(*g, v({}), Direction::Reverse, nullptr), std::invalid_argument); + EXPECT_THROW((void)cache.lookup(*g, v({9}), Direction::Reverse, nullptr), std::invalid_argument); + EXPECT_EQ(cache.stats().misses, 2); + EXPECT_EQ(cache.stats().hits, 2); + // clear() throws when frozen and keeps the entries. + try { + cache.clear(); + FAIL() << "clear() on a frozen cache"; + } catch (const std::invalid_argument& e) { + EXPECT_EQ(std::string(e.what()), + "DagCache.clear is rejected while the cache is frozen (join every reader, then thaw() and clear())"); + } + EXPECT_EQ(cache.stats().entries, 1); + // thaw(): building again; a miss searches and inserts, clear() empties (counters persist). + cache.thaw(); + cache.thaw(); // no effect when building + const auto b = cache.lookup(*g, v({2}), Direction::Forward, nullptr); + EXPECT_EQ(cache.stats().entries, 2); + cache.clear(); + st = cache.stats(); + EXPECT_EQ(st.entries, 0); + EXPECT_EQ(st.bytes, 0); + EXPECT_EQ(st.misses, 3); + EXPECT_EQ(st.hits, 2); + EXPECT_EQ(st.live_values, 2); // a and b are held by the test + // Held values stay valid after clear() and after a frozen cache is destroyed. + EXPECT_EQ(b->via_edges().size(), 18U); // roots {2} forward: three 2 -> spine edges, 15 spine -> leaf + EXPECT_EQ(a2->via_edges().size(), 18U); + std::shared_ptr c; + { + DagCache frozen(*g); + c = frozen.lookup(*g, v({3}), Direction::Reverse, nullptr); + frozen.freeze(); + } + // Roots {3}: the three spine -> 3 edges and the five other leaves' 15 leaf -> spine edges; + // roots {0, 1}: six spine -> root edges and the four other leaves' 12. + EXPECT_EQ(c->via_edges().size(), 18U); + EXPECT_EQ(a->via_edges().size(), 18U); +} + +TEST(DagCacheModes, RootsInAnyOrderShareOneEntryAndOneValidation) { + // Task 1d-msd: a lookup borrows roots that are already a sorted set (no copy on a hit) and + // normalises any other; either way the key is the sorted set, ordered by roots, then direction, + // then Eligibility uid, and the checks and their messages are node_set's. + const auto g = fabric(); + DagCache cache(*g); + const auto elig = g->eligibility(v({}), std::vector {0}); + const auto a = cache.lookup(*g, v({0, 2}), Direction::Reverse, nullptr); + for (const auto& roots : {v({2, 0}), v({0, 0, 2}), v({2, 2, 0, 2}), v({0, 2})}) { + EXPECT_EQ(cache.lookup(*g, roots, Direction::Reverse, nullptr).get(), a.get()); + } + EXPECT_EQ(cache.stats().misses, 1); + EXPECT_EQ(cache.stats().hits, 4); + // Keys that differ in a prefix, the length, the direction or the Eligibility uid are distinct + // entries, each found again by its own key in any order. + const std::vector, Direction, const Eligibility*>> keys = { + {v({0}), Direction::Reverse, nullptr}, {v({0, 1}), Direction::Reverse, nullptr}, + {v({0, 2, 3}), Direction::Reverse, nullptr}, {v({1}), Direction::Reverse, nullptr}, + {v({0, 2}), Direction::Forward, nullptr}, {v({0, 2}), Direction::Reverse, &elig}, + {v({8}), Direction::Forward, &elig}, {v({0, 1, 2, 3, 4, 5}), Direction::Reverse, nullptr}}; + std::vector> values; + for (const auto& [roots, dir, el] : keys) values.push_back(cache.lookup(*g, roots, dir, el)); + EXPECT_EQ(cache.stats().entries, 9); + EXPECT_EQ(cache.stats().misses, 9); + for (std::size_t i = 0; i < keys.size(); ++i) { + EXPECT_NE(values[i].get(), a.get()); + for (std::size_t j = 0; j < i; ++j) EXPECT_NE(values[i].get(), values[j].get()); + } + for (std::size_t i = keys.size(); i-- > 0;) { + auto roots = std::get<0>(keys[i]); + std::reverse(roots.begin(), roots.end()); + EXPECT_EQ(cache.lookup(*g, roots, std::get<1>(keys[i]), std::get<2>(keys[i])).get(), values[i].get()); + } + EXPECT_EQ(cache.stats().misses, 9); + EXPECT_EQ(cache.stats().hits, 4 + static_cast(keys.size())); + // The checks in node_set's order, sorted or not: empty, then the first id out of range in input + // order; nothing counted. + auto error_of = [&](std::vector roots) { + try { + (void)cache.lookup(*g, roots, Direction::Reverse, nullptr); + } catch (const std::invalid_argument& e) { + return std::string(e.what()); + } + return std::string(); + }; + const auto before = cache.stats(); + EXPECT_EQ(error_of(v({9})), "roots: node 9 out of node range [0, 9)"); + EXPECT_EQ(error_of(v({3, 9})), "roots: node 9 out of node range [0, 9)"); + EXPECT_EQ(error_of(v({9, 3})), "roots: node 9 out of node range [0, 9)"); + EXPECT_EQ(error_of(v({-1, 9})), "roots: node -1 out of node range [0, 9)"); + EXPECT_EQ(error_of(v({9, -1})), "roots: node 9 out of node range [0, 9)"); + EXPECT_EQ(error_of(v({3, 3, 12})), "roots: node 12 out of node range [0, 9)"); + EXPECT_FALSE(error_of(v({})).empty()); + const auto after = cache.stats(); + EXPECT_EQ(after.misses, before.misses); + EXPECT_EQ(after.hits, before.hits); + EXPECT_EQ(after.entries, before.entries); + // A frozen miss names the sorted set, borrowed or normalised. + cache.freeze(); + const std::string want = "DagCache: the frozen cache has no entry for roots {1, 4}, direction Reverse, Eligibility uid " + "0 (a frozen cache never searches)"; + EXPECT_EQ(miss_of([&] { (void)cache.lookup(*g, v({1, 4}), Direction::Reverse, nullptr); }), want); + EXPECT_EQ(miss_of([&] { (void)cache.lookup(*g, v({4, 1, 4}), Direction::Reverse, nullptr); }), want); + EXPECT_EQ(cache.stats().misses, 11); +} + +TEST(DagCacheModes, MissIsADedicatedException) { + const auto g = fabric(); + DagCache cache(*g); + cache.freeze(); + bool as_miss = false; + try { + (void)cache.lookup(*g, v({0}), Direction::Reverse, nullptr); + } catch (const std::runtime_error&) { + FAIL() << "DagCacheMiss caught as std::runtime_error"; + } catch (const std::logic_error&) { + FAIL() << "DagCacheMiss caught as std::logic_error"; + } catch (const DagCacheMiss&) { + as_miss = true; + } + EXPECT_TRUE(as_miss); +} + +namespace { + +// Frozen hits from `threads` threads at once over prepopulated keys; every value is the +// prepopulated one and every hit is counted. +void frozen_readers(int threads) { + const auto g = fabric(); + DagCache cache(*g); + std::vector> want; + for (NodeId r = 0; r < 9; ++r) want.push_back(cache.lookup(*g, v({r}), Direction::Reverse, nullptr)); + cache.freeze(); + const auto before = cache.stats(); + std::atomic wrong {0}; + std::vector pool; + constexpr int kRounds = 200; + for (int t = 0; t < threads; ++t) { + pool.emplace_back([&, t] { + for (int round = 0; round < kRounds; ++round) { + const NodeId r = static_cast((round + t) % 9); + const NodeId roots[1] = {r}; + if (cache.lookup(*g, roots, Direction::Reverse, nullptr).get() != want[static_cast(r)].get()) ++wrong; + } + }); + } + for (auto& th : pool) th.join(); + EXPECT_EQ(wrong.load(), 0); + const auto after = cache.stats(); + EXPECT_EQ(after.hits - before.hits, static_cast(threads) * kRounds); + EXPECT_EQ(after.misses, before.misses); + EXPECT_EQ(after.entries, 9); + // Joined: thaw and build again. + cache.thaw(); + (void)cache.lookup(*g, v({0, 1}), Direction::Reverse, nullptr); + EXPECT_EQ(cache.stats().entries, 10); +} + +} // namespace + +TEST(DagCacheFrozen, HitsFromOneThread) { frozen_readers(1); } +TEST(DagCacheFrozen, HitsFromTwoThreads) { frozen_readers(2); } +TEST(DagCacheFrozen, HitsFromFourThreads) { frozen_readers(4); } + +TEST(DagCacheFrozen, SharedByEveryWorkerOfABatch) { + const auto g = fabric(); + const std::vector, std::vector>> pairs = { + {v({0}), v({3})}, {v({1}), v({4})}, {v({2}), v({5})}, {v({0, 1}), v({3, 4})}}; + const auto want = batch_capacity(*g, pairs, CapacityOptions {costonly()}, {}, 1); + for (int workers : {1, 2, 4}) { + DagCache cache(*g); + for (const auto& [s, t] : pairs) (void)cache.lookup(*g, t, Direction::Reverse, nullptr); + // A building cache with two or more workers is rejected; frozen, it is shared. + if (workers > 1) { + EXPECT_THROW((void)batch_capacity(*g, pairs, CapacityOptions {costonly(), nullptr, &cache}, {}, workers), + std::invalid_argument); + } + cache.freeze(); + const auto before = cache.stats(); + const auto got = batch_capacity(*g, pairs, CapacityOptions {costonly(), nullptr, &cache}, {}, workers); + ASSERT_EQ(got.size(), want.size()); + for (std::size_t i = 0; i < got.size(); ++i) EXPECT_EQ(got[i].placed, want[i].placed) << workers; + EXPECT_EQ(cache.stats().hits - before.hits, static_cast(pairs.size())); + EXPECT_EQ(cache.stats().misses, before.misses); + } +} + +TEST(DagCacheFrozen, MissPropagatesThroughBatchCapacity) { + // An empty frozen cache: every pair misses; the lowest index's DagCacheMiss reaches the caller + // unchanged, with one worker and with several. + const auto g = fabric(); + const std::vector, std::vector>> pairs = {{v({0}), v({3})}, {v({1}), v({4})}, + {v({2}), v({5})}}; + for (int workers : {1, 2, 4}) { + DagCache cache(*g); + (void)cache.lookup(*g, v({4}), Direction::Reverse, nullptr); // only pair 1's key + cache.freeze(); + const auto msg = miss_of([&] { (void)batch_capacity(*g, pairs, CapacityOptions {costonly(), nullptr, &cache}, {}, workers); }); + EXPECT_NE(msg.find("roots {3}"), std::string::npos) << workers << ": " << msg; // pair 0, the lowest index + } +} + +TEST(DagCacheFrozen, PerPairIntersectionsAndPrepopulatedUids) { + // APPROACH 10: an intersection equal to an operand returns that operand (its uid), so the + // prepopulated key hits; one that differs from both operands gets a fresh uid and misses. + const auto g = fabric(); + const EdgeId e0[1] = {0}; + const EdgeId e01[2] = {0, 1}; + const EdgeId e1[1] = {1}; + const auto common = g->eligibility({}, e0); // excludes edge 0 + const auto narrow = g->eligibility({}, e01); // excludes 0 and 1: contained in common + const auto other = g->eligibility({}, e1); // excludes 1: neither contains the other + const std::vector, std::vector>> pairs = {{v({1}), v({4})}, {v({2}), v({5})}}; + DagCache cache(*g); + (void)cache.lookup(*g, v({4}), Direction::Reverse, &narrow); + (void)cache.lookup(*g, v({5}), Direction::Reverse, &common); + (void)cache.lookup(*g, v({4}), Direction::Reverse, &common); + cache.freeze(); + const auto hits = [&] { return cache.stats().hits; }; + // A common value containing the per-pair one: intersect returns the per-pair operand. + auto before = hits(); + const std::vector contained = {&narrow, nullptr}; + EXPECT_EQ(batch_capacity(*g, pairs, CapacityOptions {costonly(), &common, &cache}, contained, 2).size(), 2U); + EXPECT_EQ(hits() - before, 2); + // An unchanged intersection (the operand itself) and already-effective values with a null + // common value. + before = hits(); + const std::vector same = {&common, &common}; + EXPECT_EQ(batch_capacity(*g, pairs, CapacityOptions {costonly(), &common, &cache}, same, 2).size(), 2U); + const std::vector own = {&narrow, &common}; + EXPECT_EQ(batch_capacity(*g, pairs, CapacityOptions {costonly(), nullptr, &cache}, own, 2).size(), 2U); + EXPECT_EQ(hits() - before, 4); + // An intersection that differs from both operands: a fresh uid, absent from the cache. + const auto misses = cache.stats().misses; + const std::vector changed = {&other, nullptr}; + const auto msg = miss_of([&] { (void)batch_capacity(*g, pairs, CapacityOptions {costonly(), &common, &cache}, changed, 2); }); + EXPECT_NE(msg.find("roots {4}"), std::string::npos) << msg; + EXPECT_EQ(msg.find("uid " + std::to_string(common.uid()) + " "), std::string::npos) << msg; + EXPECT_EQ(msg.find("uid " + std::to_string(other.uid()) + " "), std::string::npos) << msg; + EXPECT_EQ(cache.stats().misses - misses, 1); + EXPECT_EQ(cache.stats().entries, 3); +} + +TEST(DagCacheFrozen, MissPropagatesThroughSensitivityNeverAsUnevaluable) { + // CostOnly sensitivity evaluates each candidate under a new Eligibility (a new uid): with a + // frozen cache holding only the baseline's key, the first re-evaluated deletion misses and the + // miss reaches the caller (it is not a std::runtime_error, so never `unevaluable`). + const auto g = fabric(); + for (int workers : {1, 2, 4}) { + DagCache cache(*g); + (void)cache.lookup(*g, v({4}), Direction::Reverse, nullptr); + cache.freeze(); + const auto msg = miss_of([&] { (void)sensitivity(*g, v({1}), v({4}), SensitivityOptions {costonly(), nullptr, &cache}, workers); }); + EXPECT_NE(msg.find("no entry for roots {4}"), std::string::npos) << workers << ": " << msg; + EXPECT_EQ(cache.stats().entries, 1); // nothing inserted + } +} + +TEST(DagCacheFrozen, DeletionModeSensitivityWithASuppliedFrozenCache) { + // Global sensitivity repairs the baseline's state per deletion and reads no cache: a supplied + // frozen cache, shared by two or four workers, gives the result without a cache. + const auto g = fabric(); + const auto want = sensitivity(*g, v({0, 1}), v({4, 5}), SensitivityOptions {global()}, 1); + for (int workers : {1, 2, 4}) { + DagCache cache(*g); + cache.freeze(); + const auto got = sensitivity(*g, v({0, 1}), v({4, 5}), SensitivityOptions {global(), nullptr, &cache}, workers); + EXPECT_EQ(got.baseline.placed, want.baseline.placed); + ASSERT_EQ(got.entries.size(), want.entries.size()); + for (std::size_t i = 0; i < got.entries.size(); ++i) { + EXPECT_EQ(got.entries[i].edge, want.entries[i].edge); + EXPECT_EQ(got.entries[i].loss, want.entries[i].loss); + } + EXPECT_EQ(cache.stats().hits + cache.stats().misses, 0); + } +} + +namespace { + +std::tuple all_of(const DagCache::Stats& s) { + return {s.hits, s.misses, s.entries, s.bytes, s.live_values}; +} + +} // namespace + +TEST(DagCacheFrozen, AFailedCallChangesNoStatisticButItsMiss) { + // review-1d F1, NOTES R27: a public call reading a frozen cache publishes its hits only when it + // succeeds. A batch whose pair 0 hits and pair 1 misses raises the miss and keeps no hit; the + // miss counts once, with one worker and with several (the boundary counts it, not the workers); + // the policy constructor and CostOnly sensitivity do the same. + const auto g = fabric(); + const std::vector, std::vector>> pairs = { + {v({0}), v({3})}, {v({1}), v({4})}, {v({2}), v({5})}, {v({0}), v({3})}}; + for (int workers : {1, 2, 4}) { + DagCache cache(*g); + (void)cache.lookup(*g, v({3}), Direction::Reverse, nullptr); + cache.freeze(); + auto before = all_of(cache.stats()); + // Pairs 1 and 2 miss; pairs 0 and 3 hit. + EXPECT_NE(miss_of([&] { (void)batch_capacity(*g, pairs, CapacityOptions {costonly(), nullptr, &cache}, {}, workers); }) + .find("roots {4}"), + std::string::npos); + auto want = before; + std::get<1>(want) += 1; + EXPECT_EQ(all_of(cache.stats()), want) << workers; + before = all_of(cache.stats()); + EXPECT_NE(miss_of([&] { + (void)sensitivity(*g, v({0}), v({3}), SensitivityOptions {costonly(), nullptr, &cache}, workers); + }).find("roots {3}"), + std::string::npos); + want = before; + std::get<1>(want) += 1; + EXPECT_EQ(all_of(cache.stats()), want) << workers; // the baseline's hit is dropped + } + DagCache cache(*g); + cache.freeze(); + FlowLedger fg(g); + EXPECT_FALSE(miss_of([&] { FlowPolicy p(fg, v({0}), v({3}), costonly(), nullptr, &cache); }).empty()); + EXPECT_EQ(all_of(cache.stats()), std::make_tuple(0, 1, 0, 0, 0)); +} + +TEST(DagCacheFrozen, ConcurrentCallsKeepEverySuccessfulHit) { + // Calls on one frozen cache from four threads at once, each a successful batch (two hits), a + // failing batch (its hit dropped, one miss) and a successful capacity (one hit): every + // successful call's hits are kept, no failed call's, and every failed call's miss is counted. + const auto g = fabric(); + DagCache cache(*g); + for (NodeId d : {3, 4}) (void)cache.lookup(*g, v({d}), Direction::Reverse, nullptr); + cache.freeze(); + const auto before = cache.stats(); + const CapacityOptions opts {costonly(), nullptr, &cache}; + const std::vector, std::vector>> good = {{v({0}), v({3})}, {v({1}), v({4})}}; + const std::vector, std::vector>> bad = {{v({0}), v({3})}, {v({1}), v({5})}}; + constexpr int kThreads = 4; + constexpr int kRounds = 25; + std::atomic wrong {0}; + std::vector pool; + for (int t = 0; t < kThreads; ++t) { + pool.emplace_back([&] { + for (int round = 0; round < kRounds; ++round) { + if (batch_capacity(*g, good, opts, {}, 2).size() != 2) ++wrong; + try { + (void)batch_capacity(*g, bad, opts, {}, 2); + ++wrong; + } catch (const DagCacheMiss&) { + } + if (capacity(*g, v({2}), v({3}), opts).placed <= 0) ++wrong; + } + }); + } + for (auto& th : pool) th.join(); + EXPECT_EQ(wrong.load(), 0); + const auto after = cache.stats(); + EXPECT_EQ(after.hits - before.hits, static_cast(kThreads) * kRounds * 3); + EXPECT_EQ(after.misses - before.misses, static_cast(kThreads) * kRounds); + EXPECT_EQ(after.entries, 2); + EXPECT_EQ(after.bytes, before.bytes); + EXPECT_EQ(after.live_values, before.live_values); +} + +TEST(DagCacheFrozen, ConcurrentPublicationsLoseNoIncrement) { + // Many short public calls at once (a CostOnly policy constructor: one frozen hit, published as + // its last step), so that publications from different threads overlap often: the hits are + // added atomically, none is lost. + const auto g = fabric(); + DagCache cache(*g); + (void)cache.lookup(*g, v({3}), Direction::Reverse, nullptr); + cache.freeze(); + const auto before = cache.stats().hits; + constexpr int kThreads = 8; + constexpr int kRounds = 1500; + std::vector pool; + for (int t = 0; t < kThreads; ++t) { + pool.emplace_back([&] { + FlowLedger fg(g); + for (int round = 0; round < kRounds; ++round) FlowPolicy p(fg, v({0}), v({3}), costonly(), nullptr, &cache); + }); + } + for (auto& th : pool) th.join(); + EXPECT_EQ(cache.stats().hits - before, static_cast(kThreads) * kRounds); +} diff --git a/tests/cpp/flow_ledger_tests.cpp b/tests/cpp/flow_ledger_tests.cpp index 6115b2a..d1d1fd2 100644 --- a/tests/cpp/flow_ledger_tests.cpp +++ b/tests/cpp/flow_ledger_tests.cpp @@ -9,6 +9,7 @@ // | orphaned records | n/a | n/a | n/a | clears | copied | #include +#include #include #include #include @@ -706,3 +707,117 @@ TEST(FlowLedger, RandomMultiOwnerCommitsMatchAModel) { EXPECT_GT(rejected, 100); for (auto owner : owners) LedgerAccess::release(fg, owner); } + +TEST(FlowLedger, FreshSingleRecordCommitsMatchTheGeneralRule) { + // Task 1d-msd: a commit of one new record by an owner without live records takes a shortcut + // when its edges strictly ascend (no aggregation). Against an independent model of the general + // rule, for drafts that ascend, repeat an edge, run backwards, hold zeros or negatives, and + // overflow at any position (another owner holding part of the capacity): the same outcome, the + // same message (negative amounts first, then the first infeasible edge in first-touch order with + // its aggregated total), and nothing changed by a rejected commit. + std::mt19937 rng(1007); + auto g = shared(make_graph(6, {{0, 1, 4, 1}, {1, 2, 3, 1}, {2, 3, 5, 1}, {0, 3, 2, 1}, {3, 4, 4, 1}, {4, 5, 3, 1}})); + const auto dag = all_edges(*g); + const auto m = static_cast(g->num_edges()); + FlowLedger fg(g); + const auto other = LedgerAccess::bind(fg); + std::vector held {draft_on(dag, {{0, 1}, {2, 2}, {5, 1}})}; + (void)LedgerAccess::commit(fg, other, held); + int ascending = 0; + int general = 0; + int negative = 0; + int infeasible = 0; + int accepted = 0; + for (int step = 0; step < 4000; ++step) { + const auto owner = LedgerAccess::bind(fg); + std::vector> edges; + const auto k = rng() % 5; + for (std::size_t i = 0; i < k; ++i) { + edges.emplace_back(static_cast(rng() % m), rng() % 9 == 0 ? Amount {-1} : static_cast(rng() % 4)); + } + const int shape = static_cast(rng() % 3); // ascending, sorted with repeats kept, as drawn + if (shape < 2) std::sort(edges.begin(), edges.end()); + if (shape == 0) { + edges.erase(std::unique(edges.begin(), edges.end(), [](const auto& a, const auto& b) { return a.first == b.first; }), + edges.end()); + } + const bool strictly = std::adjacent_find(edges.begin(), edges.end(), [](const auto& a, const auto& b) { + return a.first >= b.first; + }) == edges.end(); + ++(strictly ? ascending : general); + // The general rule: negatives first; then each touched edge in first-touch order with its sum. + std::string want; + for (const auto& [e, x] : edges) { + if (x < 0) want = "ledger: a negative allocation in a commit"; + } + std::vector delta(m, 0); + std::vector touched; + for (const auto& [e, x] : edges) { + if (std::find(touched.begin(), touched.end(), e) == touched.end()) touched.push_back(e); + delta[static_cast(e)] += x; + } + const auto before = fg.allocated(); + const auto before_records = fg.records(); + const auto next_id = LedgerAccess::next_flow_id(fg); + if (want.empty()) { + for (EdgeId e : touched) { + const auto ei = static_cast(e); + const Amount next = before[ei] + delta[ei]; + if (next < 0 || next > g->capacity_units()[ei]) { + want = "ledger: infeasible commit on edge " + std::to_string(e) + " (" + std::to_string(next) + " of " + + std::to_string(g->capacity_units()[ei]) + ")"; + break; + } + } + } + std::vector drafts {draft_on(dag, edges)}; + if (!want.empty()) { + try { + (void)LedgerAccess::commit(fg, owner, drafts); + ADD_FAILURE() << "step " << step << ": accepted, want " << want; + } catch (const std::logic_error& e) { + EXPECT_EQ(std::string(e.what()), want) << "step " << step; + } + ++(want.find("negative") != std::string::npos ? negative : infeasible); + EXPECT_EQ(fg.allocated(), before) << "step " << step; + EXPECT_EQ(fg.records().size(), before_records.size()) << "step " << step; + EXPECT_EQ(LedgerAccess::next_flow_id(fg), next_id) << "step " << step; + } else { + const auto ids = LedgerAccess::commit(fg, owner, drafts); + ASSERT_EQ(ids.size(), 1U); + EXPECT_EQ(ids[0], (FlowId {fg.uid(), next_id})); + const auto rec = fg.record(ids[0]); + std::vector> kept; + for (const auto& p : edges) { + if (p.second != 0) kept.push_back(p); + } + EXPECT_EQ(rec.edges, kept) << "step " << step; + EXPECT_EQ(rec.owner, owner); + for (std::size_t e = 0; e < m; ++e) EXPECT_EQ(fg.allocated()[e], before[e] + delta[e]) << "step " << step; + ++accepted; + if (rng() % 2 == 0) (void)LedgerAccess::detach(fg, owner); + } + LedgerAccess::release(fg, owner); + if (::testing::Test::HasFailure()) return; + } + EXPECT_GT(ascending, 1000); + EXPECT_GT(general, 1000); + EXPECT_GT(negative, 300); + EXPECT_GT(infeasible, 300); + EXPECT_GT(accepted, 1000); + LedgerAccess::release(fg, other); + // An owner with a live record, and a draft naming an id the owner does not hold, keep the + // general path and its rule (a fresh ledger). + FlowLedger fg2(g); + const auto owner = LedgerAccess::bind(fg2); + std::vector one {draft_on(dag, {{3, 1}})}; + const auto ids = LedgerAccess::commit(fg2, owner, one); + std::vector without_it {draft_on(dag, {{4, 1}})}; + EXPECT_THROW((void)LedgerAccess::commit(fg2, owner, without_it), std::logic_error); + const auto fresh = LedgerAccess::bind(fg2); + std::vector claims {draft_on(dag, {{4, 1}}, ids[0].second)}; + EXPECT_THROW((void)LedgerAccess::commit(fg2, fresh, claims), std::logic_error); + EXPECT_EQ(fg2.records().size(), 1U); + LedgerAccess::release(fg2, owner); + LedgerAccess::release(fg2, fresh); +} diff --git a/tests/cpp/flow_policy_fault_tests.cpp b/tests/cpp/flow_policy_fault_tests.cpp index abf64a8..31dacfd 100644 --- a/tests/cpp/flow_policy_fault_tests.cpp +++ b/tests/cpp/flow_policy_fault_tests.cpp @@ -9,6 +9,7 @@ // (fault_injection.hpp). #include +#include #include #include #include @@ -22,7 +23,7 @@ #include "netgraph/core/detail/ledger_access.hpp" #include "netgraph/core/detail/policy_engine.hpp" #include "netgraph/core/detail/pred_dag_builder.hpp" -#include "netgraph/core/detail/search.hpp" +#include "netgraph/core/detail/traversal.hpp" #include "netgraph/core/evaluators.hpp" #include "netgraph/core/flow_ledger.hpp" #include "netgraph/core/flow_policy.hpp" @@ -120,6 +121,13 @@ std::vector modes(const Graph& g) { std::make_shared(detail::PredDAGBuilder::of_edges(g, std::vector {inv[1], inv[6]}))}; out.push_back({"pinned eb cohort", {two, FlowPlacement::EqualBalanced, Admission::Lossless, det}, {0}, {5}}); out.push_back({"pinned lossy", {two, FlowPlacement::Weighted, Admission::Lossy, det}, {0}, {5}}); + // CostOnly creation in one pass (task 1d-msd): its Collapse branch (multipath, multi_edge false, + // on the creation view) and its Weighted weights (Capacity priority). + out.push_back({"costonly eb multipath collapse", + {CostOnly {}, FlowPlacement::EqualBalanced, Admission::Lossless, + sel(EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual, true, false)}, + S, D}); + out.push_back({"costonly weighted capacity", {CostOnly {}, FlowPlacement::Weighted, Admission::Lossless, det}, S, D}); return out; } @@ -199,7 +207,7 @@ TEST(PolicyFaults, EveryAllocationOfEveryRoutingModeFailsAtomically) { } } } - EXPECT_EQ(sweeps, 11 * 5); + EXPECT_EQ(sweeps, 13 * 5); EXPECT_GT(injected, sweeps * 3); // not vacuous } @@ -299,7 +307,9 @@ TEST(PolicyFaults, DagCacheInsertionIsAllOrNothing) { EXPECT_EQ(s.bytes, before.bytes) << w; EXPECT_EQ(s.live_values, before.live_values) << w; EXPECT_EQ(s.hits, before.hits) << w; - EXPECT_LE(s.misses, before.misses + 1) << w; // a failed search counts its miss + // The key is the lookup's first allocation: its failure counts nothing; a failed search (or + // insertion) after it counts its miss. + EXPECT_EQ(s.misses, before.misses + (at == 0 ? 0 : 1)) << w; const auto v = cache.lookup(*g, roots, Direction::Reverse, nullptr); // a clean retry inserts EXPECT_EQ(cache.stats().entries, 2) << w; EXPECT_TRUE(*v == detail::static_support(*g, roots, Direction::Reverse, nullptr)) << w; @@ -460,3 +470,272 @@ TEST(PolicyFaults, ScratchGrowthFailureLeavesNothingHalfGrown) { } EXPECT_GT(failures, 4); } + +TEST(PolicyFaults, FrozenLookupsFailWithoutCounting) { + // A frozen hit allocates only the key of roots that are not already a sorted set (roots that are + // one are borrowed: nothing; task 1d-msd), a frozen miss its key and the DagCacheMiss message, + // all before counting: a failure there counts nothing and changes no entry; a clean retry counts. + const auto g = graph(); + DagCache cache(*g); + const std::vector unsorted = {6, 5}; + const std::vector sorted = {5, 6}; + const std::vector absent = {5}; + const auto value = cache.lookup(*g, unsorted, Direction::Reverse, nullptr); + cache.freeze(); + for (const int kind : {0, 1, 2}) { // a hit on unsorted roots, a hit on a sorted set, a miss + const bool hit = kind != 2; + const auto& present = kind == 0 ? unsorted : sorted; + long failures = 0; + for (long at = 0;; ++at) { + const auto before = cache.stats(); + bool missed = false; + const auto call = [&] { + try { + if (cache.lookup(*g, hit ? present : absent, Direction::Reverse, nullptr).get() != value.get()) ADD_FAILURE(); + } catch (const DagCacheMiss&) { + missed = true; + } + }; + const bool failed = fails_at(at, call); + const auto s = cache.stats(); + const std::string w = std::string(hit ? "hit" : "miss") + " allocation " + std::to_string(at); + EXPECT_EQ(s.entries, 1) << w; + EXPECT_EQ(s.bytes, before.bytes) << w; + EXPECT_EQ(s.live_values, before.live_values) << w; + if (failed) { + ++failures; + EXPECT_EQ(s.hits, before.hits) << w; + EXPECT_EQ(s.misses, before.misses) << w; + continue; + } + EXPECT_EQ(missed, !hit) << w; + EXPECT_EQ(s.hits - before.hits, hit ? 1 : 0) << w; + EXPECT_EQ(s.misses - before.misses, hit ? 0 : 1) << w; + break; + } + if (kind == 1) { + EXPECT_EQ(failures, 0) << "hit on a sorted set"; // borrowed: no allocation at all + } else { + EXPECT_GE(failures, hit ? 1 : 2) << (hit ? "hit" : "miss"); // the key; the message (and a key) + } + } +} + +namespace { + +void expect_stats(const DagCache::Stats& got, const DagCache::Stats& want, const std::string& where) { + EXPECT_EQ(got.hits, want.hits) << where; + EXPECT_EQ(got.misses, want.misses) << where; + EXPECT_EQ(got.entries, want.entries) << where; + EXPECT_EQ(got.bytes, want.bytes) << where; + EXPECT_EQ(got.live_values, want.live_values) << where; +} + +// One public call reading a supplied frozen cache, under every injected failure (every `step`-th +// allocation in any thread, or every third in the worker threads only): a call failing with +// std::bad_alloc leaves all five statistics as they were; the call that completes adds exactly +// `hits` hits, or, when its outcome is the DagCacheMiss (`miss`), exactly one miss and no hit +// (NOTES R27). Returns the number of injected failures. +long sweep_frozen_stats(const std::string& name, const DagCache& cache, bool in_workers, long step, std::int64_t hits, + bool miss, const std::function& call) { + long failures = 0; + for (long at = 0;; at += in_workers ? 3 : step) { + const auto before = cache.stats(); + bool missed = false; + const auto fn = [&] { + try { + call(); + } catch (const DagCacheMiss&) { + missed = true; + } + }; + const bool failed = in_workers ? fails_in_workers_at(at, fn) : fails_at(at, fn); + const std::string w = name + " allocation " + std::to_string(at) + (in_workers ? " (worker threads)" : ""); + auto want = before; + if (failed) { + ++failures; + EXPECT_FALSE(missed) << w; + } else { + EXPECT_EQ(missed, miss) << w; + if (miss) { + want.misses += 1; + } else { + want.hits += hits; + } + } + expect_stats(cache.stats(), want, w); + if (!failed || ::testing::Test::HasFailure()) break; + } + return failures; +} + +} // namespace + +TEST(PolicyFaults, FrozenStatisticsFollowThePublicCall) { + // review-1d F1: a frozen cache's statistics are observable state (APPROACH 13; the building + // cache's limit 2 does not cover them). Every public call that can read a supplied frozen cache + // (the policy constructor in every mode, capacity in every mode, batch_capacity and sensitivity + // at 1, 2 and 4 workers, in any thread and in the worker threads only) is swept: hits, misses, + // entries, bytes and live values after a failed call equal those before it. + const auto g = graph(); + const auto all = modes(*g); + DagCache cache(*g); + for (const auto& t : {std::vector {5, 6}, std::vector {6}, std::vector {5}}) { + (void)cache.lookup(*g, t, Direction::Reverse, nullptr); + } + cache.freeze(); + const auto reads = [](const Mode& m) -> std::int64_t { return std::holds_alternative(m.cfg.routing) ? 1 : 0; }; + for (const auto& m : all) { + const long built = sweep_frozen_stats(m.name + " frozen construction", cache, false, 1, reads(m), false, [&] { + FlowLedger fg(g); + FlowPolicy p(fg, m.sources, m.destinations, m.cfg, nullptr, &cache); + }); + EXPECT_GT(built, 2) << m.name; + const CapacityOptions opts {m.cfg, nullptr, &cache, OutputOptions {true, true, true}}; + const long placed = sweep_frozen_stats(m.name + " frozen capacity", cache, false, 3, reads(m), false, + [&] { (void)capacity(*g, m.sources, m.destinations, opts); }); + EXPECT_GT(placed, 2) << m.name; + if (::testing::Test::HasFailure()) return; + } + // A CostOnly policy whose key is absent: the constructor's outcome is the miss. + const auto& eb = all[1]; // costonly eb + ASSERT_TRUE(std::holds_alternative(eb.cfg.routing)); + const std::vector absent = {4}; + EXPECT_GT(sweep_frozen_stats("frozen construction miss", cache, false, 1, 0, true, + [&] { + FlowLedger fg(g); + FlowPolicy p(fg, eb.sources, absent, eb.cfg, nullptr, &cache); + }), + 1); + const CapacityOptions shared {eb.cfg, nullptr, &cache}; + const std::vector, std::vector>> pairs = { + {eb.sources, eb.destinations}, {{1}, {6}}, {{0}, {5}}}; + // Pair 0 hits, pair 1 misses: a lower pair's hit is not kept by the failed batch. + const std::vector, std::vector>> with_miss = {{eb.sources, eb.destinations}, + {{0}, absent}}; + for (const std::int32_t workers : {1, 2, 4}) { + for (const bool in_workers : {false, true}) { + if (in_workers && workers == 1) continue; // one worker runs on the calling thread + const std::string w = " workers " + std::to_string(workers); + EXPECT_GT(sweep_frozen_stats("frozen batch" + w, cache, in_workers, 5, 3, false, + [&] { (void)batch_capacity(*g, pairs, shared, {}, workers); }), + 2) + << w; + EXPECT_GT(sweep_frozen_stats("frozen batch with a miss" + w, cache, in_workers, 3, 0, true, + [&] { (void)batch_capacity(*g, with_miss, shared, {}, workers); }), + 1) + << w; + if (::testing::Test::HasFailure()) return; + } + // CostOnly sensitivity from a source that reaches no destination: one lookup (the baseline's + // hit), no placed deletion, and the call succeeds; its failures happen after the hit. + EXPECT_GT(sweep_frozen_stats("frozen costonly sensitivity" + std::string(" workers ") + std::to_string(workers), + cache, false, 3, 1, false, + [&] { + (void)sensitivity(*g, std::vector {5}, std::vector {6}, + SensitivityOptions {eb.cfg, nullptr, &cache}, workers); + }), + 2); + } + // CostOnly sensitivity whose deletions miss (a fresh uid per deletion): the baseline's hit is + // dropped with the failed call, and the miss counts once. + EXPECT_GT(sweep_frozen_stats("frozen costonly sensitivity miss", cache, false, 1, 0, true, + [&] { + (void)sensitivity(*g, eb.sources, eb.destinations, + SensitivityOptions {eb.cfg, nullptr, &cache}, 1); + }), + 2); + EXPECT_EQ(cache.stats().entries, 3); +} + +TEST(PolicyFaults, WorkersSharingAFrozenCacheFailCleanly) { + // batch_capacity and CostOnly sensitivity with a frozen cache shared by two and four workers: + // a failure anywhere, or in the worker threads only, propagates as std::bad_alloc after the + // join, the cache keeps its entries, and a clean retry gives the reference. + const auto g = graph(); + const auto all = modes(*g); + const auto& m = all[1]; // costonly eb: reads the cache + const std::vector, std::vector>> pairs = { + {m.sources, m.destinations}, {{1}, {6}}, {{0}, {5}}}; + DagCache cache(*g); + for (const auto& [s, t] : pairs) (void)cache.lookup(*g, t, Direction::Reverse, nullptr); + const auto entries = cache.stats().entries; + cache.freeze(); + const auto want = batch_capacity(*g, pairs, CapacityOptions {m.cfg}, {}, 1); + const CapacityOptions shared {m.cfg, nullptr, &cache}; + for (const std::int32_t workers : {2, 4}) { + for (const bool in_workers : {false, true}) { + const long failures = sweep_evaluator( + "frozen batch", workers, in_workers, 5, + [&](std::int32_t w) { (void)batch_capacity(*g, pairs, shared, {}, w); }, + [&](std::int32_t w) { + EXPECT_EQ(cache.stats().entries, entries); + const auto b = batch_capacity(*g, pairs, shared, {}, w); + ASSERT_EQ(b.size(), want.size()); + for (std::size_t i = 0; i < b.size(); ++i) expect_same(b[i], want[i], "frozen batch retry"); + }); + EXPECT_GT(failures, 2) << "frozen batch workers " << workers; + } + } + // CostOnly sensitivity through a frozen cache: every re-evaluated deletion misses (a fresh + // uid), so the reference outcome is the DagCacheMiss itself (its key; the uid is fresh per + // call). With one worker an injected failure before that miss gives std::bad_alloc, and the + // retry raises the same miss. (With several workers a lower candidate's miss may win over a + // worker's std::bad_alloc: the lowest index's exception is raised.) + const auto key_of = [](const DagCacheMiss& e) { + const std::string w = e.what(); + return w.substr(0, w.find(", Eligibility uid ")); + }; + const auto costonly_sensitivity = [&](std::int32_t w) { + (void)sensitivity(*g, m.sources, m.destinations, SensitivityOptions {m.cfg, nullptr, &cache}, w); + }; + std::string want_miss; + try { + costonly_sensitivity(1); + } catch (const DagCacheMiss& e) { + want_miss = key_of(e); + } + EXPECT_EQ(want_miss, "DagCache: the frozen cache has no entry for roots {5, 6}, direction Reverse"); + const long failures = sweep_evaluator( + "frozen sensitivity", 1, false, 1, + [&](std::int32_t w) { + try { + costonly_sensitivity(w); + } catch (const DagCacheMiss&) { + } + }, + [&](std::int32_t w) { + EXPECT_EQ(cache.stats().entries, entries); + try { + costonly_sensitivity(w); + ADD_FAILURE() << "no DagCacheMiss"; + } catch (const DagCacheMiss& e) { + EXPECT_EQ(key_of(e), want_miss); + } + }); + EXPECT_GT(failures, 2) << "frozen sensitivity"; + // Global (deletion-mode) sensitivity with the frozen cache shared by every worker: it reads no + // cache, and fails and retries as without one. + const auto git = std::find_if(all.begin(), all.end(), [](const Mode& x) { return x.name == "global"; }); + ASSERT_NE(git, all.end()); + const auto& gm = *git; + const auto want_global = sensitivity(*g, gm.sources, gm.destinations, SensitivityOptions {gm.cfg}, 1); + const SensitivityOptions gopts {gm.cfg, nullptr, &cache}; + for (const std::int32_t workers : {2, 4}) { + for (const bool in_workers : {false, true}) { + const long global_failures = sweep_evaluator( + "frozen global sensitivity", workers, in_workers, 11, + [&](std::int32_t w) { (void)sensitivity(*g, gm.sources, gm.destinations, gopts, w); }, + [&](std::int32_t w) { + const auto r = sensitivity(*g, gm.sources, gm.destinations, gopts, w); + ASSERT_EQ(r.entries.size(), want_global.entries.size()); + for (std::size_t i = 0; i < r.entries.size(); ++i) { + EXPECT_EQ(r.entries[i].edge, want_global.entries[i].edge); + EXPECT_EQ(r.entries[i].loss, want_global.entries[i].loss); + } + }); + EXPECT_GT(global_failures, 2) << "frozen global sensitivity workers " << workers; + } + } + EXPECT_EQ(cache.stats().entries, entries); +} diff --git a/tests/cpp/flow_policy_tests.cpp b/tests/cpp/flow_policy_tests.cpp index 4bcce0d..0f83ccc 100644 --- a/tests/cpp/flow_policy_tests.cpp +++ b/tests/cpp/flow_policy_tests.cpp @@ -20,10 +20,10 @@ #include "netgraph/core/detail/policy_engine.hpp" #include "netgraph/core/detail/pred_dag_builder.hpp" #include "netgraph/core/detail/private_hooks.hpp" -#include "netgraph/core/detail/search.hpp" #include "netgraph/core/evaluators.hpp" #include "netgraph/core/flow_ledger.hpp" #include "netgraph/core/flow_policy.hpp" +#include "netgraph/core/search.hpp" #include "test_utils.hpp" using namespace netgraph::core; @@ -259,15 +259,16 @@ TEST(FlowPolicyConfig, EnumValuesOutsideTheirEnumerationAreRejected) { "admission: 99")); EXPECT_TRUE(contains(error_of([&] { detail::check_kernel(FlowPlacement::MaxFlow, Admission::Lossless, bad_priority); }), "priority: 99")); - EXPECT_TRUE(contains( - error_of([&] { (void)detail::spf(*g, v({0}), bad_direction, std::nullopt, sel(), std::nullopt, 1, nullptr); }), - "direction: 99 is not a value of Direction")); - EXPECT_TRUE(contains( - error_of([&] { (void)detail::spf(*g, v({0}), Direction::Forward, std::nullopt, tie_sel, std::nullopt, 1, nullptr); }), - "selection.tie_break: 99")); - EXPECT_TRUE(contains( - error_of([&] { (void)detail::spf(*g, v({0}), Direction::Forward, std::nullopt, priority_sel, std::nullopt, 1, nullptr); }), - "selection.priority: 99")); + { + SpfOptions o(Direction::Forward, sel()); + o.direction = bad_direction; + EXPECT_TRUE(contains(error_of([&] { (void)spf(*g, v({0}), o); }), "direction: 99 is not a value of Direction")); + EXPECT_TRUE(contains(error_of([&] { SpfOptions bad(bad_direction, sel()); }), "direction: 99 is not a value of Direction")); + SpfOptions t(Direction::Forward, tie_sel); + EXPECT_TRUE(contains(error_of([&] { (void)spf(*g, v({0}), t); }), "selection.tie_break: 99")); + SpfOptions p(Direction::Forward, priority_sel); + EXPECT_TRUE(contains(error_of([&] { (void)spf(*g, v({0}), p); }), "selection.priority: 99")); + } DagCache cache(*g); EXPECT_TRUE(contains(error_of([&] { (void)cache.lookup(*g, v({3}), bad_direction, nullptr); }), "direction: 99")); const auto st = cache.stats(); diff --git a/tests/cpp/kernels_tests.cpp b/tests/cpp/kernels_tests.cpp index f8124cc..c4d9c27 100644 --- a/tests/cpp/kernels_tests.cpp +++ b/tests/cpp/kernels_tests.cpp @@ -13,7 +13,9 @@ #include #include #include +#include #include +#include #include #include "netgraph/core/detail/int128.hpp" @@ -69,6 +71,12 @@ std::vector hamilton(Amount total, const std::vector& w) { return a; } +// A plan's structure field (a view of the plan's block) as a vector. +template +std::vector> vec(std::span v) { + return std::vector>(v.begin(), v.end()); +} + // Member-aligned view of a dense per-edge array. std::vector on_members(const KernelPlan& plan, const std::vector& dense) { std::vector out; @@ -459,11 +467,12 @@ TEST(Plans, SplitGraphEqualsTheWalkDefinition) { const auto on_usable = make_plan(g, kernel, Admission::Lossless, EdgePriority::Capacity, src, dsts, usable_dag, {}, &plan_cost); EXPECT_EQ(on_usable.members, on_bundle.members); - EXPECT_EQ(on_usable.topo, on_bundle.topo); - EXPECT_EQ(on_usable.out_begin, on_bundle.out_begin); - EXPECT_EQ(on_usable.out_end, on_bundle.out_end); - EXPECT_EQ(on_usable.head_pos, on_bundle.head_pos); - EXPECT_EQ(on_usable.terminal, on_bundle.terminal); + EXPECT_EQ(vec(on_usable.dsts), vec(on_bundle.dsts)); + EXPECT_EQ(vec(on_usable.topo), vec(on_bundle.topo)); + EXPECT_EQ(vec(on_usable.out_begin), vec(on_bundle.out_begin)); + EXPECT_EQ(vec(on_usable.out_end), vec(on_bundle.out_end)); + EXPECT_EQ(vec(on_usable.head_pos), vec(on_bundle.head_pos)); + EXPECT_EQ(vec(on_usable.terminal), vec(on_bundle.terminal)); EXPECT_EQ(on_usable.weights, on_bundle.weights); // The route cost is over the route (every usable edge), not only the members. dist.assign(static_cast(n), kUnreachable); diff --git a/tests/cpp/ksp_tests.cpp b/tests/cpp/ksp_tests.cpp new file mode 100644 index 0000000..9218f1b --- /dev/null +++ b/tests/cpp/ksp_tests.cpp @@ -0,0 +1,520 @@ +// KSP (APPROACH 6, rule 10) and resolve_to_paths (APPROACH 5): prototype 6's witnesses (the +// review-12 C2 and three-hop graphs, the review-13 priority-suffix trap, the prefix-capped spur, +// the parallel root under Expand and Collapse, the four-edge graph, the zero-capacity tie order, +// the static ceiling on chain-plus-bypasses), k = 1 against the forward and reverse single path, +// and agreement with exhaustive enumeration (search_reference.hpp) on random graphs; the +// resolver's order, splitting and truncation against an enumeration of every walk of a DAG. +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/detail/pred_dag_builder.hpp" +#include "netgraph/core/search.hpp" +#include "search_reference.hpp" +#include "test_utils.hpp" + +using namespace netgraph::core; +using namespace netgraph::core::test; + +namespace { + +PathSelection sel(bool multi_edge = true, EdgeTieBreak tie = EdgeTieBreak::Deterministic, + EdgePriority prio = EdgePriority::Residual) { + return PathSelection(tie, prio, true, multi_edge); +} + +std::string error_of(const std::function& fn) { + try { + fn(); + } catch (const std::invalid_argument& e) { + return e.what(); + } + return ""; +} + +// Rows (u, v, capacity, cost) given in B' order (sorted by (src, cost, dst)), so ids are the +// input positions, as prototype 6's b() arranges. +Graph bgraph(std::int32_t n, std::vector rows) { + std::stable_sort(rows.begin(), rows.end(), [](const InEdge& a, const InEdge& b) { + return std::tie(a.u, a.cost, a.v) < std::tie(b.u, b.cost, b.v); + }); + return make_graph(n, rows); +} + +std::vector> run(const Graph& g, NodeId s, NodeId t, std::int64_t k, const PathSelection& s1, + std::optional ceiling = std::nullopt, + const std::vector* residual = nullptr, const Eligibility* elig = nullptr) { + KspOptions o(k, s1); + o.max_path_cost = ceiling; + if (residual != nullptr) o.residual = std::span(*residual); + o.elig = elig; + return ksp(g, s, t, o); +} + +std::vector walk_of(const Path& p) { + std::vector out; + for (const auto& hop : p.hops()) { + EXPECT_EQ(hop.size(), 1U); + out.push_back(hop.front()); + } + return out; +} + +std::vector nodes_of(const Graph& g, const Path& p) { + std::vector out = {p.src()}; + for (const auto& hop : p.hops()) out.push_back(g.edge_dst()[static_cast(hop.front())]); + return out; +} + +std::vector costs_of(const std::vector>& r) { + std::vector out; + for (const auto& [c, p] : r) { + EXPECT_EQ(c, p.cost()); + out.push_back(c); + } + return out; +} + +} // namespace + +TEST(Ksp, Review12AndTheSinglePathFromBothDirections) { + auto c2 = bgraph(4, {{0, 1, 1, 9}, {0, 2, 1, 1}, {1, 3, 1, 1}, {2, 3, 1, 9}}); + auto three = bgraph(6, {{0, 2, 1, 1}, {2, 3, 1, 1}, {3, 5, 1, 1}, {0, 1, 1, 1}, {1, 4, 1, 1}, {4, 5, 1, 1}}); + struct Case { + const Graph* g; + NodeId t; + std::vector want; + }; + for (const auto& c : {Case {&c2, 3, {0, 2, 3}}, Case {&three, 5, {0, 1, 4, 5}}}) { + for (auto tie : {EdgeTieBreak::Deterministic, EdgeTieBreak::PreferHigherPriority}) { + auto got = run(*c.g, 0, c.t, 2, sel(true, tie)); + ASSERT_EQ(got.size(), 2U); + EXPECT_EQ(nodes_of(*c.g, got[0].second), c.want); + // k = 1 is the canonical single path of the forward and the reverse search. + SpfOptions f(Direction::Forward, PathSelection(tie, EdgePriority::Residual, false)); + f.targets = std::vector {c.t}; + SpfOptions r(Direction::Reverse, PathSelection(tie, EdgePriority::Residual, false)); + r.targets = std::vector {0}; + auto fw = spf(*c.g, std::vector {0}, f); + auto rv = spf(*c.g, std::vector {c.t}, r); + auto w = walk_of(got[0].second); + std::sort(w.begin(), w.end()); + EXPECT_EQ(std::vector(fw.dag.via_edges().begin(), fw.dag.via_edges().end()).size(), w.size()); + auto fe = std::vector(fw.dag.via_edges().begin(), fw.dag.via_edges().end()); + auto re = std::vector(rv.dag.via_edges().begin(), rv.dag.via_edges().end()); + std::sort(fe.begin(), fe.end()); + std::sort(re.begin(), re.end()); + EXPECT_EQ(fe, w); + EXPECT_EQ(re, w); + } + } +} + +TEST(Ksp, Review13PrioritySuffixTrap) { + auto g = bgraph(5, {{0, 1, 1, 1}, {1, 4, 1, 3}, {1, 2, 2, 1}, {2, 3, 2, 1}, {3, 4, 2, 1}}); + auto got = run(g, 0, 4, 1, sel(true, EdgeTieBreak::PreferHigherPriority, EdgePriority::Capacity)); + ASSERT_EQ(got.size(), 1U); + EXPECT_EQ(nodes_of(g, got[0].second), (std::vector{0, 1, 4})); +} + +TEST(Ksp, PrefixCappedSpurOrder) { + // After 0-1-4 the spur at 1 takes 1-5-4 (bottleneck 1, the prefix's) before 1-2-3-4: with the + // spur's B* capped by the prefix, the 3-hop path comes second (uncapped it would come third). + auto g = bgraph(6, {{0, 1, 1, 1}, {1, 4, 1, 3}, {1, 5, 1, 1}, {5, 4, 1, 2}, {1, 2, 2, 1}, {2, 3, 2, 1}, {3, 4, 2, 1}}); + auto got = run(g, 0, 4, 3, sel(true, EdgeTieBreak::PreferHigherPriority, EdgePriority::Capacity)); + ASSERT_EQ(got.size(), 3U); + EXPECT_EQ(nodes_of(g, got[0].second), (std::vector{0, 1, 4})); + EXPECT_EQ(nodes_of(g, got[1].second), (std::vector{0, 1, 5, 4})); + EXPECT_EQ(nodes_of(g, got[2].second), (std::vector{0, 1, 2, 3, 4})); +} + +TEST(Ksp, CandidateBottleneckIncludesThePrefix) { + // The first path 0-1-3 (cost 2) has bottleneck 1 (edge 0-1). Its deviations cost 3: at 0, + // 0-2-3 (bottleneck 5); at 1, the prefix 0-1 then the second 1-3 edge (the spur alone has + // bottleneck 10, the candidate min(1, 10) = 1). PreferHigherPriority orders 0-2-3 first; a + // candidate keyed by its spur's bottleneck alone would put the prefix's deviation first. + // Deterministic orders the two by B' ids: 0-1-3 (ids 0, 3) before 0-2-3 (ids 1, 4). + auto g = bgraph(4, {{0, 1, 1, 1}, {1, 3, 10, 1}, {0, 2, 5, 1}, {2, 3, 5, 2}, {1, 3, 10, 2}}); + auto prefer = run(g, 0, 3, 8, sel(true, EdgeTieBreak::PreferHigherPriority, EdgePriority::Capacity)); + ASSERT_EQ(costs_of(prefer), (std::vector {2, 3, 3})); + EXPECT_EQ(nodes_of(g, prefer[0].second), (std::vector {0, 1, 3})); + EXPECT_EQ(nodes_of(g, prefer[1].second), (std::vector {0, 2, 3})); + EXPECT_EQ(nodes_of(g, prefer[2].second), (std::vector {0, 1, 3})); + EXPECT_EQ(walk_of(prefer[2].second), (std::vector {0, 3})); + auto det = run(g, 0, 3, 8, sel(true, EdgeTieBreak::Deterministic, EdgePriority::Capacity)); + ASSERT_EQ(costs_of(det), (std::vector {2, 3, 3})); + EXPECT_EQ(walk_of(det[1].second), (std::vector {0, 3})); + EXPECT_EQ(walk_of(det[2].second), (std::vector {1, 4})); +} + +TEST(Ksp, ParallelMembersUnderExpandAndCollapse) { + // Two hops of two parallel members: four equal paths under Expand, one under Collapse. + auto pr = bgraph(3, {{0, 1, 1, 1}, {0, 1, 1, 1}, {1, 2, 1, 1}, {1, 2, 1, 1}}); + EXPECT_EQ(run(pr, 0, 2, 8, sel()).size(), 4U); + EXPECT_EQ(run(pr, 0, 2, 8, sel(false)).size(), 1U); + auto k3 = run(pr, 0, 2, 3, sel()); + ASSERT_EQ(k3.size(), 3U); + EXPECT_EQ(walk_of(k3[0].second), (std::vector{0, 2})); + EXPECT_EQ(walk_of(k3[1].second), (std::vector{0, 3})); + EXPECT_EQ(walk_of(k3[2].second), (std::vector{1, 2})); + // Costs 1/3 then 2/3: all four simple paths under Expand in cost order; Collapse keeps one. + auto fe = bgraph(3, {{0, 1, 1, 1}, {0, 1, 1, 3}, {1, 2, 1, 2}, {1, 2, 1, 3}}); + EXPECT_EQ(costs_of(run(fe, 0, 2, 8, sel())), (std::vector{3, 4, 5, 6})); + EXPECT_EQ(costs_of(run(fe, 0, 2, 8, sel(false))), (std::vector{3})); +} + +TEST(Ksp, ZeroCapacityEdgesAreNeverMembers) { + auto g = bgraph(4, {{0, 1, 1, 1}, {1, 3, 1, 1}, {0, 2, 1, 1}, {2, 3, 1, 1}, {0, 3, 0, 2}, {0, 3, 1, 2}}); + std::vector zero; + for (EdgeId e = 0; e < g.num_edges(); ++e) { + if (g.capacity_units()[static_cast(e)] == 0) zero.push_back(e); + } + ASSERT_EQ(zero.size(), 1U); + auto with_zero = run(g, 0, 3, 8, sel()); + auto el = g.eligibility({}, zero); + auto excluded = run(g, 0, 3, 8, sel(), std::nullopt, nullptr, &el); + ASSERT_EQ(with_zero.size(), excluded.size()); + for (std::size_t i = 0; i < with_zero.size(); ++i) { + EXPECT_TRUE(with_zero[i].second == excluded[i].second); + for (EdgeId e : walk_of(with_zero[i].second)) EXPECT_NE(e, zero[0]); + } +} + +TEST(Ksp, CeilingsOnChainPlusBypasses) { + // Chain 0-1-2-3 (cost 3) with bypasses 0->2 and 1->3 (cost 3 each): static baseline 3. + auto g = bgraph(4, {{0, 1, 1, 1}, {1, 2, 1, 1}, {2, 3, 1, 1}, {0, 2, 1, 3}, {1, 3, 1, 3}}); + EXPECT_EQ(costs_of(run(g, 0, 3, 8, sel())), (std::vector{3, 4, 4})); + EXPECT_EQ(costs_of(run(g, 0, 3, 8, sel(), Stretch {1.0})), (std::vector{3})); + EXPECT_EQ(costs_of(run(g, 0, 3, 8, sel(), Stretch {1.5})), (std::vector{3, 4, 4})); + EXPECT_EQ(costs_of(run(g, 0, 3, 8, sel(), Stretch {1.25})), (std::vector{3})); // 4 > 3.75 + EXPECT_EQ(costs_of(run(g, 0, 3, 8, sel(), Stretch {4.0 / 3.0})), (std::vector{3})); // dyadic 4/3 < 4/3 + EXPECT_EQ(costs_of(run(g, 0, 3, 8, sel(), Absolute {3})), (std::vector{3})); + EXPECT_EQ(costs_of(run(g, 0, 3, 8, sel(), Absolute {4})), (std::vector{3, 4, 4})); + EXPECT_TRUE(run(g, 0, 3, 8, sel(), Absolute {0}).empty()); // a valid empty budget + // The Stretch baseline is static: a residual gating the middle edge removes the cost-3 path but + // not the baseline, so Stretch(1) keeps nothing (the residual paths cost 4). + std::vector res(5, 1); + const auto inv = id_of_input(g); + res[static_cast(inv[2])] = 0; // the middle edge 1 -> 2 + EXPECT_TRUE(run(g, 0, 3, 8, sel(), Stretch {1.0}, &res).empty()); + EXPECT_EQ(costs_of(run(g, 0, 3, 8, sel(), std::nullopt, &res)), (std::vector{4, 4})); + // No static route under a Stretch ceiling: []. + const std::vector xn = {1, 2}; + auto el = g.eligibility(xn, {}); + EXPECT_TRUE(run(g, 0, 3, 8, sel(), Stretch {2.0}, nullptr, &el).empty()); +} + +TEST(Ksp, ValidationInTheContractOrder) { + auto g = bgraph(3, {{0, 1, 1, 1}, {1, 2, 1, 1}}); + auto other = bgraph(3, {{0, 1, 1, 1}}); + auto foreign = other.eligibility({}, std::vector {0}); + const std::vector bad = {1, 2}; + EXPECT_EQ(error_of([&] { (void)run(g, 3, 1, 1, sel()); }), "src: node 3 out of node range [0, 3)"); + EXPECT_EQ(error_of([&] { (void)run(g, 0, -1, 1, sel()); }), "dst: node -1 out of node range [0, 3)"); + EXPECT_EQ(error_of([&] { (void)run(g, 1, 1, 1, sel()); }), "node 1 is in both endpoint sets: src and dst must not overlap"); + EXPECT_EQ(error_of([&] { (void)run(g, 0, 2, 0, sel()); }), "k must be >= 1 (got 0)"); + EXPECT_EQ(error_of([&] { (void)run(g, 0, 2, 1, sel(), Absolute {-1}); }), "max_path_cost Absolute cost must be >= 0 (got -1)"); + EXPECT_EQ(error_of([&] { (void)run(g, 0, 2, 1, sel(), Stretch {0.5}); }), + "max_path_cost Stretch factor must be finite and in [1, inf) (got 0.500000)"); + EXPECT_NE(error_of([&] { (void)run(g, 0, 2, 1, sel(), Stretch {std::nan("")}); }).find("max_path_cost Stretch"), std::string::npos); + EXPECT_NE(error_of([&] { (void)run(g, 0, 2, 1, sel(), Stretch {std::numeric_limits::infinity()}); }).find("max_path_cost Stretch"), std::string::npos); + EXPECT_EQ(error_of([&] { (void)run(g, 0, 2, 1, sel(), std::nullopt, &bad); }), "residual[1] = 2 is outside [0, capacity_units[1] = 1]"); + EXPECT_EQ(error_of([&] { (void)run(g, 0, 2, 1, sel(), std::nullopt, nullptr, &foreign); }), + "foreign Eligibility: built for another graph"); + // Order: the endpoints before k before the ceiling before the residual. + EXPECT_EQ(error_of([&] { (void)run(g, 2, 2, 0, sel(), Absolute {-1}, &bad); }), + "node 2 is in both endpoint sets: src and dst must not overlap"); + EXPECT_EQ(error_of([&] { (void)run(g, 0, 2, 0, sel(), Absolute {-1}, &bad); }), "k must be >= 1 (got 0)"); + EXPECT_EQ(error_of([&] { (void)run(g, 0, 2, 1, sel(), Absolute {-1}, &bad); }), "max_path_cost Absolute cost must be >= 0 (got -1)"); + KspOptions o(1, sel()); + o.selection.tie_break = static_cast(4); + EXPECT_EQ(error_of([&] { (void)ksp(g, 0, 2, o); }), "selection.tie_break: 4 is not a value of EdgeTieBreak"); + // An excluded endpoint gives [], never an error. + const std::vector x0 = {0}; + auto el = g.eligibility(x0, {}); + EXPECT_TRUE(run(g, 0, 2, 3, sel(), std::nullopt, nullptr, &el).empty()); +} + +namespace { + +// The reference KSP list: every simple src -> dst path of the member graph sorted by the key, +// truncated at the inclusive ceiling and at k. +std::vector> reference_ksp(const Graph& g, const ref::Query& q, NodeId s, NodeId t, std::int64_t k, + std::optional ceiling) { + const auto mem = ref::members(q); + std::vector is_end(static_cast(g.num_nodes()), 0); + is_end[static_cast(t)] = 1; + std::vector> all; + std::vector walk; + std::vector on(static_cast(g.num_nodes()), 0); + on[static_cast(s)] = 1; + ref::walks(g, mem, s, is_end, walk, on, all); + std::sort(all.begin(), all.end(), [&](const auto& a, const auto& b) { return ref::key_less(q, a, b); }); + // The static baseline: the member graph without the residual, Expand. + ref::Query st = q; + st.residual = nullptr; + st.multi_edge = true; + const auto d = ref::distances(g, ref::members(st), {s}, false); + std::vector> out; + for (const auto& p : all) { + if (static_cast(out.size()) == k) break; + Cost c = 0; + for (EdgeId e : p) c += g.cost()[static_cast(e)]; + if (ceiling.has_value()) { + if (const auto* a = std::get_if(&*ceiling)) { + if (c > a->cost) break; + } else { + const Cost base = d[static_cast(t)]; + if (base == ref::kNone) return {}; + // f in {1, 1.25, 1.5, 2}: c <= f * base exactly in quarters. + const auto quarters = static_cast<__int128>(std::get(*ceiling).factor * 4); + if (static_cast<__int128>(c) * 4 > quarters * base) break; + } + } + out.push_back(p); + } + return out; +} + +} // namespace + +TEST(Ksp, RandomGraphsAgreeWithExhaustiveEnumeration) { + // The prototype-6 corpus shape (seed 6006): parallel members, residuals, masks, both tie modes, + // every priority, Expand and Collapse, k in {1, 3, 8}, Absolute and Stretch ceilings. + std::mt19937 rng(6006); + int checked = 0; + int nonempty = 0; + int deep = 0; + for (int gi = 0; gi < 300; ++gi) { + const int n = 3 + static_cast(rng() % 5); + const int m = n + static_cast(rng() % static_cast(2 * n + 2)); + std::vector rows; + for (int i = 0; i < m; ++i) { + rows.push_back({static_cast(rng() % static_cast(n)), static_cast(rng() % static_cast(n)), + static_cast(rng() % 5), static_cast(1 + rng() % 5)}); + } + if (rng() % 2 == 0) { + const auto u = static_cast(rng() % static_cast(n)); + const auto v = static_cast(rng() % static_cast(n)); + for (int i = 0; i < 2; ++i) rows.push_back({u, v, static_cast(1 + rng() % 4), static_cast(1 + rng() % 3)}); + } + rows.push_back({0, static_cast(n - 1), static_cast(1 + rng() % 3), static_cast(3 + rng() % 6)}); + auto g = make_graph(n, rows); + std::vector residual(static_cast(g.num_edges())); + for (std::size_t e = 0; e < residual.size(); ++e) { + residual[e] = static_cast(rng() % static_cast(g.capacity_units()[e] + 1)); + } + std::vector xn; + std::vector xe; + for (NodeId v = 1; v + 1 < n; ++v) { + if (rng() % 10 == 0) xn.push_back(v); + } + for (EdgeId e = 0; e < g.num_edges(); ++e) { + if (rng() % 10 == 0) xe.push_back(e); + } + const auto el = g.eligibility(xn, xe); + for (int c = 0; c < 24; ++c) { + const bool use_res = rng() % 2 == 0; + const bool masks = rng() % 10 < 3; + const EdgePriority prios[3] = {EdgePriority::Residual, EdgePriority::Capacity, EdgePriority::Headroom}; + const auto s1 = sel(rng() % 2 == 0, rng() % 2 == 0 ? EdgeTieBreak::Deterministic : EdgeTieBreak::PreferHigherPriority, + prios[rng() % 3]); + const std::int64_t ks[3] = {1, 3, 8}; + const std::int64_t k = ks[rng() % 3]; + std::optional ceiling; + const auto pick = rng() % 4; + if (pick == 2) ceiling = Absolute {static_cast(3 + rng() % 9)}; + if (pick == 3) { + const double fs[4] = {1.0, 1.25, 1.5, 2.0}; + ceiling = Stretch {fs[rng() % 4]}; + } + ref::Query q; + q.g = &g; + q.elig = masks ? &el : nullptr; + q.residual = use_res ? &residual : nullptr; + q.multi_edge = s1.multi_edge; + q.prefer = s1.tie_break == EdgeTieBreak::PreferHigherPriority; + q.priority = s1.priority; + const auto want = reference_ksp(g, q, 0, static_cast(n - 1), k, ceiling); + auto got = run(g, 0, static_cast(n - 1), k, s1, ceiling, use_res ? &residual : nullptr, masks ? &el : nullptr); + std::vector> walks; + for (const auto& [cost, p] : got) { + walks.push_back(walk_of(p)); + EXPECT_EQ(cost, p.cost()); + } + ASSERT_EQ(walks, want) << "graph " << gi << " case " << c; + ++checked; + if (!want.empty()) ++nonempty; + if (want.size() >= 3) ++deep; + } + } + EXPECT_EQ(checked, 7200); + EXPECT_GT(nonempty, 3000); + EXPECT_GT(deep, 300); +} + +namespace { + +// Every src -> dst walk of a DAG given by its edges, as hops (grouped or split), sorted by the +// resolver's key (cost, hops, edge-id sequence with a grouped hop keyed by its lowest member). +std::vector>> reference_walks(const Graph& g, const std::vector& dag, NodeId s, + NodeId t, bool split) { + std::map, std::vector> groups; + for (EdgeId e : dag) groups[{g.edge_src()[static_cast(e)], g.edge_dst()[static_cast(e)]}].push_back(e); + for (auto& [k, v] : groups) std::sort(v.begin(), v.end()); + std::vector>> out; + std::function>&)> walk = [&](NodeId u, std::vector>& hops) { + if (u == t) { + if (!split) { + out.push_back(hops); + return; + } + std::vector>> combos(1); + for (const auto& h : hops) { + std::vector>> next; + for (const auto& c : combos) { + for (EdgeId e : h) { + auto cc = c; + cc.push_back({e}); + next.push_back(cc); + } + } + combos = next; + } + out.insert(out.end(), combos.begin(), combos.end()); + return; + } + for (const auto& [k, v] : groups) { + if (k.first != u) continue; + hops.push_back(v); + walk(k.second, hops); + hops.pop_back(); + } + }; + std::vector> hops; + walk(s, hops); + auto key = [&](const std::vector>& p) { + Cost c = 0; + std::vector seq; + for (const auto& h : p) { + Cost best = g.cost()[static_cast(h.front())]; + for (EdgeId e : h) best = std::min(best, g.cost()[static_cast(e)]); + c += best; + seq.push_back(h.front()); + } + return std::make_tuple(c, p.size(), seq); + }; + std::stable_sort(out.begin(), out.end(), [&](const auto& a, const auto& b) { return key(a) < key(b); }); + return out; +} + +} // namespace + +TEST(ResolveToPaths, OrderSplitAndTruncation) { + // A DAG with a parallel pair, two routes of equal cost and a dearer one: + // ids (B' order): 0: 0->1 (1), 1: 0->1 (1), 2: 0->2 (1), 3: 0->3 (5), 4: 1->3 (1), 5: 2->3 (1). + auto g = bgraph(4, {{0, 1, 1, 1}, {0, 1, 1, 1}, {0, 2, 1, 1}, {0, 3, 1, 5}, {1, 3, 1, 1}, {2, 3, 1, 1}}); + const auto dag = detail::PredDAGBuilder::of_edges(g, std::vector {0, 1, 2, 3, 4, 5}); + auto hops_of = [](const std::vector& ps) { + std::vector>> out; + for (const auto& p : ps) out.push_back(p.hops()); + return out; + }; + // Grouped: [[0, 1], [4]] (cost 2, key 0), [[2], [5]] (cost 2, key 2), [[3]] (cost 5). + auto grouped = resolve_to_paths(g, dag, 0, 3); + EXPECT_EQ(hops_of(grouped), (std::vector>> {{{0, 1}, {4}}, {{2}, {5}}, {{3}}})); + EXPECT_EQ(grouped[0].cost(), 2); + EXPECT_EQ(grouped[2].cost(), 5); + // Split: one path per member combination, in the same order. + auto split = resolve_to_paths(g, dag, 0, 3, true); + EXPECT_EQ(hops_of(split), (std::vector>> {{{0}, {4}}, {{1}, {4}}, {{2}, {5}}, {{3}}})); + EXPECT_EQ(resolve_to_paths(g, dag, 0, 3, true, 2).size(), 2U); + EXPECT_EQ(hops_of(resolve_to_paths(g, dag, 0, 3, false, 1)), (std::vector>> {{{0, 1}, {4}}})); + // An endpoint the DAG does not join: []. + EXPECT_TRUE(resolve_to_paths(g, dag, 3, 0).empty()); + EXPECT_TRUE(resolve_to_paths(g, detail::PredDAGBuilder::of_edges(g, std::vector {4}), 0, 3).empty()); + // Validation. + auto other = bgraph(4, {{0, 1, 1, 1}}); + EXPECT_EQ(error_of([&] { (void)resolve_to_paths(other, dag, 0, 3); }), "foreign PredDAG: built for another graph"); + EXPECT_EQ(error_of([&] { (void)resolve_to_paths(g, dag, 4, 3); }), "src: node 4 out of node range [0, 4)"); + EXPECT_EQ(error_of([&] { (void)resolve_to_paths(g, dag, 0, -2); }), "dst: node -2 out of node range [0, 4)"); + EXPECT_EQ(error_of([&] { (void)resolve_to_paths(g, dag, 2, 2); }), "node 2 is in both endpoint sets: src and dst must not overlap"); + EXPECT_EQ(error_of([&] { (void)resolve_to_paths(g, dag, 0, 3, false, 0); }), "max_paths must be >= 1 (got 0)"); +} + +TEST(ResolveToPaths, RandomDagsAgreeWithEnumeration) { + // Random DAGs (edges u -> v with u < v, parallel members, mixed costs, not necessarily tight): + // the order, both forms and the truncation against the enumeration of every walk. + std::mt19937 rng(55); + int checked = 0; + for (int trial = 0; trial < 600; ++trial) { + const int n = 2 + static_cast(rng() % 7); + std::vector rows; + const int m = static_cast(rng() % 18); + for (int i = 0; i < m; ++i) { + const auto u = static_cast(rng() % static_cast(n - 1)); + const auto v = static_cast(u + 1 + rng() % static_cast(n - 1 - u)); + rows.push_back({u, v, 1, static_cast(1 + rng() % 4)}); + } + auto g = make_graph(n, rows); + std::vector edges; + for (EdgeId e = 0; e < g.num_edges(); ++e) { + if (rng() % 5 != 0) edges.push_back(e); + } + const auto dag = detail::PredDAGBuilder::of_edges(g, edges); + const NodeId s = 0; + const auto t = static_cast(n - 1); + for (bool split : {false, true}) { + const auto want = reference_walks(g, edges, s, t, split); + for (std::optional mp : {std::optional {}, std::optional {1}, + std::optional {3}}) { + const auto got = resolve_to_paths(g, dag, s, t, split, mp); + const std::size_t expect_n = mp.has_value() ? std::min(want.size(), static_cast(*mp)) : want.size(); + ASSERT_EQ(got.size(), expect_n) << trial; + for (std::size_t i = 0; i < got.size(); ++i) EXPECT_EQ(got[i].hops(), want[i]) << trial << " path " << i; + ++checked; + } + } + } + EXPECT_EQ(checked, 3600); +} + +TEST(ResolveToPaths, ResolvesASearchDagFromEitherDirection) { + // The DAG of a reverse search is forward-oriented: resolving it from a source gives the same + // walks as resolving the forward search's DAG. + auto g = bgraph(5, {{0, 1, 1, 1}, {0, 2, 1, 1}, {1, 3, 1, 1}, {2, 3, 1, 1}, {3, 4, 1, 1}, {0, 4, 1, 3}}); + SpfOptions f(Direction::Forward, PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity)); + SpfOptions r(Direction::Reverse, PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity)); + auto fw = spf(g, std::vector {0}, f); + auto rv = spf(g, std::vector {4}, r); + auto a = resolve_to_paths(g, fw.dag, 0, 4); + auto b = resolve_to_paths(g, rv.dag, 0, 4); + ASSERT_EQ(a.size(), 3U); + ASSERT_EQ(b.size(), 3U); + for (std::size_t i = 0; i < a.size(); ++i) EXPECT_TRUE(a[i] == b[i]); + EXPECT_EQ(a[0].cost(), 3); +} + +TEST(ResolveToPaths, LongChainsNeedNoRecursion) { + // A 200,000-node chain with one parallel pair: the enumeration keeps an explicit stack. + const int n = 200000; + std::vector rows; + for (int v = 0; v + 1 < n; ++v) rows.push_back({v, v + 1, 1, 1}); + rows.push_back({n / 2, n / 2 + 1, 1, 1}); + auto g = make_graph(n, rows); + std::vector all(static_cast(g.num_edges())); + for (std::size_t e = 0; e < all.size(); ++e) all[e] = static_cast(e); + const auto dag = detail::PredDAGBuilder::of_edges(g, all); + EXPECT_EQ(resolve_to_paths(g, dag, 0, n - 1, true).size(), 2U); + EXPECT_EQ(resolve_to_paths(g, dag, 0, n - 1).size(), 1U); +} diff --git a/tests/cpp/search_fault_tests.cpp b/tests/cpp/search_fault_tests.cpp new file mode 100644 index 0000000..177fad2 --- /dev/null +++ b/tests/cpp/search_fault_tests.cpp @@ -0,0 +1,254 @@ +// Allocation-failure atomicity of the search (APPROACH 13): every allocation an spf call makes +// (the workspace's acquisition and growth, the tight-edge pool, both queues, the pruning and the +// extraction scratch, the emitted DAG and the result) fails once; the call raises std::bad_alloc, +// and a clean retry and a different query afterwards give their references, so the thread's +// workspace is restored on every exit (no touched node, mark, list or queue entry survives). +// Runs in the fault-injection executable (fault_injection.hpp). +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "fault_injection.hpp" +#include "netgraph/core/detail/pred_dag_builder.hpp" +#include "netgraph/core/detail/traversal.hpp" +#include "netgraph/core/search.hpp" +#include "test_utils.hpp" + +using namespace netgraph::core; +using namespace netgraph::core::test; + +namespace { + +struct Query { + std::string name; + std::vector roots; + Direction direction; + std::optional> targets; + PathSelection selection; + bool residual; + SpfQueue queue; +}; + +Graph random_graph(int n, int m, Cost max_cost, unsigned seed) { + std::mt19937 rng(seed); + std::vector edges; + for (int i = 0; i < m; ++i) { + edges.push_back({static_cast(rng() % static_cast(n)), static_cast(rng() % static_cast(n)), + static_cast(1 + rng() % 4), static_cast(1 + rng() % static_cast(max_cost))}); + } + return make_graph(n, edges); +} + +SpfResult run(const Graph& g, const Query& q, const std::vector& residual) { + SpfOptions o(q.direction, q.selection); + o.targets = q.targets; + if (q.residual) o.residual = std::span(residual); + return spf(g, q.roots, o, q.queue); +} + +void expect_same(const SpfResult& a, const SpfResult& b, const std::string& where) { + EXPECT_EQ(a.costs, b.costs) << where; + EXPECT_EQ(a.target_cost, b.target_cost) << where; + EXPECT_TRUE(a.dag == b.dag) << where; +} + +// Runs `attempt` in a new thread, whose workspace starts empty (every growth path of the first +// search is then reached), and on a std::bad_alloc runs `after` in the same thread (the injection +// is spent by then) before the exception reaches the caller's thread. +void in_fresh_thread(const std::function& attempt, const std::function& after) { + std::exception_ptr error; + std::thread worker([&] { + try { + attempt(); + } catch (const std::bad_alloc&) { + error = std::current_exception(); + after(); + } catch (...) { + error = std::current_exception(); + } + }); + worker.join(); + if (error) std::rethrow_exception(error); +} + +} // namespace + +TEST(SearchFaults, EveryAllocationOfSpfFailsCleanly) { + // A graph large enough that the pool and the queues grow past their first capacities, with + // lists of several entries (unit costs on a dense part). The tiny search before every attempt + // runs on the same workspace and reuses it, sized for the large graph (Lease reallocates only + // below n or above 4n + 4096 nodes, so it never shrinks here): each attempt starts from a + // workspace another graph used last. The acquisition of the node arrays is swept below, from + // fresh threads. + const auto big = random_graph(400, 2400, 3, 99); + const auto tiny = make_graph(3, {{0, 1, 1, 1}, {1, 2, 1, 1}}); + std::vector residual(static_cast(big.num_edges())); + for (std::size_t e = 0; e < residual.size(); ++e) residual[e] = big.capacity_units()[e] - static_cast(e % 2); + const PathSelection det(EdgeTieBreak::Deterministic, EdgePriority::Capacity); + const PathSelection collapse(EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual, true, false); + const PathSelection single(EdgeTieBreak::PreferHigherPriority, EdgePriority::Headroom, false, true); + const std::vector queries = { + {"full forward buckets", {0}, Direction::Forward, std::nullopt, det, false, SpfQueue::Auto}, + {"full forward heap", {0, 7}, Direction::Forward, std::nullopt, det, false, SpfQueue::Heap}, + {"full reverse collapse", {3}, Direction::Reverse, std::nullopt, collapse, true, SpfQueue::Auto}, + {"anycast forward", {1}, Direction::Forward, std::vector {250, 399, 17}, det, true, SpfQueue::Auto}, + {"anycast reverse heap", {250, 399}, Direction::Reverse, std::vector {1, 2}, collapse, false, SpfQueue::Heap}, + {"single path forward", {5}, Direction::Forward, std::vector {300}, single, true, SpfQueue::Auto}, + {"single path reverse", {300}, Direction::Reverse, std::vector {5, 6}, single, true, SpfQueue::Heap}, + }; + const Query other {"other", {2}, Direction::Forward, std::vector {123}, single, true, SpfQueue::Auto}; + std::vector want; + for (const auto& q : queries) want.push_back(run(big, q, residual)); + const auto want_other = run(big, other, residual); + const auto want_tiny = spf(tiny, std::vector {0}, SpfOptions(Direction::Forward, det)); + for (std::size_t i = 0; i < queries.size(); ++i) { + const auto& q = queries[i]; + long failures = 0; + for (long k = 0;; ++k) { + (void)spf(tiny, std::vector {0}, SpfOptions(Direction::Forward, det)); // reuses the workspace + std::optional got; + const bool hit = fails_at(k, [&] { got.emplace(run(big, q, residual)); }); + if (!hit) { + ASSERT_TRUE(got.has_value()) << q.name; + expect_same(*got, want[i], q.name + " without a failure"); + break; + } + ++failures; + EXPECT_FALSE(got.has_value()) << q.name << " at " << k; + // The retry, another query and a tiny search on this thread see a clean workspace. + expect_same(run(big, q, residual), want[i], q.name + " retry after " + std::to_string(k)); + expect_same(run(big, other, residual), want_other, q.name + " other after " + std::to_string(k)); + expect_same(spf(tiny, std::vector {0}, SpfOptions(Direction::Forward, det)), want_tiny, + q.name + " tiny after " + std::to_string(k)); + } + EXPECT_GE(failures, 8) << q.name; // the sweep reached the search's allocations, not only the result + // The same sweep from an empty workspace (a new thread per attempt): the first acquisition of + // the node arrays, the bucket arrays and the queues' storage fail too. + long fresh = 0; + for (long k = 0;; ++k) { + std::optional got; + const bool hit = fails_at(k, [&] { + in_fresh_thread([&] { got.emplace(run(big, q, residual)); }, + [&] { + expect_same(run(big, q, residual), want[i], q.name + " fresh retry after " + std::to_string(k)); + expect_same(run(big, other, residual), want_other, q.name + " fresh other after " + std::to_string(k)); + }); + }); + if (!hit) { + ASSERT_TRUE(got.has_value()) << q.name; + expect_same(*got, want[i], q.name + " fresh without a failure"); + break; + } + ++fresh; + } + EXPECT_GT(fresh, failures) << q.name; + } +} + +TEST(SearchFaults, InternalRouteSearchesFailCleanly) { + // The policies' entries (route_search, support_route, static_support, static_costs) under the + // same sweep: a failure leaves nothing behind for the next search. + const auto g = random_graph(200, 1200, 2, 7); + std::vector view(static_cast(g.num_edges())); + for (std::size_t e = 0; e < view.size(); ++e) view[e] = g.capacity_units()[e]; + const PathSelection single(EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual, false, false); + const std::vector dests = {150, 199}; + const auto want_route = detail::route_search(g, detail::Gate {nullptr, view, 1}, single, 3, dests); + const auto support = detail::static_support(g, dests, Direction::Reverse, nullptr); + const detail::PriorityValues values {&g, EdgePriority::Residual, view}; + const auto prio = detail::PriorityFn::of(values); + const auto want_support = detail::support_route(g, detail::PredDAGBuilder::ascending(support), 3, dests, single, prio); + const auto want_costs = detail::static_costs(g, nullptr, dests, std::vector {0, 1, 2, 3}); + std::vector> calls = { + [&] { + auto r = detail::route_search(g, detail::Gate {nullptr, view, 1}, single, 3, dests); + EXPECT_EQ(r->edges, want_route->edges); + }, + [&] { + auto r = detail::support_route(g, detail::PredDAGBuilder::ascending(support), 3, dests, single, prio); + EXPECT_EQ(r.edges, want_support.edges); + }, + [&] { EXPECT_TRUE(detail::static_support(g, dests, Direction::Reverse, nullptr) == support); }, + [&] { EXPECT_EQ(detail::static_costs(g, nullptr, dests, std::vector {0, 1, 2, 3}), want_costs); }, + }; + for (std::size_t c = 0; c < calls.size(); ++c) { + long failures = 0; + for (long k = 0;; ++k) { + const bool hit = fails_at(k, [&] { + in_fresh_thread(calls[c], [&] { + for (auto& call : calls) call(); // every entry clean afterwards, on the same thread + }); + }); + if (!hit) break; + ++failures; + } + EXPECT_GE(failures, 6) << "call " << c; + } +} + +TEST(SearchFaults, KspAndTheResolverFailCleanly) { + // ksp (the first path, every spur search, the candidate set, the Paths) and resolve_to_paths + // (the walk back, the hops, the per-node lists and heaps, the frame stack, the Paths): every + // allocation fails once, in a new thread each time (an empty workspace), and the thread's next + // calls give their references. + const auto g = random_graph(60, 300, 3, 21); + const PathSelection expand(EdgeTieBreak::PreferHigherPriority, EdgePriority::Capacity, true, true); + const PathSelection collapse(EdgeTieBreak::Deterministic, EdgePriority::Capacity, true, false); + auto ksp_of = [&](const PathSelection& s) { + KspOptions o(6, s); + o.max_path_cost = Stretch {3.0}; + return ksp(g, 0, 59, o); + }; + const auto want_expand = ksp_of(expand); + const auto want_collapse = ksp_of(collapse); + ASSERT_GE(want_expand.size(), 3U); + // Layers 0 -> {1..5} -> {6..10} -> 59 with a parallel pair: 26 equal shortest walks. + std::vector layered; + for (NodeId a = 1; a <= 5; ++a) layered.push_back({0, a, 1, 1}); + for (NodeId a = 1; a <= 5; ++a) { + for (NodeId b = 6; b <= 10; ++b) layered.push_back({a, b, 1, 1}); + } + for (NodeId b = 6; b <= 10; ++b) layered.push_back({b, 59, 1, 1}); + layered.push_back({10, 59, 1, 1}); + const auto unit = make_graph(60, layered); + SpfOptions full(Direction::Forward, PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity)); + const auto dag = spf(unit, std::vector {0}, full).dag; + const auto want_paths = resolve_to_paths(unit, dag, 0, 59, true, 7); + ASSERT_GE(want_paths.size(), 2U); + auto same = [](const std::vector>& a, const std::vector>& b) { + if (a.size() != b.size()) return false; + for (std::size_t i = 0; i < a.size(); ++i) { + if (a[i].first != b[i].first || !(a[i].second == b[i].second)) return false; + } + return true; + }; + std::vector> calls = { + [&] { EXPECT_TRUE(same(ksp_of(expand), want_expand)); }, + [&] { EXPECT_TRUE(same(ksp_of(collapse), want_collapse)); }, + [&] { + const auto got = resolve_to_paths(unit, dag, 0, 59, true, 7); + ASSERT_EQ(got.size(), want_paths.size()); + for (std::size_t i = 0; i < got.size(); ++i) EXPECT_TRUE(got[i] == want_paths[i]); + }, + }; + for (std::size_t c = 0; c < calls.size(); ++c) { + long failures = 0; + for (long k = 0;; ++k) { + const bool hit = fails_at(k, [&] { + in_fresh_thread(calls[c], [&] { + for (auto& call : calls) call(); + }); + }); + if (!hit) break; + ++failures; + } + EXPECT_GE(failures, 10) << "call " << c; + } +} diff --git a/tests/cpp/search_reference.hpp b/tests/cpp/search_reference.hpp new file mode 100644 index 0000000..fc077ce --- /dev/null +++ b/tests/cpp/search_reference.hpp @@ -0,0 +1,206 @@ +// An independent reference of the search semantics (APPROACH 6) for the tests: the member graph +// by an explicit per-pair selection, Bellman-Ford distances, the tight-edge DAG and the pruned +// DAG by two-sided distances, and the canonical single path by enumerating every simple walk. +// It shares no code with the traversal core (no queue, no lists, no extractor). +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/graph.hpp" +#include "netgraph/core/options.hpp" +#include "netgraph/core/types.hpp" + +namespace netgraph::core::test::ref { + +// An exact priority: residual / capacity as a rational, or an integer (den 1); `inf` above all. +struct Prio { + __int128 num {0}; + __int128 den {1}; + bool inf {false}; +}; +inline int cmp(const Prio& a, const Prio& b) { + if (a.inf || b.inf) return a.inf == b.inf ? 0 : (a.inf ? 1 : -1); + const __int128 l = a.num * b.den; + const __int128 r = b.num * a.den; + return l == r ? 0 : (l < r ? -1 : 1); +} + +struct Query { + const Graph* g {nullptr}; + const Eligibility* elig {nullptr}; + const std::vector* residual {nullptr}; + Amount min_residual {1}; + bool multi_edge {true}; + bool prefer {false}; + EdgePriority priority {EdgePriority::Capacity}; +}; + +inline Prio prio_of(const Query& q, EdgeId e) { + const auto ei = static_cast(e); + const Amount cap = q.g->capacity_units()[ei]; + if (q.residual == nullptr || q.priority == EdgePriority::Capacity) return {cap, 1, false}; + const Amount r = (*q.residual)[ei]; + if (q.priority == EdgePriority::Residual) return {r, 1, false}; + return {r, cap, false}; +} + +// The member flags per edge. +inline std::vector members(const Query& q) { + const Graph& g = *q.g; + const auto m = static_cast(g.num_edges()); + std::vector cand(m, 0); + for (std::size_t e = 0; e < m; ++e) { + if (q.elig != nullptr && !q.elig->edge_ok(static_cast(e))) continue; + if (g.capacity_units()[e] < 1) continue; + if (q.residual != nullptr && (*q.residual)[e] < q.min_residual) continue; + cand[e] = 1; + } + if (q.multi_edge) return cand; + // select_adjacency: the minimum cost, then the higher priority under prefer, then the lower id. + auto better = [&](EdgeId a, EdgeId b) { + const Cost ca = g.cost()[static_cast(a)]; + const Cost cb = g.cost()[static_cast(b)]; + if (ca != cb) return ca < cb; + if (q.prefer) { + const int c = cmp(prio_of(q, a), prio_of(q, b)); + if (c != 0) return c > 0; + } + return a < b; + }; + std::map, EdgeId> best; + for (std::size_t e = 0; e < m; ++e) { + if (cand[e] == 0) continue; + const auto key = std::make_pair(g.edge_src()[e], g.edge_dst()[e]); + auto it = best.find(key); + if (it == best.end()) { + best[key] = static_cast(e); + } else if (better(static_cast(e), it->second)) { + it->second = static_cast(e); + } + } + std::vector out(m, 0); + for (const auto& [k, e] : best) out[static_cast(e)] = 1; + return out; +} + +constexpr Cost kNone = kUnreachable; + +// Bellman-Ford over the members: from the set (forward) or toward it (reverse). +inline std::vector distances(const Graph& g, const std::vector& mem, const std::vector& set, + bool reverse) { + std::vector d(static_cast(g.num_nodes()), kNone); + for (NodeId v : set) d[static_cast(v)] = 0; + for (bool changed = true; changed;) { + changed = false; + for (std::size_t e = 0; e < mem.size(); ++e) { + if (mem[e] == 0) continue; + const auto a = static_cast(reverse ? g.edge_dst()[e] : g.edge_src()[e]); + const auto b = static_cast(reverse ? g.edge_src()[e] : g.edge_dst()[e]); + if (d[a] == kNone) continue; + const Cost nd = d[a] + g.cost()[e]; + if (nd < d[b]) { + d[b] = nd; + changed = true; + } + } + } + return d; +} + +// The full DAG of a search from (forward) or toward (reverse) the roots: tight member edges. +inline std::set full_dag(const Graph& g, const std::vector& mem, const std::vector& d, bool reverse) { + std::set out; + for (std::size_t e = 0; e < mem.size(); ++e) { + if (mem[e] == 0) continue; + const Cost du = d[static_cast(g.edge_src()[e])]; + const Cost dv = d[static_cast(g.edge_dst()[e])]; + if (du == kNone || dv == kNone) continue; + if (reverse ? dv + g.cost()[e] == du : du + g.cost()[e] == dv) out.insert(static_cast(e)); + } + return out; +} + +// The walks from the start set to the end set of minimum cost d*: (d*, their edges); d* kNone +// when no end is reachable. +inline std::pair> pruned(const Graph& g, const std::vector& mem, + const std::vector& starts, const std::vector& ends) { + const auto ds = distances(g, mem, starts, false); + const auto de = distances(g, mem, ends, true); + Cost dstar = kNone; + for (NodeId t : ends) dstar = std::min(dstar, ds[static_cast(t)]); + std::set out; + if (dstar == kNone) return {dstar, out}; + for (std::size_t e = 0; e < mem.size(); ++e) { + if (mem[e] == 0) continue; + const Cost du = ds[static_cast(g.edge_src()[e])]; + const Cost dv = de[static_cast(g.edge_dst()[e])]; + if (du != kNone && dv != kNone && du + g.cost()[e] + dv == dstar) out.insert(static_cast(e)); + } + return {dstar, out}; +} + +// Every simple walk from a start to an end (stopping at the first end entered) over the members. +inline void walks(const Graph& g, const std::vector& mem, NodeId u, const std::vector& is_end, + std::vector& walk, std::vector& on, std::vector>& out) { + if (is_end[static_cast(u)] != 0) { + out.push_back(walk); + return; + } + for (std::size_t e = 0; e < mem.size(); ++e) { + if (mem[e] == 0 || g.edge_src()[e] != u) continue; + const NodeId v = g.edge_dst()[e]; + if (on[static_cast(v)] != 0) continue; + on[static_cast(v)] = 1; + walk.push_back(static_cast(e)); + walks(g, mem, v, is_end, walk, on, out); + walk.pop_back(); + on[static_cast(v)] = 0; + } +} + +// The canonical key (cost, -bottleneck under prefer, hops, edge-id sequence) as a strict order. +inline bool key_less(const Query& q, const std::vector& a, const std::vector& b) { + const Graph& g = *q.g; + Cost ca = 0, cb = 0; + for (EdgeId e : a) ca += g.cost()[static_cast(e)]; + for (EdgeId e : b) cb += g.cost()[static_cast(e)]; + if (ca != cb) return ca < cb; + if (q.prefer) { + Prio ba {0, 1, true}, bb {0, 1, true}; + for (EdgeId e : a) { + if (cmp(prio_of(q, e), ba) < 0) ba = prio_of(q, e); + } + for (EdgeId e : b) { + if (cmp(prio_of(q, e), bb) < 0) bb = prio_of(q, e); + } + const int c = cmp(ba, bb); + if (c != 0) return c > 0; + } + if (a.size() != b.size()) return a.size() < b.size(); + return a < b; +} + +// The canonical single path from the start set to the end set, or nullopt. +inline std::optional> canonical(const Query& q, const std::vector& mem, + const std::vector& starts, const std::vector& ends) { + const Graph& g = *q.g; + std::vector is_end(static_cast(g.num_nodes()), 0); + for (NodeId t : ends) is_end[static_cast(t)] = 1; + std::vector> all; + for (NodeId s : starts) { + std::vector walk; + std::vector on(static_cast(g.num_nodes()), 0); + on[static_cast(s)] = 1; + walks(g, mem, s, is_end, walk, on, all); + } + if (all.empty()) return std::nullopt; + return *std::min_element(all.begin(), all.end(), [&](const auto& a, const auto& b) { return key_less(q, a, b); }); +} + +} // namespace netgraph::core::test::ref diff --git a/tests/cpp/search_tests.cpp b/tests/cpp/search_tests.cpp index 03c4b98..173a912 100644 --- a/tests/cpp/search_tests.cpp +++ b/tests/cpp/search_tests.cpp @@ -1,19 +1,28 @@ -// The internal search of APPROACH 6: the member graph and -// select_adjacency, the unique DAG in both directions, anycast pruning, the staged canonical -// single path on the review witnesses and against exhaustive enumeration, validation. +// The search of APPROACH 6 through the public spf and the traversal core's internal entries: the +// member graph and select_adjacency, the unique DAG in both directions, anycast pruning, the staged +// canonical single path on the review witnesses, the canonical predecessor order, both queues and +// their dispatch, and agreement with an independent reference (search_reference.hpp) on random +// graphs. #include #include #include #include +#include +#include +#include #include +#include #include #include -#include +#include #include -#include "netgraph/core/detail/search.hpp" +#include "netgraph/core/detail/pred_dag_builder.hpp" +#include "netgraph/core/detail/traversal.hpp" #include "netgraph/core/options.hpp" +#include "netgraph/core/search.hpp" +#include "search_reference.hpp" #include "test_utils.hpp" using namespace netgraph::core; @@ -21,19 +30,11 @@ using namespace netgraph::core::test; namespace { -PathSelection sel(bool multipath = true, bool multi_edge = true, - EdgeTieBreak tie = EdgeTieBreak::Deterministic, EdgePriority prio = EdgePriority::Residual) { +PathSelection sel(bool multipath = true, bool multi_edge = true, EdgeTieBreak tie = EdgeTieBreak::Deterministic, + EdgePriority prio = EdgePriority::Residual) { return PathSelection(tie, prio, multipath, multi_edge); } -std::vector members_of(const std::vector& mask) { - std::vector out; - for (std::size_t e = 0; e < mask.size(); ++e) { - if (mask[e] != 0) out.push_back(static_cast(e)); - } - return out; -} - std::string error_of(const std::function& fn) { try { fn(); @@ -43,55 +44,105 @@ std::string error_of(const std::function& fn) { return ""; } -detail::SpfOut run(const Graph& g, std::vector roots, Direction dir, - std::optional> targets, const PathSelection& s, - std::optional> residual = std::nullopt, Amount min_residual = 1, - const Eligibility* elig = nullptr) { - std::optional> t; - if (targets.has_value()) t = std::span(*targets); - std::optional> r; - if (residual.has_value()) r = std::span(*residual); - return detail::spf(g, roots, dir, t, s, r, min_residual, elig); +SpfResult run(const Graph& g, std::vector roots, Direction dir, std::optional> targets, + const PathSelection& s, const std::vector* residual = nullptr, Amount min_residual = 1, + const Eligibility* elig = nullptr, SpfQueue queue = SpfQueue::Auto) { + SpfOptions o(dir, s); + o.targets = std::move(targets); + if (residual != nullptr) o.residual = std::span(*residual); + o.min_residual = min_residual; + o.elig = elig; + return spf(g, roots, o, queue); +} + +std::vector edges_of(const PredDAG& dag) { + std::vector v(dag.via_edges().begin(), dag.via_edges().end()); + std::sort(v.begin(), v.end()); + return v; +} + +std::vector sorted(std::vector v) { + std::sort(v.begin(), v.end()); + return v; +} + +// The DAG's canonical form: entries grouped by child, strictly ascending via within a child, +// every via joining (parent, child); the internal ascending list equals the sorted via. +void expect_canonical(const Graph& g, const PredDAG& dag) { + const auto off = dag.parent_offsets(); + ASSERT_EQ(off.size(), static_cast(g.num_nodes()) + 1); + for (std::size_t v = 0; v + 1 < off.size(); ++v) { + for (auto k = off[v]; k < off[v + 1]; ++k) { + const auto e = static_cast(dag.via_edges()[static_cast(k)]); + EXPECT_EQ(g.edge_dst()[e], static_cast(v)); + EXPECT_EQ(g.edge_src()[e], dag.parents()[static_cast(k)]); + if (k > off[v]) EXPECT_LT(dag.via_edges()[static_cast(k) - 1], dag.via_edges()[static_cast(k)]); + } + } + const auto asc = detail::PredDAGBuilder::ascending(dag); + EXPECT_EQ(std::vector(asc.begin(), asc.end()), edges_of(dag)); } } // namespace TEST(Search, MemberGraphStaticEligibilityCapacityAndResidual) { - // ids: 0: 0->1 cap 0, 1: 0->1 cap 3, 2: 0->1 cap 5 (cost 2), 3: 1->2 cap 4 + // ids: 0: 0->1 cap 0, 1: 0->1 cap 3, 2: 0->1 cap 5 (cost 2), 3: 1->2 cap 4. A full search keeps + // the tight members: the cost-2 member is never tight while a cost-1 member exists. auto g = make_graph(3, {{0, 1, 0, 1}, {0, 1, 3, 1}, {0, 1, 5, 2}, {1, 2, 4, 1}}); - auto m = detail::member_mask(g, nullptr, sel(), {}, 1); - EXPECT_EQ(members_of(m), (std::vector{1, 2, 3})); // capacity < 1 unit never routable + EXPECT_EQ(edges_of(run(g, {0}, Direction::Forward, std::nullopt, sel()).dag), (std::vector{1, 3})); + // A residual gates at min_residual: residual [0, 1, 5, 4] at 2 units removes id 1, so the + // cost-2 member carries node 1 (distance 2). const std::vector res = {0, 1, 5, 4}; - EXPECT_EQ(members_of(detail::member_mask(g, nullptr, sel(), res, 1)), (std::vector{1, 2, 3})); - EXPECT_EQ(members_of(detail::member_mask(g, nullptr, sel(), res, 2)), (std::vector{2, 3})); + auto gated = run(g, {0}, Direction::Forward, std::nullopt, sel(), &res, 2); + EXPECT_EQ(*gated.costs, (std::vector{0, 2, 3})); + EXPECT_EQ(edges_of(gated.dag), (std::vector{2, 3})); + // min_residual without a residual gates nothing (static eligibility stays one unit). + auto static_only = run(g, {0}, Direction::Forward, std::nullopt, sel(), nullptr, 4); + EXPECT_EQ(*static_only.costs, (std::vector{0, 1, 2})); const std::vector xe = {3}; auto el = g.eligibility({}, xe); - EXPECT_EQ(members_of(detail::member_mask(g, &el, sel(), {}, 1)), (std::vector{1, 2})); + auto masked = run(g, {0}, Direction::Forward, std::nullopt, sel(), nullptr, 1, &el); + EXPECT_EQ(*masked.costs, (std::vector{0, 1, kUnreachable})); } TEST(Search, CollapseBySelectAdjacency) { // Parallel 0->1: id 0 cost 1 cap 2, id 1 cost 1 cap 6, id 2 cost 1 cap 4, id 3 cost 2 cap 9. auto g = make_graph(2, {{0, 1, 2, 1}, {0, 1, 6, 1}, {0, 1, 4, 1}, {0, 1, 9, 2}}); - // Deterministic: minimum cost, then the lowest EdgeId. - EXPECT_EQ(members_of(detail::member_mask(g, nullptr, sel(true, false), {}, 1)), (std::vector{0})); + auto member = [&](const PathSelection& s, const std::vector* res = nullptr) { + return edges_of(run(g, {0}, Direction::Forward, std::nullopt, s, res).dag); + }; + // Expand keeps every tight member; Deterministic Collapse the lowest EdgeId among the cheapest. + EXPECT_EQ(member(sel()), (std::vector{0, 1, 2})); + EXPECT_EQ(member(sel(true, false)), (std::vector{0})); // PreferHigherPriority on Capacity: the highest capacity among the cheapest. - auto cap = sel(true, false, EdgeTieBreak::PreferHigherPriority, EdgePriority::Capacity); - EXPECT_EQ(members_of(detail::member_mask(g, nullptr, cap, {}, 1)), (std::vector{1})); + EXPECT_EQ(member(sel(true, false, EdgeTieBreak::PreferHigherPriority, EdgePriority::Capacity)), (std::vector{1})); // Residual priority reads the residual; equal residual keeps the lowest id. - auto resid = sel(true, false, EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual); - EXPECT_EQ(members_of(detail::member_mask(g, nullptr, resid, std::vector {2, 1, 2, 9}, 1)), - (std::vector{0})); - EXPECT_EQ(members_of(detail::member_mask(g, nullptr, resid, std::vector {1, 1, 3, 9}, 1)), - (std::vector{2})); + const auto resid = sel(true, false, EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual); + const std::vector r1 = {2, 1, 2, 9}; + const std::vector r2 = {1, 1, 3, 9}; + EXPECT_EQ(member(resid, &r1), (std::vector{0})); + EXPECT_EQ(member(resid, &r2), (std::vector{2})); // Headroom compares residual / capacity exactly: 1/2 < 4/6 = 2/3 > 2/4. - auto head = sel(true, false, EdgeTieBreak::PreferHigherPriority, EdgePriority::Headroom); - EXPECT_EQ(members_of(detail::member_mask(g, nullptr, head, std::vector {1, 4, 2, 9}, 1)), - (std::vector{1})); + const auto head = sel(true, false, EdgeTieBreak::PreferHigherPriority, EdgePriority::Headroom); + const std::vector h1 = {1, 4, 2, 9}; + const std::vector h2 = {1, 1, 2, 9}; + EXPECT_EQ(member(head, &h1), (std::vector{1})); // Equal rational headroom (2/4 == 1/2): the lowest EdgeId. - EXPECT_EQ(members_of(detail::member_mask(g, nullptr, head, std::vector {1, 1, 2, 9}, 1)), - (std::vector{0})); + EXPECT_EQ(member(head, &h2), (std::vector{0})); // Without a residual every priority reads static capacities. - EXPECT_EQ(members_of(detail::member_mask(g, nullptr, resid, {}, 1)), (std::vector{1})); + EXPECT_EQ(member(resid), (std::vector{1})); +} + +TEST(Search, HeadroomRationalsBeyondDoubles) { + // review-1d-refine's witness: (a - 1) / a < a / (a + 1) for a = 2^54, while both round to the + // same double. Parallel 0->1 members: id 0 residual a - 1 of capacity a, id 1 residual a of + // capacity a + 1; Headroom Collapse must keep id 1. + const Amount a = Amount {1} << 54; + auto g = make_graph(2, {{0, 1, a, 1}, {0, 1, a + 1, 1}}); + const std::vector res = {a - 1, a}; + auto r = run(g, {0}, Direction::Forward, std::nullopt, + sel(true, false, EdgeTieBreak::PreferHigherPriority, EdgePriority::Headroom), &res); + EXPECT_EQ(edges_of(r.dag), (std::vector{1})); } TEST(Search, FullDagForwardAndReverse) { @@ -99,18 +150,44 @@ TEST(Search, FullDagForwardAndReverse) { auto g = make_graph(4, {{0, 1, 1, 1}, {0, 1, 1, 1}, {0, 2, 1, 2}, {1, 3, 1, 1}, {2, 3, 1, 1}}); auto f = run(g, {0}, Direction::Forward, std::nullopt, sel()); ASSERT_TRUE(f.costs.has_value()); + EXPECT_FALSE(f.target_cost.has_value()); EXPECT_EQ(*f.costs, (std::vector{0, 1, 2, 2})); // Tight edges: both parallel members, 0->2 (d(2) = 2) and 1->3; 2->3 is not tight (2 + 1 > 2). - EXPECT_EQ(f.edges, (std::vector{0, 1, 2, 3})); + EXPECT_EQ(edges_of(f.dag), (std::vector{0, 1, 2, 3})); + expect_canonical(g, f.dag); auto r = run(g, {3}, Direction::Reverse, std::nullopt, sel()); EXPECT_EQ(*r.costs, (std::vector{2, 1, 1, 0})); // The reverse DAG toward 3 is the forward-oriented set of tight edges toward 3. - EXPECT_EQ(r.edges, (std::vector{0, 1, 3, 4})); + EXPECT_EQ(edges_of(r.dag), (std::vector{0, 1, 3, 4})); + expect_canonical(g, r.dag); // Unreached nodes keep kUnreachable. auto u = run(g, {2}, Direction::Forward, std::nullopt, sel()); EXPECT_EQ(*u.costs, (std::vector{kUnreachable, kUnreachable, 0, 1})); } +TEST(Search, PredecessorOrderIsAscendingViaIndependentlyOfDiscovery) { + // review-1d-refine's four-node witness: ids 0: 0->1 (1), 1: 0->2 (2), 2: 1->3 (2), 3: 2->3 (1). + // Node 3's tight parents are discovered via 2 (from node 1, settled first) and then via 3; the + // list holds them newest first, [3, 2]; the DAG must list [2, 3] whatever the queue. + auto g = make_graph(4, {{0, 1, 1, 1}, {0, 2, 1, 2}, {1, 3, 1, 2}, {2, 3, 1, 1}}); + for (auto q : {SpfQueue::Auto, SpfQueue::Heap}) { + for (bool targets : {false, true}) { + auto r = run(g, {0}, Direction::Forward, targets ? std::optional>(std::vector {3}) + : std::nullopt, + sel(), nullptr, 1, nullptr, q); + const auto off = r.dag.parent_offsets(); + ASSERT_EQ(off[4] - off[3], 2); + EXPECT_EQ(r.dag.via_edges()[static_cast(off[3])], 2); + EXPECT_EQ(r.dag.via_edges()[static_cast(off[3]) + 1], 3); + expect_canonical(g, r.dag); + } + } + // The same through the reverse search, whose lists hang on the tails. + auto rev = run(g, {3}, Direction::Reverse, std::nullopt, sel()); + expect_canonical(g, rev.dag); + EXPECT_EQ(edges_of(rev.dag), (std::vector{0, 1, 2, 3})); +} + TEST(Search, AnycastPruningAndDirectionIndependence) { // 0->1 (1), 1->2 (1), 0->3 (2), 3->2 (5), 1->4 (5): targets {2, 4}: nearest is 2 at cost 2. auto g = make_graph(5, {{0, 1, 1, 1}, {1, 2, 1, 1}, {0, 3, 1, 2}, {3, 2, 1, 5}, {1, 4, 1, 5}}); @@ -119,58 +196,108 @@ TEST(Search, AnycastPruningAndDirectionIndependence) { ASSERT_TRUE(f.target_cost.has_value()); EXPECT_EQ(*f.target_cost, 2); EXPECT_FALSE(f.costs.has_value()); - std::vector want = {inv[0], inv[1]}; - std::sort(want.begin(), want.end()); - EXPECT_EQ(f.edges, want); + EXPECT_EQ(edges_of(f.dag), sorted({inv[0], inv[1]})); // A reverse search rooted at the targets with the source as its target: the same edges. auto r = run(g, {2, 4}, Direction::Reverse, std::vector {0}, sel()); - EXPECT_EQ(r.edges, f.edges); + EXPECT_EQ(edges_of(r.dag), edges_of(f.dag)); EXPECT_EQ(r.target_cost, f.target_cost); - // No reachable target: nullopt, no edges. + // No reachable target: kUnreachable, no edges. auto none = run(g, {2}, Direction::Forward, std::vector {0}, sel()); - EXPECT_FALSE(none.target_cost.has_value()); - EXPECT_TRUE(none.edges.empty()); + EXPECT_EQ(none.target_cost, kUnreachable); + EXPECT_TRUE(none.dag.via_edges().empty()); +} + +TEST(Search, AnycastKeepsEveryEquallyNearTarget) { + // Two targets at the same distance through different branches, a third farther: the search + // stops at the first target popped but keeps the walks to both nearest ones. + // ids by input: 0: 0->1, 1: 0->2, 2: 1->3, 3: 2->4, 4: 0->5 (cost 5). + auto g = make_graph(6, {{0, 1, 1, 1}, {0, 2, 1, 1}, {1, 3, 1, 1}, {2, 4, 1, 1}, {0, 5, 1, 5}}); + const auto inv = id_of_input(g); + for (auto q : {SpfQueue::Auto, SpfQueue::Heap}) { + auto r = run(g, {0}, Direction::Forward, std::vector {3, 4, 5}, sel(), nullptr, 1, nullptr, q); + EXPECT_EQ(r.target_cost, 2); + EXPECT_EQ(edges_of(r.dag), sorted({inv[0], inv[1], inv[2], inv[3]})); + } } TEST(Search, ExcludedRootsAndTargetsAreSkipped) { auto g = make_graph(3, {{0, 1, 1, 1}, {1, 2, 1, 1}}); const std::vector xn = {0}; auto el = g.eligibility(xn, {}); - auto r = run(g, {0}, Direction::Forward, std::vector {2}, sel(), std::nullopt, 1, &el); - EXPECT_FALSE(r.target_cost.has_value()); - EXPECT_TRUE(r.edges.empty()); - auto full = run(g, {0}, Direction::Forward, std::nullopt, sel(), std::nullopt, 1, &el); + auto r = run(g, {0}, Direction::Forward, std::vector {2}, sel(), nullptr, 1, &el); + EXPECT_EQ(r.target_cost, kUnreachable); + EXPECT_TRUE(r.dag.via_edges().empty()); + auto full = run(g, {0}, Direction::Forward, std::nullopt, sel(), nullptr, 1, &el); EXPECT_EQ(*full.costs, (std::vector{kUnreachable, kUnreachable, kUnreachable})); + // An excluded root in a set is skipped; the others search. + auto two = run(g, {0, 1}, Direction::Forward, std::nullopt, sel(), nullptr, 1, &el); + EXPECT_EQ(*two.costs, (std::vector{kUnreachable, 0, 1})); + // An excluded target is unreachable; another target still counts. + const std::vector x2 = {2}; + auto el2 = g.eligibility(x2, {}); + auto t = run(g, {0}, Direction::Forward, std::vector {1, 2}, sel(), nullptr, 1, &el2); + EXPECT_EQ(t.target_cost, 1); } -TEST(Search, ValidationBeforeAnyWork) { +TEST(Search, ValidationBeforeAnyWorkInTheContractOrder) { auto g = make_graph(3, {{0, 1, 2, 1}, {1, 2, 2, 1}}); auto other = make_graph(3, {{0, 1, 2, 1}}); auto foreign = other.eligibility({}, std::vector {0}); - EXPECT_NE(error_of([&] { (void)run(g, {}, Direction::Forward, std::nullopt, sel()); }).find("roots"), std::string::npos); - EXPECT_NE(error_of([&] { (void)run(g, {3}, Direction::Forward, std::nullopt, sel()); }).find("out of node range"), - std::string::npos); - EXPECT_NE(error_of([&] { (void)run(g, {0}, Direction::Forward, std::vector {}, sel()); }).find("targets"), - std::string::npos); - EXPECT_NE(error_of([&] { (void)run(g, {0, 1}, Direction::Forward, std::vector {1}, sel()); }) - .find("both endpoint sets"), - std::string::npos); - EXPECT_NE(error_of([&] { (void)run(g, {0}, Direction::Forward, std::nullopt, sel(false)); }).find("requires targets"), - std::string::npos); - EXPECT_NE(error_of([&] { - (void)run(g, {0}, Direction::Forward, std::nullopt, sel(), std::vector {1}); - }).find("residual has length"), - std::string::npos); - EXPECT_NE(error_of([&] { - (void)run(g, {0}, Direction::Forward, std::nullopt, sel(), std::vector {1, 3}); - }).find("outside [0, capacity_units"), - std::string::npos); - EXPECT_NE(error_of([&] { (void)run(g, {0}, Direction::Forward, std::nullopt, sel(), std::nullopt, 0); }) - .find("min_residual"), - std::string::npos); - EXPECT_NE(error_of([&] { (void)run(g, {0}, Direction::Forward, std::nullopt, sel(), std::nullopt, 1, &foreign); }) - .find("foreign Eligibility"), - std::string::npos); + const std::vector short_res = {1}; + const std::vector big_res = {1, 3}; + const std::vector ok_res = {1, 1}; + EXPECT_EQ(error_of([&] { (void)run(g, {}, Direction::Forward, std::nullopt, sel()); }), "roots: the node set is empty"); + EXPECT_EQ(error_of([&] { (void)run(g, {3}, Direction::Forward, std::nullopt, sel()); }), + "roots: node 3 out of node range [0, 3)"); + EXPECT_EQ(error_of([&] { (void)run(g, {0}, Direction::Forward, std::vector {}, sel()); }), + "targets: the node set is empty"); + EXPECT_EQ(error_of([&] { (void)run(g, {0}, Direction::Forward, std::vector {-1}, sel()); }), + "targets: node -1 out of node range [0, 3)"); + EXPECT_EQ(error_of([&] { (void)run(g, {0, 1}, Direction::Forward, std::vector {1}, sel()); }), + "node 1 is in both endpoint sets: roots and targets must not overlap"); + EXPECT_EQ(error_of([&] { (void)run(g, {0}, Direction::Forward, std::nullopt, sel(false)); }), + "multipath = false requires targets (the single path joins a root to a target)"); + EXPECT_EQ(error_of([&] { (void)run(g, {0}, Direction::Forward, std::nullopt, sel(), &short_res); }), + "residual has length 1, expected 2 (one entry per edge)"); + EXPECT_EQ(error_of([&] { (void)run(g, {0}, Direction::Forward, std::nullopt, sel(), &big_res); }), + "residual[1] = 3 is outside [0, capacity_units[1] = 2]"); + EXPECT_EQ(error_of([&] { (void)run(g, {0}, Direction::Forward, std::nullopt, sel(), nullptr, 0); }), + "min_residual must be at least 1 unit (got 0)"); + EXPECT_EQ(error_of([&] { (void)run(g, {0}, Direction::Forward, std::nullopt, sel(), nullptr, 1, &foreign); }), + "foreign Eligibility: built for another graph"); + // The order when several defects apply: roots before targets before the overlap before the + // single path before the residual before min_residual before the Eligibility. + EXPECT_EQ(error_of([&] { (void)run(g, {}, Direction::Forward, std::vector {}, sel()); }), + "roots: the node set is empty"); + EXPECT_EQ(error_of([&] { (void)run(g, {0}, Direction::Forward, std::vector {0, 7}, sel()); }), + "targets: node 7 out of node range [0, 3)"); + EXPECT_EQ(error_of([&] { (void)run(g, {0}, Direction::Forward, std::vector {0}, sel(), &short_res); }), + "node 0 is in both endpoint sets: roots and targets must not overlap"); + EXPECT_EQ(error_of([&] { (void)run(g, {0}, Direction::Forward, std::nullopt, sel(false), &short_res, 0); }), + "multipath = false requires targets (the single path joins a root to a target)"); + EXPECT_EQ(error_of([&] { (void)run(g, {0}, Direction::Forward, std::nullopt, sel(), &big_res, 0, &foreign); }), + "residual[1] = 3 is outside [0, capacity_units[1] = 2]"); + EXPECT_EQ(error_of([&] { (void)run(g, {0}, Direction::Forward, std::nullopt, sel(), &ok_res, 0, &foreign); }), + "min_residual must be at least 1 unit (got 0)"); + // Every root excluded with valid sets is an empty result, never an error; an excluded root + // does not excuse a defect. + const std::vector all = {0, 1, 2}; + auto none = g.eligibility(all, {}); + EXPECT_EQ(run(g, {0}, Direction::Forward, std::vector {2}, sel(), nullptr, 1, &none).target_cost, kUnreachable); + EXPECT_EQ(error_of([&] { (void)run(g, {0}, Direction::Forward, std::vector {0}, sel(), nullptr, 1, &none); }), + "node 0 is in both endpoint sets: roots and targets must not overlap"); + // Enumeration values outside their enumeration, at construction and at the entry. + EXPECT_EQ(error_of([&] { SpfOptions o(static_cast(9), sel()); }), "direction: 9 is not a value of Direction"); + SpfOptions bad(Direction::Forward, sel()); + bad.direction = static_cast(7); + EXPECT_EQ(error_of([&] { (void)spf(g, std::vector {0}, bad); }), "direction: 7 is not a value of Direction"); + SpfOptions bad_sel(Direction::Forward, sel()); + bad_sel.selection.priority = static_cast(5); + EXPECT_EQ(error_of([&] { (void)spf(g, std::vector {0}, bad_sel); }), + "selection.priority: 5 is not a value of EdgePriority"); + SpfOptions good(Direction::Forward, sel()); + EXPECT_EQ(error_of([&] { (void)spf(g, std::vector {0}, good, static_cast(3)); }), + "queue: 3 is not a value of SpfQueue"); } TEST(Search, StagedPathReview13D3Witness) { @@ -180,15 +307,12 @@ TEST(Search, StagedPathReview13D3Witness) { auto g = make_graph(5, {{0, 1, 1, 1}, {1, 4, 1, 3}, {1, 2, 2, 1}, {2, 3, 2, 1}, {3, 4, 2, 1}}); const auto inv = id_of_input(g); for (auto tie : {EdgeTieBreak::Deterministic, EdgeTieBreak::PreferHigherPriority}) { - auto r = run(g, {0}, Direction::Forward, std::vector {4}, - sel(false, true, tie, EdgePriority::Capacity)); - ASSERT_TRUE(r.path.has_value()); - EXPECT_EQ(r.path->walk, (std::vector{inv[0], inv[1]})); - EXPECT_EQ(r.path->cost, 4); + auto r = run(g, {0}, Direction::Forward, std::vector {4}, sel(false, true, tie, EdgePriority::Capacity)); + EXPECT_EQ(edges_of(r.dag), sorted({inv[0], inv[1]})); + EXPECT_EQ(r.target_cost, 4); // The reverse call with the roles swapped returns the same walk. auto rev = run(g, {4}, Direction::Reverse, std::vector {0}, sel(false, true, tie, EdgePriority::Capacity)); - ASSERT_TRUE(rev.path.has_value()); - EXPECT_EQ(rev.path->walk, r.path->walk); + EXPECT_EQ(edges_of(rev.dag), edges_of(r.dag)); } } @@ -199,116 +323,40 @@ TEST(Search, StagedPathBottleneckThenHopsThenIds) { auto g = make_graph(4, {{0, 1, 5, 1}, {1, 3, 1, 1}, {0, 2, 3, 1}, {2, 3, 3, 1}, {0, 3, 2, 2}}); const auto inv = id_of_input(g); auto det = run(g, {0}, Direction::Forward, std::vector {3}, sel(false)); - EXPECT_EQ(det.path->walk, (std::vector{inv[4]})); + EXPECT_EQ(edges_of(det.dag), (std::vector{inv[4]})); auto pri = run(g, {0}, Direction::Forward, std::vector {3}, sel(false, true, EdgeTieBreak::PreferHigherPriority, EdgePriority::Capacity)); - EXPECT_EQ(pri.path->walk, (std::vector{inv[2], inv[3]})); + EXPECT_EQ(edges_of(pri.dag), sorted({inv[2], inv[3]})); // Residual priority reads the residual: on capacities 5 everywhere and residuals by input // [5, 4, 1, 3, 2], 0->1->3 has bottleneck 4, 0->2->3 has 1, the direct edge 2: B* = 4. auto g5 = make_graph(4, {{0, 1, 5, 1}, {1, 3, 5, 1}, {0, 2, 5, 1}, {2, 3, 5, 1}, {0, 3, 5, 2}}); const auto inv5 = id_of_input(g5); const auto residual = to_ids(g5, std::vector {5, 4, 1, 3, 2}); auto res = run(g5, {0}, Direction::Forward, std::vector {3}, - sel(false, true, EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual), residual); - EXPECT_EQ(res.path->walk, (std::vector{inv5[0], inv5[1]})); + sel(false, true, EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual), &residual); + EXPECT_EQ(edges_of(res.dag), sorted({inv5[0], inv5[1]})); // Capacity priority on the same graph: every walk has bottleneck 5, so hops decide. auto cap5 = run(g5, {0}, Direction::Forward, std::vector {3}, - sel(false, true, EdgeTieBreak::PreferHigherPriority, EdgePriority::Capacity), residual); - EXPECT_EQ(cap5.path->walk, (std::vector{inv5[4]})); + sel(false, true, EdgeTieBreak::PreferHigherPriority, EdgePriority::Capacity), &residual); + EXPECT_EQ(edges_of(cap5.dag), (std::vector{inv5[4]})); // Deterministic ignores capacities and residuals entirely. - auto det_res = run(g5, {0}, Direction::Forward, std::vector {3}, sel(false), residual); - EXPECT_EQ(det_res.path->walk, (std::vector{inv5[4]})); -} - -namespace { - -// Every simple path from a source to a destination over the members (tiny graphs). -void enumerate(const Graph& g, const std::vector& member, NodeId u, - const std::vector& dsts, std::vector& walk, std::vector& on, - std::vector>& out) { - if (std::find(dsts.begin(), dsts.end(), u) != dsts.end()) { - out.push_back(walk); - return; - } - const auto row = g.row_offsets(); - for (auto e = row[static_cast(u)]; e < row[static_cast(u) + 1]; ++e) { - if (member[static_cast(e)] == 0) continue; - const NodeId v = g.edge_dst()[static_cast(e)]; - if (on[static_cast(v)] != 0) continue; - on[static_cast(v)] = 1; - walk.push_back(e); - enumerate(g, member, v, dsts, walk, on, out); - walk.pop_back(); - on[static_cast(v)] = 0; - } + auto det_res = run(g5, {0}, Direction::Forward, std::vector {3}, sel(false), &residual); + EXPECT_EQ(edges_of(det_res.dag), (std::vector{inv5[4]})); } -} // namespace - -TEST(Search, StagedPathAgainstEnumeration) { - // The canonical key (cost, -bottleneck under PreferHigherPriority, hops, edge-id sequence) - // over every simple walk of the member graph, against the staged construction. - std::mt19937 rng(20261004); - int checked = 0; - for (int trial = 0; trial < 2000; ++trial) { - const int n = 2 + static_cast(rng() % 6); - const int m = 1 + static_cast(rng() % 14); - std::vector edges; - for (int i = 0; i < m; ++i) { - edges.push_back({static_cast(rng() % n), static_cast(rng() % n), - static_cast(rng() % 5), static_cast(1 + rng() % 3)}); - } - auto g = make_graph(n, edges); - std::vector residual(static_cast(g.num_edges())); - for (std::size_t e = 0; e < residual.size(); ++e) { - const Amount c = g.capacity_units()[e]; - residual[e] = c == 0 ? 0 : static_cast(rng() % static_cast(c + 1)); - } - const bool use_res = (rng() % 2) == 0; - const auto tie = (rng() % 2) == 0 ? EdgeTieBreak::Deterministic : EdgeTieBreak::PreferHigherPriority; - const EdgePriority prios[3] = {EdgePriority::Residual, EdgePriority::Capacity, EdgePriority::Headroom}; - const auto prio = prios[rng() % 3]; - const bool multi_edge = (rng() % 2) == 0; - const NodeId s = static_cast(rng() % n); - NodeId t = static_cast(rng() % n); - if (t == s) t = static_cast((s + 1) % n); - const auto selection = sel(false, multi_edge, tie, prio); - std::optional> res; - if (use_res) res = residual; - auto got = run(g, {s}, Direction::Forward, std::vector {t}, selection, res); - const auto member = detail::member_mask(g, nullptr, selection, use_res ? std::span(residual) - : std::span {}, 1); - std::vector> walks; - std::vector walk; - std::vector on(static_cast(n), 0); - on[static_cast(s)] = 1; - enumerate(g, member, s, {t}, walk, on, walks); - if (walks.empty()) { - EXPECT_FALSE(got.path.has_value()); - continue; - } - const detail::PriorityValues pv {&g, prio, use_res ? std::span(residual) : std::span {}}; - auto key_less = [&](const std::vector& a, const std::vector& b) { - Cost ca = 0, cb = 0; - for (EdgeId e : a) ca += g.cost()[static_cast(e)]; - for (EdgeId e : b) cb += g.cost()[static_cast(e)]; - if (ca != cb) return ca < cb; - if (tie == EdgeTieBreak::PreferHigherPriority) { - detail::Priority ba {0, 1, true}, bb {0, 1, true}; - for (EdgeId e : a) if (detail::compare(pv.of(e), ba) < 0) ba = pv.of(e); - for (EdgeId e : b) if (detail::compare(pv.of(e), bb) < 0) bb = pv.of(e); - const int c = detail::compare(ba, bb); - if (c != 0) return c > 0; - } - if (a.size() != b.size()) return a.size() < b.size(); - return a < b; - }; - const auto best = *std::min_element(walks.begin(), walks.end(), key_less); - ASSERT_TRUE(got.path.has_value()); - EXPECT_EQ(got.path->walk, best) << "trial " << trial; - ++checked; +TEST(Search, StagedPathPrefixTrapNeedsTheFullKey) { + // review-1d-refine section 1: a cost-2 direct prefix 0->2 with bottleneck 5 against a two-edge + // cost-2 prefix 0->1->2 with bottleneck 10, then a cost-1 edge of priority 1 to the target 3. + // Both complete walks cost 3 with bottleneck 1, so hops decide: [0->2, 2->3]. A search that kept + // only the wider prefix would return the three-hop walk. + auto g = make_graph(4, {{0, 2, 5, 2}, {0, 1, 10, 1}, {1, 2, 10, 1}, {2, 3, 1, 1}}); + const auto inv = id_of_input(g); + for (auto dir : {Direction::Forward, Direction::Reverse}) { + auto r = dir == Direction::Forward + ? run(g, {0}, dir, std::vector {3}, sel(false, true, EdgeTieBreak::PreferHigherPriority, EdgePriority::Capacity)) + : run(g, {3}, dir, std::vector {0}, sel(false, true, EdgeTieBreak::PreferHigherPriority, EdgePriority::Capacity)); + EXPECT_EQ(edges_of(r.dag), sorted({inv[0], inv[3]})); } - EXPECT_GT(checked, 600); } TEST(Search, MultiRootSinglePathStartsAtTheNearestRootByHops) { @@ -317,8 +365,7 @@ TEST(Search, MultiRootSinglePathStartsAtTheNearestRootByHops) { auto g = make_graph(4, {{0, 3, 1, 2}, {1, 2, 1, 1}, {2, 3, 1, 1}}); const auto inv = id_of_input(g); auto r = run(g, {0, 1}, Direction::Forward, std::vector {3}, sel(false)); - ASSERT_TRUE(r.path.has_value()); - EXPECT_EQ(r.path->walk, (std::vector{inv[0]})); + EXPECT_EQ(edges_of(r.dag), (std::vector{inv[0]})); } TEST(Search, StagedPathReview13CanonicalWitnesses) { @@ -327,22 +374,445 @@ TEST(Search, StagedPathReview13CanonicalWitnesses) { // (all capacities 1: priority ties), both directions. auto c2 = make_graph(4, {{0, 1, 1, 9}, {0, 2, 1, 1}, {1, 3, 1, 1}, {2, 3, 1, 9}}); auto three = make_graph(6, {{0, 2, 1, 1}, {2, 3, 1, 1}, {3, 5, 1, 1}, {0, 1, 1, 1}, {1, 4, 1, 1}, {4, 5, 1, 1}}); - auto nodes_of = [](const Graph& g, const std::vector& walk) { - std::vector out = {g.edge_src()[static_cast(walk.front())]}; - for (EdgeId e : walk) out.push_back(g.edge_dst()[static_cast(e)]); + auto nodes_of = [](const Graph& g, const PredDAG& dag) { + std::set out; + for (EdgeId e : edges_of(dag)) { + out.insert(g.edge_src()[static_cast(e)]); + out.insert(g.edge_dst()[static_cast(e)]); + } return out; }; for (auto tie : {EdgeTieBreak::Deterministic, EdgeTieBreak::PreferHigherPriority}) { for (auto prio : {EdgePriority::Residual, EdgePriority::Capacity, EdgePriority::Headroom}) { const auto s = sel(false, true, tie, prio); auto a = run(c2, {0}, Direction::Forward, std::vector {3}, s); - EXPECT_EQ(nodes_of(c2, a.path->walk), (std::vector{0, 2, 3})); + EXPECT_EQ(nodes_of(c2, a.dag), (std::set{0, 2, 3})); auto ar = run(c2, {3}, Direction::Reverse, std::vector {0}, s); - EXPECT_EQ(ar.path->walk, a.path->walk); + EXPECT_EQ(edges_of(ar.dag), edges_of(a.dag)); auto b = run(three, {0}, Direction::Forward, std::vector {5}, s); - EXPECT_EQ(nodes_of(three, b.path->walk), (std::vector{0, 1, 4, 5})); + EXPECT_EQ(nodes_of(three, b.dag), (std::set{0, 1, 4, 5})); auto br = run(three, {5}, Direction::Reverse, std::vector {0}, s); - EXPECT_EQ(br.path->walk, b.path->walk); + EXPECT_EQ(edges_of(br.dag), edges_of(b.dag)); + } + } +} + +namespace { + +// One random query against the reference (search_reference.hpp), on both queues. +struct RandomCase { + Graph g; + std::vector residual; + bool use_res; + Amount min_residual; + std::optional elig; + PathSelection selection; + Direction direction; + std::vector roots; + std::optional> targets; +}; + +Graph random_graph(std::mt19937& rng, int n, int m, Cost max_cost, Cost scale = 1) { + std::vector edges; + for (int i = 0; i < m; ++i) { + edges.push_back({static_cast(rng() % static_cast(n)), static_cast(rng() % static_cast(n)), + static_cast(rng() % 5), scale * static_cast(1 + rng() % static_cast(max_cost))}); + } + return make_graph(n, edges); +} + +// The expected result of a query by the reference: costs, the DAG's edges, target_cost. +struct Expected { + std::optional> costs; + std::vector edges; + std::optional target_cost; +}; + +Expected expect_of(const RandomCase& c) { + const Graph& g = c.g; + ref::Query q; + q.g = &g; + q.elig = c.elig ? &*c.elig : nullptr; + q.residual = c.use_res ? &c.residual : nullptr; + q.min_residual = c.min_residual; + q.multi_edge = c.selection.multi_edge; + q.prefer = c.selection.tie_break == EdgeTieBreak::PreferHigherPriority; + q.priority = c.selection.priority; + const auto mem = ref::members(q); + auto live = [&](const std::vector& set) { + std::vector s(set.begin(), set.end()); + std::sort(s.begin(), s.end()); + s.erase(std::unique(s.begin(), s.end()), s.end()); + std::vector out; + for (NodeId v : s) { + if (q.elig == nullptr || q.elig->node_ok(v)) out.push_back(v); + } + return out; + }; + const auto roots = live(c.roots); + const bool reverse = c.direction == Direction::Reverse; + Expected out; + if (!c.targets.has_value()) { + auto d = ref::distances(g, mem, roots, reverse); + const auto dag = ref::full_dag(g, mem, d, reverse); + out.costs = d; + out.edges.assign(dag.begin(), dag.end()); + return out; + } + const auto targets = live(*c.targets); + out.target_cost = kUnreachable; + if (roots.empty() || targets.empty()) return out; + const auto& starts = reverse ? targets : roots; + const auto& ends = reverse ? roots : targets; + auto [dstar, edges] = ref::pruned(g, mem, starts, ends); + if (dstar == ref::kNone) return out; + out.target_cost = dstar; + if (c.selection.multipath) { + out.edges.assign(edges.begin(), edges.end()); + } else { + auto walk = ref::canonical(q, mem, starts, ends); + out.edges = sorted(*walk); + } + return out; +} + +} // namespace + +TEST(Search, RandomQueriesAgreeWithTheReferenceOnBothQueues) { + std::mt19937 rng(20261006); + int checked = 0; + int single = 0; + for (int trial = 0; trial < 3000; ++trial) { + const int n = 2 + static_cast(rng() % 7); + const int m = static_cast(rng() % 16); + const Cost max_cost = (trial % 5 == 0) ? 40 : 4; + const Cost scale = (trial % 7 == 0) ? 3 : 1; // a common gcd above 1 + RandomCase c {random_graph(rng, n, m, max_cost, scale), {}, (rng() % 2) == 0, 1 + static_cast(rng() % 3), + std::nullopt, PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity), + (rng() % 2) == 0 ? Direction::Forward : Direction::Reverse, {}, std::nullopt}; + c.residual.resize(static_cast(c.g.num_edges())); + for (std::size_t e = 0; e < c.residual.size(); ++e) { + const Amount cap = c.g.capacity_units()[e]; + c.residual[e] = cap == 0 ? 0 : static_cast(rng() % static_cast(cap + 1)); + } + if (rng() % 3 == 0) { + std::vector xn; + std::vector xe; + if (rng() % 2 == 0) xn.push_back(static_cast(rng() % static_cast(n))); + if (c.g.num_edges() > 0 && rng() % 2 == 0) xe.push_back(static_cast(rng() % static_cast(c.g.num_edges()))); + c.elig = c.g.eligibility(xn, xe); + } + const EdgePriority prios[3] = {EdgePriority::Residual, EdgePriority::Capacity, EdgePriority::Headroom}; + const bool with_targets = (rng() % 3) != 0; + c.selection = PathSelection((rng() % 2) == 0 ? EdgeTieBreak::Deterministic : EdgeTieBreak::PreferHigherPriority, + prios[rng() % 3], !with_targets || (rng() % 2) == 0, (rng() % 2) == 0); + // Disjoint random root and target sets (duplicates allowed in the input). + std::vector perm(static_cast(n)); + std::iota(perm.begin(), perm.end(), 0); + std::shuffle(perm.begin(), perm.end(), rng); + const std::size_t nr = 1 + rng() % static_cast(std::max(1, n / 2)); + c.roots.assign(perm.begin(), perm.begin() + static_cast(nr)); + if (rng() % 4 == 0) c.roots.push_back(c.roots.front()); + if (with_targets) { + const std::size_t nt = 1 + rng() % static_cast(std::max(1, perm.size() - nr)); + c.targets = std::vector(perm.begin() + static_cast(nr), + perm.begin() + static_cast(std::min(perm.size(), nr + nt))); + if (c.targets->empty()) c.targets = std::nullopt; + } + if (!c.targets.has_value() && !c.selection.multipath) { + c.selection = PathSelection(c.selection.tie_break, c.selection.priority, true, c.selection.multi_edge); + } + const auto want = expect_of(c); + for (auto queue : {SpfQueue::Auto, SpfQueue::Heap}) { + auto got = run(c.g, c.roots, c.direction, c.targets, c.selection, c.use_res ? &c.residual : nullptr, c.min_residual, + c.elig ? &*c.elig : nullptr, queue); + ASSERT_EQ(got.costs, want.costs) << "trial " << trial; + ASSERT_EQ(edges_of(got.dag), want.edges) << "trial " << trial << " queue " << static_cast(queue); + ASSERT_EQ(got.target_cost, want.target_cost) << "trial " << trial; + expect_canonical(c.g, got.dag); + } + ++checked; + if (c.targets.has_value() && !c.selection.multipath && want.target_cost != kUnreachable) ++single; + } + EXPECT_EQ(checked, 3000); + EXPECT_GT(single, 300); +} + +TEST(Search, DirectionsWithExchangedEndpointsDescribeTheSameWalks) { + // A forward search from S with targets T and a reverse search rooted at T with targets S give + // the same pruned DAG, target cost and single path, for every selection. + std::mt19937 rng(7); + for (int trial = 0; trial < 500; ++trial) { + const int n = 4 + static_cast(rng() % 8); + auto g = random_graph(rng, n, 4 + static_cast(rng() % 20), 3); + std::vector perm(static_cast(n)); + std::iota(perm.begin(), perm.end(), 0); + std::shuffle(perm.begin(), perm.end(), rng); + std::vector s(perm.begin(), perm.begin() + 1 + static_cast(rng() % 2)); + std::vector t(perm.end() - 1 - static_cast(rng() % 2), perm.end()); + for (auto tie : {EdgeTieBreak::Deterministic, EdgeTieBreak::PreferHigherPriority}) { + for (bool mp : {true, false}) { + for (bool me : {true, false}) { + const auto s1 = sel(mp, me, tie, EdgePriority::Capacity); + auto f = run(g, s, Direction::Forward, t, s1); + auto r = run(g, t, Direction::Reverse, s, s1); + ASSERT_EQ(f.target_cost, r.target_cost) << trial; + ASSERT_EQ(edges_of(f.dag), edges_of(r.dag)) << trial; + } + } + } + } +} + +TEST(Search, TieModesShareTheDagOfAFixedMemberGraph) { + // Under Expand the member graph does not depend on the tie mode or the priority, so neither + // does the DAG; under Collapse the member may differ (and with it the DAG). + std::mt19937 rng(11); + for (int trial = 0; trial < 300; ++trial) { + auto g = random_graph(rng, 6, 14, 2); + std::optional> t; + if (trial % 2 == 0) t = std::vector {5}; + const auto base = edges_of(run(g, {0}, Direction::Forward, t, sel()).dag); + for (auto tie : {EdgeTieBreak::Deterministic, EdgeTieBreak::PreferHigherPriority}) { + for (auto prio : {EdgePriority::Residual, EdgePriority::Capacity, EdgePriority::Headroom}) { + EXPECT_EQ(edges_of(run(g, {0}, Direction::Forward, t, sel(true, true, tie, prio)).dag), base); + } + } + } +} + +TEST(Search, QueueDispatchAtTheWidthLimit) { +#if NETGRAPH_CORE_PRIVATE_HOOKS + auto& counts = detail::thread_queue_counts(); + auto queue_of = [&](const Graph& g, SpfQueue q) { + const auto before = counts; + auto r = run(g, {0}, Direction::Forward, std::nullopt, sel(), nullptr, 1, nullptr, q); + EXPECT_EQ(*r.costs, (std::vector{0, g.cost()[0], kUnreachable})); + return counts.buckets > before.buckets ? std::string("buckets") : std::string("heap"); + }; + // W = max_cost / gcd + 1: 65,535 / 1 + 1 = 65,536 buckets (the limit) and 65,536 + 1 above it; + // a large common gcd makes the width 2. + auto at = make_graph(3, {{0, 1, 1, 65535}, {1, 0, 1, 1}}); + auto above = make_graph(3, {{0, 1, 1, 65536}, {1, 0, 1, 1}}); + const Cost big = Cost {1} << 40; + auto gcd = make_graph(3, {{0, 1, 1, big}, {1, 0, 1, 2 * big}}); + EXPECT_EQ(detail::bucket_width(at), 65536); + EXPECT_EQ(detail::bucket_width(above), 65537); + EXPECT_EQ(detail::bucket_width(gcd), 3); + EXPECT_EQ(queue_of(at, SpfQueue::Auto), "buckets"); + EXPECT_EQ(queue_of(above, SpfQueue::Auto), "heap"); + EXPECT_EQ(queue_of(gcd, SpfQueue::Auto), "buckets"); + EXPECT_EQ(queue_of(at, SpfQueue::Heap), "heap"); + EXPECT_EQ(queue_of(gcd, SpfQueue::Heap), "heap"); + // A graph without edges has width 1: buckets. + auto bare = make_graph(2, {}); + EXPECT_EQ(detail::bucket_width(bare), 1); +#else + GTEST_SKIP() << "the queue counters are a private hook"; +#endif +} + +namespace { + +// A textbook Dijkstra over the candidate edges (the test's own, for large graphs where +// Bellman-Ford is too slow): the costs and the tight-edge set of a full forward search. +std::pair, std::vector> dijkstra(const Graph& g, const std::vector& roots) { + std::vector> out(static_cast(g.num_nodes())); + for (std::size_t e = 0; e < static_cast(g.num_edges()); ++e) { + if (g.capacity_units()[e] >= 1) out[static_cast(g.edge_src()[e])].push_back(e); + } + std::vector d(static_cast(g.num_nodes()), kUnreachable); + std::priority_queue, std::vector>, std::greater<>> pq; + for (NodeId r : roots) { + d[static_cast(r)] = 0; + pq.emplace(0, r); + } + while (!pq.empty()) { + auto [du, u] = pq.top(); + pq.pop(); + if (du != d[static_cast(u)]) continue; + for (std::size_t e : out[static_cast(u)]) { + const auto v = static_cast(g.edge_dst()[e]); + if (du + g.cost()[e] < d[v]) { + d[v] = du + g.cost()[e]; + pq.emplace(d[v], static_cast(v)); + } + } + } + std::vector tight; + for (std::size_t e = 0; e < static_cast(g.num_edges()); ++e) { + const Cost du = d[static_cast(g.edge_src()[e])]; + const Cost dv = d[static_cast(g.edge_dst()[e])]; + if (g.capacity_units()[e] >= 1 && du != kUnreachable && dv != kUnreachable && du + g.cost()[e] == dv) { + tight.push_back(static_cast(e)); + } + } + return {d, tight}; +} + +} // namespace + +TEST(Search, BucketWraparoundRingsAndChains) { + // Small widths with distances many times the width: the cyclic buckets wrap around repeatedly; + // rings and chains keep a frontier of one item (the inline item and its promotion), and chords + // promote it. Every result equals the heap's and the textbook Dijkstra's. + std::mt19937 rng(5); + for (int shape = 0; shape < 4; ++shape) { + const int n = 400 + 37 * shape; + std::vector edges; + for (int v = 0; v < n; ++v) { + edges.push_back({v, (v + 1) % n, 1, 1 + static_cast(rng() % 3)}); // the ring + if (shape >= 1 && rng() % 5 == 0) edges.push_back({v, (v + 7) % n, 1, 3}); // chords + if (shape >= 2 && rng() % 3 == 0) edges.push_back({v, (v + 2) % n, 1, 2}); + if (shape == 3 && rng() % 4 == 0) edges.push_back({v, (v + 1) % n, 1, 1}); // parallel + } + auto g = make_graph(n, edges); + EXPECT_LE(detail::bucket_width(g), 4); + const auto [d, tight] = dijkstra(g, {0}); + for (auto q : {SpfQueue::Auto, SpfQueue::Heap}) { + auto r = run(g, {0}, Direction::Forward, std::nullopt, sel(), nullptr, 1, nullptr, q); + EXPECT_EQ(*r.costs, d) << "shape " << shape; + EXPECT_EQ(edges_of(r.dag), tight) << "shape " << shape; + } + } +} + +TEST(Search, EarlyStopsLeaveNothingForTheNextQuery) { + // A target search stops with pending entries in the queue and touched tentative nodes; the next + // searches on this thread (other graphs, sizes, directions and queues) must see a clean + // workspace. Alternating graph sizes also exercise the arrays' growth and shrink. + std::mt19937 rng(3); + auto big = random_graph(rng, 3000, 24000, 30); + auto small = random_graph(rng, 9, 30, 3); + auto huge = random_graph(rng, 20000, 40000, 5); + const auto [d_big, t_big] = dijkstra(big, {0}); + const auto [d_small, t_small] = dijkstra(small, {1}); + for (int round = 0; round < 6; ++round) { + for (auto q : {SpfQueue::Auto, SpfQueue::Heap}) { + auto stop = run(big, {0}, Direction::Forward, std::vector {static_cast(1 + round)}, sel(), nullptr, 1, + nullptr, q); + EXPECT_EQ(stop.target_cost, d_big[static_cast(1 + round)]); + auto s = run(small, {1}, Direction::Forward, std::nullopt, sel(), nullptr, 1, nullptr, q); + EXPECT_EQ(*s.costs, d_small); + EXPECT_EQ(edges_of(s.dag), t_small); + if (round % 2 == 0) (void)run(huge, {5}, Direction::Reverse, std::vector {7}, sel(false), nullptr, 1, nullptr, q); + auto b = run(big, {0}, Direction::Forward, std::nullopt, sel(), nullptr, 1, nullptr, q); + EXPECT_EQ(*b.costs, d_big); + EXPECT_EQ(edges_of(b.dag), t_big); + } + } +} + +TEST(Search, LargeGraphsBothQueuesIdentical) { + // Every SpfResult field identical on both queues, for full and target searches in both + // directions, on graphs where Auto takes the buckets. + std::mt19937 rng(17); + for (int shape = 0; shape < 3; ++shape) { + auto g = random_graph(rng, 2000, 16000, shape == 2 ? 1 : 31); + ASSERT_TRUE(detail::uses_buckets(g, SpfQueue::Auto)); + for (int k = 0; k < 6; ++k) { + const auto s = sel(k % 2 == 0, k % 3 != 0, k % 2 == 0 ? EdgeTieBreak::Deterministic : EdgeTieBreak::PreferHigherPriority, + EdgePriority::Capacity); + const Direction dir = k % 2 == 0 ? Direction::Forward : Direction::Reverse; + std::optional> t; + if (k >= 2) t = std::vector {static_cast(100 + k), static_cast(900 + k)}; + if (!t.has_value() && !s.multipath) continue; + auto a = run(g, {static_cast(k)}, dir, t, s, nullptr, 1, nullptr, SpfQueue::Auto); + auto h = run(g, {static_cast(k)}, dir, t, s, nullptr, 1, nullptr, SpfQueue::Heap); + EXPECT_EQ(a.costs, h.costs); + EXPECT_EQ(a.target_cost, h.target_cost); + EXPECT_TRUE(a.dag == h.dag); + } + } +} + +TEST(Search, InternalEntriesMatchThePublicSearch) { + // route_search (CapacityAware), support_route (CostOnly on the cached support) and + // static_costs (rosters) against spf on the same questions. + std::mt19937 rng(23); + for (int trial = 0; trial < 400; ++trial) { + const int n = 3 + static_cast(rng() % 8); + auto g = random_graph(rng, n, 3 + static_cast(rng() % 22), 3); + const NodeId src = 0; + std::vector dests = {static_cast(n - 1)}; + if (n > 4 && rng() % 2 == 0) dests.insert(dests.begin(), static_cast(n - 2)); + std::vector residual(static_cast(g.num_edges())); + for (std::size_t e = 0; e < residual.size(); ++e) { + const Amount cap = g.capacity_units()[e]; + residual[e] = cap == 0 ? 0 : static_cast(rng() % static_cast(cap + 1)); + } + for (bool mp : {true, false}) { + for (bool me : {true, false}) { + for (auto tie : {EdgeTieBreak::Deterministic, EdgeTieBreak::PreferHigherPriority}) { + const auto s = sel(mp, me, tie, EdgePriority::Residual); + // CapacityAware: gated at 2 units on the residual. + auto want = run(g, {src}, Direction::Forward, dests, s, &residual, 2); + auto got = detail::route_search(g, detail::Gate {nullptr, residual, 2}, s, src, dests); + if (want.target_cost == kUnreachable) { + EXPECT_FALSE(got.has_value()); + } else { + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(got->cost, *want.target_cost); + EXPECT_EQ(got->edges, edges_of(want.dag)); + } + // CostOnly: the static support toward dests projected with static priorities equals the + // static search from src (no residual: every priority reads capacities). + const auto support = detail::static_support(g, dests, Direction::Reverse, nullptr); + const detail::PriorityValues values {&g, EdgePriority::Residual, {}}; + auto proj = detail::support_route(g, detail::PredDAGBuilder::ascending(support), src, dests, s, + detail::PriorityFn::of(values)); + auto stat = run(g, {src}, Direction::Forward, dests, s); + if (stat.target_cost == kUnreachable) { + EXPECT_TRUE(proj.edges.empty()); + } else { + EXPECT_EQ(proj.edges, edges_of(stat.dag)) << trial; + EXPECT_EQ(proj.cost, *stat.target_cost); + } + } + } + } + const auto costs = detail::static_costs(g, nullptr, dests, std::vector {0, 1}); + auto rev = run(g, dests, Direction::Reverse, std::nullopt, sel()); + EXPECT_EQ(costs, (std::vector{(*rev.costs)[0], (*rev.costs)[1]})); + } +} + +TEST(Search, ReverseSupportsOrderEveryTailsEdgesAtAnyDegree) { + // Task 1d-msd: a reverse full search emits each tail's tight out-edges in ascending EdgeId, by one + // bit per out-edge id up to 64 out-edges and by sorting above: hubs with 63, 64, 65 and 129 + // out-edges, tight (cost 1, parallel copies, ties) and not (cost 2 or no capacity), against the + // reference's full DAG, with and without an Eligibility. + // Ids rank a node's costlier edges last, so the all-tight variant makes the last ids tight too. + std::mt19937 rng(64); + for (const auto& [degree, all_tight] : std::vector> { + {1, false}, {63, false}, {64, false}, {65, false}, {129, false}, {64, true}, {65, true}, {66, true}}) { + const NodeId root = 0; + const NodeId hub = 1; + const int spokes = 40; // nodes 2 .. 41, each one hop from the root + std::vector edges; + for (int k = 0; k < degree; ++k) { + const auto to = static_cast(2 + rng() % spokes); + const Cost cost = !all_tight && rng() % 4 == 0 ? 2 : 1; + edges.push_back({hub, to, static_cast(!all_tight && rng() % 5 == 0 ? 0 : 3), cost}); + } + for (NodeId s = 2; s < 2 + spokes; ++s) edges.push_back({s, root, 4, 1}); + const Graph g = make_graph(2 + spokes, edges); + ASSERT_EQ(g.row_offsets()[2] - g.row_offsets()[1], degree); + for (bool with_elig : {false, true}) { + std::optional elig; + if (with_elig) elig = g.eligibility(std::vector {5}, std::vector {g.row_offsets()[1]}); + ref::Query q; + q.g = &g; + q.elig = elig ? &*elig : nullptr; + const auto mem = ref::members(q); + const auto d = ref::distances(g, mem, {root}, true); + const auto want = ref::full_dag(g, mem, d, true); + const auto got = detail::static_support(g, std::vector {root}, Direction::Reverse, q.elig); + const auto asc = detail::PredDAGBuilder::ascending(got); + EXPECT_EQ(std::vector(asc.begin(), asc.end()), std::vector(want.begin(), want.end())) + << "degree " << degree << " all tight " << all_tight << " elig " << with_elig; + if (all_tight && !with_elig) EXPECT_EQ(asc.size(), static_cast(degree + spokes)); + EXPECT_TRUE(std::is_sorted(asc.begin(), asc.end())); } } } diff --git a/tests/py/test_apportionment.py b/tests/py/test_apportionment.py new file mode 100644 index 0000000..8321550 --- /dev/null +++ b/tests/py/test_apportionment.py @@ -0,0 +1,23 @@ +"""The apportionment through the private hooks (``_round_robin``, ``_highest_averages``, +APPROACH 2.1).""" + +from __future__ import annotations + +import _netgraph_core as ext +import pytest +from private_hooks import needs_hooks + + +@needs_hooks +def test_apportionment_entries(): + assert ext._round_robin(7, 3) == [3, 2, 2] + assert ext._highest_averages(10, [3, 9, 9]) == [1, 5, 4] + assert ext._highest_averages(11, [3, 9, 9]) == [1, 5, 5] + assert ext._highest_averages(4, [1, 3, 3]) == [0, 2, 2] + big = (1 << 62) - 2 + a = ext._highest_averages(big, [(1 << 60) + 1, (1 << 60) - 1, 7]) + assert sum(a) == big + with pytest.raises(ValueError, match="outside"): + ext._highest_averages(1, [0, 1]) + with pytest.raises(ValueError, match="2\\^62"): + ext._highest_averages(1, [1 << 61, 1 << 61]) diff --git a/tests/py/test_dag_cache.py b/tests/py/test_dag_cache.py new file mode 100644 index 0000000..808b8e2 --- /dev/null +++ b/tests/py/test_dag_cache.py @@ -0,0 +1,357 @@ +"""The DagCache's modes through the binding (APPROACH 10, 11): the transition table, DagCacheMiss +as a dedicated exception (not a RuntimeError, so never a sensitivity status), the evaluators' +sharing rules (a frozen cache shared by every worker, a building one rejected with two or more), +the reuse of prepopulated Eligibility uids, frozen lookups from several Python threads, and a +frozen cache's statistics under failed calls (review-1d F1, NOTES R27).""" + +from __future__ import annotations + +import threading + +import _netgraph_core as ext +import pytest +from private_hooks import needs_hooks + +import netgraph_core as ngc + + +def fabric(): + """Leaves 0..5 and spines 6..8, every leaf joined to every spine both ways (capacity 4, + cost 1).""" + rows = [(leaf, s) for leaf in range(6) for s in range(6, 9)] + rows += [(s, leaf) for leaf, s in rows] + return ngc.Graph( + 9, + [r[0] for r in rows], + [r[1] for r in rows], + [4] * len(rows), + [1] * len(rows), + 1.0, + ) + + +def costonly(): + return ngc.FlowPolicyConfig( + routing=ngc.CostOnly(), + placement=ngc.FlowPlacement.EQUAL_BALANCED, + admission=ngc.Admission.LOSSLESS, + selection=ngc.PathSelection( + ngc.EdgeTieBreak.DETERMINISTIC, ngc.EdgePriority.CAPACITY + ), + ) + + +def prepopulate(g, keys, elig=None): + """A building cache holding the CostOnly support of each destination set (a capacity call + looks up the destinations' reverse support; the source is spine 6), frozen afterwards.""" + cache = ngc.DagCache(g) + for dst in keys: + ngc.capacity(g, 6, dst, ngc.CapacityOptions(costonly(), elig=elig, cache=cache)) + cache.freeze() + return cache + + +def stats(cache): + s = cache.stats() + return (s.hits, s.misses, s.entries) + + +def test_transition_table(): + g = fabric() + cache = ngc.DagCache(g) + opts = ngc.CapacityOptions(costonly(), cache=cache) + want = ngc.capacity(g, 0, 3, ngc.CapacityOptions(costonly())).placed + assert ngc.capacity(g, 0, 3, opts).placed == want + assert stats(cache) == (0, 1, 1) + cache.freeze() + cache.freeze() # no effect + assert ngc.capacity(g, 1, 3, opts).placed == want # a hit + assert stats(cache) == (1, 1, 1) + with pytest.raises( + ngc.DagCacheMiss, match=r"no entry for roots \{4\}, direction Reverse" + ): + ngc.capacity(g, 0, 4, opts) + assert stats(cache) == (1, 2, 1) # one miss, nothing inserted + with pytest.raises(ValueError, match="rejected while the cache is frozen"): + cache.clear() + assert stats(cache) == (1, 2, 1) + cache.thaw() + cache.thaw() # no effect + assert ( + ngc.capacity(g, 0, 4, opts).placed == want + ) # building again: searches, inserts + assert stats(cache) == (1, 3, 2) + cache.clear() + assert stats(cache) == (1, 3, 0) + + +def test_miss_is_a_dedicated_exception(): + assert issubclass(ngc.DagCacheMiss, Exception) + assert not issubclass(ngc.DagCacheMiss, (RuntimeError, ValueError, TypeError)) + assert ngc.DagCacheMiss.__doc__ + + +def test_frozen_cache_shared_by_every_worker(): + g = fabric() + pairs = [(0, 3), (1, 4), (2, 5), ([0, 1], [3, 4])] + want = [ + r.placed for r in ngc.batch_capacity(g, pairs, ngc.CapacityOptions(costonly())) + ] + building = ngc.DagCache(g) + with pytest.raises(ValueError, match="workers == 1"): + ngc.batch_capacity( + g, pairs, ngc.CapacityOptions(costonly(), cache=building), workers=2 + ) + cache = prepopulate(g, [[3], [4], [5], [3, 4]]) + before = stats(cache) + for workers in (1, 2, 4): + got = ngc.batch_capacity( + g, pairs, ngc.CapacityOptions(costonly(), cache=cache), workers=workers + ) + assert [r.placed for r in got] == want + assert stats(cache) == (before[0] + 12, before[1], 4) + # The lowest failing pair index's miss: pairs 0 and 2 miss, pair 0's key is reported. + partial = prepopulate(g, [[4]]) + for workers in (1, 2, 4): + with pytest.raises(ngc.DagCacheMiss, match=r"roots \{3\}"): + ngc.batch_capacity( + g, + pairs[:3], + ngc.CapacityOptions(costonly(), cache=partial), + workers=workers, + ) + + +def test_prepopulated_uids_and_intersections(): + g = fabric() + common = g.eligibility(excluded_edges=[0]) + narrow = g.eligibility(excluded_edges=[0, 1]) # contained in common + other = g.eligibility(excluded_edges=[1]) # the intersection with common is new + pairs = [(1, 4), (2, 5)] + cache = ngc.DagCache(g) + ngc.capacity(g, 1, 4, ngc.CapacityOptions(costonly(), elig=narrow, cache=cache)) + ngc.capacity(g, 2, 5, ngc.CapacityOptions(costonly(), elig=common, cache=cache)) + cache.freeze() + opts = ngc.CapacityOptions(costonly(), elig=common, cache=cache) + ngc.batch_capacity(g, pairs, opts, [narrow, None], workers=2) # returns the operand + ngc.batch_capacity( + g, + pairs, + ngc.CapacityOptions(costonly(), cache=cache), + [narrow, common], + workers=2, + ) + assert stats(cache) == (4, 2, 2) + with pytest.raises(ngc.DagCacheMiss, match=r"roots \{4\}"): + ngc.batch_capacity(g, pairs, opts, [other, None], workers=2) + + +def test_sensitivity_propagates_the_miss_and_global_reads_no_cache(): + g = fabric() + cache = prepopulate(g, [[4]]) + opts = ngc.SensitivityOptions(costonly(), cache=cache) + for workers in (1, 2, 4): + with pytest.raises(ngc.DagCacheMiss, match=r"roots \{4\}"): + ngc.sensitivity(g, 1, 4, opts, workers) + glob = ngc.FlowPolicyConfig( + routing=ngc.Global(), + placement=ngc.FlowPlacement.MAX_FLOW, + admission=ngc.Admission.LOSSLESS, + selection=ngc.PathSelection( + ngc.EdgeTieBreak.DETERMINISTIC, ngc.EdgePriority.CAPACITY + ), + ) + want = ngc.sensitivity(g, [0, 1], [4, 5], ngc.SensitivityOptions(glob)) + empty = ngc.DagCache(g) + empty.freeze() + for workers in (1, 2, 4): + got = ngc.sensitivity( + g, [0, 1], [4, 5], ngc.SensitivityOptions(glob, cache=empty), workers + ) + assert got.baseline.placed == want.baseline.placed + assert [(e.edge, e.loss) for e in got.entries] == [ + (e.edge, e.loss) for e in want.entries + ] + assert stats(empty) == (0, 0, 0) + + +def test_frozen_lookups_from_python_threads(): + g = fabric() + cache = prepopulate(g, [[d] for d in range(6)]) + want = { + d: ngc.capacity(g, 6, d, ngc.CapacityOptions(costonly())).placed + for d in range(6) + } + errors = [] + + def reader(t): + try: + for i in range(60): + d = (i + t) % 6 + r = ngc.capacity(g, 6, d, ngc.CapacityOptions(costonly(), cache=cache)) + assert r.placed == want[d] + except Exception as exc: # noqa: BLE001 - reported below + errors.append(exc) + + before = stats(cache) + threads = [threading.Thread(target=reader, args=(t,)) for t in range(4)] + for t in threads: + t.start() + for t in threads: + t.join() + assert errors == [] + assert stats(cache) == (before[0] + 240, before[1], 6) + + +def all_stats(cache): + s = cache.stats() + return (s.hits, s.misses, s.entries, s.bytes, s.live_values) + + +def plus(stats, hits=0, misses=0): + return (stats[0] + hits, stats[1] + misses, *stats[2:]) + + +def test_a_failed_call_changes_no_statistic_but_its_miss(): + # A public call publishes a frozen cache's hits only when it succeeds: a batch whose pair 0 + # hits and pair 1 misses raises the miss, keeps no hit and counts the miss once, with one + # worker and with several; CostOnly sensitivity drops its baseline's hit the same way. + g = fabric() + cache = prepopulate(g, [[3]]) + opts = ngc.CapacityOptions(costonly(), cache=cache) + for workers in (1, 2, 4): + before = all_stats(cache) + with pytest.raises(ngc.DagCacheMiss, match=r"roots \{4\}"): + ngc.batch_capacity( + g, [(0, 3), (1, 4), (2, 5), (0, 3)], opts, workers=workers + ) + assert all_stats(cache) == plus(before, misses=1) + before = all_stats(cache) + with pytest.raises(ngc.DagCacheMiss, match=r"roots \{3\}"): + ngc.sensitivity( + g, 0, 3, ngc.SensitivityOptions(costonly(), cache=cache), workers + ) + assert all_stats(cache) == plus(before, misses=1) + before = all_stats(cache) + assert ngc.batch_capacity(g, [(0, 3), (1, 3)], opts, workers=2) + assert all_stats(cache) == plus(before, hits=2) + + +def test_concurrent_calls_keep_every_successful_hit(): + # Python threads (the evaluators release the GIL) on one frozen cache, each alternating a + # successful capacity (one hit) and a failing batch (its hit dropped, one miss). + g = fabric() + cache = prepopulate(g, [[3], [4]]) + opts = ngc.CapacityOptions(costonly(), cache=cache) + errors = [] + + def caller(): + try: + for _ in range(40): + assert ngc.capacity(g, 0, 3, opts).placed > 0 + with pytest.raises(ngc.DagCacheMiss): + ngc.batch_capacity(g, [(1, 4), (2, 5)], opts, workers=2) + except Exception as exc: # noqa: BLE001 - reported below + errors.append(exc) + + before = all_stats(cache) + threads = [threading.Thread(target=caller) for _ in range(4)] + for t in threads: + t.start() + for t in threads: + t.join() + assert errors == [] + assert all_stats(cache) == plus(before, hits=160, misses=160) + + +def sweep_frozen(cache, fn, args, hits=0, miss=False): + """fn(*args) with each Python object allocation failing in turn: a failed call leaves all + five statistics as they were, the result object's allocation included (the reviewer's + witness: capacity's allocation 8); the call that completes adds exactly `hits` hits, or, when + its outcome is the DagCacheMiss, one miss. One case keeps the miss: when the call has ended + in the miss and pybind11's translation of DagCacheMiss into its Python exception fails + (MemoryError instead), the miss is counted (NOTES R27, APPROACH 13 limit 1); every such + failure comes after every allocation of the call itself. Returns the number of failures.""" + failures = 0 + translated = ( + None # the first allocation index past the call (translation of the miss) + ) + for k in range(1000): + before = all_stats(cache) + hit, _, _, error = ext._fail_python_allocation(fn, args, k) + after = all_stats(cache) + if not hit: + if miss: + assert isinstance(error, ngc.DagCacheMiss), repr(error) + assert after == plus(before, misses=1), (k, before, after) + else: + assert error is None, repr(error) + assert after == plus(before, hits=hits), (k, before, after) + return failures + failures += 1 + assert isinstance(error, (MemoryError, RuntimeError)), (k, repr(error)) + if miss and after == plus(before, misses=1): + assert isinstance(error, MemoryError), (k, repr(error)) + translated = k if translated is None else translated + continue + assert translated is None, ( + k, + translated, + "a failure inside the call after its translation", + ) + assert after == before, (k, before, after) + pytest.fail(f"{fn.__name__} never completed") + + +@needs_hooks +def test_frozen_statistics_survive_python_allocation_failures(): + g = fabric() + cache = prepopulate(g, [[3], [4], [3, 4]]) + opts = ngc.CapacityOptions( + costonly(), + cache=cache, + outputs=ngc.OutputOptions(edge_flows=True, residual=True, bottleneck=True), + ) + pairs = [(0, 3), (1, 4), ([0, 1], [3, 4])] + assert sweep_frozen(cache, ngc.capacity, (g, 0, 3, opts), hits=1) >= 3 + assert sweep_frozen(cache, ngc.capacity, (g, 0, 5, opts), miss=True) >= 1 + for workers in (1, 2): + assert ( + sweep_frozen( + cache, ngc.batch_capacity, (g, pairs, opts, None, workers), hits=3 + ) + >= 3 + ) + assert ( + sweep_frozen( + cache, + ngc.batch_capacity, + (g, [(0, 3), (1, 5)], opts, None, workers), + miss=True, + ) + >= 1 + ) + sens = ngc.SensitivityOptions(costonly(), cache=cache) + assert sweep_frozen(cache, ngc.sensitivity, (g, 0, 3, sens, 1), miss=True) >= 1 + # CostOnly sensitivity from a source that reaches no destination: one hit (the baseline's), + # no placed deletion, and a result with an entry per edge. + island = ngc.Graph(3, [0, 1], [1, 0], [4, 4], [1, 1], 1.0) + small = ngc.DagCache(island) + ngc.capacity(island, 0, 1, ngc.CapacityOptions(costonly(), cache=small)) + small.freeze() + for workers in (1, 2): + assert ( + sweep_frozen( + small, + ngc.sensitivity, + ( + island, + 2, + 1, + ngc.SensitivityOptions(costonly(), cache=small), + workers, + ), + hits=1, + ) + >= 3 + ) diff --git a/tests/py/test_internal_search.py b/tests/py/test_internal_search.py deleted file mode 100644 index 6685977..0000000 --- a/tests/py/test_internal_search.py +++ /dev/null @@ -1,99 +0,0 @@ -"""The internal search through its private entry (``_spf``, APPROACH 6) and the apportionment -through the private hooks (``_round_robin``, ``_highest_averages``, APPROACH 2.1).""" - -from __future__ import annotations - -import _netgraph_core as ext -import pytest -from private_hooks import needs_hooks - -import netgraph_core as ngc - -DET = ngc.EdgeTieBreak.DETERMINISTIC -PREFER = ngc.EdgeTieBreak.PREFER_HIGHER_PRIORITY -CAP = ngc.EdgePriority.CAPACITY - - -def build(n, edges, q=1.0): - return ngc.Graph( - n, - [e[0] for e in edges], - [e[1] for e in edges], - [e[2] for e in edges], - [e[3] for e in edges], - q, - ) - - -def ids(g, positions): - return sorted(int(e) for e, p in enumerate(g.input_index()) if p in positions) - - -def test_full_search_and_anycast(): - g = build(5, [(0, 1, 1, 1), (1, 2, 1, 1), (0, 3, 1, 2), (3, 2, 1, 5), (1, 4, 1, 5)]) - full = ext._spf(g, 0, "forward", None, ngc.PathSelection(DET, CAP)) - assert full["costs"] == [0, 1, 2, 2, 6] - assert "target_cost" not in full - any_ = ext._spf(g, [0], "forward", [2, 4], ngc.PathSelection(DET, CAP)) - assert any_["target_cost"] == 2 - assert sorted(int(e) for e in any_["dag"].via_edges()) == ids(g, {0, 1}) - rev = ext._spf(g, [2, 4], "reverse", [0], ngc.PathSelection(DET, CAP)) - assert rev["dag"] == any_["dag"] - none = ext._spf(g, 2, "forward", 0, ngc.PathSelection(DET, CAP)) - assert none["target_cost"] is None - assert list(none["dag"].via_edges()) == [] - unreachable = ext._spf(g, 2, "forward", None, ngc.PathSelection(DET, CAP)) - assert unreachable["costs"] == [None, None, 0, None, None] - - -def test_staged_single_path_review13_d3(): - # s -> v (1, prio 1), v -> t (3, prio 1), v -> a -> b -> t (1 + 1 + 1, prio 2): the - # bottleneck of every walk is 1, so the two-hop walk wins under both tie modes. - g = build(5, [(0, 1, 1, 1), (1, 4, 1, 3), (1, 2, 2, 1), (2, 3, 2, 1), (3, 4, 2, 1)]) - for tie in (DET, PREFER): - r = ext._spf(g, 0, "forward", 4, ngc.PathSelection(tie, CAP, multipath=False)) - assert r["path"] == ids(g, {0, 1}) - assert r["target_cost"] == 4 - - -def test_search_validation(): - g = build(3, [(0, 1, 2, 1), (1, 2, 2, 1)]) - sel = ngc.PathSelection(DET, CAP) - with pytest.raises(ValueError, match="requires targets"): - ext._spf(g, 0, "forward", None, ngc.PathSelection(DET, CAP, multipath=False)) - with pytest.raises(ValueError, match="both endpoint sets"): - ext._spf(g, [0, 1], "forward", [1], sel) - with pytest.raises(ValueError, match="empty"): - ext._spf(g, [], "forward", None, sel) - with pytest.raises(ValueError, match="residual has length"): - ext._spf(g, 0, "forward", None, sel, [1]) - with pytest.raises(ValueError, match=r"residual\[1\] = 3"): - ext._spf(g, 0, "forward", None, sel, [1, 3]) - with pytest.raises(ValueError, match="min_residual"): - ext._spf(g, 0, "forward", None, sel, None, 0) - with pytest.raises(ValueError, match="direction"): - ext._spf(g, 0, "sideways", None, sel) - gated = ext._spf(g, 0, "forward", None, sel, [2, 1], 2) - assert gated["costs"] == [0, 1, None] - # Section 6's order (review-1b R15): the endpoint checks precede the residual's. - with pytest.raises(ValueError, match="both endpoint sets"): - ext._spf(g, 0, "forward", 0, sel, []) - with pytest.raises(ValueError, match="requires targets"): - ext._spf( - g, 0, "forward", None, ngc.PathSelection(DET, CAP, multipath=False), [] - ) - - -@needs_hooks -def test_apportionment_entries(): - assert ext._round_robin(7, 3) == [3, 2, 2] - assert ext._highest_averages(10, [3, 9, 9]) == [1, 5, 4] - assert ext._highest_averages(11, [3, 9, 9]) == [1, 5, 5] - assert ext._highest_averages(4, [1, 3, 3]) == [0, 2, 2] - big = (1 << 62) - 2 - a = ext._highest_averages(big, [(1 << 60) + 1, (1 << 60) - 1, 7]) - assert sum(a) == big - with pytest.raises(ValueError, match="outside"): - ext._highest_averages(1, [0, 1]) - with pytest.raises(ValueError, match="2\\^62"): - ext._highest_averages(1, [1 << 61, 1 << 61]) diff --git a/tests/py/test_ksp.py b/tests/py/test_ksp.py new file mode 100644 index 0000000..a67b215 --- /dev/null +++ b/tests/py/test_ksp.py @@ -0,0 +1,198 @@ +"""KSP and resolve_to_paths through the binding (APPROACH 5, 6, rule 10): the order, Expand and +Collapse, the ceilings, k = 1 against the single path of spf, the validation order on +unnarrowed values, and the all-or-nothing construction of the result lists.""" + +from __future__ import annotations + +import _netgraph_core as ext +import pytest +from private_hooks import needs_hooks + +import netgraph_core as ngc + +DET = ngc.EdgeTieBreak.DETERMINISTIC +PREFER = ngc.EdgeTieBreak.PREFER_HIGHER_PRIORITY +CAP = ngc.EdgePriority.CAPACITY + + +def bgraph(n, rows): + """Rows (u, v, capacity, cost) in B' order (src, cost, dst): ids are input positions.""" + rows = sorted(rows, key=lambda r: (r[0], r[3], r[1])) + return ngc.Graph( + n, + [r[0] for r in rows], + [r[1] for r in rows], + [r[2] for r in rows], + [r[3] for r in rows], + 1.0, + ) + + +def ksp(g, s, t, k, *, multi_edge=True, tie=DET, **kw): + sel = ngc.PathSelection(tie, CAP, multi_edge=multi_edge) + return ngc.ksp(g, s, t, ngc.KspOptions(k, sel, **kw)) + + +CHAIN = [(0, 1, 1, 1), (1, 2, 1, 1), (2, 3, 1, 1), (0, 2, 1, 3), (1, 3, 1, 3)] + + +def test_order_and_ceilings(): + g = bgraph(4, CHAIN) + got = ksp(g, 0, 3, 8) + assert [c for c, _ in got] == [3, 4, 4] + assert all(isinstance(p, ngc.Path) and p.cost() == c for c, p in got) + assert got[0][1].hops() == [[0], [2], [4]] + assert [c for c, _ in ksp(g, 0, 3, 8, max_path_cost=ngc.Stretch(1.0))] == [3] + assert [c for c, _ in ksp(g, 0, 3, 8, max_path_cost=ngc.Stretch(1.5))] == [3, 4, 4] + assert [c for c, _ in ksp(g, 0, 3, 8, max_path_cost=ngc.Absolute(3))] == [3] + assert ksp(g, 0, 3, 8, max_path_cost=ngc.Absolute(0)) == [] + assert len(ksp(g, 0, 3, 2)) == 2 + # The Stretch baseline is static: gating the middle edge drops the cost-3 path only. + residual = [1, 1, 0, 1, 1] + assert ksp(g, 0, 3, 8, max_path_cost=ngc.Stretch(1.0), residual=residual) == [] + assert [c for c, _ in ksp(g, 0, 3, 8, residual=residual)] == [4, 4] + excluded = g.eligibility([0], []) + assert ksp(g, 0, 3, 8, elig=excluded) == [] + + +def test_expand_collapse_and_the_single_path(): + pr = bgraph(3, [(0, 1, 1, 1), (0, 1, 1, 1), (1, 2, 1, 1), (1, 2, 1, 1)]) + assert len(ksp(pr, 0, 2, 8)) == 4 + assert len(ksp(pr, 0, 2, 8, multi_edge=False)) == 1 + assert [p.hops() for _c, p in ksp(pr, 0, 2, 3)] == [ + [[0], [2]], + [[0], [3]], + [[1], [2]], + ] + three = bgraph( + 6, + [ + (0, 2, 1, 1), + (2, 3, 1, 1), + (3, 5, 1, 1), + (0, 1, 1, 1), + (1, 4, 1, 1), + (4, 5, 1, 1), + ], + ) + for tie in (DET, PREFER): + first = ksp(three, 0, 5, 1, tie=tie)[0][1] + single = ngc.PathSelection(tie, CAP, multipath=False) + for direction, roots, targets in ( + (ngc.Direction.FORWARD, 0, 5), + (ngc.Direction.REVERSE, 5, 0), + ): + r = ngc.spf( + three, roots, ngc.SpfOptions(direction, single, targets=targets) + ) + assert r.dag == first.to_dag() + + +def test_ksp_validation_order(): + g = bgraph(3, [(0, 1, 1, 1), (1, 2, 1, 1)]) + other = bgraph(3, [(0, 1, 1, 1)]) + with pytest.raises(ValueError, match=r"^src: node 3 out of node range \[0, 3\)$"): + ksp(g, 3, 1, 1) + with pytest.raises(ValueError, match=r"^dst: node -1 out of node range"): + ksp(g, 0, -1, 1) + with pytest.raises(ValueError, match=r"^dst: node 1180591620717411303424 out of"): + ksp(g, 0, 2**70, 1) + with pytest.raises( + ValueError, match="^node 1 is in both endpoint sets: src and dst" + ): + ksp(g, 1, 1, 1) + with pytest.raises(ValueError, match=r"^k must be >= 1 \(got 0\)$"): + ksp(g, 0, 2, 0) + with pytest.raises(ValueError, match="k must be an integer"): + ksp(g, 0, 2, 1.5) + with pytest.raises(ValueError, match="^max_path_cost Absolute cost must be >= 0"): + ksp(g, 0, 2, 1, max_path_cost=ngc.Absolute(-1)) + with pytest.raises(ValueError, match="^max_path_cost Stretch factor"): + ksp(g, 0, 2, 1, max_path_cost=ngc.Stretch(0.5)) + with pytest.raises(ValueError, match="^max_path_cost Stretch factor"): + ksp(g, 0, 2, 1, max_path_cost=ngc.Stretch(float("nan"))) + with pytest.raises(TypeError, match="max_path_cost must be Absolute, Stretch"): + ksp(g, 0, 2, 1, max_path_cost=3) + with pytest.raises(ValueError, match=r"^residual\[1\] = 2 is outside"): + ksp(g, 0, 2, 1, residual=[1, 2]) + with pytest.raises(ValueError, match="^foreign Eligibility"): + ksp(g, 0, 2, 1, elig=other.eligibility([], [0])) + # The order: the endpoints, k, the ceiling, the residual. + with pytest.raises(ValueError, match="both endpoint sets"): + ksp(g, 2, 2, 0, max_path_cost=ngc.Absolute(-1), residual=[9]) + with pytest.raises(ValueError, match="^k must be"): + ksp(g, 0, 2, 0, max_path_cost=ngc.Absolute(-1), residual=[9]) + with pytest.raises(ValueError, match="^max_path_cost"): + ksp(g, 0, 2, 1, max_path_cost=ngc.Absolute(-1), residual=[9]) + # A huge k lists every path (no error). + assert len(ksp(g, 0, 2, 2**70)) == 1 + with pytest.raises(TypeError, match="KspOptions expected"): + ngc.ksp(g, 0, 2, ngc.PathSelection(DET, CAP)) + + +def test_resolve_to_paths(): + # 0->1 twice (cost 1), 0->2 (1), 0->3 (5), 1->3 (1), 2->3 (1): ids in this order. + g = bgraph( + 4, + [ + (0, 1, 1, 1), + (0, 1, 1, 1), + (0, 2, 1, 1), + (0, 3, 1, 5), + (1, 3, 1, 1), + (2, 3, 1, 1), + ], + ) + dag = ngc.PredDAG.from_csr( + g, [0, 0, 2, 3, 6], [0, 0, 0, 0, 1, 2], [0, 1, 2, 3, 4, 5] + ) + grouped = ngc.resolve_to_paths(g, dag, 0, 3) + assert [p.hops() for p in grouped] == [[[0, 1], [4]], [[2], [5]], [[3]]] + assert [p.cost() for p in grouped] == [2, 2, 5] + split = ngc.resolve_to_paths(g, dag, 0, 3, split_parallel_edges=True) + assert [p.hops() for p in split] == [[[0], [4]], [[1], [4]], [[2], [5]], [[3]]] + assert len(ngc.resolve_to_paths(g, dag, 0, 3, True, 2)) == 2 + assert ngc.resolve_to_paths(g, dag, 3, 0) == [] + other = bgraph(4, [(0, 1, 1, 1)]) + with pytest.raises(ValueError, match="^foreign PredDAG"): + ngc.resolve_to_paths(other, dag, 0, 3) + with pytest.raises(ValueError, match=r"^src: node 4 out of node range \[0, 4\)$"): + ngc.resolve_to_paths(g, dag, 4, 3) + with pytest.raises( + ValueError, match="^node 2 is in both endpoint sets: src and dst" + ): + ngc.resolve_to_paths(g, dag, 2, 2) + with pytest.raises(ValueError, match=r"^max_paths must be >= 1 \(got 0\)$"): + ngc.resolve_to_paths(g, dag, 0, 3, False, 0) + # A search's DAG resolves the same way from a reverse search. + sel = ngc.PathSelection(DET, CAP) + fw = ngc.spf(g, 0, ngc.SpfOptions(ngc.Direction.FORWARD, sel)).dag + rv = ngc.spf(g, 3, ngc.SpfOptions(ngc.Direction.REVERSE, sel)).dag + assert ngc.resolve_to_paths(g, fw, 0, 3) == ngc.resolve_to_paths(g, rv, 0, 3) + + +@needs_hooks +def test_python_result_lists_are_all_or_nothing(): + g = bgraph(4, CHAIN) + opts = ngc.KspOptions(8, ngc.PathSelection(DET, CAP)) + want = ngc.ksp(g, 0, 3, opts) + dag = ngc.spf( + g, 0, ngc.SpfOptions(ngc.Direction.FORWARD, ngc.PathSelection(DET, CAP)) + ).dag + want_paths = ngc.resolve_to_paths(g, dag, 0, 2, True) + for fn, args, check in ( + (ngc.ksp, (g, 0, 3, opts), lambda r: r == want), + (ngc.resolve_to_paths, (g, dag, 0, 2, True), lambda r: r == want_paths), + ): + failures = 0 + for k in range(200): + hit, _seen, result, error = ext._fail_python_allocation(fn, args, k) + if not hit: + assert check(result) + break + failures += 1 + assert result is None and isinstance(error, (MemoryError, RuntimeError)), ( + error + ) + assert check(fn(*args)) + assert failures >= 3 diff --git a/tests/py/test_public_surface.py b/tests/py/test_public_surface.py index 638b980..45fb68a 100644 --- a/tests/py/test_public_surface.py +++ b/tests/py/test_public_surface.py @@ -1,7 +1,8 @@ """The public surface: the graph on units, Eligibility, the conversions, the route structures, -the selection and placement axes, the ledger, the policy engine, the DagCache and the -evaluators; the extension's private entries, which netgraph_core never re-exports; and the -spellings the interface grooming retired (APPROACH 12), which exist nowhere, without aliases.""" +the selection and placement axes, the ledger, the policy engine, the DagCache, the evaluators +and the public search; the extension's private entry, which netgraph_core never re-exports; +the spellings the interface grooming retired and the retired search entry points (APPROACH 12), +which exist nowhere, without aliases.""" import _netgraph_core import pytest @@ -37,6 +38,7 @@ "FlowPolicy", "MAX_FLOWS_PER_CALL", "DagCache", + "DagCacheMiss", "DagCacheStats", "CapacityOptions", "SensitivityOptions", @@ -46,10 +48,18 @@ "capacity", "batch_capacity", "sensitivity", + "Direction", + "SpfOptions", + "SpfResult", + "UNREACHABLE", + "spf", + "KspOptions", + "ksp", + "resolve_to_paths", } -# Shipped private entries: the flow core's recorder entry (APPROACH 2.2) and the internal search. -PRIVATE = {"_place_global_units", "_spf"} +# The shipped private entry: the flow core's recorder entry (APPROACH 2.2). +PRIVATE = {"_place_global_units"} # The private hooks (NETGRAPH_CORE_PRIVATE_HOOKS: the development build only): module functions # and class members. @@ -103,15 +113,36 @@ def test_extension_surface_is_exactly_the_public_and_private_sets(): "kMaxFlowsPerCall", } +# The retired search entry points (APPROACH 12) and the 1b private search: never bound. +RETIRED_SEARCH = { + "shortest_paths", + "shortest_paths_to", + "spf_dag", + "spf_to", + "spf_reverse", + "k_shortest_paths", + "_spf", + "EdgeSelection", + "PathAlg", +} + def test_retired_spellings_are_absent(): - for name in RETIRED: + for name in RETIRED | RETIRED_SEARCH: assert not hasattr(_netgraph_core, name), name assert not hasattr(ngc, name), name assert ngc.Graph.__name__ == "Graph" assert ngc.FlowLedger.__name__ == "FlowLedger" assert ngc.OutputOptions.__name__ == "OutputOptions" assert ngc.DiminishingReturns.__name__ == "DiminishingReturns" + # PredDAG and Path carry no resolver or alternative constructors (APPROACH 12). + for name in ("resolve_to_paths", "from_edges", "from_hops"): + assert not hasattr(ngc.PredDAG, name), name + for name in ("from_edges", "from_hops"): + assert not hasattr(ngc.Path, name), name + # The search queue is C++ only. + assert not hasattr(ngc, "SpfQueue") + assert ngc.UNREACHABLE == 2**63 - 1 def _config(): diff --git a/tests/py/test_search.py b/tests/py/test_search.py new file mode 100644 index 0000000..048bb90 --- /dev/null +++ b/tests/py/test_search.py @@ -0,0 +1,224 @@ +"""The public search through the binding (``spf``, APPROACH 6): results, the DAG's canonical +form, the validation order on unnarrowed values, the enumerations, and the all-or-nothing +construction of the result objects.""" + +from __future__ import annotations + +import _netgraph_core as ext +import numpy as np +import pytest +from private_hooks import needs_hooks + +import netgraph_core as ngc + +DET = ngc.EdgeTieBreak.DETERMINISTIC +PREFER = ngc.EdgeTieBreak.PREFER_HIGHER_PRIORITY +CAP = ngc.EdgePriority.CAPACITY +RES = ngc.EdgePriority.RESIDUAL +FWD = ngc.Direction.FORWARD +REV = ngc.Direction.REVERSE + + +def build(n, edges, q=1.0): + return ngc.Graph( + n, + [e[0] for e in edges], + [e[1] for e in edges], + [e[2] for e in edges], + [e[3] for e in edges], + q, + ) + + +def ids(g, positions): + return sorted(int(e) for e, p in enumerate(g.input_index()) if p in positions) + + +def via(dag): + return sorted(int(e) for e in dag.via_edges()) + + +def opts(direction=FWD, selection=None, **kw): + return ngc.SpfOptions(direction, selection or ngc.PathSelection(DET, CAP), **kw) + + +ANYCAST = [(0, 1, 1, 1), (1, 2, 1, 1), (0, 3, 1, 2), (3, 2, 1, 5), (1, 4, 1, 5)] + + +def test_full_search_and_anycast(): + g = build(5, ANYCAST) + full = ngc.spf(g, 0, opts()) + assert full.costs.dtype == np.int64 + assert list(full.costs) == [0, 1, 2, 2, 6] + assert full.target_cost is None + assert not full.costs.flags.writeable + any_ = ngc.spf(g, [0], opts(targets=[2, 4])) + assert any_.costs is None + assert any_.target_cost == 2 + assert via(any_.dag) == ids(g, {0, 1}) + rev = ngc.spf(g, [2, 4], opts(REV, targets=0)) + assert rev.dag == any_.dag + assert rev.target_cost == 2 + none = ngc.spf(g, 2, opts(targets=0)) + assert none.target_cost is None + assert list(none.dag.via_edges()) == [] + unreachable = ngc.spf(g, 2, opts()) + assert list(unreachable.costs) == [ + ngc.UNREACHABLE, + ngc.UNREACHABLE, + 0, + ngc.UNREACHABLE, + ngc.UNREACHABLE, + ] + # Reading a field twice returns the same object: the result allocates nothing on access. + assert full.dag is full.dag and full.costs is full.costs + assert "target_cost=2" in repr(any_) + + +def test_reverse_full_search_is_forward_oriented(): + g = build(4, [(0, 1, 1, 1), (0, 1, 1, 1), (0, 2, 1, 2), (1, 3, 1, 1), (2, 3, 1, 1)]) + r = ngc.spf(g, 3, opts(REV)) + assert list(r.costs) == [2, 1, 1, 0] + assert via(r.dag) == ids(g, {0, 1, 3, 4}) + off = list(r.dag.parent_offsets()) + assert off[0] == 0 and off[-1] == len(r.dag.via_edges()) + + +def test_staged_single_path_review13_d3_both_directions(): + # s -> v (1, prio 1), v -> t (3, prio 1), v -> a -> b -> t (1 + 1 + 1, prio 2): the + # bottleneck of every walk is 1, so the two-hop walk wins under both tie modes. + g = build(5, [(0, 1, 1, 1), (1, 4, 1, 3), (1, 2, 2, 1), (2, 3, 2, 1), (3, 4, 2, 1)]) + for tie in (DET, PREFER): + s = ngc.PathSelection(tie, CAP, multipath=False) + f = ngc.spf(g, 0, opts(selection=s, targets=4)) + assert via(f.dag) == ids(g, {0, 1}) + assert f.target_cost == 4 + r = ngc.spf(g, 4, opts(REV, selection=s, targets=0)) + assert r.dag == f.dag + + +def test_residual_gating_and_priority(): + # Capacities 5; residuals by input [5, 4, 1, 3, 2]: 0->1->3 has bottleneck 4. + g = build(4, [(0, 1, 5, 1), (1, 3, 5, 1), (0, 2, 5, 1), (2, 3, 5, 1), (0, 3, 5, 2)]) + by_input = [5, 4, 1, 3, 2] + residual = np.array([by_input[int(p)] for p in g.input_index()], dtype=np.int64) + s = ngc.PathSelection(PREFER, RES, multipath=False) + r = ngc.spf(g, 0, opts(selection=s, targets=3, residual=residual)) + assert via(r.dag) == ids(g, {0, 1}) + # Gated at 4 units: only 0->1 (5) and 1->3 (4) are routable on that walk; the direct edge + # (residual 2) is gated out, so the cost-2 walk is the two-hop one through node 1. + gated = ngc.spf(g, 0, opts(targets=3, residual=residual, min_residual=4)) + assert gated.target_cost == 2 + assert via(gated.dag) == ids(g, {0, 1}) + # min_residual without a residual gates nothing. + assert ngc.spf(g, 0, opts(min_residual=7)).costs[3] == 2 + + +def test_validation_order_on_unnarrowed_values(): + g = build(3, [(0, 1, 2, 1), (1, 2, 2, 1)]) + other = build(3, [(0, 1, 2, 1)]) + with pytest.raises(ValueError, match=r"^roots: the node set is empty$"): + ngc.spf(g, [], opts()) + with pytest.raises(ValueError, match=r"^roots: node 3 out of node range \[0, 3\)$"): + ngc.spf(g, 3, opts()) + big = 2**70 + with pytest.raises(ValueError, match=rf"^roots: node {big} out of node range"): + ngc.spf(g, [0, big], opts()) + with pytest.raises(ValueError, match=r"^targets: the node set is empty$"): + ngc.spf(g, 0, opts(targets=[])) + with pytest.raises(ValueError, match=r"^targets: node -1 out of node range"): + ngc.spf(g, 0, opts(targets=[-1])) + with pytest.raises( + ValueError, match=r"^node 1 is in both endpoint sets: roots and targets" + ): + ngc.spf(g, [0, 1], opts(targets=[1])) + with pytest.raises(ValueError, match="^multipath = false requires targets"): + ngc.spf(g, 0, opts(selection=ngc.PathSelection(DET, CAP, multipath=False))) + with pytest.raises(ValueError, match="^residual has length 1, expected 2"): + ngc.spf(g, 0, opts(residual=[1])) + with pytest.raises(ValueError, match=r"^residual\[1\] = 3 is outside"): + ngc.spf(g, 0, opts(residual=[1, 3])) + with pytest.raises(ValueError, match=r"^residual\[0\] = -" + str(big)): + ngc.spf(g, 0, opts(residual=[-big, 1])) + with pytest.raises( + ValueError, match=r"^min_residual must be at least 1 unit \(got 0\)$" + ): + ngc.spf(g, 0, opts(min_residual=0)) + with pytest.raises(ValueError, match=r"min_residual must be an integer"): + ngc.spf(g, 0, opts(min_residual=1.5)) + with pytest.raises(ValueError, match="^foreign Eligibility"): + ngc.spf(g, 0, opts(elig=other.eligibility([], [0]))) + # Several defects: the contract's order (roots, targets, overlap, single path, residual, + # min_residual, Eligibility). + with pytest.raises(ValueError, match="^roots: the node set is empty$"): + ngc.spf(g, [], opts(targets=[], residual=[1], min_residual=0)) + with pytest.raises(ValueError, match="both endpoint sets"): + ngc.spf(g, 0, opts(targets=0, residual=[])) + with pytest.raises(ValueError, match="requires targets"): + ngc.spf( + g, + 0, + opts(selection=ngc.PathSelection(DET, CAP, multipath=False), residual=[]), + ) + with pytest.raises(ValueError, match="^residual"): + ngc.spf(g, 0, opts(residual=[1, 9], min_residual=0)) + with pytest.raises(ValueError, match="^min_residual"): + ngc.spf(g, 0, opts(min_residual=0, elig=other.eligibility([], [0]))) + # A huge min_residual gates every residual edge (no error). + assert list(ngc.spf(g, 0, opts(residual=[2, 2], min_residual=big)).costs)[1:] == [ + ngc.UNREACHABLE, + ngc.UNREACHABLE, + ] + + +def test_options_types_and_enumerations(): + g = build(2, [(0, 1, 1, 1)]) + o = opts(targets=[1], min_residual=3) + assert o.direction == FWD and o.targets == [1] and o.min_residual == 3 + assert o.residual is None and o.elig is None + with pytest.raises(ValueError, match="^direction: 7 is not a value of Direction$"): + ngc.SpfOptions(ngc.Direction(7), ngc.PathSelection(DET, CAP)) + with pytest.raises(TypeError): + ngc.SpfOptions(FWD, ngc.PathSelection(DET, CAP), fanout_edges=[0]) + with pytest.raises(TypeError, match=r"^spf\(\): incompatible function arguments"): + ngc.spf(g, 0, opts(), fanout_edges=[0]) + with pytest.raises(TypeError, match="SpfOptions expected"): + ngc.spf(g, 0, ngc.PathSelection(DET, CAP)) + with pytest.raises(TypeError): + ngc.SpfOptions(FWD) + # Roots as a NumPy array, with duplicates. + r = ngc.spf(g, np.array([0, 0], dtype=np.int32), opts()) + assert list(r.costs) == [0, 1] + + +@needs_hooks +def test_python_result_construction_is_all_or_nothing(): + # Every Python object allocation of the call fails once: the call raises (never crashes) + # and a clean retry gives the same result. + g = build(5, ANYCAST) + o = opts(targets=[2, 4]) + want = ngc.spf(g, 0, o) + seen = None + k = 0 + failures = 0 + while True: + hit, seen, result, error = ext._fail_python_allocation(ngc.spf, (g, 0, o), k) + if not hit: + assert result is not None and result.dag == want.dag + break + failures += 1 + assert result is None and isinstance(error, (MemoryError, RuntimeError)), error + again = ngc.spf(g, 0, o) + assert again.dag == want.dag and again.target_cost == want.target_cost + k += 1 + assert failures >= 3 and seen > 0 + full = opts() + want_full = ngc.spf(g, 0, full) + for k in range(40): + hit, _seen, result, error = ext._fail_python_allocation( + ngc.spf, (g, 0, full), k + ) + if not hit: + assert list(result.costs) == list(want_full.costs) + break + assert result is None and error is not None From c1c9de77a328a56aef3f55be34f5aec8cb82fec0 Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Sun, 11 Oct 2026 22:25:31 -0700 Subject: [PATCH 08/12] Phase 1e: graph construction by counting passes The Graph constructor numbers the edges with stable counting passes instead of a full sort. Costs are ranked through a hash table that reuses the graph's own arrays; above 65,536 distinct costs, or when the table probes too long, the costs are sorted instead. Results are unchanged; construction takes 21 to 91 % less time than 0.12. An empty NumPy view from a graph accessor now also keeps its graph alive. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +- CMakeLists.txt | 5 +- bindings/python/module.cpp | 3 +- include/netgraph/core/detail/graph_access.hpp | 80 ++ include/netgraph/core/graph.hpp | 33 +- python/netgraph_core/_docs.py | 8 + src/graph.cpp | 453 ++++++++-- src/search.cpp | 5 +- tests/cpp/fault_injection.hpp | 2 + tests/cpp/flow_ledger_fault_tests.cpp | 20 +- tests/cpp/graph_concurrency_tests.cpp | 156 ++++ tests/cpp/graph_fault_tests.cpp | 178 ++++ tests/cpp/graph_layout_tests.cpp | 800 ++++++++++++++++++ tests/cpp/graph_reference_1d.hpp | 200 +++++ tests/cpp/layout_checker.hpp | 221 +++++ tests/py/test_graph.py | 106 +++ 16 files changed, 2207 insertions(+), 70 deletions(-) create mode 100644 include/netgraph/core/detail/graph_access.hpp create mode 100644 tests/cpp/graph_concurrency_tests.cpp create mode 100644 tests/cpp/graph_fault_tests.cpp create mode 100644 tests/cpp/graph_layout_tests.cpp create mode 100644 tests/cpp/graph_reference_1d.hpp create mode 100644 tests/cpp/layout_checker.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f74b4d..30bdb5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,12 @@ units), one flow core, one placement engine. It replaces the 0.11 API. `EdgeId` is the rank of an edge in `(src, cost, dst, input)` with `input_index()` mapping ids to input positions. Validation reports the first defect in a fixed order (the resolution, the unit values, the shapes, zero or negative costs, the remaining bounds including the cost-sum - guard), on the exact integers also beyond int64. Accessors return read-only NumPy views. + guard), on the exact integers also beyond int64. Accessors return read-only NumPy views that + keep their graph alive, also when empty. + Construction uses stable counting passes: expected linear in edges plus nodes for bounded, + well-distributed distinct costs. It sorts all costs above 65,536 distinct costs or when the + cumulative hash-probe budget is exhausted. An edgeless graph needs validation and two + zero-filled offset arrays. Moving a graph no longer allocates. `Eligibility` is built from excluded node ids and EdgeIds, with `Eligibility.intersect(a, b)`. `to_units` (round half up) and `as_float` are the only conversions between physical values and units. diff --git a/CMakeLists.txt b/CMakeLists.txt index 4a5183a..03c66aa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -19,6 +19,8 @@ if(NETGRAPH_CORE_BUILD_TESTS) enable_testing() add_executable(netgraph_core_tests tests/cpp/graph_tests.cpp + tests/cpp/graph_layout_tests.cpp + tests/cpp/graph_concurrency_tests.cpp tests/cpp/flow_core_tests.cpp tests/cpp/flow_core_deletion_tests.cpp tests/cpp/eligibility_tests.cpp @@ -45,7 +47,8 @@ add_executable(netgraph_core_tests endif() # Allocation-failure tests replace the global operator new: a separate executable. add_executable(netgraph_core_fault_tests tests/cpp/flow_ledger_fault_tests.cpp tests/cpp/flow_policy_fault_tests.cpp - tests/cpp/flow_core_deletion_fault_tests.cpp tests/cpp/search_fault_tests.cpp) + tests/cpp/flow_core_deletion_fault_tests.cpp tests/cpp/search_fault_tests.cpp + tests/cpp/graph_fault_tests.cpp) target_link_libraries(netgraph_core_fault_tests PRIVATE netgraph_core GTest::gtest_main) target_include_directories(netgraph_core_fault_tests PRIVATE tests/cpp) if(NETGRAPH_CORE_COVERAGE AND (CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")) diff --git a/bindings/python/module.cpp b/bindings/python/module.cpp index 18ca689..1a29525 100644 --- a/bindings/python/module.cpp +++ b/bindings/python/module.cpp @@ -72,8 +72,9 @@ namespace { // Read-only NumPy view of a span owned by `owner`. template py::array readonly_view(std::span s, const py::handle& owner, const py::dtype& dtype) { + static constexpr T empty_element {}; py::array arr(dtype, {static_cast(s.size())}, {static_cast(sizeof(T))}, - s.data(), owner); + s.empty() ? &empty_element : s.data(), owner); py::detail::array_proxy(arr.ptr())->flags &= ~py::detail::npy_api::NPY_ARRAY_WRITEABLE_; return arr; } diff --git a/include/netgraph/core/detail/graph_access.hpp b/include/netgraph/core/detail/graph_access.hpp new file mode 100644 index 0000000..3ad4913 --- /dev/null +++ b/include/netgraph/core/detail/graph_access.hpp @@ -0,0 +1,80 @@ +/* The graph's internal entry points (internal, APPROACH 3). + + The internal reverse-source copy (APPROACH 3: "the stored copy is internal, for locality") is + read by the reverse search through this struct; the tests read it, and the retained array + bytes, the same way. Header-only and inline: nothing here is a library symbol, and no Python + name exists for it. The construction counters exist in hooks builds only (the C++ tests' + branch observations); the hooks-off symbol check lists their names. */ +#pragma once + +#include +#include +#include +#include + +#include "netgraph/core/graph.hpp" + +namespace netgraph::core::detail { + +// The fixed hash of the distinct-cost table (splitmix64's finalizer; the table index is its low +// bits). The construction uses it; the tests build colliding costs from it. +[[nodiscard]] inline std::uint64_t cost_hash(Cost c) noexcept { + auto x = static_cast(c); + x ^= x >> 30; + x *= 0xbf58476d1ce4e5b9ULL; + x ^= x >> 27; + x *= 0x94d049bb133111ebULL; + x ^= x >> 31; + return x; +} + +struct GraphAccess { + // in_col_indices[slot] = edge_src()[in_adj_edge_index()[slot]], filled before the graph is + // published and never written after. + [[nodiscard]] static std::span in_col_indices(const Graph& g) noexcept { return g.in_col_indices_; } + + // The retained arrays' heap payload: capacity() and size() times the element size, summed. + struct Retained { + std::size_t capacity_bytes {0}; + std::size_t size_bytes {0}; + std::size_t arrays {0}; + }; + [[nodiscard]] static Retained retained(const Graph& g) noexcept { + Retained r; + auto add = [&r](const auto& v) { + using T = typename std::remove_cvref_t::value_type; + r.capacity_bytes += v.capacity() * sizeof(T); + r.size_bytes += v.size() * sizeof(T); + ++r.arrays; + }; + add(g.capacity_); + add(g.cost_); + add(g.src_); + add(g.dst_); + add(g.input_index_); + add(g.row_offsets_); + add(g.in_row_offsets_); + add(g.in_adj_edge_index_); + add(g.in_col_indices_); + return r; + } +}; + +#if NETGRAPH_CORE_PRIVATE_HOOKS +// Test evidence (C++ tests, hooks builds only): what the last construction on this thread did. +struct ConstructionCounts { + std::int64_t hashed {0}; // constructions whose ranks came from the hash table + std::int64_t fallback {0}; // constructions whose ranks came from the sort of all costs + std::int64_t distinct_limit {0}; // fallbacks at the (L + 1)th distinct cost + std::int64_t exhausted_collection {0}; // fallbacks: the probe budget ran out at an input lookup + std::int64_t exhausted_growth {0}; // fallbacks: the probe budget ran out during a table growth + std::int64_t last_distinct {0}; // K of the last construction + std::int64_t last_table_slots {0}; + std::int64_t last_probes {0}; // probes examined by the last construction (<= last_budget) + std::int64_t last_budget {0}; // its B = 8E + 8 x 65,536 + std::int64_t fused {0}; // constructions taking the combined-key passes (N K < E) +}; +ConstructionCounts& thread_construction_counts() noexcept; +#endif + +} // namespace netgraph::core::detail diff --git a/include/netgraph/core/graph.hpp b/include/netgraph/core/graph.hpp index f657d56..c261fcc 100644 --- a/include/netgraph/core/graph.hpp +++ b/include/netgraph/core/graph.hpp @@ -10,6 +10,10 @@ namespace netgraph::core { +namespace detail { +struct GraphAccess; +} // namespace detail + // Converts a physical value to units of the resolution q, rounding half up. The only way a // physical number enters Core. Throws std::invalid_argument for NaN, infinity, a negative value, // a result at or above the domain bound 2^62, or a resolution that is not a power of two in @@ -33,8 +37,20 @@ namespace netgraph::core { // in the same order before narrowing any value, so the reported defect does not depend on the // representation of the input. // +// Construction (APPROACH 3): stable counting passes by dst, cost rank and src give the id order, +// two more by cost rank and dst the reverse CSR; when N K < E, combined keys do it in two forward +// passes (dst, then (src, rank)) and one reverse pass ((dst, rank)). An edgeless graph is its +// validation and two zero-filled offset arrays. The cost ranks come from the distinct costs +// collected in a hash table while there are at most 65,536 of them and a cumulative probe budget +// of 8E + 8 x 65,536 table slots lasts, else from a sort of all costs; both give the same ranks. +// Expected linear in E + N for a bounded number of distinct costs, O(N + E log(E + 1)) at worst. +// Retained: 36 bytes per edge plus 8 (N + 1) bytes of offsets. +// // A copy is a new graph: same ids and input map, a new uid. Uids come from one process-wide -// 64-bit counter and are never reused. +// 64-bit counter and are never reused. A move transfers every array and the uid without +// allocation. The source retains its resolution, has uid, node count, edge count, maximum cost and +// cost GCD zero, exposes empty edge arrays, and exposes one zero in each offset view. Its empty +// offset vectors use an immutable static zero for those views. class Graph { public: Graph(std::int32_t num_nodes, @@ -65,11 +81,11 @@ class Graph { // id -> input position. The inverse permutation is the caller's (one O(E) scatter). [[nodiscard]] std::span input_index() const noexcept { return input_index_; } // Forward CSR offsets (N + 1 entries, [0] = 0, [N] = E): the id ranges of the out-edges. - [[nodiscard]] std::span row_offsets() const noexcept { return row_offsets_; } + [[nodiscard]] std::span row_offsets() const noexcept { return offsets(row_offsets_); } // Reverse CSR: the in-edges of v are in_adj_edge_index()[in_row_offsets()[v] .. // in_row_offsets()[v + 1]) (public EdgeIds); the reverse column is // edge_src()[in_adj_edge_index()[slot]]. - [[nodiscard]] std::span in_row_offsets() const noexcept { return in_row_offsets_; } + [[nodiscard]] std::span in_row_offsets() const noexcept { return offsets(in_row_offsets_); } [[nodiscard]] std::span in_adj_edge_index() const noexcept { return in_adj_edge_index_; } // Static eligibility from excluded ids (duplicates and unsorted lists accepted, out-of-range @@ -79,6 +95,14 @@ class Graph { std::span excluded_edges = {}) const; private: + friend struct detail::GraphAccess; + + // The offsets of a moved-from graph (its vectors are empty): one zero. + static constexpr std::int32_t kNoOffsets[1] {0}; + [[nodiscard]] static std::span offsets(const std::vector& v) noexcept { + return v.empty() ? std::span(kNoOffsets) : std::span(v); + } + std::uint64_t uid_ {0}; double resolution_ {1.0}; std::int32_t num_nodes_ {0}; @@ -92,6 +116,9 @@ class Graph { std::vector row_offsets_ {}; std::vector in_row_offsets_ {}; std::vector in_adj_edge_index_ {}; + // The source of each reverse slot (edge_src()[in_adj_edge_index()[slot]]), stored for the + // reverse search's locality; internal (detail::GraphAccess). + std::vector in_col_indices_ {}; }; } // namespace netgraph::core diff --git a/python/netgraph_core/_docs.py b/python/netgraph_core/_docs.py index 6422e47..3a6e8e2 100644 --- a/python/netgraph_core/_docs.py +++ b/python/netgraph_core/_docs.py @@ -109,6 +109,14 @@ class Graph: position of edge ``e`` (the inverse permutation is the caller's). Every other per-edge array is in id order. Accessors return read-only NumPy views that keep the graph alive. + + Construction uses stable counting passes, not comparison sorts: expected time is + linear in ``E + N`` while the number of distinct costs is bounded, and at most + ``O(N + E log(E + 1))`` in the worst case (more than 65,536 distinct costs, or + costs that collide too often in the hash table, fall back to one sort of all + costs). A graph without edges costs only validation and its two offset arrays. The + graph keeps ``36 E + 8 (N + 1)`` bytes of arrays: the eight views plus an internal + copy of each reverse slot's source node, which the reverse search reads. """ def __init__( diff --git a/src/graph.cpp b/src/graph.cpp index 8ba5bee..f70f645 100644 --- a/src/graph.cpp +++ b/src/graph.cpp @@ -1,18 +1,44 @@ /* Graph: the one graph type on integer units (APPROACH 2.1, 3). - Construction validates the inputs in the documented order, then orders the edges by the - stable key (src, cost, dst, input): a counting pass by src places every input position in - its source's bucket (input order kept), and each bucket is sorted by (cost, dst, input). The - rank in that order is the public EdgeId, so the out-edges of u are the id range - [row_offsets[u], row_offsets[u + 1]). The reverse CSR lists the in-edges of v in - (cost, src, input) order: a counting pass by dst over the ids, then each bucket sorted by - (cost, src, id). Within one destination, equal (cost, src) means equal (src, cost, dst), whose - ids are in input order, so (cost, src, id) is (cost, src, input). + Construction validates the inputs in the documented order, then builds layout B' by stable + counting passes (each distributes items into buckets numbered by a small integer key and keeps + the earlier order inside a bucket). The cost rank of an edge is the position of its cost among + the K distinct costs, sorted. The id order is (src, cost, dst, input), whose rank is the public + EdgeId, so the out-edges of u are the id range [row_offsets[u], row_offsets[u + 1]); the + in-edges of v are listed in (cost, src, input) order. An edgeless graph is its validation and + two zero-filled offset arrays. + * N K >= E: three passes (dst, cost rank, src; least significant first) give the id order, and + two over the ids (cost rank, then dst) the reverse lists: within one (dst, cost) group the + id order is (src, input). + * N K < E (combined keys): a pass by dst, then one by (src, rank), give the id order, and one + pass over the ids by (dst, rank) the reverse lists; the two (N K + 1)-entry histograms live + in the not-yet-filled capacity and cost arrays. + The offset arrays serve as their own cursors; a pass that advances them is followed by one + that walks back, so no pass over the nodes restores them. + + The ranks: each edge first gets its cost's first-appearance ordinal from an open-addressing + table (fixed mixing, linear probing, load at most 1/4, doubled from 64 up to 2^18 slots, each + growth rebuilt from the distinct costs in their order); an + edge whose cost equals the previous edge's reuses that ordinal without a lookup. The K + distinct costs are then radix-sorted. Hashing stops on the 65,537th distinct cost, or before + a probe (one table slot examined, the terminating one included, counted across the lookups + and the growths) would exceed 8E + 8 x 65,536; the ranks then come from a radix sort of all + costs (16-bit digits). Both paths give the same ranks. + + Memory: every retained array is allocated first, at its final size; until it is filled, each + serves as scratch. The ranks go into in_col_indices_; the hash table borrows capacity_ (keys) and + src_ (ordinals) while it has at most E slots, the distinct costs cost_, their sort dst_, + input_index_ and in_adj_edge_index_; the passes use in_adj_edge_index_ (the middle forward + pass), capacity_ and cost_ (the K rank cursors or the combined histograms), and the fallback + input_index_ and in_adj_edge_index_. The only other buffers are a table larger than E slots (its + own vectors, the old and new ones briefly together at a growth) and the fallback's 2^16 digit + counts. A table of at most E slots frees no separate buffer that the allocator could keep cached. */ #include "netgraph/core/graph.hpp" #include +#include #include #include #include @@ -21,6 +47,8 @@ #include #include +#include "netgraph/core/detail/graph_access.hpp" +#include "netgraph/core/detail/private_hooks.hpp" #include "netgraph/core/detail/uid.hpp" namespace netgraph::core { @@ -32,6 +60,13 @@ std::uint64_t next_uid() noexcept { return counter.fetch_add(1, std::memory_order_relaxed) + 1; } +#if NETGRAPH_CORE_PRIVATE_HOOKS +ConstructionCounts& thread_construction_counts() noexcept { + thread_local ConstructionCounts counts; + return counts; +} +#endif + } // namespace detail namespace { @@ -52,6 +87,217 @@ int resolution_exponent(double q) { return k; } +// The cost ranks --------------------------------------------------------------------------- + + +constexpr std::int64_t kMaxHashedCosts = 65536; // L + +// The cumulative probe budget: take() runs before every table slot is examined and refuses the +// probe that would exceed the limit. +struct ProbeBudget { + std::int64_t used {0}; + std::int64_t limit {0}; + bool take() noexcept { + if (used == limit) return false; + ++used; + return true; + } +}; + +constexpr std::size_t kNoSlot = ~std::size_t {0}; + +// The distinct-cost table: key 0 marks an empty slot (costs are >= 1 after validation). +struct CostTable { + std::span key; + std::span ord; // the key's first-appearance ordinal + std::size_t mask {0}; + + // The slot holding c, or the empty slot where c belongs; kNoSlot when the budget refuses a + // probe. Every slot examined is one probe, the terminating one included. + std::size_t find(Cost c, ProbeBudget& b) const noexcept { + std::size_t s = static_cast(detail::cost_hash(c)) & mask; + for (;;) { + if (!b.take()) return kNoSlot; + if (key[s] == 0 || key[s] == c) return s; + s = (s + 1) & mask; + } + } +}; + +// Where the table lives: the first `slots` entries of two retained arrays not yet filled (E slots +// each), so the table allocates nothing and leaves no freed block behind (a freed block the +// allocator keeps cached would count in the process's resident peak); only a table larger than E +// slots gets vectors of its own. A new table is empty: growth rebuilds it from the distinct costs. +struct TableStore { + std::span key_room; + std::span ord_room; + std::vector key_own {}; + std::vector ord_own {}; + + CostTable make(std::size_t slots) { + if (slots <= key_room.size()) { + const std::span key = key_room.first(slots); + std::fill(key.begin(), key.end(), Cost {0}); + return {key, ord_room.first(slots), slots - 1}; + } + key_own.assign(slots, 0); + ord_own.resize(slots); + return {key_own, ord_own, slots - 1}; + } +}; + +// The retained arrays the hash path borrows before they are filled (each E entries, K <= E). +struct HashRooms { + std::span keys; // the table's keys while it has at most E slots + std::span ords; // the table's ordinals, the same + std::span distinct; // the distinct costs in first-appearance order + std::span a, b, c; // the rank step: ordinal -> rank, and its two position arrays +}; + +enum class RankPath { Hashed, DistinctLimit, ExhaustedCollection, ExhaustedGrowth }; + +struct RankOutcome { + RankPath path {RankPath::Hashed}; + std::int64_t distinct {0}; + std::int64_t table_slots {0}; + std::int64_t probes {0}; + std::int64_t budget {0}; +}; + +// Ranks from a sort of all E costs: a stable LSD radix sort of the input positions by cost, +// 16 bits per pass, then one scan of the sorted positions. Digits above the largest cost's bit +// length are not visited, and a pass whose digit is the same for every cost is skipped. +// `a` and `b` (E slots each) hold the positions; `count` has 2^16 entries. +template +std::int64_t ranks_by_sort(std::span cost, Cost max_cost, std::span rank, + std::span a, std::span b, + std::span count) { + const std::size_t m = cost.size(); + if (m == 0) return 0; + constexpr std::uint64_t kMask = (std::uint64_t {1} << kBits) - 1; + std::iota(a.begin(), a.end(), 0); + const auto top = static_cast(max_cost); + for (int shift = 0; shift < 64 && (top >> shift) != 0; shift += kBits) { + auto digit = [&](std::int32_t i) { + const auto c = static_cast(cost[static_cast(i)]); + return static_cast((c >> shift) & kMask); + }; + std::fill(count.begin(), count.end(), 0); + for (std::size_t j = 0; j < m; ++j) ++count[digit(static_cast(j))]; + if (count[digit(0)] == static_cast(m)) continue; // one digit value + for (std::size_t d = 0, sum = 0; d < count.size(); ++d) { + const auto c = static_cast(count[d]); + count[d] = static_cast(sum); + sum += c; + } + for (std::size_t j = 0; j < m; ++j) b[static_cast(count[digit(a[j])]++)] = a[j]; + std::swap(a, b); + } + std::int32_t r = 0; + rank[static_cast(a[0])] = 0; + for (std::size_t j = 1; j < m; ++j) { + const auto p = static_cast(a[j]); + if (cost[p] != cost[static_cast(a[j - 1])]) ++r; + rank[p] = r; + } + return std::int64_t {r} + 1; +} + +// Ordinals into `rank` (input coordinates), then ranks. The table, the distinct costs and the rank +// step live in `room` (retained arrays not yet filled); a table larger than E slots gets vectors of +// its own, freed on return. Growth rebuilds the bigger table from the distinct costs in their +// order, counting every probe. Any path other than Hashed leaves `rank` partly written for the +// caller to overwrite. +RankOutcome ranks_by_hash(std::span cost, std::span rank, const HashRooms& room) { + const std::size_t m = cost.size(); + RankOutcome out; + ProbeBudget b {0, 8 * static_cast(m) + 8 * kMaxHashedCosts}; + out.budget = b.limit; + if (m == 0) return out; // no cost, no table + std::size_t k = 0; // the distinct costs so far: room.distinct[0, k) + { + TableStore store {room.keys, room.ords}; + CostTable t = store.make(64); + auto stop = [&](RankPath path) { + out.path = path; + out.probes = b.used; + out.table_slots = static_cast(t.key.size()); + return out; + }; + Cost prev = 0; // a run of equal costs reuses the previous ordinal without a lookup + std::int32_t prev_ord = 0; + for (std::size_t i = 0; i < m; ++i) { + const Cost c = cost[i]; + if (c == prev) { + rank[i] = prev_ord; + continue; + } + std::size_t s = t.find(c, b); + if (s == kNoSlot) return stop(RankPath::ExhaustedCollection); + if (t.key[s] == 0) { + if (static_cast(k) == kMaxHashedCosts) return stop(RankPath::DistinctLimit); + if (4 * (k + 1) > t.key.size()) { // keep the load <= 1/4: grow first + t = store.make(t.key.size() * 2); + for (std::size_t j = 0; j < k; ++j) { + const std::size_t d = t.find(room.distinct[j], b); + if (d == kNoSlot) return stop(RankPath::ExhaustedGrowth); + t.key[d] = room.distinct[j]; + t.ord[d] = static_cast(j); + } + s = t.find(c, b); + if (s == kNoSlot) return stop(RankPath::ExhaustedGrowth); + } + t.key[s] = c; + t.ord[s] = static_cast(k); + room.distinct[k++] = c; + } + rank[i] = t.ord[s]; + prev = c; + prev_ord = t.ord[s]; + } + out.table_slots = static_cast(t.key.size()); + } + out.probes = b.used; + // Ordinal -> rank without the table: a radix sort of the K distinct costs. + const std::span by_ord = room.distinct.first(k); + const std::span rank_of_ord = room.a.first(k); + std::array count {}; + ranks_by_sort<8>(by_ord, *std::max_element(by_ord.begin(), by_ord.end()), rank_of_ord, room.b.first(k), + room.c.first(k), count); + for (std::size_t i = 0; i < m; ++i) rank[i] = rank_of_ord[static_cast(rank[i])]; + out.distinct = static_cast(k); + return out; +} + +// Offsets from cursors that a scatter advanced to the ends: off[k] = start(k + 1) becomes +// off[k] = start(k), off[0] = 0 (the last entry of an (N + 1)-array keeps its value). +// Counts in off[0, n) become inclusive sums (each node's end), off[n] the total: a scatter that +// decrements these cursors, walking its input backwards, leaves the starts (stable, no shift). +// The sum stays in a register (a sum through memory chains every node on the previous store). +void counts_to_ends(std::span off) noexcept { + std::int32_t sum = 0; + for (std::size_t k = 0; k + 1 < off.size(); ++k) { + sum += off[k]; + off[k] = sum; + } + off.back() = sum; +} + +// Counts in off[1, n] (node v's at v + 1) become the starts, off[0] = 0 (a running sum as above). +void counts_to_starts(std::span off) noexcept { + std::int32_t sum = 0; + for (std::size_t k = 1; k < off.size(); ++k) { + sum += off[k]; + off[k] = sum; + } +} + +template +void ends_to_starts(std::span off) noexcept { + for (std::size_t k = off.size(); k-- > 1;) off[k] = off[k - 1]; + if (!off.empty()) off[0] = 0; +} + } // namespace Amount to_units(double x, double resolution) { @@ -159,34 +405,138 @@ Graph::Graph(std::int32_t num_nodes, cost_gcd_ = cost_gcd; const auto n = static_cast(num_nodes); - // Ids: rank in (src, cost, dst, input). - row_offsets_.assign(n + 1, 0); - for (std::size_t i = 0; i < m; ++i) ++row_offsets_[static_cast(src[i]) + 1]; - for (std::size_t u = 0; u < n; ++u) row_offsets_[u + 1] += row_offsets_[u]; + // Every retained array first (36 E + 8 (N + 1) bytes); until it is filled, each serves as + // scratch. The cost ranks go into the copy's array (by input position); the hash table, the + // distinct costs and the rank step borrow the others, so the hash path allocates nothing of its + // own while the table has at most E slots (no freed block stays behind in the allocator's cache). + capacity_.resize(m); + cost_.resize(m); + src_.resize(m); + dst_.resize(m); input_index_.resize(m); + in_adj_edge_index_.resize(m); + in_col_indices_.resize(m); + row_offsets_.assign(n + 1, 0); + in_row_offsets_.assign(n + 1, 0); + const std::span rank(in_col_indices_); + const RankOutcome ro = + ranks_by_hash(cost, rank, HashRooms {capacity_, src_, cost_, dst_, input_index_, in_adj_edge_index_}); + const std::span order(input_index_); // forward passes 1 and 3 (final) + const std::span mid(in_adj_edge_index_); // forward pass 2 + const std::span bucket(capacity_); // the K rank cursors (K <= E) + + // Any other path: the ranks from a sort of all costs (the same ranks). + std::int64_t k = ro.distinct; + if (ro.path != RankPath::Hashed) { + std::vector count(std::size_t {1} << 16); + k = ranks_by_sort<16>(cost, max_cost, rank, input_index_, in_adj_edge_index_, count); + } + const auto kk = static_cast(k); +#if NETGRAPH_CORE_PRIVATE_HOOKS { - std::vector cursor(row_offsets_.begin(), row_offsets_.end() - 1); + auto& c = detail::thread_construction_counts(); + ++(ro.path == RankPath::Hashed ? c.hashed : c.fallback); + if (ro.path == RankPath::DistinctLimit) ++c.distinct_limit; + if (ro.path == RankPath::ExhaustedCollection) ++c.exhausted_collection; + if (ro.path == RankPath::ExhaustedGrowth) ++c.exhausted_growth; + c.last_distinct = k; + c.last_table_slots = ro.table_slots; + c.last_probes = ro.probes; + c.last_budget = ro.budget; + } +#endif + // An edgeless graph is its validation and its two zero-filled offset arrays (APPROACH 3). + if (m == 0) return; + + // The passes. With few nodes times few costs (N K < E), the cost rank rides with an endpoint as + // one key: forward by dst, then by (src, rank); reverse by (dst, rank) over the ids (proposal P4). + // Otherwise the five single-key passes: forward by dst, rank, src; reverse by rank, then dst. + // Both give the same orders. P4's two (N K + 1)-entry histograms live in the not-yet-filled + // capacity and cost arrays (N K + 1 <= E), so its work is O(E). + const bool fused = n * kk < m; + const std::span by_dst_rank(capacity_); // P4: (dst, rank) histogram, then cursors + const std::span by_src_rank(cost_); // P4: (src, rank) histogram, then cursors + if (fused) { +#if NETGRAPH_CORE_PRIVATE_HOOKS + ++detail::thread_construction_counts().fused; +#endif + const std::size_t nk = n * kk; + std::fill(by_dst_rank.begin(), by_dst_rank.begin() + static_cast(nk + 1), 0); + std::fill(by_src_rank.begin(), by_src_rank.begin() + static_cast(nk + 1), 0); + // One counter update per loop: two in one loop ran several times slower (NOTES section 1). + for (std::size_t i = 0; i < m; ++i) ++by_dst_rank[static_cast(dst[i]) * kk + static_cast(rank[i]) + 1]; + for (std::size_t i = 0; i < m; ++i) ++by_src_rank[static_cast(src[i]) * kk + static_cast(rank[i]) + 1]; + for (std::size_t x = 0, sum = 0; x <= nk; ++x) sum = static_cast(by_dst_rank[x] += static_cast(sum)); + for (std::size_t x = 0, sum = 0; x <= nk; ++x) sum = static_cast(by_src_rank[x] += static_cast(sum)); + for (std::size_t v = 0; v < n; ++v) { + in_row_offsets_[v] = static_cast(by_dst_rank[(v + 1) * kk]); // ends: pass 1's cursors + row_offsets_[v] = static_cast(by_src_rank[v * kk]); + } + in_row_offsets_[n] = row_offsets_[n] = static_cast(m); + } else { + for (std::size_t i = 0; i < m; ++i) ++in_row_offsets_[static_cast(dst[i]) + 1]; + counts_to_starts(std::span(in_row_offsets_)); + } + + // Forward pass 1, by dst, from input order. The reverse offsets serve as cursors, so no pass + // over the nodes restores them: P4 walks the input backwards from the ends to the starts; the + // single-key passes walk it forwards from the starts to the ends, and reverse pass 2 walks back. + if (fused) { + for (std::size_t i = m; i-- > 0;) { + order[static_cast(--in_row_offsets_[static_cast(dst[i])])] = static_cast(i); + } + } else { for (std::size_t i = 0; i < m; ++i) { - input_index_[static_cast(cursor[static_cast(src[i])]++)] = - static_cast(i); + order[static_cast(in_row_offsets_[static_cast(dst[i])]++)] = static_cast(i); } } - for (std::size_t u = 0; u < n; ++u) { - auto first = input_index_.begin() + row_offsets_[u]; - auto last = input_index_.begin() + row_offsets_[u + 1]; - if (last - first < 2) continue; - std::sort(first, last, [&](std::int32_t a, std::int32_t b) { - const auto ia = static_cast(a); - const auto ib = static_cast(b); - if (cost[ia] != cost[ib]) return cost[ia] < cost[ib]; - if (dst[ia] != dst[ib]) return dst[ia] < dst[ib]; - return a < b; - }); + + if (fused) { + // Forward pass 2, by (src, rank), into `mid`: the final id order; the two arrays trade places. + for (std::size_t j = 0; j < m; ++j) { + const auto i = static_cast(order[j]); + const std::size_t key = static_cast(src[i]) * kk + static_cast(rank[i]); + mid[static_cast(by_src_rank[key]++)] = static_cast(i); + } + input_index_.swap(in_adj_edge_index_); + // Reverse, the ids by (dst, rank): within a key the id order gives (src, input). + for (std::size_t e = 0; e < m; ++e) { + const auto i = static_cast(input_index_[e]); + const std::size_t key = static_cast(dst[i]) * kk + static_cast(rank[i]); + in_adj_edge_index_[static_cast(by_dst_rank[key]++)] = static_cast(e); + } + } else { + // Forward pass 2, by cost rank; the cursors stay for reverse pass 1. + std::fill(bucket.begin(), bucket.begin() + static_cast(kk), 0); + for (std::size_t i = 0; i < m; ++i) ++bucket[static_cast(rank[i])]; + for (std::size_t r = 0, sum = 0; r < kk; ++r) { + const auto c = static_cast(bucket[r]); + bucket[r] = static_cast(sum); + sum += c; + } + for (std::size_t j = 0; j < m; ++j) { + const auto i = static_cast(order[j]); + mid[static_cast(bucket[static_cast(rank[i])]++)] = static_cast(i); + } + ends_to_starts(bucket.first(kk)); + + // Forward pass 3, by src; the src histogram is the forward offsets. + for (std::size_t i = 0; i < m; ++i) ++row_offsets_[static_cast(src[i])]; + counts_to_ends(std::span(row_offsets_)); + for (std::size_t j = m; j-- > 0;) { + const auto i = static_cast(mid[j]); + order[static_cast(--row_offsets_[static_cast(src[i])])] = static_cast(i); + } + + // Reverse pass 1, by cost rank over the ids (the id order is (src, cost, dst, input)). + const std::span by_cost(in_adj_edge_index_); // free again after pass 3 + for (std::size_t e = 0; e < m; ++e) { + const auto r = static_cast(rank[static_cast(order[e])]); + by_cost[static_cast(bucket[r]++)] = static_cast(e); + } } - capacity_.resize(m); - cost_.resize(m); - src_.resize(m); - dst_.resize(m); + + // The per-edge values, permuted once (the cursors and the ranks are no longer read). for (std::size_t e = 0; e < m; ++e) { const auto i = static_cast(input_index_[e]); capacity_[e] = capacity_units[i]; @@ -195,30 +545,19 @@ Graph::Graph(std::int32_t num_nodes, dst_[e] = dst[i]; } - // Reverse CSR in (cost, src, input) order. - in_row_offsets_.assign(n + 1, 0); - for (std::size_t e = 0; e < m; ++e) ++in_row_offsets_[static_cast(dst_[e]) + 1]; - for (std::size_t v = 0; v < n; ++v) in_row_offsets_[v + 1] += in_row_offsets_[v]; - in_adj_edge_index_.resize(m); - { - std::vector cursor(in_row_offsets_.begin(), in_row_offsets_.end() - 1); - for (std::size_t e = 0; e < m; ++e) { - in_adj_edge_index_[static_cast(cursor[static_cast(dst_[e])]++)] = - static_cast(e); + if (!fused) { + // Reverse pass 2, by dst, into the ranks' array, walked backwards: the reverse offsets hold + // the ends after pass 1 and return to the starts. Then the two slot arrays trade places. + const std::span slots(in_col_indices_); + const std::span by_cost(in_adj_edge_index_); + for (std::size_t j = m; j-- > 0;) { + const auto e = static_cast(by_cost[j]); + slots[static_cast(--in_row_offsets_[static_cast(dst_[e])])] = static_cast(e); } + in_adj_edge_index_.swap(in_col_indices_); } - for (std::size_t v = 0; v < n; ++v) { - auto first = in_adj_edge_index_.begin() + in_row_offsets_[v]; - auto last = in_adj_edge_index_.begin() + in_row_offsets_[v + 1]; - if (last - first < 2) continue; - std::sort(first, last, [&](EdgeId a, EdgeId b) { - const auto ia = static_cast(a); - const auto ib = static_cast(b); - if (cost_[ia] != cost_[ib]) return cost_[ia] < cost_[ib]; - if (src_[ia] != src_[ib]) return src_[ia] < src_[ib]; - return a < b; - }); - } + // The source copy, over the ranks' (or the free) array. + for (std::size_t s = 0; s < m; ++s) in_col_indices_[s] = src_[static_cast(in_adj_edge_index_[s])]; } Graph::Graph(const Graph& other) @@ -234,8 +573,10 @@ Graph::Graph(const Graph& other) input_index_(other.input_index_), row_offsets_(other.row_offsets_), in_row_offsets_(other.in_row_offsets_), - in_adj_edge_index_(other.in_adj_edge_index_) {} + in_adj_edge_index_(other.in_adj_edge_index_), + in_col_indices_(other.in_col_indices_) {} +// No allocation: the source keeps empty vectors, and its offset views read the static zero. Graph::Graph(Graph&& other) noexcept : uid_(std::exchange(other.uid_, 0)), resolution_(other.resolution_), @@ -249,9 +590,7 @@ Graph::Graph(Graph&& other) noexcept input_index_(std::move(other.input_index_)), row_offsets_(std::move(other.row_offsets_)), in_row_offsets_(std::move(other.in_row_offsets_)), - in_adj_edge_index_(std::move(other.in_adj_edge_index_)) { - other.row_offsets_.assign(1, 0); - other.in_row_offsets_.assign(1, 0); -} + in_adj_edge_index_(std::move(other.in_adj_edge_index_)), + in_col_indices_(std::move(other.in_col_indices_)) {} } // namespace netgraph::core diff --git a/src/search.cpp b/src/search.cpp index 0fbfcac..0488ed0 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -25,6 +25,7 @@ #include "netgraph/core/detail/endpoints.hpp" #include "netgraph/core/detail/enum_domain.hpp" +#include "netgraph/core/detail/graph_access.hpp" #include "netgraph/core/detail/int128.hpp" #include "netgraph/core/detail/pred_dag_builder.hpp" #include "netgraph/core/detail/traversal.hpp" @@ -412,11 +413,11 @@ Cost loop(Workspace& ws, Q& q, const SearchIn& in) { const Graph& g = *in.g; const Cost* const cost = g.cost().data(); const Amount* const cap = g.capacity_units().data(); - const NodeId* const esrc = g.edge_src().data(); const NodeId* const edst = g.edge_dst().data(); const std::int32_t* const row = g.row_offsets().data(); const std::int32_t* const in_row = g.in_row_offsets().data(); const EdgeId* const in_adj = g.in_adj_edge_index().data(); + const NodeId* const in_col = detail::GraphAccess::in_col_indices(g).data(); // in_col[s] = esrc[in_adj[s]] const std::uint8_t* const allowed = in.gate.elig != nullptr ? in.gate.elig->edge_allowed().data() : nullptr; const Amount* const res = in.gate.residual.empty() ? nullptr : in.gate.residual.data(); const Amount minr = in.gate.min_residual; @@ -475,7 +476,7 @@ Cost loop(Workspace& ws, Q& q, const SearchIn& in) { for (auto s = static_cast(in_row[ui]); s < static_cast(in_row[ui + 1]); ++s) { const auto e = static_cast(in_adj[s]); if (!candidate(e)) continue; - relax(esrc[e], d + cost[e], static_cast(e)); + relax(in_col[s], d + cost[e], static_cast(e)); } } } diff --git a/tests/cpp/fault_injection.hpp b/tests/cpp/fault_injection.hpp index 472a375..d46a039 100644 --- a/tests/cpp/fault_injection.hpp +++ b/tests/cpp/fault_injection.hpp @@ -19,6 +19,8 @@ // Allocations left before the injected failure; negative: no injection. extern std::atomic g_countdown; +// Live allocations of the replacement operator new (leak accounting). +extern std::atomic g_live; // Runs fn with the k-th allocation (from 0) failing; returns whether that failure happened // (and expects that a reached failure propagated as std::bad_alloc). diff --git a/tests/cpp/flow_ledger_fault_tests.cpp b/tests/cpp/flow_ledger_fault_tests.cpp index 8bc6526..7d8a000 100644 --- a/tests/cpp/flow_ledger_fault_tests.cpp +++ b/tests/cpp/flow_ledger_fault_tests.cpp @@ -27,6 +27,10 @@ // Allocations left before the injected failure; negative: no injection (fault_injection.hpp). // Shared by every thread; with g_workers_only the calling test thread's allocations do not count. std::atomic g_countdown {-1}; +// Live allocations of the replacement operator new (a successful allocation counts up, the +// delete of a non-null pointer counts down): the sweeps compare it before and after an operation +// to count leaks, which ASan's leak detection (off on macOS) would not. +std::atomic g_live {0}; static std::atomic g_workers_only {false}; static thread_local bool t_test_thread = false; @@ -36,14 +40,20 @@ void* operator new(std::size_t n) { g_countdown.fetch_sub(1, std::memory_order_relaxed) == 0) { throw std::bad_alloc(); } - if (void* p = std::malloc(n == 0 ? 1 : n)) return p; + if (void* p = std::malloc(n == 0 ? 1 : n)) { + g_live.fetch_add(1, std::memory_order_relaxed); + return p; + } throw std::bad_alloc(); } void* operator new[](std::size_t n) { return ::operator new(n); } -void operator delete(void* p) noexcept { std::free(p); } -void operator delete[](void* p) noexcept { std::free(p); } -void operator delete(void* p, std::size_t) noexcept { std::free(p); } -void operator delete[](void* p, std::size_t) noexcept { std::free(p); } +void operator delete(void* p) noexcept { + if (p != nullptr) g_live.fetch_sub(1, std::memory_order_relaxed); + std::free(p); +} +void operator delete[](void* p) noexcept { ::operator delete(p); } +void operator delete(void* p, std::size_t) noexcept { ::operator delete(p); } +void operator delete[](void* p, std::size_t) noexcept { ::operator delete(p); } using namespace netgraph::core; using namespace netgraph::core::test; diff --git a/tests/cpp/graph_concurrency_tests.cpp b/tests/cpp/graph_concurrency_tests.cpp new file mode 100644 index 0000000..ea71a71 --- /dev/null +++ b/tests/cpp/graph_concurrency_tests.cpp @@ -0,0 +1,156 @@ +// The graph under concurrency (APPROACH 13 threading; task 1e), run in the ThreadSanitizer suite: +// independent constructions at the same time (no shared hash or counting scratch), and full +// forward and reverse searches through both queues on one published immutable graph and on its +// copy, each thread with its own options, compared with a serial reference; every array of the +// graph is unchanged after. One batch path builds its own reverse support in its workers (no +// prepopulated frozen cache), so the reverse-source copy is read by worker threads. +#include + +#include +#include +#include +#include +#include + +#include "graph_reference_1d.hpp" +#include "netgraph/core/detail/graph_access.hpp" +#include "netgraph/core/evaluators.hpp" +#include "netgraph/core/graph.hpp" +#include "netgraph/core/search.hpp" + +using namespace netgraph::core; +using namespace netgraph::core::test; + +namespace { + +struct Input { + std::int32_t n {0}; + std::vector src {}, dst {}; + std::vector cap {}; + std::vector cost {}; +}; + +Input random_input(std::int32_t n, std::size_t m, Cost max_cost, std::uint64_t seed) { + std::mt19937_64 rng(seed); + Input in {n}; + for (std::size_t i = 0; i < m; ++i) { + in.src.push_back(static_cast(rng() % static_cast(n))); + in.dst.push_back(static_cast(rng() % static_cast(n))); + in.cap.push_back(static_cast(1 + rng() % 4)); + in.cost.push_back(static_cast(1 + rng() % static_cast(max_cost))); + } + return in; +} + +// Every array of a graph, for the before/after comparison. +struct Snapshot { + std::vector capacity; + std::vector cost; + std::vector src, dst, in_col; + std::vector input_index, row_offsets, in_row_offsets; + std::vector in_adj; + bool operator==(const Snapshot&) const = default; +}; + +Snapshot snapshot(const Graph& g) { + const auto col = detail::GraphAccess::in_col_indices(g); + return {{g.capacity_units().begin(), g.capacity_units().end()}, + {g.cost().begin(), g.cost().end()}, + {g.edge_src().begin(), g.edge_src().end()}, + {g.edge_dst().begin(), g.edge_dst().end()}, + {col.begin(), col.end()}, + {g.input_index().begin(), g.input_index().end()}, + {g.row_offsets().begin(), g.row_offsets().end()}, + {g.in_row_offsets().begin(), g.in_row_offsets().end()}, + {g.in_adj_edge_index().begin(), g.in_adj_edge_index().end()}}; +} + +} // namespace + +TEST(GraphConcurrency, IndependentConstructionsAtTheSameTime) { + constexpr int kThreads = 8; + std::vector inputs; + for (int t = 0; t < kThreads; ++t) { + // Alternate the hash path and the fallback (K > 65,536) across threads. + inputs.push_back(t % 2 == 0 ? random_input(2000, 20000, 31, static_cast(t)) + : random_input(4096, 70000, Cost{1} << 40, static_cast(t))); + } + std::vector> got(kThreads), got_rev(kThreads); + std::vector threads; + for (int t = 0; t < kThreads; ++t) { + threads.emplace_back([&, t] { + const auto& in = inputs[static_cast(t)]; + const Graph g(in.n, in.src, in.dst, in.cap, in.cost, 1.0); + got[static_cast(t)].assign(g.input_index().begin(), g.input_index().end()); + got_rev[static_cast(t)].assign(g.in_adj_edge_index().begin(), g.in_adj_edge_index().end()); + }); + } + for (auto& th : threads) th.join(); + for (int t = 0; t < kThreads; ++t) { + const auto& in = inputs[static_cast(t)]; + const auto r = reference_1d(in.n, in.src, in.dst, in.cap, in.cost, 1.0); + EXPECT_EQ(got[static_cast(t)], r.input_index) << t; + EXPECT_EQ(got_rev[static_cast(t)], r.in_adj_edge_index) << t; + } +} + +TEST(GraphConcurrency, SharedGraphSearchesBothDirectionsBothQueues) { + const auto in = random_input(3000, 30000, 20, 99); + const Graph g(in.n, in.src, in.dst, in.cap, in.cost, 1.0); + const Graph copy(g); + const auto before = snapshot(g); + struct Job { + const Graph* graph; + Direction direction; + SpfQueue queue; + NodeId root; + }; + std::vector jobs; + for (const Graph* x : {&g, ©}) { + for (Direction d : {Direction::Forward, Direction::Reverse}) { + for (SpfQueue q : {SpfQueue::Auto, SpfQueue::Heap}) { + for (NodeId r : {0, 17, 2999}) jobs.push_back({x, d, q, r}); + } + } + } + auto run = [](const Job& j) { + const std::vector roots {j.root}; + // A full search keeping every tight edge (multipath, every parallel member). + const SpfOptions o(j.direction, PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity)); + return spf(*j.graph, roots, o, j.queue); + }; + std::vector serial; + for (const auto& j : jobs) serial.push_back(run(j)); + std::vector> parallel(jobs.size()); + std::vector threads; + for (std::size_t i = 0; i < jobs.size(); ++i) threads.emplace_back([&, i] { parallel[i] = run(jobs[i]); }); + for (auto& th : threads) th.join(); + for (std::size_t i = 0; i < jobs.size(); ++i) { + ASSERT_TRUE(parallel[i].has_value()); + EXPECT_EQ(parallel[i]->costs, serial[i].costs) << i; + EXPECT_TRUE(parallel[i]->dag == serial[i].dag) << i; + } + EXPECT_TRUE(snapshot(g) == before); + EXPECT_TRUE(snapshot(copy) == before); +} + +// Workers of batch_capacity without a supplied cache each build their own reverse static support +// (the CostOnly policy's DAG toward the destinations) on the shared graph. +TEST(GraphConcurrency, BatchWorkersBuildTheirOwnReverseSupport) { + const auto in = random_input(400, 4000, 9, 5); + const Graph g(in.n, in.src, in.dst, in.cap, in.cost, 1.0); + const auto before = snapshot(g); + std::vector, std::vector>> pairs; + for (NodeId s = 0; s < 32; ++s) pairs.push_back({{s}, {static_cast(399 - s)}}); + // CostOnly routing: each policy's DAG is the reverse static support toward its destination. + const CapacityOptions opts {FlowPolicyConfig(CostOnly {}, FlowPlacement::EqualBalanced, Admission::Lossless, + PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity))}; + const auto serial = batch_capacity(g, pairs, opts, {}, 1); + const auto parallel = batch_capacity(g, pairs, opts, {}, 4); + ASSERT_EQ(serial.size(), parallel.size()); + for (std::size_t i = 0; i < serial.size(); ++i) { + EXPECT_EQ(serial[i].placed, parallel[i].placed) << i; + EXPECT_EQ(serial[i].edge_flows, parallel[i].edge_flows) << i; + } + EXPECT_TRUE(snapshot(g) == before); +} diff --git a/tests/cpp/graph_fault_tests.cpp b/tests/cpp/graph_fault_tests.cpp new file mode 100644 index 0000000..c5b50d9 --- /dev/null +++ b/tests/cpp/graph_fault_tests.cpp @@ -0,0 +1,178 @@ +// Allocation-failure atomicity of the graph's construction and copy, and the allocation-free +// move (APPROACH 13; task 1e). Runs in the fault-injection executable (fault_injection.hpp), whose +// replacement operator new fails the chosen allocation and counts the live allocations +// (g_live, in flow_ledger_fault_tests.cpp's replacement functions). +// +// Every allocation of every selected construction path fails once: the constructor raises +// std::bad_alloc, the caller's input arrays are unchanged, no allocation stays live (leaks are +// counted, not left to ASan, whose leak detection is off on macOS), and a clean retry gives the +// reference arrays and passes the complete layout checker. The paths: an empty graph, isolated +// nodes, the hash path from creation through growth and collisions, the K-limit transition into +// the fallback, the fallback from the start, the probe-budget fallbacks during collection and +// during a table growth, and the copy constructor. +#include + +#include +#include +#include +#include + +#include "fault_injection.hpp" +#include "graph_reference_1d.hpp" +#include "layout_checker.hpp" +#include "netgraph/core/detail/graph_access.hpp" +#include "netgraph/core/graph.hpp" + + + +using namespace netgraph::core; +using namespace netgraph::core::test; +using detail::GraphAccess; + +namespace { + +struct Input { + std::string name; + std::int32_t n {0}; + std::vector src {}, dst {}; + std::vector cap {}; + std::vector cost {}; +}; + +Input exact_k(std::int64_t k, std::size_t extra, std::int32_t n) { + Input in {"K=" + std::to_string(k), n}; + for (std::int64_t c = 1; c <= k; ++c) in.cost.push_back(c * 5 + 2); + for (std::size_t j = 0; j < extra; ++j) in.cost.push_back(in.cost[(j * 7919) % in.cost.size()]); + for (std::size_t i = 0; i < in.cost.size(); ++i) { + in.src.push_back(static_cast((i * 31) % static_cast(n))); + in.dst.push_back(static_cast((i * 17 + 3) % static_cast(n))); + in.cap.push_back(static_cast(i % 9)); + } + return in; +} + +Input colliding(std::size_t count) { + Input in {"collisions", 8}; + for (Cost c = 1; in.cost.size() < count; ++c) { + if ((detail::cost_hash(c) & 63) == 9) in.cost.push_back(c); + } + for (std::size_t i = 0; i < in.cost.size(); ++i) { + in.src.push_back(static_cast(i % 8)); + in.dst.push_back(static_cast((i + 3) % 8)); + in.cap.push_back(1); + } + return in; +} + +// The probe-budget fallbacks (closed-form shapes of GraphLayout.ProbeBudgetFallbackInCollectionAndGrowth): +// 64 slot-zero costs plus D duplicates alternating the 63rd and 64th, optionally a 65th one. +Input budget_shape(const std::string& name, std::size_t dups, bool tail) { + std::vector z; + for (Cost c = 1; z.size() < 65; ++c) { + if ((detail::cost_hash(c) & 2047) == 0) z.push_back(c); + } + Input in {name, 7}; + std::vector c(z.begin(), z.begin() + 64); + for (std::size_t i = 0; i < dups; ++i) c.push_back(z[i % 2 == 0 ? 62 : 63]); + if (tail) c.push_back(z[64]); + for (std::size_t i = 0; i < c.size(); ++i) { + in.src.push_back(static_cast(i % 7)); + in.dst.push_back(static_cast((i * 3) % 7)); + in.cap.push_back(static_cast(i % 11)); + in.cost.push_back(c[i]); + } + return in; +} + +std::vector paths() { + return {Input {"empty", 0}, + Input {"isolated", 64}, + exact_k(1, 10, 4), + exact_k(31, 200, 16), + exact_k(500, 100, 32), // table growths + colliding(40), + exact_k(65537, 50, 4096), // the K-limit transition after a full table + exact_k(70000, 0, 4096), + budget_shape("budget exhausted in collection", 10000, false), + budget_shape("budget exhausted in growth", 9380, true)}; +} + +void expect_built(const Graph& g, const Input& in, const std::string& where) { + const auto r = reference_1d(in.n, in.src, in.dst, in.cap, in.cost, 1.0); + EXPECT_EQ(std::vector(g.input_index().begin(), g.input_index().end()), r.input_index) << where; + EXPECT_EQ(std::vector(g.in_adj_edge_index().begin(), g.in_adj_edge_index().end()), r.in_adj_edge_index) + << where; + const auto bad = check_layout({g.num_nodes(), g.num_edges(), g.capacity_units(), g.cost(), g.edge_src(), g.edge_dst(), + g.input_index(), g.row_offsets(), g.in_row_offsets(), g.in_adj_edge_index(), + GraphAccess::in_col_indices(g), g.max_cost(), g.cost_gcd()}, + {in.n, in.src, in.dst, in.cap, in.cost}); + EXPECT_TRUE(bad.empty()) << where << ": " << (bad.empty() ? "" : bad.front()); +} + +} // namespace + +TEST(GraphFaults, EveryConstructionAllocationFailsCleanly) { + for (const auto& in : paths()) { + const Input saved = in; + long k = 0; + for (;; ++k) { + const long live = g_live.load(); + const bool failed = fails_at(k, [&] { const Graph g(in.n, in.src, in.dst, in.cap, in.cost, 1.0); }); + EXPECT_EQ(g_live.load(), live) << in.name << " allocation " << k << ": a leak"; + EXPECT_TRUE(in.src == saved.src && in.dst == saved.dst && in.cap == saved.cap && in.cost == saved.cost) + << in.name << " allocation " << k << ": the input changed"; + if (!failed) break; + const Graph retry(in.n, in.src, in.dst, in.cap, in.cost, 1.0); + expect_built(retry, in, in.name + " retry after allocation " + std::to_string(k)); + } + // The allocation count of each path is recorded for NOTES.md (the observed list). + RecordProperty(in.name + " allocations", static_cast(k)); + // An edgeless graph allocates its two offset arrays only; with edges, the nine retained + // arrays at least. + if (in.src.empty()) { + EXPECT_EQ(k, 2) << in.name; + } else { + EXPECT_GE(k, 9) << in.name; + } + } +} + +TEST(GraphFaults, EveryCopyAllocationFailsCleanly) { + for (const auto& in : paths()) { + const Graph g(in.n, in.src, in.dst, in.cap, in.cost, 1.0); + for (long k = 0;; ++k) { + const long live = g_live.load(); + const bool failed = fails_at(k, [&] { const Graph c(g); }); + EXPECT_EQ(g_live.load(), live) << in.name << " allocation " << k << ": a leak"; + expect_built(g, in, in.name + " copied-from after allocation " + std::to_string(k)); + if (!failed) break; + const Graph retry(g); + expect_built(retry, in, in.name + " copy retry"); + EXPECT_NE(retry.uid(), g.uid()); + } + } +} + +// The declared noexcept move allocates nothing. The armed injection fails the first allocation +// anywhere in the child: an allocating move would throw inside noexcept and terminate the child, +// which the parent reports as a failure (death-test subprocess, so the suite survives). +TEST(GraphFaults, MoveIsAllocationFreeUnderAnArmedFailure) { + GTEST_FLAG_SET(death_test_style, "threadsafe"); + const auto in = exact_k(31, 200, 16); + EXPECT_EXIT( + { + Graph g(in.n, in.src, in.dst, in.cap, in.cost, 1.0); + const auto uid = g.uid(); + const auto edges = g.num_edges(); + const long live = g_live.load(); + g_countdown = 0; // the next allocation, in any thread, throws + Graph m(std::move(g)); + const bool untouched = g_countdown.load() == 0; // no allocation was attempted + g_countdown = -1; + const bool ok = untouched && g_live.load() == live && m.uid() == uid && m.num_edges() == edges && + g.num_edges() == 0 && g.row_offsets().size() == 1 && // NOLINT(bugprone-use-after-move) + g.resolution() == m.resolution(); + std::_Exit(ok ? 0 : 3); + }, + ::testing::ExitedWithCode(0), ""); +} diff --git a/tests/cpp/graph_layout_tests.cpp b/tests/cpp/graph_layout_tests.cpp new file mode 100644 index 0000000..53ac375 --- /dev/null +++ b/tests/cpp/graph_layout_tests.cpp @@ -0,0 +1,800 @@ +// Layout B' storage and construction (APPROACH 3; task 1e): the independent layout checker on +// every graph, its corruption controls, the differential test against the pinned 1d constructor +// (every array, every error), the hash/fallback boundary, copies and moves, and the retained +// array payload. The manifest instance 22@1e.storage names 13 obligations; each test below names +// the one it covers ("manifest N"). +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "graph_reference_1d.hpp" +#include "layout_checker.hpp" +#include "netgraph/core/dag_cache.hpp" +#include "netgraph/core/detail/graph_access.hpp" +#include "netgraph/core/evaluators.hpp" +#include "netgraph/core/graph.hpp" +#include "netgraph/core/pred_dag.hpp" +#include "netgraph/core/search.hpp" +#include "test_utils.hpp" + +using namespace netgraph::core; +using namespace netgraph::core::test; +using detail::GraphAccess; + +namespace { + +constexpr Amount L62 = Amount{1} << 62; + +struct Input { + std::string name; + std::int32_t n {0}; + std::vector src {}, dst {}; + std::vector cap {}; + std::vector cost {}; + double q {1.0}; +}; + +LayoutInput view(const Input& in) { return {in.n, in.src, in.dst, in.cap, in.cost}; } + +LayoutSpans spans_of(const Graph& g) { + return {g.num_nodes(), g.num_edges(), g.capacity_units(), g.cost(), g.edge_src(), g.edge_dst(), g.input_index(), + g.row_offsets(), g.in_row_offsets(), g.in_adj_edge_index(), GraphAccess::in_col_indices(g), g.max_cost(), + g.cost_gcd()}; +} + +template +std::vector vec(std::span s) { + return {s.begin(), s.end()}; +} + +// Every public array, its element width, the counts, the resolution and the scans equal the +// reference's; the internal copy equals the reference's edge_src[in_adj_edge_index[slot]]. +void expect_same_as_reference(const Graph& g, const Input& in) { + const auto r = reference_1d(in.n, in.src, in.dst, in.cap, in.cost, in.q); + static_assert(sizeof(decltype(g.capacity_units())::element_type) == 8); + static_assert(sizeof(decltype(g.cost())::element_type) == 8); + static_assert(sizeof(decltype(g.edge_src())::element_type) == 4); + static_assert(sizeof(decltype(g.input_index())::element_type) == 4); + static_assert(sizeof(decltype(g.in_adj_edge_index())::element_type) == 4); + EXPECT_EQ(g.num_nodes(), r.num_nodes) << in.name; + EXPECT_EQ(g.num_edges(), static_cast(r.capacity.size())) << in.name; + EXPECT_EQ(g.resolution(), r.resolution) << in.name; + EXPECT_EQ(g.max_cost(), r.max_cost) << in.name; + EXPECT_EQ(g.cost_gcd(), r.cost_gcd) << in.name; + EXPECT_EQ(vec(g.capacity_units()), r.capacity) << in.name; + EXPECT_EQ(vec(g.cost()), r.cost) << in.name; + EXPECT_EQ(vec(g.edge_src()), r.src) << in.name; + EXPECT_EQ(vec(g.edge_dst()), r.dst) << in.name; + EXPECT_EQ(vec(g.input_index()), r.input_index) << in.name; + EXPECT_EQ(vec(g.row_offsets()), r.row_offsets) << in.name; + EXPECT_EQ(vec(g.in_row_offsets()), r.in_row_offsets) << in.name; + EXPECT_EQ(vec(g.in_adj_edge_index()), r.in_adj_edge_index) << in.name; + EXPECT_EQ(vec(GraphAccess::in_col_indices(g)), reference_in_col_indices(r)) << in.name; + const auto bad = check_layout(spans_of(g), view(in)); + EXPECT_TRUE(bad.empty()) << in.name << ": " << (bad.empty() ? "" : bad.front()); +} + +Graph build(const Input& in) { return Graph(in.n, in.src, in.dst, in.cap, in.cost, in.q); } + +std::int64_t distinct(std::vector c) { + std::sort(c.begin(), c.end()); + return std::unique(c.begin(), c.end()) - c.begin(); +} + +// --------------------------------------------------------------------------- generators + +Input random_input(std::string name, std::int32_t n, std::size_t m, Cost max_cost, std::uint64_t seed) { + std::mt19937_64 rng(seed); + Input in {std::move(name), n}; + for (std::size_t i = 0; i < m; ++i) { + in.src.push_back(static_cast(rng() % static_cast(n))); + in.dst.push_back(static_cast(rng() % static_cast(n))); + in.cap.push_back(static_cast(rng() % 5)); // zero capacities included + in.cost.push_back(static_cast(1 + rng() % static_cast(max_cost))); + } + return in; +} + +Input permuted(const Input& in, const std::vector& p, const std::string& suffix) { + Input out {in.name + suffix, in.n, {}, {}, {}, {}, in.q}; + for (std::size_t i : p) { + out.src.push_back(in.src[i]); + out.dst.push_back(in.dst[i]); + out.cap.push_back(in.cap[i]); + out.cost.push_back(in.cost[i]); + } + return out; +} + +// The same edges in B' order, reversed and shuffled (all property arrays permuted together). +std::vector orders(const Input& in, std::uint64_t seed) { + const auto r = reference_1d(in.n, in.src, in.dst, in.cap, in.cost, in.q); + std::vector sorted(r.input_index.begin(), r.input_index.end()); + std::vector rev(in.src.size()); + std::iota(rev.rbegin(), rev.rend(), 0); + std::vector sh(in.src.size()); + std::iota(sh.begin(), sh.end(), 0); + std::shuffle(sh.begin(), sh.end(), std::mt19937_64(seed)); + return {in, permuted(in, sorted, " (B' order)"), permuted(in, rev, " (reversed)"), permuted(in, sh, " (shuffled)")}; +} + +// Exactly k distinct costs, each present, plus `extra` repeats; distinct capacities. +Input exact_k(std::int64_t k, std::size_t extra, std::int32_t n, std::uint64_t seed, Cost stride = 3) { + std::mt19937_64 rng(seed); + Input in {"K=" + std::to_string(k), n}; + for (std::int64_t c = 1; c <= k; ++c) in.cost.push_back(c * stride + 1); + for (std::size_t j = 0; j < extra; ++j) in.cost.push_back(in.cost[rng() % in.cost.size()]); + std::shuffle(in.cost.begin(), in.cost.end(), rng); + for (std::size_t i = 0; i < in.cost.size(); ++i) { + in.src.push_back(static_cast(rng() % static_cast(n))); + in.dst.push_back(static_cast(rng() % static_cast(n))); + in.cap.push_back(static_cast(1 + i)); + } + return in; +} + +// Costs that share a table slot under the given mask of the construction's hash. +std::vector colliding_costs(std::uint64_t mask, std::size_t count) { + std::vector out; + for (Cost c = 1; out.size() < count; ++c) { + if ((detail::cost_hash(c) & mask) == 5) out.push_back(c); + } + return out; +} + +} // namespace + +// --------------------------------------------------------------------------- differential + +TEST(GraphLayout, DifferentialSmallAndDegenerateShapes) { + std::vector cases = { + {"N=0", 0}, + {"N=1, E=0", 1}, + {"N=1 self-loops", 1, {0, 0, 0}, {0, 0, 0}, {3, 1, 2}, {2, 2, 1}}, + {"E=0, N=1M", 1 << 20}, + random_input("N >> E", 1 << 18, 64, 9, 3), + {"parallel edges, distinct capacities", 2, {0, 0, 0, 0}, {1, 1, 1, 1}, {4, 3, 2, 1}, {5, 5, 5, 5}}, + }; + for (const auto& in : cases) expect_same_as_reference(build(in), in); +} + +TEST(GraphLayout, DifferentialRandomGraphsInFourOrders) { + for (std::uint64_t seed = 1; seed <= 200; ++seed) { + const auto base = random_input("seed " + std::to_string(seed), 1 + static_cast(seed % 37), + 1 + seed % 211, 1 + static_cast(seed % 13), seed); + for (const auto& in : orders(base, seed)) expect_same_as_reference(build(in), in); + } +} + +TEST(GraphLayout, DifferentialOneLongRowEachDirection) { + Input out {"long out-row", 50}; + for (int i = 0; i < 20000; ++i) { + out.src.push_back(7); + out.dst.push_back(i % 50); + out.cap.push_back(i); + out.cost.push_back(1 + (i * 7919) % 31); + } + for (const auto& in : orders(out, 11)) expect_same_as_reference(build(in), in); + Input in = out; + in.name = "long in-row"; + std::swap(in.src, in.dst); + for (const auto& x : orders(in, 12)) expect_same_as_reference(build(x), x); +} + +TEST(GraphLayout, DifferentialDistinctCostCounts) { + for (std::int64_t k : {1, 2, 31, 4096, 65535, 65536, 65537}) { + const auto in = exact_k(k, 4096, 4096, static_cast(k)); + ASSERT_EQ(distinct(in.cost), k); + expect_same_as_reference(build(in), in); + } + // K about E: every cost distinct. + const auto all = exact_k(200000, 0, 4096, 7); + expect_same_as_reference(build(all), all); +} + +TEST(GraphLayout, DifferentialWideIntegers) { + // Costs and units above 2^53, sums just below 2^62; separate from the large-K cases (the cost + // sum bound forbids combining them). + const Cost big = Cost{1} << 59; // four of them sum to 2^61: below the bound + Input wide {"wide costs", 3, {0, 0, 1, 2, 0}, {1, 1, 2, 0, 1}, {(Amount{1} << 53) + 1, Amount{1} << 61, 0, 5, 3}, + {big + 3, big + 1, big + 2, 1, big + 1}}; + expect_same_as_reference(build(wide), wide); + Input sums {"sums just below 2^62", 2, {0, 0}, {1, 1}, {L62 / 2, L62 / 2 - 1}, {L62 / 2, L62 / 2 - 1}}; + expect_same_as_reference(build(sums), sums); + Input units {"units above 2^53", 2, {0, 1}, {1, 0}, {(Amount{1} << 53) + 1, (Amount{1} << 53) + 3}, {1, 1}}; + expect_same_as_reference(build(units), units); +} + +TEST(GraphLayout, DifferentialHashCollisions) { + // Costs colliding in the initial table (64 slots) and in the full one (2^18 slots at load 1/4). + for (std::uint64_t mask : {std::uint64_t {63}, (std::uint64_t {1} << 18) - 1}) { + Input in {"collisions mask " + std::to_string(mask), 16}; + in.cost = colliding_costs(mask, 40); + for (std::size_t i = 0; i < in.cost.size(); ++i) { + in.src.push_back(static_cast(i % 16)); + in.dst.push_back(static_cast((i * 5) % 16)); + in.cap.push_back(static_cast(i)); + } + expect_same_as_reference(build(in), in); + } +} + +// --------------------------------------------------------------------------- errors + +namespace { + +// The exception a construction raises, as (type name, message); ("", "") when none. +template +std::pair outcome(F&& fn) { + try { + fn(); + } catch (const std::exception& e) { + return {typeid(e).name(), e.what()}; + } + return {"", ""}; +} + +// The defects of APPROACH 3's validation steps, each applied to a copy of a valid input. +using Defect = std::function; + +std::vector> defects() { + return { + {"resolution", [](Input& x) { x.q = 3.0; }}, + {"capacity negative at 2", [](Input& x) { x.cap[2] = -1; }}, + {"capacity 2^62 at 1", [](Input& x) { x.cap[1] = L62; }}, + {"num_nodes negative", [](Input& x) { x.n = -1; }}, + {"dst shorter", [](Input& x) { x.dst.pop_back(); }}, + {"cost longer", [](Input& x) { x.cost.push_back(1); }}, + {"cost zero at 3", [](Input& x) { x.cost[3] = 0; }}, + {"cost negative at 1", [](Input& x) { x.cost[1] = -4; }}, + {"src out of range at 2", [](Input& x) { x.src[2] = x.n; }}, + {"dst negative at 1", [](Input& x) { x.dst[1] = -1; }}, + {"capacity sum", [](Input& x) { x.cap[0] = L62 / 2, x.cap[3] = L62 / 2; }}, + {"cost sum", [](Input& x) { x.cost[1] = L62 / 2, x.cost[2] = L62 / 2; }}, + }; +} + +} // namespace + +TEST(GraphLayout, DifferentialErrorsSingleAndPairwise) { + const Input base {"base", 4, {0, 1, 2, 3, 0}, {1, 2, 3, 0, 2}, {1, 2, 3, 4, 5}, {1, 2, 3, 4, 5}}; + const auto ds = defects(); + std::size_t checked = 0; + for (std::size_t a = 0; a < ds.size(); ++a) { + for (std::size_t b = a; b < ds.size(); ++b) { + Input in = base; + ds[a].second(in); + if (b != a) ds[b].second(in); + const auto got = outcome([&] { (void)build(in); }); + const auto want = outcome([&] { (void)reference_1d(in.n, in.src, in.dst, in.cap, in.cost, in.q); }); + ASSERT_FALSE(want.first.empty()) << ds[a].first << " + " << ds[b].first; + EXPECT_EQ(got, want) << ds[a].first << " + " << ds[b].first; + ++checked; + } + } + EXPECT_EQ(checked, ds.size() * (ds.size() + 1) / 2); +} + +TEST(GraphLayout, DifferentialErrorsFirstInvalidIndexAndSumOrder) { + // Two defects of one kind: the lower input index is named. + Input in {"two costs", 3, {0, 1, 2, 0}, {1, 2, 0, 2}, {1, 1, 1, 1}, {1, -2, 0, 1}}; + auto got = outcome([&] { (void)build(in); }); + EXPECT_EQ(got, outcome([&] { (void)reference_1d(in.n, in.src, in.dst, in.cap, in.cost, in.q); })); + EXPECT_NE(got.second.find("cost[1] = -2"), std::string::npos) << got.second; + // Capacity and cost sums both over: the one whose running sum crosses first, in input order. + Input both {"sums", 2, {0, 0, 0}, {1, 1, 1}, {1, L62 / 2, L62 / 2}, {L62 / 2, L62 / 2, 1}}; + got = outcome([&] { (void)build(both); }); + EXPECT_EQ(got, outcome([&] { (void)reference_1d(both.n, both.src, both.dst, both.cap, both.cost, both.q); })); + EXPECT_NE(got.second.find("edge costs"), std::string::npos) << got.second; +} + +// --------------------------------------------------------------------------- manifest obligations + +// manifest 1-8: the corruption controls of the checker, on a graph built by the constructor. +// The fixture repeats (src, cost, dst) keys with distinct capacities and input positions, and its +// reverse slot order differs from its id order, with different source values. +TEST(GraphLayout, CheckerRejectsEveryCorruption) { + const Input in {"controls", 4, {0, 2, 0, 1, 0, 3, 2, 0}, {1, 1, 1, 1, 1, 1, 3, 3}, {5, 6, 7, 8, 9, 1, 2, 3}, + {2, 1, 2, 1, 2, 1, 4, 4}}; + const Graph g = build(in); + ASSERT_TRUE(check_layout(spans_of(g), view(in)).empty()); + struct Copy { + std::vector capacity; + std::vector cost; + std::vector src, dst; + std::vector input_index, row_offsets, in_row_offsets; + std::vector in_adj; + std::vector in_col; + Cost max_cost, cost_gcd; + }; + const Copy clean {vec(g.capacity_units()), vec(g.cost()), vec(g.edge_src()), vec(g.edge_dst()), vec(g.input_index()), + vec(g.row_offsets()), vec(g.in_row_offsets()), vec(g.in_adj_edge_index()), + vec(GraphAccess::in_col_indices(g)), g.max_cost(), g.cost_gcd()}; + bool reverse_differs = false; + for (std::size_t s = 0; s < clean.in_adj.size(); ++s) reverse_differs |= clean.in_adj[s] != static_cast(s); + ASSERT_TRUE(reverse_differs); + auto check = [&](const Copy& c) { + return check_layout({in.n, static_cast(in.src.size()), c.capacity, c.cost, c.src, c.dst, c.input_index, + c.row_offsets, c.in_row_offsets, c.in_adj, std::span(c.in_col), c.max_cost, + c.cost_gcd}, + view(in)); + }; + const std::vector>> controls = { + // manifest 1: wrong source or row boundary + {"wrong source", "forward:membership", [](Copy& c) { c.src[0] = 1, c.input_index[0] = c.input_index[0]; }}, + {"row boundary moved", "forward:membership", [](Copy& c) { ++c.row_offsets[1]; }}, + // manifest 2: all-zero offsets (a graph with edges) + {"all-zero offsets", "row_offsets:end", [](Copy& c) { std::fill(c.row_offsets.begin(), c.row_offsets.end(), 0); }}, + // manifest 3: missing or duplicated reverse coverage + {"duplicated reverse slot", "slot:permutation", [](Copy& c) { c.in_adj[1] = c.in_adj[0]; }}, + {"wrong reverse destination", "reverse:membership", [](Copy& c) { ++c.in_row_offsets[1]; }}, + // manifest 4: (src, cost) substituted for (cost, src) + {"(src, cost) reverse order", "reverse:order", + [](Copy& c) { + for (std::size_t v = 0; v + 1 < c.in_row_offsets.size(); ++v) { + std::sort(c.in_adj.begin() + c.in_row_offsets[v], c.in_adj.begin() + c.in_row_offsets[v + 1], + [&](EdgeId p, EdgeId q) { + return std::tuple(c.src[static_cast(p)], c.cost[static_cast(p)], p) < + std::tuple(c.src[static_cast(q)], c.cost[static_cast(q)], q); + }); + } + for (std::size_t s = 0; s < c.in_col.size(); ++s) c.in_col[s] = c.src[static_cast(c.in_adj[s])]; + }}, + // manifest 5: unstable equal-key members + {"unstable equal keys", "forward:order", + [](Copy& c) { + for (std::size_t e = 0; e + 1 < c.cost.size(); ++e) { + if (c.src[e] == c.src[e + 1] && c.cost[e] == c.cost[e + 1] && c.dst[e] == c.dst[e + 1]) { + std::swap(c.input_index[e], c.input_index[e + 1]); + std::swap(c.capacity[e], c.capacity[e + 1]); + return; + } + } + FAIL() << "the fixture has no equal-key pair"; + }}, + // manifest 6: corrupted map + {"map entries swapped", "property:capacity", [](Copy& c) { std::swap(c.input_index[0], c.input_index[1]); }}, + // manifest 7: property-only mispermutation + {"capacities swapped", "property:capacity", [](Copy& c) { std::swap(c.capacity[1], c.capacity[2]); }}, + // manifest 8: out-of-range map or slot before access + {"negative map entry", "map:permutation", [](Copy& c) { c.input_index[1] = -3; }}, + {"map entry E", "map:permutation", [](Copy& c) { c.input_index[1] = static_cast(c.input_index.size()); }}, + {"slot out of range", "slot:permutation", [](Copy& c) { c.in_adj[2] = 1 << 30; }}, + {"negative slot", "slot:permutation", [](Copy& c) { c.in_adj[2] = -1; }}, + // the brief's additional controls + {"truncated capacity", "size:capacity", [](Copy& c) { c.capacity.pop_back(); }}, + {"oversized slots", "size:in_adj_edge_index", [](Copy& c) { c.in_adj.push_back(0); }}, + {"truncated offsets", "size:in_row_offsets", [](Copy& c) { c.in_row_offsets.pop_back(); }}, + {"truncated copy", "size:in_col_indices", [](Copy& c) { c.in_col.pop_back(); }}, + {"negative offset", "row_offsets:range", [](Copy& c) { c.row_offsets[1] = -1; }}, + {"excessive offset", "in_row_offsets:range", [](Copy& c) { c.in_row_offsets[2] = 1 << 30; }}, + {"wrong copied source", "copy:source", [](Copy& c) { c.in_col[0] = (c.in_col[0] + 1) % 4; }}, + {"copy indexed by id", "copy:source", + [](Copy& c) { + for (std::size_t s = 0; s < c.in_col.size(); ++s) c.in_col[s] = c.src[s]; + }}, + {"copied source out of range", "range:in_col_indices", [](Copy& c) { c.in_col[0] = 4; }}, + {"changed maximum", "scan:max_cost", [](Copy& c) { ++c.max_cost; }}, + {"changed GCD", "scan:cost_gcd", [](Copy& c) { c.cost_gcd = 2; }}, + }; + for (const auto& [name, code, corrupt] : controls) { + Copy c = clean; + corrupt(c); + const auto bad = check(c); + EXPECT_NE(std::find(bad.begin(), bad.end(), code), bad.end()) << name << ": expected " << code; + } + // Empty graphs are positive controls: all-zero offsets are correct without edges. + for (std::int32_t n : {0, 1, 1000}) { + const Input e {"empty", n}; + EXPECT_TRUE(check_layout(spans_of(build(e)), view(e)).empty()) << n; + } +} + +// manifest 9: hash and fallback boundary at 65,536 distinct costs (the branch observation), on +// inputs that do not exhaust the probe budget. +TEST(GraphLayout, HashAndFallbackBoundaryAt65536DistinctCosts) { +#if NETGRAPH_CORE_PRIVATE_HOOKS + for (const auto& [k, hashed] : std::vector> {{65535, true}, {65536, true}, {65537, false}}) { + const auto in = exact_k(k, 1000, 4096, static_cast(k)); + ASSERT_EQ(distinct(in.cost), k); + auto& counts = detail::thread_construction_counts(); + const auto before = counts; + const Graph g = build(in); + EXPECT_EQ(counts.hashed - before.hashed, hashed ? 1 : 0) << k; + EXPECT_EQ(counts.fallback - before.fallback, hashed ? 0 : 1) << k; + EXPECT_EQ(counts.distinct_limit - before.distinct_limit, hashed ? 0 : 1) << k; + EXPECT_EQ(counts.exhausted_collection - before.exhausted_collection, 0) << k; + EXPECT_EQ(counts.exhausted_growth - before.exhausted_growth, 0) << k; + EXPECT_LE(counts.last_probes, counts.last_budget) << k; + if (hashed) { + EXPECT_EQ(counts.last_distinct, k); + EXPECT_LE(counts.last_table_slots, std::int64_t {1} << 18); // load <= 1/4 at K = 65,536 + EXPECT_GE(counts.last_table_slots, 4 * k); + } + expect_same_as_reference(g, in); + } +#else + GTEST_SKIP() << "branch observations need the private hooks"; +#endif +} + +// Proposal P4: with N K < E the passes run on combined keys; N K = E must take the five single-key +// passes. Both give the reference arrays, in every input order, and the random differential +// inputs reach both branches. +TEST(GraphLayout, CombinedKeyPassesOnlyBelowNTimesKEqualsE) { +#if NETGRAPH_CORE_PRIVATE_HOOKS + auto shaped = [](std::int32_t n, std::int64_t k, std::size_t e, std::uint64_t seed) { + std::mt19937_64 rng(seed); + Input in {"N=" + std::to_string(n) + " K=" + std::to_string(k) + " E=" + std::to_string(e), n}; + for (std::size_t i = 0; i < e; ++i) { + in.src.push_back(static_cast(rng() % static_cast(n))); + in.dst.push_back(static_cast(rng() % static_cast(n))); + in.cap.push_back(static_cast(1 + i)); + const auto j = i < static_cast(k) ? static_cast(i) : static_cast(rng() % static_cast(k)); + in.cost.push_back(3 * j + 1); + } + return in; + }; + for (const auto& [n, k] : std::vector> {{5, 1}, {10, 3}, {400, 2}, {64, 31}}) { + const auto nk = static_cast(n) * static_cast(k); + for (const auto& [e, fused] : std::vector> {{nk + 1, true}, {nk, false}}) { + for (const auto& in : orders(shaped(n, k, e, nk + e), e)) { + ASSERT_EQ(distinct(in.cost), k) << in.name; + auto& counts = detail::thread_construction_counts(); + const auto before = counts.fused; + const Graph g = build(in); + EXPECT_EQ(counts.fused - before, fused ? 1 : 0) << in.name; + expect_same_as_reference(g, in); + } + } + } + std::int64_t fused = 0, plain = 0; + for (std::uint64_t seed = 1; seed <= 200; ++seed) { + const auto in = random_input("seed " + std::to_string(seed), 1 + static_cast(seed % 37), + 1 + seed % 211, 1 + static_cast(seed % 13), seed); + const auto before = detail::thread_construction_counts().fused; + (void)build(in); + (detail::thread_construction_counts().fused > before ? fused : plain) += 1; + } + EXPECT_GT(fused, 0); + EXPECT_GT(plain, 0); +#else + GTEST_SKIP() << "branch observations need the private hooks"; +#endif +} + +// The fallback's 16-bit digits (review-1e-consult/REPORT-2): N = 2, two copies of [1, ..., 65,536, +// 2^59] (K = 65,537, E = 131,074 = N K: the single-key passes; the cost sum is below 2^62), then +// one more cost of 1 (E = N K + 1: P4). Both take the distinct-limit fallback (observed); by the +// costs' values its radix sorts on the digits at 0, 16 and 48 bits and skips the one at 32 (one +// digit value), with arrays equal to the reference in four input orders. +TEST(GraphLayout, RadixFallbackDigitsOnBothSidesOfP4) { +#if NETGRAPH_CORE_PRIVATE_HOOKS + Input base {"two copies of 1..65,536 and 2^59", 2}; + for (int copy = 0; copy < 2; ++copy) { + for (Cost c = 1; c <= 65536; ++c) base.cost.push_back(c); + base.cost.push_back(Cost {1} << 59); + } + Input plus = base; + plus.name += " + one cost 1"; + plus.cost.push_back(1); + for (Input* in : {&base, &plus}) { + for (std::size_t i = 0; i < in->cost.size(); ++i) { + in->src.push_back(static_cast(i % 2)); + in->dst.push_back(static_cast((i / 2) % 2)); + in->cap.push_back(static_cast(1 + i)); + } + } + ASSERT_EQ(base.cost.size(), 131074u); + for (const auto& [x, fused] : std::vector> {{&base, false}, {&plus, true}}) { + ASSERT_EQ(distinct(x->cost), 65537); + for (const auto& in : orders(*x, x->cost.size())) { + auto& counts = detail::thread_construction_counts(); + const auto before = counts; + const Graph g = build(in); + EXPECT_EQ(counts.distinct_limit - before.distinct_limit, 1) << in.name; + EXPECT_EQ(counts.fallback - before.fallback, 1) << in.name; + EXPECT_EQ(counts.fused - before.fused, fused ? 1 : 0) << in.name; + EXPECT_EQ(counts.last_distinct, 65537) << in.name; + expect_same_as_reference(g, in); + } + } +#else + GTEST_SKIP() << "branch observations need the private hooks"; +#endif +} + +// A run of equal costs reuses the previous ordinal without a lookup: one cost repeated costs +// exactly one probe; two alternating costs in different slots cost one probe per edge. +TEST(GraphLayout, RunsOfEqualCostsSkipTheLookup) { +#if NETGRAPH_CORE_PRIVATE_HOOKS + Cost a = 1, b = 2; + while ((detail::cost_hash(a) & 63) == (detail::cost_hash(b) & 63)) ++b; + for (const bool alternate : {false, true}) { + Input in {alternate ? "alternating" : "one run", 9}; + for (int i = 0; i < 1000; ++i) { + in.src.push_back(i % 9); + in.dst.push_back((i * 4) % 9); + in.cap.push_back(i); + in.cost.push_back(alternate && i % 2 == 1 ? b : a); + } + const Graph g = build(in); + EXPECT_EQ(detail::thread_construction_counts().last_probes, alternate ? 1000 : 1) << in.name; + expect_same_as_reference(g, in); + } +#else + GTEST_SKIP() << "branch observations need the private hooks"; +#endif +} + +// The cumulative probe budget B = 8E + 8 x 65,536 (APPROACH 3 revision 43). Costs whose hash has +// 11 low zero bits start at slot 0 in every table up to 2,048 slots, so they form one cluster: +// the j-th of them (from 0) is found after j + 1 probes. After the 64 cluster costs come D +// duplicates alternating z[62], z[63] (a run of one cost reuses its ordinal without a lookup, so +// the duplicates must alternate), each costing 63 or 64 probes while B grows by 8 per edge. The +// boundaries come from an exact model of the probe accounting (build/1e/prep/budget_model.py): +// * collection: exhausted at an input lookup exactly when D >= 9,406; +// * growth: with a 65th cluster cost at the end (its lookup, then the reinsertion into 512 +// slots, then the lookup repeated), exhausted inside the growth for 9,366 <= D <= 9,404, not at +// all for D <= 9,365, and at the 65th cost's first lookup (collection) for D = 9,405. +// Exhausted constructions use exactly B probes and none more; the arrays equal the reference. +TEST(GraphLayout, ProbeBudgetFallbackInCollectionAndGrowth) { +#if NETGRAPH_CORE_PRIVATE_HOOKS + std::vector z; + for (Cost c = 1; z.size() < 65; ++c) { + if ((detail::cost_hash(c) & 2047) == 0) z.push_back(c); + } + auto shape = [&](std::size_t dups, bool tail) { + Input in {"cluster D=" + std::to_string(dups) + (tail ? " + 65th" : ""), 7}; + std::vector c(z.begin(), z.begin() + 64); + for (std::size_t i = 0; i < dups; ++i) c.push_back(z[i % 2 == 0 ? 62 : 63]); + if (tail) c.push_back(z[64]); + for (std::size_t i = 0; i < c.size(); ++i) { + in.src.push_back(static_cast(i % 7)); + in.dst.push_back(static_cast((i * 3) % 7)); + in.cap.push_back(static_cast(i % 11)); + in.cost.push_back(c[i]); + } + return in; + }; + enum Where { None, Collection, Growth }; + const std::vector> cases = { + {9000, false, None}, {9405, false, None}, {9406, false, Collection}, {10000, false, Collection}, + {9300, true, None}, {9365, true, None}, {9366, true, Growth}, {9380, true, Growth}, + {9404, true, Growth}, {9405, true, Collection}, + }; + + for (const auto& [dups, tail, where] : cases) { + const auto in = shape(dups, tail); + auto& counts = detail::thread_construction_counts(); + const auto before = counts; + const Graph g = build(in); + const std::int64_t budget = 8 * static_cast(in.src.size()) + 8 * 65536; + EXPECT_EQ(counts.last_budget, budget) << in.name; + EXPECT_LE(counts.last_probes, budget) << in.name; // no lookup examines more than B in total + EXPECT_EQ(counts.exhausted_collection - before.exhausted_collection, where == Collection ? 1 : 0) << in.name; + EXPECT_EQ(counts.exhausted_growth - before.exhausted_growth, where == Growth ? 1 : 0) << in.name; + EXPECT_EQ(counts.fallback - before.fallback, where == None ? 0 : 1) << in.name; + EXPECT_EQ(counts.hashed - before.hashed, where == None ? 1 : 0) << in.name; + if (where != None) EXPECT_EQ(counts.last_probes, budget) << in.name; + expect_same_as_reference(g, in); + } +#else + GTEST_SKIP() << "branch observations need the private hooks"; +#endif +} + +// The budget stays cumulative across the large table's growths (review-1e): 64 costs share their +// low 18 hash bits, so they collide in every table up to 262,144 slots; further costs grow the +// table to K = 32,769; then the last two colliding costs alternate. An independent count (linear +// probing, every examined slot counted, a growth reinserting the distinct costs in first-appearance +// order) finds the first edge count whose probes exceed B: the collection exhausts the budget +// there, and one edge fewer stays hashed with exactly the counted probes. +TEST(GraphLayout, ProbeBudgetIsCumulativeAcrossTheLargeTableGrowths) { +#if NETGRAPH_CORE_PRIVATE_HOOKS + std::vector z; + for (Cost c = 1; z.size() < 64; ++c) { + if ((detail::cost_hash(c) & 262143) == 0) z.push_back(c); + } + std::vector seq = z; + for (Cost c = 1; seq.size() < 32769; ++c) { + if (!std::binary_search(z.begin(), z.end(), c)) seq.push_back(c); + } + std::vector table(64, 0); + std::vector order; + std::int64_t probes = 0; + auto locate = [&](Cost c) { + for (std::size_t at = detail::cost_hash(c) & (table.size() - 1);; at = (at + 1) & (table.size() - 1)) { + ++probes; + if (table[at] == 0 || table[at] == c) return at; + } + }; + for (const Cost c : seq) { + std::size_t at = locate(c); + if (table[at] != 0) continue; + if (4 * (order.size() + 1) > table.size()) { + table.assign(2 * table.size(), 0); + for (const Cost d : order) table[locate(d)] = d; + at = locate(c); + } + table[at] = c; + order.push_back(c); + } + ASSERT_EQ(order.size(), 32769U); + ASSERT_EQ(table.size(), 262144U); + std::int64_t prefix_probes = 0; // all probes of the longest prefix within its budget + for (std::size_t d = 0;; ++d) { + ASSERT_LT(d, 100000U); + const std::int64_t before = probes; + seq.push_back(z[d % 2 == 0 ? 62 : 63]); + locate(seq.back()); + if (probes > 8 * static_cast(seq.size()) + 8 * 65536) { + prefix_probes = before; + break; + } + } + EXPECT_EQ(seq.size(), 44436U); // the review's independent count (review-1e/probe.log) + + auto input = [&](std::size_t e) { + Input in {"late growth E=" + std::to_string(e), 31}; + for (std::size_t i = 0; i < e; ++i) { + in.src.push_back(static_cast(i % 31)); + in.dst.push_back(static_cast((i * 7) % 31)); + in.cap.push_back(static_cast(i % 13)); + in.cost.push_back(seq[i]); + } + return in; + }; + auto& counts = detail::thread_construction_counts(); + for (const std::size_t e : {seq.size() - 1, seq.size()}) { + const bool exhausts = e == seq.size(); + const auto in = input(e); + const auto before = counts; + const Graph g = build(in); + const std::int64_t budget = 8 * static_cast(e) + 8 * 65536; + EXPECT_EQ(counts.last_budget, budget) << in.name; + EXPECT_EQ(counts.last_probes, exhausts ? budget : prefix_probes) << in.name; + EXPECT_EQ(counts.exhausted_collection - before.exhausted_collection, exhausts ? 1 : 0) << in.name; + EXPECT_EQ(counts.exhausted_growth - before.exhausted_growth, 0) << in.name; + EXPECT_EQ(counts.hashed - before.hashed, exhausts ? 0 : 1) << in.name; + if (!exhausts) EXPECT_EQ(counts.last_distinct, 32769) << in.name; + expect_same_as_reference(g, in); + } +#else + GTEST_SKIP() << "branch observations need the private hooks"; +#endif +} + +// manifest 10: exact units above 2^53 (capacities and costs kept as int64, not through double). +TEST(GraphLayout, ExactUnitsAboveTwo53) { + const Amount big = (Amount{1} << 53) + 1; + const Input in {"2^53 + 1", 2, {0, 0, 1}, {1, 1, 0}, {big, big + 2, 0}, {(Cost{1} << 53) + 1, (Cost{1} << 53) + 3, 1}}; + const Graph g = build(in); + expect_same_as_reference(g, in); + EXPECT_EQ(g.capacity_units()[0], big); + EXPECT_EQ(g.cost()[0], (Cost{1} << 53) + 1); +} + +// manifest 11: a copy keeps every array and the map and draws a new uid. +TEST(GraphLayout, CopyKeepsEveryArrayAndDrawsANewUid) { + for (const auto& in : {exact_k(31, 500, 64, 1), exact_k(65537, 10, 512, 2), Input {"empty", 5}}) { + const Graph g = build(in); + const Graph c(g); + EXPECT_NE(c.uid(), g.uid()); + EXPECT_GT(c.uid(), g.uid()); // a relation, not an absolute counter value + expect_same_as_reference(c, in); + EXPECT_EQ(GraphAccess::retained(c).size_bytes, GraphAccess::retained(g).size_bytes); + } +} + +// The move (P1, review-1e-consult): every value and the uid transferred without allocation +// (the fault executable's witness); the source keeps its resolution, has uid, N, E, maximum and +// GCD zero, empty edge arrays and one zero in each offset view. Copying and moving an already +// moved-from graph give the same state. +TEST(GraphLayout, MoveTransfersEverythingAndLeavesAnEmptyGraph) { + const auto in = exact_k(31, 500, 64, 3); + const double q = std::ldexp(1.0, -12); + Graph g(in.n, in.src, in.dst, in.cap, in.cost, q); + const auto uid = g.uid(); + const Graph m(std::move(g)); + EXPECT_EQ(m.uid(), uid); + EXPECT_EQ(m.resolution(), q); + Input with_q = in; + with_q.q = q; + expect_same_as_reference(m, with_q); + auto expect_moved_from = [&](const Graph& x, const char* what) { + EXPECT_EQ(x.resolution(), q) << what; + EXPECT_EQ(x.num_nodes(), 0) << what; + EXPECT_EQ(x.num_edges(), 0) << what; + EXPECT_EQ(x.max_cost(), 0) << what; + EXPECT_EQ(x.cost_gcd(), 0) << what; + EXPECT_TRUE(x.capacity_units().empty() && x.cost().empty() && x.edge_src().empty() && x.edge_dst().empty() && + x.input_index().empty() && x.in_adj_edge_index().empty() && GraphAccess::in_col_indices(x).empty()) + << what; + EXPECT_EQ(vec(x.row_offsets()), (std::vector {0})) << what; + EXPECT_EQ(vec(x.in_row_offsets()), (std::vector {0})) << what; + EXPECT_TRUE(check_layout(spans_of(x), view(Input {"moved-from", 0})).empty()) << what; + }; + // NOLINTBEGIN(bugprone-use-after-move): the documented moved-from state. + EXPECT_EQ(g.uid(), 0u); + expect_moved_from(g, "source"); + const Graph copied(g); + EXPECT_GT(copied.uid(), uid); // a copy is a new graph, even of an empty one + expect_moved_from(copied, "copy of the source"); // except its uid + Graph again(std::move(g)); + EXPECT_EQ(again.uid(), 0u); + expect_moved_from(again, "moved again"); + expect_moved_from(g, "source moved twice"); + // NOLINTEND(bugprone-use-after-move) +} + +// The retained array payload: 36 E + 8 (N + 1) bytes in exactly nine arrays, capacities equal to +// sizes (no slack, no retained scratch, no inverse map, no physical-capacity array). +TEST(GraphLayout, RetainedPayloadIs36BytesPerEdgePlusOffsets) { + std::vector cases = {Input {"N=0", 0}, Input {"isolated", 1000}, exact_k(31, 4000, 512, 4), + exact_k(65537, 100, 4096, 5)}; + for (const auto& in : cases) { + const Graph g = build(in); + const Graph c(g); + Graph moved_from = build(in); + const Graph m(std::move(moved_from)); + const std::size_t e = in.src.size(); + const std::size_t want = 36 * e + 8 * (static_cast(in.n) + 1); + for (const Graph* x : {&g, &c, &m}) { + const auto r = GraphAccess::retained(*x); + EXPECT_EQ(r.arrays, 9u) << in.name; + EXPECT_EQ(r.size_bytes, want) << in.name; + EXPECT_EQ(r.capacity_bytes, want) << in.name; + } + } +} + +// Manifest 12: graph-bound objects from another graph are rejected at their existing use sites, +// on a twin graph with identical arrays (only the uid differs, so the check is by identity, not by +// shape): the evaluators' Eligibility and DagCache checks, and a Path's DAG (to_dag keeps the +// Path's graph) in resolve_to_paths. The flow core, ledger, policy, KSP, search and cache sites +// have their own tests (NOTES section 7, item 3). +TEST(GraphLayout, ForeignGraphBoundObjectsAreRejectedAtTheirUseSites) { + const std::vector src {0, 0, 1, 2}, dst {1, 2, 3, 3}; + const std::vector cap {5, 5, 5, 5}; + const std::vector cost {1, 1, 1, 1}; + const Graph g(4, src, dst, cap, cost, 1.0), twin(4, src, dst, cap, cost, 1.0); + ASSERT_NE(g.uid(), twin.uid()); + const auto message = [](const std::function& f) { + try { + f(); + } catch (const std::invalid_argument& e) { + return std::string(e.what()); + } + return std::string("no std::invalid_argument"); + }; + const std::vector s {0}, d {3}; + const FlowPolicyConfig cfg {CostOnly {}, FlowPlacement::MaxFlow, Admission::Lossless, + PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity)}; + const Eligibility el = twin.eligibility({}, std::vector {0}); + DagCache cache(twin); + const std::string elig_msg = "foreign Eligibility: built for another graph"; + const std::string cache_msg = "foreign DagCache: bound to another graph"; + EXPECT_EQ(message([&] { (void)capacity(g, s, d, CapacityOptions {cfg, &el}); }), elig_msg); + EXPECT_EQ(message([&] { (void)capacity(g, s, d, CapacityOptions {cfg, nullptr, &cache}); }), cache_msg); + const std::vector, std::vector>> pairs {{s, d}}; + EXPECT_EQ(message([&] { (void)batch_capacity(g, pairs, CapacityOptions {cfg, nullptr, &cache}, {}, 1); }), cache_msg); + EXPECT_EQ(message([&] { (void)sensitivity(g, s, d, SensitivityOptions {cfg, &el}, 1); }), elig_msg); + EXPECT_EQ(message([&] { (void)sensitivity(g, s, d, SensitivityOptions {cfg, nullptr, &cache}, 1); }), cache_msg); + const Path p(twin, {{0}, {2}}); + EXPECT_EQ(message([&] { (void)resolve_to_paths(g, p.to_dag(), 0, 3); }), "foreign PredDAG: built for another graph"); + // The same calls on the owning graph succeed (the controls). + EXPECT_NO_THROW((void)capacity(twin, s, d, CapacityOptions {cfg, &el, &cache})); + EXPECT_EQ(resolve_to_paths(twin, p.to_dag(), 0, 3).size(), 1U); +} diff --git a/tests/cpp/graph_reference_1d.hpp b/tests/cpp/graph_reference_1d.hpp new file mode 100644 index 0000000..c506e39 --- /dev/null +++ b/tests/cpp/graph_reference_1d.hpp @@ -0,0 +1,200 @@ +// The 1d graph constructor kept as a test-only reference (task 1e, differential tests). +// +// Origin, pinned: src/graph.cpp of commit 3e1f3ea6e (sha256 c9af7b06c48a741fbac33d40283c50c2 +// 6763dcda7a5e7acf4be0195bf896ab68), the function Graph::Graph(num_nodes, src, dst, +// capacity_units, cost, resolution) at lines 91-222 and resolution_exponent at lines 42-53. +// The statements are copied unchanged; only the members became fields of Reference1d and the +// uid is not drawn. Do not edit this file to follow a later constructor: it is the fixed point +// the new construction is compared with (every array, every error type and message). +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/types.hpp" + +namespace netgraph::core::test { + +// The 1d graph's state, member for member (graph.hpp at 3e1f3ea6e, without the uid). +struct Reference1d { + double resolution {1.0}; + std::int32_t num_nodes {0}; + Cost max_cost {0}; + Cost cost_gcd {0}; + std::vector capacity; + std::vector cost; + std::vector src; + std::vector dst; + std::vector input_index; + std::vector row_offsets; + std::vector in_row_offsets; + std::vector in_adj_edge_index; +}; + +namespace reference_1d_detail { + +inline int resolution_exponent(double q) { + if (!std::isfinite(q) || q <= 0.0) { + throw std::invalid_argument("resolution must be a power of two in [2^-32, 2^32]"); + } + int exp = 0; + const double mantissa = std::frexp(q, &exp); // q = mantissa * 2^exp, mantissa in [0.5, 1) + const int k = exp - 1; + if (mantissa != 0.5 || k < -32 || k > 32) { + throw std::invalid_argument("resolution must be a power of two in [2^-32, 2^32]"); + } + return k; +} + +} // namespace reference_1d_detail + +inline Reference1d reference_1d(std::int32_t num_nodes, + std::span src, + std::span dst, + std::span capacity_units, + std::span cost, + double resolution) { + Reference1d r; + (void)reference_1d_detail::resolution_exponent(resolution); + // The unit values. + for (std::size_t i = 0; i < capacity_units.size(); ++i) { + const Amount c = capacity_units[i]; + if (c < 0 || c >= kDomainLimit) { + throw std::invalid_argument("capacity_units[" + std::to_string(i) + "] = " + std::to_string(c) + + " is outside the units domain [0, 2^62)"); + } + } + // The array shapes. + if (num_nodes < 0) { + throw std::invalid_argument("num_nodes must be >= 0 (got " + std::to_string(num_nodes) + ")"); + } + const std::size_t m = src.size(); + if (dst.size() != m || capacity_units.size() != m || cost.size() != m) { + throw std::invalid_argument("src, dst, capacity_units and cost must have the same length (got " + + std::to_string(src.size()) + ", " + std::to_string(dst.size()) + ", " + + std::to_string(capacity_units.size()) + ", " + + std::to_string(cost.size()) + ")"); + } + if (m > static_cast(std::numeric_limits::max())) { + throw std::invalid_argument("the number of edges exceeds INT32_MAX (array length)"); + } + // Zero or negative costs (rule 28). + Cost max_cost = 0; + Cost cost_gcd = 0; + for (std::size_t i = 0; i < m; ++i) { + if (cost[i] < 1) { + throw std::invalid_argument("cost[" + std::to_string(i) + "] = " + std::to_string(cost[i]) + + ": costs must be positive (cost >= 1)"); + } + max_cost = std::max(max_cost, cost[i]); + // A running GCD of 1 stays 1 (gcd(1, c) = 1): the remaining edges are only checked. + if (cost_gcd != 1) cost_gcd = std::gcd(cost_gcd, cost[i]); + } + // The remaining bounds. + for (std::size_t i = 0; i < m; ++i) { + if (src[i] < 0 || src[i] >= num_nodes || dst[i] < 0 || dst[i] >= num_nodes) { + throw std::invalid_argument("edge " + std::to_string(i) + ": endpoint (" + std::to_string(src[i]) + + ", " + std::to_string(dst[i]) + ") out of node range [0, " + + std::to_string(num_nodes) + ")"); + } + } + constexpr auto kLimit = static_cast(kDomainLimit); + std::uint64_t cap_sum = 0; + std::uint64_t cost_sum = 0; + for (std::size_t i = 0; i < m; ++i) { + cap_sum += static_cast(capacity_units[i]); // each < 2^62: no wrap before the check + if (cap_sum >= kLimit) { + throw std::invalid_argument("the sum of all capacities reaches the bound of the units domain (2^62 units)"); + } + cost_sum += static_cast(cost[i]); + if (cost_sum >= kLimit) { + throw std::invalid_argument( + "the sum of all edge costs reaches the bound 2^62: path costs would overflow int64"); + } + } + + r.resolution = resolution; + r.num_nodes = num_nodes; + r.max_cost = max_cost; + r.cost_gcd = cost_gcd; + const auto n = static_cast(num_nodes); + + // Ids: rank in (src, cost, dst, input). + r.row_offsets.assign(n + 1, 0); + for (std::size_t i = 0; i < m; ++i) ++r.row_offsets[static_cast(src[i]) + 1]; + for (std::size_t u = 0; u < n; ++u) r.row_offsets[u + 1] += r.row_offsets[u]; + r.input_index.resize(m); + { + std::vector cursor(r.row_offsets.begin(), r.row_offsets.end() - 1); + for (std::size_t i = 0; i < m; ++i) { + r.input_index[static_cast(cursor[static_cast(src[i])]++)] = + static_cast(i); + } + } + for (std::size_t u = 0; u < n; ++u) { + auto first = r.input_index.begin() + r.row_offsets[u]; + auto last = r.input_index.begin() + r.row_offsets[u + 1]; + if (last - first < 2) continue; + std::sort(first, last, [&](std::int32_t a, std::int32_t b) { + const auto ia = static_cast(a); + const auto ib = static_cast(b); + if (cost[ia] != cost[ib]) return cost[ia] < cost[ib]; + if (dst[ia] != dst[ib]) return dst[ia] < dst[ib]; + return a < b; + }); + } + r.capacity.resize(m); + r.cost.resize(m); + r.src.resize(m); + r.dst.resize(m); + for (std::size_t e = 0; e < m; ++e) { + const auto i = static_cast(r.input_index[e]); + r.capacity[e] = capacity_units[i]; + r.cost[e] = cost[i]; + r.src[e] = src[i]; + r.dst[e] = dst[i]; + } + + // Reverse CSR in (cost, src, input) order. + r.in_row_offsets.assign(n + 1, 0); + for (std::size_t e = 0; e < m; ++e) ++r.in_row_offsets[static_cast(r.dst[e]) + 1]; + for (std::size_t v = 0; v < n; ++v) r.in_row_offsets[v + 1] += r.in_row_offsets[v]; + r.in_adj_edge_index.resize(m); + { + std::vector cursor(r.in_row_offsets.begin(), r.in_row_offsets.end() - 1); + for (std::size_t e = 0; e < m; ++e) { + r.in_adj_edge_index[static_cast(cursor[static_cast(r.dst[e])]++)] = + static_cast(e); + } + } + for (std::size_t v = 0; v < n; ++v) { + auto first = r.in_adj_edge_index.begin() + r.in_row_offsets[v]; + auto last = r.in_adj_edge_index.begin() + r.in_row_offsets[v + 1]; + if (last - first < 2) continue; + std::sort(first, last, [&](EdgeId a, EdgeId b) { + const auto ia = static_cast(a); + const auto ib = static_cast(b); + if (r.cost[ia] != r.cost[ib]) return r.cost[ia] < r.cost[ib]; + if (r.src[ia] != r.src[ib]) return r.src[ia] < r.src[ib]; + return a < b; + }); + } + return r; +} + +// The internal reverse-source column the 1e graph stores, derived from the reference (1d has no +// stored copy): edge_src[in_adj_edge_index[slot]]. +inline std::vector reference_in_col_indices(const Reference1d& r) { + std::vector out(r.in_adj_edge_index.size()); + for (std::size_t s = 0; s < out.size(); ++s) out[s] = r.src[static_cast(r.in_adj_edge_index[s])]; + return out; +} + +} // namespace netgraph::core::test diff --git a/tests/cpp/layout_checker.hpp b/tests/cpp/layout_checker.hpp new file mode 100644 index 0000000..6a56e55 --- /dev/null +++ b/tests/cpp/layout_checker.hpp @@ -0,0 +1,221 @@ +// An independent checker of the graph's stored layout (APPROACH 3, layout B'; task 1e). +// +// It reads the arrays as spans and the caller's input separately, and derives every expectation +// from the contract's text, never from the constructor's helpers: +// 1. sizes first, before any element is read: N >= 0, E >= 0, the input arrays of length E, +// every per-edge array and the input map of length E, both offset arrays of length N + 1, +// the reverse slots of length E, the internal reverse-source copy of length E when present; +// 2. then ranges, before any value is used as an index: offsets in [0, E], [0] = 0, [N] = E, +// non-decreasing; endpoints in [0, N); the map and the slots in [0, E) and permutations; +// the copied sources in [0, N); +// 3. then the order: the ids of row u have source u and strictly increasing (cost, dst, input); +// the slots of v hold edges with destination v in strictly increasing (cost, src, input); +// each copied source equals edge_src[in_adj_edge_index[slot]]; every property is the input +// array permuted by the map; max_cost and cost_gcd are the full scans (0 without edges). +// A failing step stops the check (later steps would read through unchecked values). The result +// is the list of defect codes of the failing step, empty when the layout is correct. +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/types.hpp" + +namespace netgraph::core::test { + +// The caller's input, in input coordinates. +struct LayoutInput { + std::int64_t num_nodes {0}; + std::span src {}; + std::span dst {}; + std::span capacity {}; + std::span cost {}; +}; + +// What a graph stores (or claims to store). +struct LayoutSpans { + std::int64_t num_nodes {0}; + std::int64_t num_edges {0}; + std::span capacity {}; + std::span cost {}; + std::span src {}; + std::span dst {}; + std::span input_index {}; + std::span row_offsets {}; + std::span in_row_offsets {}; + std::span in_adj_edge_index {}; + std::optional> in_col_indices {}; // the internal copy, when stored + Cost max_cost {0}; + Cost cost_gcd {0}; +}; + +namespace layout_checker_detail { + +inline void offsets_ok(std::span off, std::int64_t n, std::int64_t m, const char* name, + std::vector& bad) { + bool range = true; + bool monotone = true; + for (std::size_t k = 0; k < off.size(); ++k) { + if (off[k] < 0 || off[k] > m) range = false; + if (k > 0 && off[k] < off[k - 1]) monotone = false; + } + if (!range) bad.push_back(std::string(name) + ":range"); + if (off[0] != 0) bad.push_back(std::string(name) + ":start"); + if (off[static_cast(n)] != m) bad.push_back(std::string(name) + ":end"); + if (!monotone) bad.push_back(std::string(name) + ":monotone"); +} + +template +bool permutation_of_range(std::span a, std::int64_t m) { + std::vector seen(static_cast(m), 0); + for (const T x : a) { + if (x < 0 || x >= m) return false; + if (seen[static_cast(x)] != 0) return false; + seen[static_cast(x)] = 1; + } + return true; +} + +} // namespace layout_checker_detail + +inline std::vector check_layout(const LayoutSpans& g, const LayoutInput& in) { + using namespace layout_checker_detail; + std::vector bad; + const std::int64_t n = g.num_nodes; + const std::int64_t m = g.num_edges; + // 1. Sizes (no element is read). + if (n < 0 || n != in.num_nodes) bad.emplace_back("size:num_nodes"); + if (m < 0 || static_cast(m) != in.src.size()) bad.emplace_back("size:num_edges"); + if (!bad.empty()) return bad; + const auto me = static_cast(m); + const auto np1 = static_cast(n) + 1; + auto size_is = [&](std::size_t got, std::size_t want, const char* name) { + if (got != want) bad.emplace_back(std::string("size:") + name); + }; + size_is(in.dst.size(), me, "input.dst"); + size_is(in.capacity.size(), me, "input.capacity"); + size_is(in.cost.size(), me, "input.cost"); + size_is(g.capacity.size(), me, "capacity"); + size_is(g.cost.size(), me, "cost"); + size_is(g.src.size(), me, "src"); + size_is(g.dst.size(), me, "dst"); + size_is(g.input_index.size(), me, "input_index"); + size_is(g.row_offsets.size(), np1, "row_offsets"); + size_is(g.in_row_offsets.size(), np1, "in_row_offsets"); + size_is(g.in_adj_edge_index.size(), me, "in_adj_edge_index"); + if (g.in_col_indices.has_value()) size_is(g.in_col_indices->size(), me, "in_col_indices"); + if (!bad.empty()) return bad; + + // 2. Ranges and permutations (values are read, none is used as an index yet). + offsets_ok(g.row_offsets, n, m, "row_offsets", bad); + offsets_ok(g.in_row_offsets, n, m, "in_row_offsets", bad); + for (std::size_t e = 0; e < me; ++e) { + if (g.src[e] < 0 || g.src[e] >= n) { + bad.emplace_back("range:src"); + break; + } + } + for (std::size_t e = 0; e < me; ++e) { + if (g.dst[e] < 0 || g.dst[e] >= n) { + bad.emplace_back("range:dst"); + break; + } + } + if (!permutation_of_range(g.input_index, m)) bad.emplace_back("map:permutation"); + if (!permutation_of_range(g.in_adj_edge_index, m)) bad.emplace_back("slot:permutation"); + if (g.in_col_indices.has_value()) { + for (const NodeId x : *g.in_col_indices) { + if (x < 0 || x >= n) { + bad.emplace_back("range:in_col_indices"); + break; + } + } + } + if (!bad.empty()) return bad; + + // 3. Properties, order, the copy, the scans. + for (std::size_t e = 0; e < me; ++e) { + const auto i = static_cast(g.input_index[e]); + if (g.capacity[e] != in.capacity[i]) { + bad.emplace_back("property:capacity"); + break; + } + } + for (std::size_t e = 0; e < me; ++e) { + if (g.cost[e] != in.cost[static_cast(g.input_index[e])]) { + bad.emplace_back("property:cost"); + break; + } + } + for (std::size_t e = 0; e < me; ++e) { + if (g.src[e] != in.src[static_cast(g.input_index[e])]) { + bad.emplace_back("property:src"); + break; + } + } + for (std::size_t e = 0; e < me; ++e) { + if (g.dst[e] != in.dst[static_cast(g.input_index[e])]) { + bad.emplace_back("property:dst"); + break; + } + } + // Forward rows: the ids of row u, in (cost, dst, input) strictly increasing, source u. + bool fwd_member = true; + bool fwd_order = true; + for (std::size_t u = 0; u + 1 < np1; ++u) { + for (auto e = static_cast(g.row_offsets[u]); e < static_cast(g.row_offsets[u + 1]); ++e) { + if (g.src[e] != static_cast(u)) fwd_member = false; + if (e > static_cast(g.row_offsets[u]) && + !(std::tuple(g.cost[e - 1], g.dst[e - 1], g.input_index[e - 1]) < + std::tuple(g.cost[e], g.dst[e], g.input_index[e]))) { + fwd_order = false; + } + } + } + if (!fwd_member) bad.emplace_back("forward:membership"); + if (!fwd_order) bad.emplace_back("forward:order"); + // Reverse rows: the slots of v, edges with destination v in (cost, src, input) strictly increasing. + bool rev_member = true; + bool rev_order = true; + for (std::size_t v = 0; v + 1 < np1; ++v) { + const auto lo = static_cast(g.in_row_offsets[v]); + const auto hi = static_cast(g.in_row_offsets[v + 1]); + for (std::size_t s = lo; s < hi; ++s) { + const auto e = static_cast(g.in_adj_edge_index[s]); + if (g.dst[e] != static_cast(v)) rev_member = false; + if (s > lo) { + const auto p = static_cast(g.in_adj_edge_index[s - 1]); + if (!(std::tuple(g.cost[p], g.src[p], g.input_index[p]) < std::tuple(g.cost[e], g.src[e], g.input_index[e]))) { + rev_order = false; + } + } + } + } + if (!rev_member) bad.emplace_back("reverse:membership"); + if (!rev_order) bad.emplace_back("reverse:order"); + if (g.in_col_indices.has_value()) { + for (std::size_t s = 0; s < me; ++s) { + if ((*g.in_col_indices)[s] != g.src[static_cast(g.in_adj_edge_index[s])]) { + bad.emplace_back("copy:source"); + break; + } + } + } + // The scans, from the input (0 on an empty graph). + Cost mx = 0; + Cost gcd = 0; + for (const Cost c : in.cost) { + mx = c > mx ? c : mx; + gcd = std::gcd(gcd, c); + } + if (g.max_cost != mx) bad.emplace_back("scan:max_cost"); + if (g.cost_gcd != gcd) bad.emplace_back("scan:cost_gcd"); + return bad; +} + +} // namespace netgraph::core::test diff --git a/tests/py/test_graph.py b/tests/py/test_graph.py index 19826b9..faf9864 100644 --- a/tests/py/test_graph.py +++ b/tests/py/test_graph.py @@ -4,6 +4,7 @@ from __future__ import annotations import gc +import weakref import numpy as np import pytest @@ -352,3 +353,108 @@ def test_shape_step_keeps_its_place_in_the_order(): 2, np.array([0]), np.array([1], dtype=np.uint8), np.array([7]), (3,), 1.0 ) assert g.capacity_units().tolist() == [7] + + +def test_repeated_views_share_storage_and_refuse_write_access(): + g = build(3, EDGES) + for name in ( + "capacity_units", + "cost", + "edge_src", + "edge_dst", + "input_index", + "row_offsets", + "in_row_offsets", + "in_adj_edge_index", + ): + a, b = getattr(g, name)(), getattr(g, name)() + assert np.shares_memory(a, b), name + with pytest.raises(ValueError): + a.setflags(write=True) + assert not a.flags.writeable + + +VIEW_DTYPES = { + "capacity_units": np.int64, + "cost": np.int64, + "edge_src": np.int32, + "edge_dst": np.int32, + "input_index": np.int32, + "row_offsets": np.int32, + "in_row_offsets": np.int32, + "in_adj_edge_index": np.int32, +} + + +@pytest.mark.parametrize( + ("n", "edges"), + [(0, []), (100, []), (3, EDGES)], + ids=["empty", "isolated", "nonempty"], +) +def test_every_view_keeps_its_graph_alive_and_refuses_write_access(n, edges): + # An empty view has no bytes to share, so the owner is checked by identity and a weak + # reference rather than by np.shares_memory. + for name, dtype in VIEW_DTYPES.items(): + g = build(n, edges) + ref = weakref.ref(g) + view = getattr(g, name)() + assert view.base is g, name + assert view.dtype == dtype, name + assert not view.flags.writeable, name + with pytest.raises(ValueError): + view[...] = 0 + with pytest.raises(ValueError): + view.setflags(write=True) + del g + gc.collect() + assert ref() is not None, name + del view + gc.collect() + assert ref() is None, name + + +def _reference_layout(n, src, dst, cap, cost): + # B' ids: (src, cost, dst, input); the reverse slots: (dst, cost, src, input). + i = np.arange(len(src)) + order = np.lexsort((i, dst, cost, src)) + pos = np.empty_like(order) + pos[order] = np.arange(len(order)) + rev = pos[np.lexsort((i, src, cost, dst))] + return order, np.searchsorted(src[order], np.arange(n + 1)), rev + + +@pytest.mark.parametrize( + "shape", + ["hash", "combined_keys", "all_cost_sort", "reversed", "edgeless"], +) +def test_construction_orders_match_a_reference(shape): + # One input per construction path of APPROACH 3: distinct-cost hashing, the combined-key + # passes (N * K < E), the all-cost sort (K > 65,536), a reversed B' order, no edges. + rng = np.random.default_rng(7) + n, e = 300, 1000 # N * K = 1500 > E: the separate passes + if shape == "combined_keys": + n, e = 20, 4000 # N * K = 100 < E + if shape == "edgeless": + e = 0 + src = rng.integers(0, n, e) + dst = rng.integers(0, n, e) + cost = rng.integers(1, 6, e) + if shape == "all_cost_sort": + e = 70000 + src, dst = rng.integers(0, n, e), rng.integers(0, n, e) + cost = rng.permutation(np.arange(1, e + 1)) + cap = rng.integers(0, 1 << 40, e) + if shape == "reversed": + order, _, _ = _reference_layout(n, src, dst, cap, cost) + src, dst, cap, cost = (a[order[::-1]] for a in (src, dst, cap, cost)) + g = ngc.Graph(n, src, dst, cap, cost, 1) + order, row, rev = _reference_layout(n, src, dst, cap, cost) + assert g.input_index().tolist() == order.tolist() + assert g.row_offsets().tolist() == row.tolist() + assert g.in_adj_edge_index().tolist() == rev.tolist() + assert ( + g.in_row_offsets().tolist() + == np.searchsorted(np.sort(dst), np.arange(n + 1)).tolist() + ) + assert g.capacity_units().tolist() == cap[order].tolist() + assert g.max_cost() == (int(cost.max()) if e else 0) From 16e51007fdd2c63474d3bcf5ac4c78dad6ea1094 Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Sun, 11 Oct 2026 22:25:31 -0700 Subject: [PATCH 09/12] Cleanup: the rebuilt Core made coherent, documented and tested The bindings are split by subject, the type stub is generated from the module and checked, and docs/semantics.md states the API's rules, each with a test. A double free when a result object could not be wrapped is fixed, and inputs too large for the flow core are now refused. Dead code and the old research and timing material are gone. Results match accepted 1e with the private hooks on and off; only the field that names the producing build differs. Co-Authored-By: Claude Opus 5.5 --- .coveragerc | 2 - .github/ci-constraints.txt | 78 + .github/workflows/release.yml | 77 +- .github/workflows/tests.yml | 92 +- .gitignore | 231 +- .pre-commit-config.yaml | 18 +- AGENTS.md | 17 +- CHANGELOG.md | 107 +- CMakeLists.txt | 338 +-- CONTRIBUTING.md | 45 +- Makefile | 112 +- README.md | 202 +- bindings/python/axes.cpp | 89 + bindings/python/dag_cache.cpp | 60 + bindings/python/eligibility.cpp | 54 + bindings/python/evaluators.cpp | 241 ++ bindings/python/flow_ledger.cpp | 103 + bindings/python/flow_policy.cpp | 190 ++ bindings/python/flow_record.cpp | 73 + bindings/python/graph.cpp | 216 ++ bindings/python/hooks.cpp | 217 ++ bindings/python/integers.hpp | 232 ++ bindings/python/module.cpp | 2192 +---------------- bindings/python/path.cpp | 78 + bindings/python/paths.cpp | 183 ++ bindings/python/placement_result.cpp | 107 + bindings/python/policy_config.cpp | 68 + bindings/python/pred_dag.cpp | 127 + bindings/python/publication.hpp | 89 + bindings/python/recorder.cpp | 109 + bindings/python/registration.hpp | 40 + bindings/python/routing.cpp | 231 ++ bindings/python/search.cpp | 182 ++ bindings/python/trace.hpp | 54 + bindings/python/units.cpp | 108 + bindings/python/values.hpp | 111 + dev/coverage_summary.py | 136 +- dev/cpp-tests.sh | 110 +- dev/perf/README.md | 21 - dev/perf/spf_queue/PLAN.md | 581 ----- dev/perf/spf_queue/README.md | 73 - dev/perf/spf_queue/bench_netgraph.py | 393 --- dev/perf/spf_queue/bitgate/dump.cpp | 157 -- dev/perf/spf_queue/bitgate/sha256.txt | 3 - dev/perf/spf_queue/bitgate/status.txt | 1 - dev/perf/spf_queue/build.sh | 31 - dev/perf/spf_queue/harness.cpp | 388 --- .../spf_queue/results/core/environment.txt | 5 - dev/perf/spf_queue/results/core/load.txt | 5 - .../spf_queue/results/core/sweep-a1-old.csv | 7 - .../spf_queue/results/core/sweep-a2-old.csv | 7 - .../results/core/sweep-b-new-heap.csv | 7 - .../spf_queue/results/core/sweep-b-new.csv | 7 - .../spf_queue/results/core/time-a1-old.csv | 24 - .../spf_queue/results/core/time-a2-old.csv | 24 - .../results/core/time-b-new-heap.csv | 24 - .../spf_queue/results/core/time-b-new.csv | 24 - dev/perf/spf_queue/results/dag/a1-old.csv | 11 - dev/perf/spf_queue/results/dag/a2-old.csv | 11 - dev/perf/spf_queue/results/dag/b-new-dag.csv | 11 - dev/perf/spf_queue/results/dag/b-new-full.csv | 11 - .../spf_queue/results/dag/environment.txt | 6 - dev/perf/spf_queue/results/dag/load.txt | 2 - .../results/first/correctness-sanitized.txt | 2 - .../results/first/correctness-teeth.txt | 1 - .../spf_queue/results/first/correctness.txt | 2 - .../spf_queue/results/first/environment.txt | 8 - dev/perf/spf_queue/results/first/load.txt | 15 - .../results/first/sweep-bsort-sparse.csv | 7 - .../results/first/sweep-bucket-sparse.csv | 7 - .../results/first/sweep-heap-sparse.csv | 7 - .../spf_queue/results/first/sweep-ref.csv | 7 - .../results/first/time-bsort-fresh-b.csv | 20 - .../results/first/time-bsort-sparse-b.csv | 20 - .../results/first/time-bucket-fresh-b.csv | 20 - .../results/first/time-bucket-full-b.csv | 20 - .../results/first/time-bucket-sparse-b.csv | 20 - .../results/first/time-heap-fresh-b.csv | 20 - .../results/first/time-heap-full-b.csv | 20 - .../results/first/time-heap-sparse-b.csv | 20 - .../spf_queue/results/first/time-ref-a1.csv | 20 - .../spf_queue/results/first/time-ref-a2.csv | 20 - .../spf_queue/results/netgraph-api/a1-old.csv | 17 - .../spf_queue/results/netgraph-api/a2-old.csv | 17 - .../results/netgraph-api/b-new-heap.csv | 17 - .../spf_queue/results/netgraph-api/b-new.csv | 17 - .../results/netgraph-api/environment.txt | 8 - .../spf_queue/results/netgraph-api/load.txt | 5 - .../spf_queue/results/netgraph/a1-old.csv | 17 - .../spf_queue/results/netgraph/a2-old.csv | 17 - .../spf_queue/results/netgraph/b-new-heap.csv | 17 - dev/perf/spf_queue/results/netgraph/b-new.csv | 17 - .../results/netgraph/environment.txt | 8 - .../results/netgraph/hashseed-determinism.txt | 9 - dev/perf/spf_queue/results/netgraph/load.txt | 5 - .../spf_queue/results/priority/a1-old.csv | 11 - .../spf_queue/results/priority/a2-old.csv | 11 - .../results/priority/b-new-capacity.csv | 11 - .../results/priority/b-new-headroom.csv | 11 - .../priority/b-new-residual-single.csv | 11 - dev/perf/spf_queue/results/priority/b-new.csv | 11 - .../results/priority/environment.txt | 6 - dev/perf/spf_queue/results/priority/load.txt | 5 - .../spf_queue/results/short/environment.txt | 3 - dev/perf/spf_queue/results/short/load.txt | 8 - .../results/short/sweep-bucket2-sparse.csv | 7 - .../results/short/time-bucket-hybrid-b.csv | 9 - .../results/short/time-bucket-sparse-b.csv | 9 - .../results/short/time-bucket2-fresh-b.csv | 9 - .../results/short/time-bucket2-hybrid-b.csv | 9 - .../results/short/time-bucket2-sparse-b.csv | 9 - .../results/short/time-heap-hybrid-b.csv | 9 - .../spf_queue/results/short/time-ref-a1.csv | 9 - .../spf_queue/results/short/time-ref-a2.csv | 9 - dev/perf/spf_queue/review-2026-10/BRIEF.md | 79 - dev/perf/spf_queue/review-2026-10/REPORT.md | 280 --- .../spf_queue/review-2026-10/append.patch | 23 - .../spf_queue/review-2026-10/build-flow.sh | 18 - .../spf_queue/review-2026-10/build-review.sh | 15 - .../review-2026-10/check-and-build.sh | 16 - .../spf_queue/review-2026-10/combined.patch | 41 - .../spf_queue/review-2026-10/construction.cpp | 28 - dev/perf/spf_queue/review-2026-10/driver.cpp | 51 - .../spf_queue/review-2026-10/early-exit.cpp | 8 - .../review-2026-10/exception-fix.patch | 24 - .../spf_queue/review-2026-10/fault-public.cpp | 30 - .../review-2026-10/fixed-routing.patch | 52 - .../spf_queue/review-2026-10/flow-driver.cpp | 30 - .../spf_queue/review-2026-10/frontpop.patch | 13 - dev/perf/spf_queue/review-2026-10/gcd.cpp | 165 -- dev/perf/spf_queue/review-2026-10/gcd.patch | 11 - .../spf_queue/review-2026-10/heapitem.patch | 26 - .../spf_queue/review-2026-10/inline.patch | 70 - .../spf_queue/review-2026-10/notouch.patch | 29 - .../review-2026-10/results/append-c.csv | 7 - .../review-2026-10/results/append.csv | 7 - .../review-2026-10/results/bitgate-sha256.txt | 2 - .../review-2026-10/results/boundary-heap.csv | 15 - .../review-2026-10/results/boundary-new1.csv | 15 - .../review-2026-10/results/boundary-old1.csv | 15 - .../review-2026-10/results/boundary-old2.csv | 15 - .../results/boundary-settled.csv | 15 - .../review-2026-10/results/build-gcd.csv | 2 - .../review-2026-10/results/build-new1.csv | 2 - .../review-2026-10/results/build-new2.csv | 2 - .../review-2026-10/results/build-old1.csv | 2 - .../review-2026-10/results/build-old2.csv | 2 - .../results/build-repeat-gcd.csv | 2 - .../results/build-repeat-new1.csv | 2 - .../results/build-repeat-new2.csv | 2 - .../review-2026-10/results/combined.csv | 7 - .../results/correctness-append.txt | 1 - .../results/correctness-baseline.txt | 1 - .../results/correctness-inline.txt | 1 - .../results/correctness-settled.txt | 1 - .../review-2026-10/results/early-new.txt | 1 - .../review-2026-10/results/early-old.txt | 1 - .../review-2026-10/results/environment.txt | 17 - .../review-2026-10/results/fault-fixed.txt | 4 - .../review-2026-10/results/fault-old.txt | 4 - .../review-2026-10/results/fault.txt | 4 - .../review-2026-10/results/final-sha256.txt | 68 - .../review-2026-10/results/flow-fixed.csv | 5 - .../review-2026-10/results/flow-new1.csv | 5 - .../review-2026-10/results/flow-new2.csv | 5 - .../review-2026-10/results/flow-old1.csv | 5 - .../review-2026-10/results/flow-old2.csv | 5 - .../review-2026-10/results/frontpop.csv | 7 - .../review-2026-10/results/heap-a1.csv | 7 - .../review-2026-10/results/heap-a2.csv | 7 - .../review-2026-10/results/heap-b.csv | 7 - .../review-2026-10/results/heapitem.csv | 7 - .../review-2026-10/results/inline-c.csv | 7 - .../review-2026-10/results/inline.csv | 7 - .../results/load-flow-screen.txt | 5 - .../review-2026-10/results/load-flow.txt | 5 - .../review-2026-10/results/load-second.txt | 24 - .../review-2026-10/results/load-third.txt | 10 - .../spf_queue/review-2026-10/results/load.txt | 12 - .../review-2026-10/results/memory.txt | 3 - .../results/netgraph-environment.txt | 16 - .../review-2026-10/results/netgraph-heap.csv | 5 - .../review-2026-10/results/netgraph-new.csv | 5 - .../review-2026-10/results/netgraph-old1.csv | 5 - .../review-2026-10/results/netgraph-old2.csv | 5 - .../review-2026-10/results/new-a1.csv | 7 - .../review-2026-10/results/new-a2.csv | 7 - .../review-2026-10/results/new-b1.csv | 7 - .../review-2026-10/results/new-b2.csv | 7 - .../review-2026-10/results/new-c1.csv | 7 - .../review-2026-10/results/new-c2.csv | 7 - .../review-2026-10/results/notouch.csv | 7 - .../review-2026-10/results/old-a1.csv | 7 - .../review-2026-10/results/old-a2.csv | 7 - .../review-2026-10/results/old-b1.csv | 7 - .../review-2026-10/results/old-b2.csv | 7 - .../review-2026-10/results/old-c1.csv | 7 - .../review-2026-10/results/old-c2.csv | 7 - .../review-2026-10/results/reverse-heap.csv | 5 - .../review-2026-10/results/reverse-new1.csv | 5 - .../review-2026-10/results/reverse-old1.csv | 5 - .../review-2026-10/results/reverse-old2.csv | 5 - .../results/reverse-settled.csv | 5 - .../review-2026-10/results/safety.txt | 1 - .../results/screen-flow-fixed.csv | 5 - .../results/screen-flow-new1.csv | 5 - .../results/screen-flow-new2.csv | 5 - .../results/screen-flow-old1.csv | 5 - .../results/screen-flow-old2.csv | 5 - .../review-2026-10/results/settled-b.csv | 7 - .../review-2026-10/results/settled-heap.csv | 7 - .../review-2026-10/results/settled.csv | 7 - .../review-2026-10/results/singleton.csv | 7 - .../results/tracked-verification.txt | 1 - .../results/validate-flow-variant.txt | 8 - .../spf_queue/review-2026-10/robustness.cpp | 48 - .../review-2026-10/run-netgraph-review.py | 55 - .../spf_queue/review-2026-10/run-review.py | 51 - .../spf_queue/review-2026-10/run-second.py | 62 - .../spf_queue/review-2026-10/run-third.py | 30 - dev/perf/spf_queue/review-2026-10/safety.cpp | 33 - .../spf_queue/review-2026-10/settled.patch | 20 - .../spf_queue/review-2026-10/singleton.patch | 29 - .../review-2026-10/validate-flow.cpp | 28 - .../review-2026-10/validate-review.sh | 18 - dev/perf/spf_queue/run_aba.sh | 24 - dev/perf/spf_queue/run_core_aba.sh | 26 - dev/perf/spf_queue/run_netgraph_aba.sh | 35 - dev/perf/spf_queue/spf_variants.hpp | 492 ---- dev/perf/spf_queue/summarize.py | 83 - dev/perf/spf_queue/summarize_core.py | 34 - dev/perf/spf_queue/summarize_netgraph.py | 44 - dev/research/README.md | 11 - dev/research/metal_pathfinding/README.md | 293 --- dev/research/metal_pathfinding/build.sh | 14 - .../metal_pathfinding/capabilities.mm | 28 - dev/research/metal_pathfinding/experiment.mm | 526 ---- .../metal_pathfinding/optimization/README.md | 279 --- .../metal_pathfinding/optimization/build.sh | 12 - .../optimization/build_cpu.sh | 11 - .../optimization/cpu_algorithms.mm | 102 - .../optimization/experiment.mm | 439 ---- .../optimization/kernels.metal | 166 -- .../results/aba/base-a1-stderr.txt | 1 - .../optimization/results/aba/base-a1.csv | 37 - .../results/aba/base-a2-stderr.txt | 1 - .../optimization/results/aba/base-a2.csv | 37 - .../results/aba/cpu-a1-stderr.txt | 1 - .../optimization/results/aba/cpu-a1.csv | 49 - .../results/aba/cpu-a2-stderr.txt | 1 - .../optimization/results/aba/cpu-a2.csv | 49 - .../optimization/results/aba/environment.txt | 20 - .../results/aba/gpu-load-base-a1.txt | 1 - .../results/aba/gpu-load-base-a2.txt | 1 - .../results/aba/gpu-load-cpu-a1.txt | 1 - .../results/aba/gpu-load-cpu-a2.txt | 1 - .../results/aba/gpu-load-optimized-b.txt | 1 - .../optimization/results/aba/load-base-a1.txt | 40 - .../optimization/results/aba/load-base-a2.txt | 40 - .../optimization/results/aba/load-cpu-a1.txt | 40 - .../optimization/results/aba/load-cpu-a2.txt | 40 - .../results/aba/load-optimized-b.txt | 40 - .../results/aba/optimized-b-stderr.txt | 1 - .../optimization/results/aba/optimized-b.csv | 217 -- .../optimization/results/aba/summary.json | 570 ----- .../optimization/results/aba/summary.md | 317 --- .../optimization/results/aba/time-base-a1.txt | 2 - .../optimization/results/aba/time-base-a2.txt | 2 - .../optimization/results/aba/time-cpu-a1.txt | 2 - .../optimization/results/aba/time-cpu-a2.txt | 2 - .../results/aba/time-optimized-b.txt | 2 - .../optimization/results/audit.json | 66 - .../results/correctness-final-stderr.txt | 1 - .../results/correctness-final.csv | 337 --- .../results/correctness-sanitized-stderr.txt | 3 - .../results/correctness-sanitized.csv | 337 --- .../results/correctness-stderr.txt | 1 - .../optimization/results/correctness.csv | 337 --- .../results/cpu-algorithms-aba/a1-stderr.txt | 0 .../results/cpu-algorithms-aba/a1.csv | 15 - .../results/cpu-algorithms-aba/a2-stderr.txt | 0 .../results/cpu-algorithms-aba/a2.csv | 15 - .../results/cpu-algorithms-aba/b-stderr.txt | 1 - .../results/cpu-algorithms-aba/b.csv | 217 -- .../cpu-algorithms-aba/environment.txt | 5 - .../cpu-algorithms-aba/gpu-load-a1.txt | 1 - .../cpu-algorithms-aba/gpu-load-a2.txt | 1 - .../results/cpu-algorithms-aba/gpu-load-b.txt | 1 - .../results/cpu-algorithms-aba/load-a1.txt | 40 - .../results/cpu-algorithms-aba/load-a2.txt | 40 - .../results/cpu-algorithms-aba/load-b.txt | 40 - .../results/cpu-algorithms-aba/summary.json | 248 -- .../results/cpu-algorithms-aba/summary.md | 13 - .../cpu-algorithms-correctness-stderr.txt | 0 .../results/cpu-algorithms-correctness.csv | 25 - .../cpu-algorithms-full-sanitized-stderr.txt | 0 .../results/cpu-algorithms-full-sanitized.csv | 15 - .../cpu-algorithms-sanitized-stderr.txt | 0 .../results/cpu-algorithms-sanitized.csv | 25 - .../optimization/results/sanitize-test.txt | 352 --- .../results/screen-known-limitations.txt | 6 - .../results/screen-source-sha256.txt | 2 - .../optimization/results/screen-stderr.txt | 1 - .../optimization/results/screen.csv | 253 -- .../optimization/results/smoke-stderr.txt | 1 - .../optimization/results/smoke.csv | 85 - .../results/stress-sanitized-stderr.txt | 3 - .../optimization/results/stress-sanitized.csv | 29 - .../optimization/results/stress-stderr.txt | 1 - .../optimization/results/stress.csv | 29 - .../results/workspace-check-final.txt | 391 --- .../workspace-check-format-failure.txt | 29 - .../optimization/results/workspace-check.txt | 391 --- .../metal_pathfinding/optimization/run_aba.sh | 30 - .../optimization/run_cpu_aba.sh | 21 - .../optimization/summarize.py | 106 - .../optimization/summarize_cpu.py | 54 - .../results/aba-initial/a1-stderr.txt | 0 .../results/aba-initial/a1.csv | 82 - .../results/aba-initial/a2-stderr.txt | 0 .../results/aba-initial/a2.csv | 82 - .../results/aba-initial/b-stderr.txt | 1 - .../results/aba-initial/b.csv | 69 - .../results/aba-initial/environment.txt | 17 - .../results/aba-initial/gpu-load-a1.txt | 1 - .../results/aba-initial/gpu-load-a2.txt | 1 - .../results/aba-initial/gpu-load-b.txt | 1 - .../results/aba-initial/load-a1.txt | 40 - .../results/aba-initial/load-a2.txt | 40 - .../results/aba-initial/load-b.txt | 40 - .../results/aba-initial/summary.md | 38 - .../results/aba/a1-stderr.txt | 0 .../metal_pathfinding/results/aba/a1.csv | 109 - .../results/aba/a2-stderr.txt | 0 .../metal_pathfinding/results/aba/a2.csv | 109 - .../results/aba/b-stderr.txt | 1 - .../metal_pathfinding/results/aba/b.csv | 69 - .../results/aba/environment.txt | 17 - .../results/aba/gpu-load-a1.txt | 1 - .../results/aba/gpu-load-a2.txt | 1 - .../results/aba/gpu-load-b.txt | 1 - .../metal_pathfinding/results/aba/load-a1.txt | 40 - .../metal_pathfinding/results/aba/load-a2.txt | 40 - .../metal_pathfinding/results/aba/load-b.txt | 40 - .../metal_pathfinding/results/aba/summary.md | 74 - .../metal_pathfinding/results/audit.json | 44 - .../results/capabilities.txt | 31 - .../results/correctness-sanitized.csv | 37 - .../results/correctness-stderr.txt | 1 - .../metal_pathfinding/results/correctness.csv | 37 - .../results/cpu-sanitized.csv | 41 - .../results/cpu-validation.txt | 0 .../results/rejected-race/a1-stderr.txt | 0 .../results/rejected-race/a1.csv | 82 - .../results/rejected-race/b-stderr.txt | 2 - .../results/rejected-race/b.csv | 23 - .../results/rejected-race/environment.txt | 17 - .../results/rejected-race/load-a1.txt | 40 - .../results/rejected-race/load-b.txt | 40 - .../results/rejected-race/pre-fix-smoke.csv | 21 - .../results/sanitize-test.txt | 372 --- .../results/stress-sanitized.csv | 7 - .../results/stress-stderr.txt | 1 - .../results/stress-validation.txt | 3 - .../metal_pathfinding/results/stress.csv | 7 - .../metal_pathfinding/results/validation.txt | 3 - .../results/witnesses-sanitized.txt | 9 - .../results/witnesses-stderr.txt | 1 - .../results/witnesses-validation.txt | 3 - .../metal_pathfinding/results/witnesses.txt | 9 - .../results/workspace-check.txt | 411 ---- dev/research/metal_pathfinding/run_aba.sh | 24 - dev/research/metal_pathfinding/sssp.metal | 122 - dev/research/metal_pathfinding/summarize.py | 74 - dev/run-checks.sh | 334 ++- dev/stubs/check_clients.py | 234 ++ dev/stubs/check_contents.py | 168 ++ dev/stubs/generate.py | 603 +++++ dev/stubs/overrides.pyi | 560 +++++ dev/stubs/run.sh | 135 + dev/stubs/test_checks.py | 431 ++++ dev/stubs/test_generate.py | 426 ++++ dev/test_coverage_summary.py | 165 ++ docs/semantics.md | 1070 ++++++++ include/netgraph/core/dag_cache.hpp | 41 +- include/netgraph/core/detail/cache_access.hpp | 17 +- include/netgraph/core/detail/edge_amounts.hpp | 34 + include/netgraph/core/detail/endpoints.hpp | 4 +- include/netgraph/core/detail/enum_domain.hpp | 2 +- .../netgraph/core/detail/evaluator_checks.hpp | 7 +- include/netgraph/core/detail/flow_core.hpp | 57 +- include/netgraph/core/detail/graph_access.hpp | 16 +- include/netgraph/core/detail/hooks_flag.hpp | 11 + include/netgraph/core/detail/int128.hpp | 33 +- include/netgraph/core/detail/kernels.hpp | 113 +- .../netgraph/core/detail/ledger_access.hpp | 9 +- include/netgraph/core/detail/node_map.hpp | 8 +- .../netgraph/core/detail/policy_engine.hpp | 17 +- .../netgraph/core/detail/pred_dag_builder.hpp | 11 +- .../netgraph/core/detail/private_hooks.hpp | 22 +- include/netgraph/core/detail/trace.hpp | 10 +- include/netgraph/core/detail/traversal.hpp | 68 +- include/netgraph/core/eligibility.hpp | 5 +- include/netgraph/core/evaluators.hpp | 76 +- include/netgraph/core/flow_ledger.hpp | 19 +- include/netgraph/core/flow_policy.hpp | 106 +- include/netgraph/core/flow_record.hpp | 15 +- include/netgraph/core/graph.hpp | 55 +- include/netgraph/core/options.hpp | 60 +- include/netgraph/core/pred_dag.hpp | 16 +- include/netgraph/core/search.hpp | 52 +- include/netgraph/core/types.hpp | 13 +- pyproject.toml | 81 +- python/netgraph_core/__init__.py | 96 +- python/netgraph_core/__init__.pyi | 1789 ++++++++++++++ python/netgraph_core/_docs.py | 1191 --------- src/dag_cache.cpp | 4 +- src/eligibility.cpp | 8 +- src/evaluators.cpp | 64 +- src/flow_core.cpp | 285 ++- src/flow_ledger.cpp | 37 +- src/flow_policy.cpp | 175 +- src/graph.cpp | 100 +- src/kernels.cpp | 104 +- src/pred_dag.cpp | 5 +- src/search.cpp | 110 +- tests/cpp/cost_only_creation_tests.cpp | 290 ++- tests/cpp/dag_cache_tests.cpp | 254 +- tests/cpp/eligibility_tests.cpp | 33 +- tests/cpp/fault_injection.cpp | 107 + tests/cpp/fault_injection.hpp | 204 +- tests/cpp/flow_core_deletion_fault_tests.cpp | 519 ++-- tests/cpp/flow_core_deletion_tests.cpp | 890 ++++++- tests/cpp/flow_core_tests.cpp | 450 +++- tests/cpp/flow_ledger_fault_tests.cpp | 759 ++++-- tests/cpp/flow_ledger_tests.cpp | 597 ++++- tests/cpp/flow_policy_fault_tests.cpp | 854 +++++-- tests/cpp/flow_policy_fixture_tests.cpp | 684 +++-- tests/cpp/flow_policy_tests.cpp | 1645 +++++++++++-- tests/cpp/graph_concurrency_tests.cpp | 206 +- tests/cpp/graph_fault_tests.cpp | 238 +- tests/cpp/graph_inputs.hpp | 166 ++ tests/cpp/graph_layout_tests.cpp | 523 ++-- ...h_reference_1d.hpp => graph_reference.hpp} | 31 +- tests/cpp/graph_tests.cpp | 195 +- tests/cpp/kernels_tests.cpp | 854 +++++-- tests/cpp/ksp_tests.cpp | 237 +- tests/cpp/layout_checker.hpp | 32 +- tests/cpp/pinned_one.hpp | 9 +- tests/cpp/pred_dag_tests.cpp | 159 +- tests/cpp/search_fault_tests.cpp | 119 +- tests/cpp/search_reference.hpp | 67 +- tests/cpp/search_tests.cpp | 555 ++++- tests/cpp/state_comparators.hpp | 151 ++ tests/cpp/test_int128.hpp | 183 ++ tests/cpp/test_utils.hpp | 617 ++++- tests/py/flow_oracle.py | 111 +- tests/py/helpers.py | 205 ++ tests/py/private_hooks.py | 10 +- tests/py/test_apportionment.py | 12 +- tests/py/test_dag_cache.py | 272 +- tests/py/test_docs_examples.py | 201 ++ tests/py/test_edge_amounts.py | 193 ++ tests/py/test_flow_core.py | 650 ++++- tests/py/test_flow_ledger.py | 257 +- tests/py/test_flow_policy.py | 893 +++++-- tests/py/test_flow_policy_fixtures.py | 412 +++- tests/py/test_graph.py | 359 ++- tests/py/test_ksp.py | 198 +- tests/py/test_native_allocation.py | 486 ++++ tests/py/test_public_surface.py | 244 +- tests/py/test_route_structures.py | 266 +- tests/py/test_search.py | 272 +- tests/py/test_units.py | 84 +- tests/reference/policy/README.md | 109 + .../policy/export_policy_fixtures.py | 461 ++++ tests/reference/policy/p1_amount/apportion.py | 131 + tests/reference/policy/p1_amount/certify.py | 273 ++ tests/reference/policy/p1_amount/flowcore.py | 367 +++ tests/reference/policy/p1_amount/graph.py | 243 ++ tests/reference/policy/p1_amount/kernels.py | 305 +++ tests/reference/policy/p1_amount/ledger.py | 300 +++ tests/reference/policy/p1_amount/search.py | 422 ++++ tests/reference/policy/p1_amount/snap.py | 174 ++ tests/reference/policy/p1_amount/units.py | 98 + tests/reference/policy/p2_transaction/_p1.py | 13 + .../policy/p2_transaction/fixtures.py | 2090 ++++++++++++++++ .../reference/policy/p2_transaction/policy.py | 1048 ++++++++ .../policy/p2_transaction/recorder.py | 31 + .../policy/p2_transaction/run_fixtures.py | 62 + tests/typing/invalid_client.py | 59 + tests/typing/valid_client.py | 118 + third_party/pybind11/LICENSE | 29 + 493 files changed, 30213 insertions(+), 23119 deletions(-) create mode 100644 .github/ci-constraints.txt create mode 100644 bindings/python/axes.cpp create mode 100644 bindings/python/dag_cache.cpp create mode 100644 bindings/python/eligibility.cpp create mode 100644 bindings/python/evaluators.cpp create mode 100644 bindings/python/flow_ledger.cpp create mode 100644 bindings/python/flow_policy.cpp create mode 100644 bindings/python/flow_record.cpp create mode 100644 bindings/python/graph.cpp create mode 100644 bindings/python/hooks.cpp create mode 100644 bindings/python/integers.hpp create mode 100644 bindings/python/path.cpp create mode 100644 bindings/python/paths.cpp create mode 100644 bindings/python/placement_result.cpp create mode 100644 bindings/python/policy_config.cpp create mode 100644 bindings/python/pred_dag.cpp create mode 100644 bindings/python/publication.hpp create mode 100644 bindings/python/recorder.cpp create mode 100644 bindings/python/registration.hpp create mode 100644 bindings/python/routing.cpp create mode 100644 bindings/python/search.cpp create mode 100644 bindings/python/trace.hpp create mode 100644 bindings/python/units.cpp create mode 100644 bindings/python/values.hpp delete mode 100644 dev/perf/README.md delete mode 100644 dev/perf/spf_queue/PLAN.md delete mode 100644 dev/perf/spf_queue/README.md delete mode 100644 dev/perf/spf_queue/bench_netgraph.py delete mode 100644 dev/perf/spf_queue/bitgate/dump.cpp delete mode 100644 dev/perf/spf_queue/bitgate/sha256.txt delete mode 100644 dev/perf/spf_queue/bitgate/status.txt delete mode 100755 dev/perf/spf_queue/build.sh delete mode 100644 dev/perf/spf_queue/harness.cpp delete mode 100644 dev/perf/spf_queue/results/core/environment.txt delete mode 100644 dev/perf/spf_queue/results/core/load.txt delete mode 100644 dev/perf/spf_queue/results/core/sweep-a1-old.csv delete mode 100644 dev/perf/spf_queue/results/core/sweep-a2-old.csv delete mode 100644 dev/perf/spf_queue/results/core/sweep-b-new-heap.csv delete mode 100644 dev/perf/spf_queue/results/core/sweep-b-new.csv delete mode 100644 dev/perf/spf_queue/results/core/time-a1-old.csv delete mode 100644 dev/perf/spf_queue/results/core/time-a2-old.csv delete mode 100644 dev/perf/spf_queue/results/core/time-b-new-heap.csv delete mode 100644 dev/perf/spf_queue/results/core/time-b-new.csv delete mode 100644 dev/perf/spf_queue/results/dag/a1-old.csv delete mode 100644 dev/perf/spf_queue/results/dag/a2-old.csv delete mode 100644 dev/perf/spf_queue/results/dag/b-new-dag.csv delete mode 100644 dev/perf/spf_queue/results/dag/b-new-full.csv delete mode 100644 dev/perf/spf_queue/results/dag/environment.txt delete mode 100644 dev/perf/spf_queue/results/dag/load.txt delete mode 100644 dev/perf/spf_queue/results/first/correctness-sanitized.txt delete mode 100644 dev/perf/spf_queue/results/first/correctness-teeth.txt delete mode 100644 dev/perf/spf_queue/results/first/correctness.txt delete mode 100644 dev/perf/spf_queue/results/first/environment.txt delete mode 100644 dev/perf/spf_queue/results/first/load.txt delete mode 100644 dev/perf/spf_queue/results/first/sweep-bsort-sparse.csv delete mode 100644 dev/perf/spf_queue/results/first/sweep-bucket-sparse.csv delete mode 100644 dev/perf/spf_queue/results/first/sweep-heap-sparse.csv delete mode 100644 dev/perf/spf_queue/results/first/sweep-ref.csv delete mode 100644 dev/perf/spf_queue/results/first/time-bsort-fresh-b.csv delete mode 100644 dev/perf/spf_queue/results/first/time-bsort-sparse-b.csv delete mode 100644 dev/perf/spf_queue/results/first/time-bucket-fresh-b.csv delete mode 100644 dev/perf/spf_queue/results/first/time-bucket-full-b.csv delete mode 100644 dev/perf/spf_queue/results/first/time-bucket-sparse-b.csv delete mode 100644 dev/perf/spf_queue/results/first/time-heap-fresh-b.csv delete mode 100644 dev/perf/spf_queue/results/first/time-heap-full-b.csv delete mode 100644 dev/perf/spf_queue/results/first/time-heap-sparse-b.csv delete mode 100644 dev/perf/spf_queue/results/first/time-ref-a1.csv delete mode 100644 dev/perf/spf_queue/results/first/time-ref-a2.csv delete mode 100644 dev/perf/spf_queue/results/netgraph-api/a1-old.csv delete mode 100644 dev/perf/spf_queue/results/netgraph-api/a2-old.csv delete mode 100644 dev/perf/spf_queue/results/netgraph-api/b-new-heap.csv delete mode 100644 dev/perf/spf_queue/results/netgraph-api/b-new.csv delete mode 100644 dev/perf/spf_queue/results/netgraph-api/environment.txt delete mode 100644 dev/perf/spf_queue/results/netgraph-api/load.txt delete mode 100644 dev/perf/spf_queue/results/netgraph/a1-old.csv delete mode 100644 dev/perf/spf_queue/results/netgraph/a2-old.csv delete mode 100644 dev/perf/spf_queue/results/netgraph/b-new-heap.csv delete mode 100644 dev/perf/spf_queue/results/netgraph/b-new.csv delete mode 100644 dev/perf/spf_queue/results/netgraph/environment.txt delete mode 100644 dev/perf/spf_queue/results/netgraph/hashseed-determinism.txt delete mode 100644 dev/perf/spf_queue/results/netgraph/load.txt delete mode 100644 dev/perf/spf_queue/results/priority/a1-old.csv delete mode 100644 dev/perf/spf_queue/results/priority/a2-old.csv delete mode 100644 dev/perf/spf_queue/results/priority/b-new-capacity.csv delete mode 100644 dev/perf/spf_queue/results/priority/b-new-headroom.csv delete mode 100644 dev/perf/spf_queue/results/priority/b-new-residual-single.csv delete mode 100644 dev/perf/spf_queue/results/priority/b-new.csv delete mode 100644 dev/perf/spf_queue/results/priority/environment.txt delete mode 100644 dev/perf/spf_queue/results/priority/load.txt delete mode 100644 dev/perf/spf_queue/results/short/environment.txt delete mode 100644 dev/perf/spf_queue/results/short/load.txt delete mode 100644 dev/perf/spf_queue/results/short/sweep-bucket2-sparse.csv delete mode 100644 dev/perf/spf_queue/results/short/time-bucket-hybrid-b.csv delete mode 100644 dev/perf/spf_queue/results/short/time-bucket-sparse-b.csv delete mode 100644 dev/perf/spf_queue/results/short/time-bucket2-fresh-b.csv delete mode 100644 dev/perf/spf_queue/results/short/time-bucket2-hybrid-b.csv delete mode 100644 dev/perf/spf_queue/results/short/time-bucket2-sparse-b.csv delete mode 100644 dev/perf/spf_queue/results/short/time-heap-hybrid-b.csv delete mode 100644 dev/perf/spf_queue/results/short/time-ref-a1.csv delete mode 100644 dev/perf/spf_queue/results/short/time-ref-a2.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/BRIEF.md delete mode 100644 dev/perf/spf_queue/review-2026-10/REPORT.md delete mode 100644 dev/perf/spf_queue/review-2026-10/append.patch delete mode 100644 dev/perf/spf_queue/review-2026-10/build-flow.sh delete mode 100644 dev/perf/spf_queue/review-2026-10/build-review.sh delete mode 100644 dev/perf/spf_queue/review-2026-10/check-and-build.sh delete mode 100644 dev/perf/spf_queue/review-2026-10/combined.patch delete mode 100644 dev/perf/spf_queue/review-2026-10/construction.cpp delete mode 100644 dev/perf/spf_queue/review-2026-10/driver.cpp delete mode 100644 dev/perf/spf_queue/review-2026-10/early-exit.cpp delete mode 100644 dev/perf/spf_queue/review-2026-10/exception-fix.patch delete mode 100644 dev/perf/spf_queue/review-2026-10/fault-public.cpp delete mode 100644 dev/perf/spf_queue/review-2026-10/fixed-routing.patch delete mode 100644 dev/perf/spf_queue/review-2026-10/flow-driver.cpp delete mode 100644 dev/perf/spf_queue/review-2026-10/frontpop.patch delete mode 100644 dev/perf/spf_queue/review-2026-10/gcd.cpp delete mode 100644 dev/perf/spf_queue/review-2026-10/gcd.patch delete mode 100644 dev/perf/spf_queue/review-2026-10/heapitem.patch delete mode 100644 dev/perf/spf_queue/review-2026-10/inline.patch delete mode 100644 dev/perf/spf_queue/review-2026-10/notouch.patch delete mode 100644 dev/perf/spf_queue/review-2026-10/results/append-c.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/append.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/bitgate-sha256.txt delete mode 100644 dev/perf/spf_queue/review-2026-10/results/boundary-heap.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/boundary-new1.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/boundary-old1.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/boundary-old2.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/boundary-settled.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/build-gcd.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/build-new1.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/build-new2.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/build-old1.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/build-old2.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/build-repeat-gcd.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/build-repeat-new1.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/build-repeat-new2.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/combined.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/correctness-append.txt delete mode 100644 dev/perf/spf_queue/review-2026-10/results/correctness-baseline.txt delete mode 100644 dev/perf/spf_queue/review-2026-10/results/correctness-inline.txt delete mode 100644 dev/perf/spf_queue/review-2026-10/results/correctness-settled.txt delete mode 100644 dev/perf/spf_queue/review-2026-10/results/early-new.txt delete mode 100644 dev/perf/spf_queue/review-2026-10/results/early-old.txt delete mode 100644 dev/perf/spf_queue/review-2026-10/results/environment.txt delete mode 100644 dev/perf/spf_queue/review-2026-10/results/fault-fixed.txt delete mode 100644 dev/perf/spf_queue/review-2026-10/results/fault-old.txt delete mode 100644 dev/perf/spf_queue/review-2026-10/results/fault.txt delete mode 100644 dev/perf/spf_queue/review-2026-10/results/final-sha256.txt delete mode 100644 dev/perf/spf_queue/review-2026-10/results/flow-fixed.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/flow-new1.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/flow-new2.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/flow-old1.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/flow-old2.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/frontpop.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/heap-a1.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/heap-a2.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/heap-b.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/heapitem.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/inline-c.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/inline.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/load-flow-screen.txt delete mode 100644 dev/perf/spf_queue/review-2026-10/results/load-flow.txt delete mode 100644 dev/perf/spf_queue/review-2026-10/results/load-second.txt delete mode 100644 dev/perf/spf_queue/review-2026-10/results/load-third.txt delete mode 100644 dev/perf/spf_queue/review-2026-10/results/load.txt delete mode 100644 dev/perf/spf_queue/review-2026-10/results/memory.txt delete mode 100644 dev/perf/spf_queue/review-2026-10/results/netgraph-environment.txt delete mode 100644 dev/perf/spf_queue/review-2026-10/results/netgraph-heap.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/netgraph-new.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/netgraph-old1.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/netgraph-old2.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/new-a1.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/new-a2.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/new-b1.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/new-b2.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/new-c1.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/new-c2.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/notouch.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/old-a1.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/old-a2.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/old-b1.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/old-b2.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/old-c1.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/old-c2.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/reverse-heap.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/reverse-new1.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/reverse-old1.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/reverse-old2.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/reverse-settled.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/safety.txt delete mode 100644 dev/perf/spf_queue/review-2026-10/results/screen-flow-fixed.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/screen-flow-new1.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/screen-flow-new2.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/screen-flow-old1.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/screen-flow-old2.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/settled-b.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/settled-heap.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/settled.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/singleton.csv delete mode 100644 dev/perf/spf_queue/review-2026-10/results/tracked-verification.txt delete mode 100644 dev/perf/spf_queue/review-2026-10/results/validate-flow-variant.txt delete mode 100644 dev/perf/spf_queue/review-2026-10/robustness.cpp delete mode 100644 dev/perf/spf_queue/review-2026-10/run-netgraph-review.py delete mode 100644 dev/perf/spf_queue/review-2026-10/run-review.py delete mode 100644 dev/perf/spf_queue/review-2026-10/run-second.py delete mode 100644 dev/perf/spf_queue/review-2026-10/run-third.py delete mode 100644 dev/perf/spf_queue/review-2026-10/safety.cpp delete mode 100644 dev/perf/spf_queue/review-2026-10/settled.patch delete mode 100644 dev/perf/spf_queue/review-2026-10/singleton.patch delete mode 100644 dev/perf/spf_queue/review-2026-10/validate-flow.cpp delete mode 100644 dev/perf/spf_queue/review-2026-10/validate-review.sh delete mode 100755 dev/perf/spf_queue/run_aba.sh delete mode 100755 dev/perf/spf_queue/run_core_aba.sh delete mode 100755 dev/perf/spf_queue/run_netgraph_aba.sh delete mode 100644 dev/perf/spf_queue/spf_variants.hpp delete mode 100644 dev/perf/spf_queue/summarize.py delete mode 100644 dev/perf/spf_queue/summarize_core.py delete mode 100644 dev/perf/spf_queue/summarize_netgraph.py delete mode 100644 dev/research/README.md delete mode 100644 dev/research/metal_pathfinding/README.md delete mode 100644 dev/research/metal_pathfinding/build.sh delete mode 100644 dev/research/metal_pathfinding/capabilities.mm delete mode 100644 dev/research/metal_pathfinding/experiment.mm delete mode 100644 dev/research/metal_pathfinding/optimization/README.md delete mode 100644 dev/research/metal_pathfinding/optimization/build.sh delete mode 100644 dev/research/metal_pathfinding/optimization/build_cpu.sh delete mode 100644 dev/research/metal_pathfinding/optimization/cpu_algorithms.mm delete mode 100644 dev/research/metal_pathfinding/optimization/experiment.mm delete mode 100644 dev/research/metal_pathfinding/optimization/kernels.metal delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/base-a1-stderr.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/base-a1.csv delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/base-a2-stderr.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/base-a2.csv delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/cpu-a1-stderr.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/cpu-a1.csv delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/cpu-a2-stderr.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/cpu-a2.csv delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/environment.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/gpu-load-base-a1.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/gpu-load-base-a2.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/gpu-load-cpu-a1.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/gpu-load-cpu-a2.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/gpu-load-optimized-b.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/load-base-a1.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/load-base-a2.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/load-cpu-a1.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/load-cpu-a2.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/load-optimized-b.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/optimized-b-stderr.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/optimized-b.csv delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/summary.json delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/summary.md delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/time-base-a1.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/time-base-a2.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/time-cpu-a1.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/time-cpu-a2.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/aba/time-optimized-b.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/audit.json delete mode 100644 dev/research/metal_pathfinding/optimization/results/correctness-final-stderr.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/correctness-final.csv delete mode 100644 dev/research/metal_pathfinding/optimization/results/correctness-sanitized-stderr.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/correctness-sanitized.csv delete mode 100644 dev/research/metal_pathfinding/optimization/results/correctness-stderr.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/correctness.csv delete mode 100644 dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/a1-stderr.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/a1.csv delete mode 100644 dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/a2-stderr.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/a2.csv delete mode 100644 dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/b-stderr.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/b.csv delete mode 100644 dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/environment.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/gpu-load-a1.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/gpu-load-a2.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/gpu-load-b.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/load-a1.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/load-a2.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/load-b.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/summary.json delete mode 100644 dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/summary.md delete mode 100644 dev/research/metal_pathfinding/optimization/results/cpu-algorithms-correctness-stderr.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/cpu-algorithms-correctness.csv delete mode 100644 dev/research/metal_pathfinding/optimization/results/cpu-algorithms-full-sanitized-stderr.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/cpu-algorithms-full-sanitized.csv delete mode 100644 dev/research/metal_pathfinding/optimization/results/cpu-algorithms-sanitized-stderr.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/cpu-algorithms-sanitized.csv delete mode 100644 dev/research/metal_pathfinding/optimization/results/sanitize-test.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/screen-known-limitations.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/screen-source-sha256.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/screen-stderr.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/screen.csv delete mode 100644 dev/research/metal_pathfinding/optimization/results/smoke-stderr.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/smoke.csv delete mode 100644 dev/research/metal_pathfinding/optimization/results/stress-sanitized-stderr.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/stress-sanitized.csv delete mode 100644 dev/research/metal_pathfinding/optimization/results/stress-stderr.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/stress.csv delete mode 100644 dev/research/metal_pathfinding/optimization/results/workspace-check-final.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/workspace-check-format-failure.txt delete mode 100644 dev/research/metal_pathfinding/optimization/results/workspace-check.txt delete mode 100644 dev/research/metal_pathfinding/optimization/run_aba.sh delete mode 100644 dev/research/metal_pathfinding/optimization/run_cpu_aba.sh delete mode 100644 dev/research/metal_pathfinding/optimization/summarize.py delete mode 100644 dev/research/metal_pathfinding/optimization/summarize_cpu.py delete mode 100644 dev/research/metal_pathfinding/results/aba-initial/a1-stderr.txt delete mode 100644 dev/research/metal_pathfinding/results/aba-initial/a1.csv delete mode 100644 dev/research/metal_pathfinding/results/aba-initial/a2-stderr.txt delete mode 100644 dev/research/metal_pathfinding/results/aba-initial/a2.csv delete mode 100644 dev/research/metal_pathfinding/results/aba-initial/b-stderr.txt delete mode 100644 dev/research/metal_pathfinding/results/aba-initial/b.csv delete mode 100644 dev/research/metal_pathfinding/results/aba-initial/environment.txt delete mode 100644 dev/research/metal_pathfinding/results/aba-initial/gpu-load-a1.txt delete mode 100644 dev/research/metal_pathfinding/results/aba-initial/gpu-load-a2.txt delete mode 100644 dev/research/metal_pathfinding/results/aba-initial/gpu-load-b.txt delete mode 100644 dev/research/metal_pathfinding/results/aba-initial/load-a1.txt delete mode 100644 dev/research/metal_pathfinding/results/aba-initial/load-a2.txt delete mode 100644 dev/research/metal_pathfinding/results/aba-initial/load-b.txt delete mode 100644 dev/research/metal_pathfinding/results/aba-initial/summary.md delete mode 100644 dev/research/metal_pathfinding/results/aba/a1-stderr.txt delete mode 100644 dev/research/metal_pathfinding/results/aba/a1.csv delete mode 100644 dev/research/metal_pathfinding/results/aba/a2-stderr.txt delete mode 100644 dev/research/metal_pathfinding/results/aba/a2.csv delete mode 100644 dev/research/metal_pathfinding/results/aba/b-stderr.txt delete mode 100644 dev/research/metal_pathfinding/results/aba/b.csv delete mode 100644 dev/research/metal_pathfinding/results/aba/environment.txt delete mode 100644 dev/research/metal_pathfinding/results/aba/gpu-load-a1.txt delete mode 100644 dev/research/metal_pathfinding/results/aba/gpu-load-a2.txt delete mode 100644 dev/research/metal_pathfinding/results/aba/gpu-load-b.txt delete mode 100644 dev/research/metal_pathfinding/results/aba/load-a1.txt delete mode 100644 dev/research/metal_pathfinding/results/aba/load-a2.txt delete mode 100644 dev/research/metal_pathfinding/results/aba/load-b.txt delete mode 100644 dev/research/metal_pathfinding/results/aba/summary.md delete mode 100644 dev/research/metal_pathfinding/results/audit.json delete mode 100644 dev/research/metal_pathfinding/results/capabilities.txt delete mode 100644 dev/research/metal_pathfinding/results/correctness-sanitized.csv delete mode 100644 dev/research/metal_pathfinding/results/correctness-stderr.txt delete mode 100644 dev/research/metal_pathfinding/results/correctness.csv delete mode 100644 dev/research/metal_pathfinding/results/cpu-sanitized.csv delete mode 100644 dev/research/metal_pathfinding/results/cpu-validation.txt delete mode 100644 dev/research/metal_pathfinding/results/rejected-race/a1-stderr.txt delete mode 100644 dev/research/metal_pathfinding/results/rejected-race/a1.csv delete mode 100644 dev/research/metal_pathfinding/results/rejected-race/b-stderr.txt delete mode 100644 dev/research/metal_pathfinding/results/rejected-race/b.csv delete mode 100644 dev/research/metal_pathfinding/results/rejected-race/environment.txt delete mode 100644 dev/research/metal_pathfinding/results/rejected-race/load-a1.txt delete mode 100644 dev/research/metal_pathfinding/results/rejected-race/load-b.txt delete mode 100644 dev/research/metal_pathfinding/results/rejected-race/pre-fix-smoke.csv delete mode 100644 dev/research/metal_pathfinding/results/sanitize-test.txt delete mode 100644 dev/research/metal_pathfinding/results/stress-sanitized.csv delete mode 100644 dev/research/metal_pathfinding/results/stress-stderr.txt delete mode 100644 dev/research/metal_pathfinding/results/stress-validation.txt delete mode 100644 dev/research/metal_pathfinding/results/stress.csv delete mode 100644 dev/research/metal_pathfinding/results/validation.txt delete mode 100644 dev/research/metal_pathfinding/results/witnesses-sanitized.txt delete mode 100644 dev/research/metal_pathfinding/results/witnesses-stderr.txt delete mode 100644 dev/research/metal_pathfinding/results/witnesses-validation.txt delete mode 100644 dev/research/metal_pathfinding/results/witnesses.txt delete mode 100644 dev/research/metal_pathfinding/results/workspace-check.txt delete mode 100644 dev/research/metal_pathfinding/run_aba.sh delete mode 100644 dev/research/metal_pathfinding/sssp.metal delete mode 100644 dev/research/metal_pathfinding/summarize.py create mode 100644 dev/stubs/check_clients.py create mode 100644 dev/stubs/check_contents.py create mode 100644 dev/stubs/generate.py create mode 100644 dev/stubs/overrides.pyi create mode 100755 dev/stubs/run.sh create mode 100644 dev/stubs/test_checks.py create mode 100644 dev/stubs/test_generate.py create mode 100644 dev/test_coverage_summary.py create mode 100644 docs/semantics.md create mode 100644 include/netgraph/core/detail/edge_amounts.hpp create mode 100644 include/netgraph/core/detail/hooks_flag.hpp create mode 100644 python/netgraph_core/__init__.pyi delete mode 100644 python/netgraph_core/_docs.py create mode 100644 tests/cpp/fault_injection.cpp create mode 100644 tests/cpp/graph_inputs.hpp rename tests/cpp/{graph_reference_1d.hpp => graph_reference.hpp} (89%) create mode 100644 tests/cpp/state_comparators.hpp create mode 100644 tests/cpp/test_int128.hpp create mode 100644 tests/py/helpers.py create mode 100644 tests/py/test_docs_examples.py create mode 100644 tests/py/test_edge_amounts.py create mode 100644 tests/py/test_native_allocation.py create mode 100644 tests/reference/policy/README.md create mode 100644 tests/reference/policy/export_policy_fixtures.py create mode 100644 tests/reference/policy/p1_amount/apportion.py create mode 100644 tests/reference/policy/p1_amount/certify.py create mode 100644 tests/reference/policy/p1_amount/flowcore.py create mode 100644 tests/reference/policy/p1_amount/graph.py create mode 100644 tests/reference/policy/p1_amount/kernels.py create mode 100644 tests/reference/policy/p1_amount/ledger.py create mode 100644 tests/reference/policy/p1_amount/search.py create mode 100644 tests/reference/policy/p1_amount/snap.py create mode 100644 tests/reference/policy/p1_amount/units.py create mode 100644 tests/reference/policy/p2_transaction/_p1.py create mode 100644 tests/reference/policy/p2_transaction/fixtures.py create mode 100644 tests/reference/policy/p2_transaction/policy.py create mode 100644 tests/reference/policy/p2_transaction/recorder.py create mode 100644 tests/reference/policy/p2_transaction/run_fixtures.py create mode 100644 tests/typing/invalid_client.py create mode 100644 tests/typing/valid_client.py create mode 100644 third_party/pybind11/LICENSE diff --git a/.coveragerc b/.coveragerc index e312baf..4aaecbe 100644 --- a/.coveragerc +++ b/.coveragerc @@ -3,8 +3,6 @@ branch = True data_file = build/coverage/.coverage source = netgraph_core -omit = - python/netgraph_core/_docs.py [report] show_missing = True diff --git a/.github/ci-constraints.txt b/.github/ci-constraints.txt new file mode 100644 index 0000000..d0991f1 --- /dev/null +++ b/.github/ci-constraints.txt @@ -0,0 +1,78 @@ +# Exact versions for CI's Python environments and for the coverage venv of `make cov`, so a new +# release of a tool cannot change a CI result without a change in this repository: the accepted +# development environment's versions, and for what only Linux, Windows or Python 3.11 installs, +# the version a resolver chose for those platforms (its marker says where it applies). pip itself +# is one of them: a job installs this pip (`pip install -U -c` this file, which also downgrades a +# newer one) before anything else. They reach a build environment only through the mechanism of +# the tool that fills it: pip's own isolated build takes --build-constraint or +# PIP_BUILD_CONSTRAINT (since pip 26.2 never -c or PIP_CONSTRAINT), `python -m build` takes +# PIP_CONSTRAINT, and its uv installer (the release wheels) UV_CONSTRAINT. NumPy 2.5 needs Python +# 3.12; Python 3.11 gets the last NumPy that supports it. Update deliberately. +build==1.5.0 +certifi==2026.7.22 +cfgv==3.5.0 +charset-normalizer==3.5.1 +cmake==4.4.2 +colorlog==6.12.0 +coverage==7.15.4 +distlib==0.4.3 +docutils==0.23 +filelock==3.32.3 +gcovr==8.6 +id==1.6.1 +identify==2.6.19 +idna==3.19 +iniconfig==2.3.0 +jaraco.classes==3.4.0 +jaraco.context==6.1.2 +jaraco.functools==4.6.0 +Jinja2==3.1.6 +keyring==25.7.0 +lxml==6.1.2 +markdown-it-py==4.2.0 +MarkupSafe==3.0.3 +mdurl==0.1.2 +more-itertools==11.1.0 +nh3==0.3.6 +ninja==1.13.0 +nodeenv==1.10.0 +packaging==26.3 +pathspec==1.1.1 +pip==26.2.1 +platformdirs==4.11.3 +pluggy==1.6.0 +pre_commit==4.6.2 +pybind11==3.1.0 +pybind11-stubgen==3.0.0 +Pygments==2.21.0 +pyproject_hooks==1.2.0 +pyright==1.1.401 +pytest==9.1.1 +pytest-cov==7.1.0 +python-discovery==1.5.2 +PyYAML==6.0.3 +readme_renderer==45.0 +requests==2.34.2 +requests-toolbelt==1.0.0 +rfc3986==2.0.0 +rich==15.0.0 +ruff==0.11.13 +scikit_build_core==1.0.3 +setuptools==84.0.0 +twine==7.0.0 +typing_extensions==4.16.0 +urllib3==2.7.0 +virtualenv==21.7.4 +wheel==0.48.0 +numpy==2.5.2; python_version >= "3.12" +numpy==2.4.4; python_version < "3.12" +backports.tarfile==1.2.0; python_version < "3.12" +importlib_metadata==9.0.1; python_version < "3.12" +zipp==4.1.1; python_version < "3.12" +cffi==2.1.1; sys_platform == "linux" +cryptography==50.0.2; sys_platform == "linux" +jeepney==0.9.0; sys_platform == "linux" +pycparser==3.11; sys_platform == "linux" +SecretStorage==3.5.0; sys_platform == "linux" +colorama==0.4.6; sys_platform == "win32" +pywin32-ctypes==0.2.3; sys_platform == "win32" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85d5023..04fe87b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,8 +48,11 @@ jobs: steps: - uses: actions/checkout@v4 - # Install uv for faster dependency installation (required on macOS, bundled in manylinux) + # Install uv for faster dependency installation (required on macOS, bundled in manylinux). + # An exact version, like the Python tools of .github/ci-constraints.txt. - uses: astral-sh/setup-uv@v4 + with: + version: "0.11.16" # Build wheels using cibuildwheel - name: Build wheels @@ -62,10 +65,26 @@ jobs: # Use uv for faster dependency installation (10-100x faster than pip) CIBW_BUILD_FRONTEND: "build[uv]" + # The pins of .github/ci-constraints.txt for what uv installs without constraints of + # cibuildwheel's own: the build requirements of every wheel (scikit-build-core and its + # dependencies; pybind11 is pinned in pyproject.toml) and the test requirements. uv + # reads UV_CONSTRAINT only when a call passes no -c, so cibuildwheel's own tools keep + # its pins. Linux builds in a container that holds the project at /project. + CIBW_ENVIRONMENT_LINUX: "UV_CONSTRAINT=/project/.github/ci-constraints.txt" + CIBW_ENVIRONMENT_MACOS: UV_CONSTRAINT="$GITHUB_WORKSPACE/.github/ci-constraints.txt" + CIBW_ENVIRONMENT_WINDOWS: UV_CONSTRAINT="$GITHUB_WORKSPACE/.github/ci-constraints.txt" # Target architecture per matrix entry (native compilation, no QEMU) CIBW_ARCHS: "${{ matrix.cibw_archs }}" - CIBW_TEST_COMMAND: 'python -c "import netgraph_core; print(netgraph_core.__version__)"' + # The Python suite on every built wheel (the shipped configuration, hooks off; the + # free-threaded cp314t wheels included): exactly the tests that need a hook skip. + # Windows runs pytest without dev/run-checks.sh, after the same check of the hook + # setting: the wheel lacks the private hooks. + CIBW_TEST_REQUIRES: "pytest==9.1.1" + CIBW_TEST_COMMAND: "PYTHON=python bash {project}/dev/run-checks.sh pytest off -p no:cacheprovider" + CIBW_TEST_COMMAND_WINDOWS: >- + python -c "import sys, _netgraph_core as m; sys.exit('the wheel carries the private hooks' if hasattr(m, '_max_flows_per_call') else 0)" + && python -m pytest {project}/tests/py -p no:cacheprovider CIBW_BEFORE_ALL_LINUX: "yum install -y libatomic || (apt-get update && apt-get install -y libatomic1) || apk add libatomic" - uses: actions/upload-artifact@v4 @@ -73,20 +92,59 @@ jobs: name: cibw-wheels-${{ matrix.os }}-${{ matrix.cibw_archs }} path: ./wheelhouse/*.whl + # build fills its isolated environment with a plain pip install, which PIP_CONSTRAINT reaches; + # the same variable pins pip itself and build. build_sdist: name: Build SDist runs-on: ubuntu-latest + env: + PIP_CONSTRAINT: ${{ github.workspace }}/.github/ci-constraints.txt steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" - name: Build SDist - run: pipx run build --sdist + run: | + python -m pip install -U pip + python -m pip install build + python -m build --sdist - uses: actions/upload-artifact@v4 with: name: cibw-sdist path: dist/*.tar.gz + # An installation built from the source distribution, tested with the Python suite of the same + # commit. pip builds it in an isolated environment of its own, which since pip 26.2 takes only + # build constraints (PIP_BUILD_CONSTRAINT), never PIP_CONSTRAINT: both name the same file. + # PIP_CONSTRAINT pins the venv's pip itself and pytest. + test_sdist: + name: Test an installation built from the SDist + needs: [build_sdist] + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: actions/download-artifact@v4 + with: + name: cibw-sdist + path: sdist + - name: Build and install from the SDist + env: + PIP_CONSTRAINT: ${{ github.workspace }}/.github/ci-constraints.txt + PIP_BUILD_CONSTRAINT: ${{ github.workspace }}/.github/ci-constraints.txt + run: | + python -m venv "$RUNNER_TEMP/sdist-venv" + "$RUNNER_TEMP/sdist-venv/bin/python" -m pip install -U pip + "$RUNNER_TEMP/sdist-venv/bin/python" -m pip install pytest + "$RUNNER_TEMP/sdist-venv/bin/python" -m pip install -v sdist/*.tar.gz + - name: Python tests against the installation + run: PYTHON="$RUNNER_TEMP/sdist-venv/bin/python" bash dev/run-checks.sh pytest off -p no:cacheprovider + test_wheels_centos_stream_9: name: Test wheels (CentOS Stream 9, py${{ matrix.python-version }}) needs: [build_wheels] @@ -100,6 +158,8 @@ jobs: - name: Install Python run: dnf install -y python${{ matrix.python-version }} python${{ matrix.python-version }}-pip + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 with: pattern: cibw-wheels-ubuntu-* @@ -108,13 +168,16 @@ jobs: - name: Install and test wheel run: | - python${{ matrix.python-version }} -m pip install --upgrade pip - python${{ matrix.python-version }} -m pip install netgraph-core --find-links wheelhouse/ - python${{ matrix.python-version }} -c "import netgraph_core; print('CentOS Stream 9:', netgraph_core.__version__)" + python${{ matrix.python-version }} -m venv /tmp/wheel-venv + /tmp/wheel-venv/bin/python -m pip install --upgrade -c .github/ci-constraints.txt pip + /tmp/wheel-venv/bin/python -m pip install -c .github/ci-constraints.txt pytest numpy + # --no-index: the wheel built above, never a published one of the same version. + /tmp/wheel-venv/bin/python -m pip install --no-index --find-links wheelhouse/ netgraph-core + PYTHON=/tmp/wheel-venv/bin/python bash dev/run-checks.sh pytest off -p no:cacheprovider publish_pypi: name: Publish to PyPI - needs: [check_version, build_wheels, build_sdist, test_wheels_centos_stream_9] + needs: [check_version, build_wheels, build_sdist, test_sdist, test_wheels_centos_stream_9] runs-on: ubuntu-latest environment: pypi permissions: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ba61abb..21b70c1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,6 +6,10 @@ on: pull_request: workflow_dispatch: +# Every job installs its Python tools with the pins of .github/ci-constraints.txt, pip itself +# first. An editable install builds the package in an isolated environment of pip's own, which +# since pip 26.2 takes only build constraints (--build-constraint), never -c: both name the same +# file. jobs: test: name: Unit tests (${{ matrix.os }}, py${{ matrix.python-version }}) @@ -15,18 +19,30 @@ jobs: matrix: os: [ubuntu-22.04, macos-latest, windows-latest] python-version: ["3.11", "3.12", "3.13", "3.14"] + include: + # Free-threaded CPython (no GIL): the module declares py::mod_gil_not_used(). + - os: ubuntu-22.04 + python-version: "3.14t" steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + # bash on every OS: PowerShell would not stop when the first command fails. - name: Install deps + shell: bash run: | - python -m pip install -U pip wheel - python -m pip install -e .[dev] - - name: Run CI checks (lint + C++/Python tests) + python -m pip install -U -c .github/ci-constraints.txt pip + python -m pip install -c .github/ci-constraints.txt --build-constraint .github/ci-constraints.txt -e ".[dev]" + - name: Run CI checks (lint + C++ tests with and without the hooks + Python tests) if: runner.os != 'Windows' run: make check-ci + # Windows runs pytest without dev/run-checks.sh, so this step checks the hook setting that + # script would: the editable build carries the private hooks (no test then skips). A step of + # its own: PowerShell does not stop at a failing command inside one step. + - name: Check the editable build carries the private hooks (Windows) + if: runner.os == 'Windows' + run: python -c "import sys, _netgraph_core as m; sys.exit(0 if hasattr(m, '_max_flows_per_call') else 'the editable build lacks the private hooks')" - name: Run Python tests (Windows) if: runner.os == 'Windows' run: python -m pytest tests/py/ -v @@ -53,12 +69,71 @@ jobs: - name: Install Python deps run: | - python${{ matrix.python-version }} -m pip install -U pip wheel - python${{ matrix.python-version }} -m pip install -e .[dev] + python${{ matrix.python-version }} -m pip install -U -c .github/ci-constraints.txt pip + python${{ matrix.python-version }} -m pip install -c .github/ci-constraints.txt \ + --build-constraint .github/ci-constraints.txt -e ".[dev]" - - name: Run CI checks (lint + C++/Python tests) + - name: Run CI checks (lint + C++ tests with and without the hooks + Python tests) run: PYTHON=python${{ matrix.python-version }} make check-ci + # The C++ tests with MSVC: a Release build with CMake's link-time optimisation (the wheel's + # configuration), with and without the private hooks. + cpp-windows-msvc: + name: C++ tests (windows, MSVC) + runs-on: windows-latest + defaults: + run: + shell: bash + env: + CC: cl + CXX: cl + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install build tools + run: | + python -m pip install -U -c .github/ci-constraints.txt pip + python -m pip install -c .github/ci-constraints.txt cmake ninja + - uses: ilammy/msvc-dev-cmd@v1 + - name: C++ tests, private hooks on + run: bash dev/cpp-tests.sh release + - name: C++ tests, private hooks off + run: bash dev/cpp-tests.sh off + + sanitizers: + name: C++ tests (ubuntu, ASan + UBSan) + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install build tools + run: | + python -m pip install -U -c .github/ci-constraints.txt pip + python -m pip install -c .github/ci-constraints.txt cmake ninja + - name: C++ tests under AddressSanitizer and UndefinedBehaviorSanitizer + run: bash dev/cpp-tests.sh sanitize + + # The generated type stub python/netgraph_core/__init__.pyi (make stubs-check): no drift from a + # fresh generation out of a wheel of this commit, the stub and py.typed in the wheel and the + # sdist, and the typed clients of tests/typing (valid: no diagnostic; invalid: exactly the + # expected ones) under pinned Pyright against the installed wheel and an installation built + # from the sdist. The script makes its own pinned venvs. Python 3.14: the version the + # committed stub is generated with. + stubs: + name: Type stub and typed clients (ubuntu, py3.14) + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.14" + - name: Stub drift, package contents, typed clients (wheel and sdist) + run: PYTHON=python bash dev/stubs/run.sh check + coverage: name: Coverage (ubuntu, py3.11) runs-on: ubuntu-22.04 @@ -67,10 +142,7 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.11" - - name: Install deps - run: | - python -m pip install -U pip wheel - python -m pip install -e .[dev] + # make cov builds its own pinned venv (build/coverage-venv) and fails on any failure. - name: Generate coverage (Python + C++) run: make cov - name: Upload coverage artifacts diff --git a/.gitignore b/.gitignore index eca4b33..3b8c2d3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,229 +1,32 @@ -# Byte-compiled / optimized / DLL files +# Python __pycache__/ *.py[codz] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ *.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ -.pytest_cache/ -.benchmarks/ -cover/ - -# Consolidated coverage artifacts -build/coverage/ - -# CTest output if created in project root -Testing/ -CTestTestfile.cmake - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock -#poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -#pdm.lock -#pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -#pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.envrc -.venv -env/ venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -.vscode/ - -# Ruff stuff: +.venv/ +.pytest_cache/ .ruff_cache/ -# PyPI configuration file -.pypirc +# Build outputs: CMake and scikit-build directories, distributions, compiled extensions +build/ +dist/ +*.so -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore +# Credentials of `make publish` +.pypirc -# OS-specific -.DS_Store +# The backup and reject files `patch` leaves beside the files it patches +*.orig +*.rej -# IDEs +# Editors and OS +.vscode/ .idea/ *.code-workspace +.DS_Store -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ -.pgo-profile/ +# Local output of browser-automation tools (Playwright's MCP server) +.playwright-mcp/ # Personal Superset lifecycle overrides .superset/config.local.json - -# Performance harness run logs -dev/perf/**/*.log diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e33a25d..3f8522a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,14 +4,12 @@ repos: hooks: - id: check-merge-conflict - id: check-yaml - # Recorded measurement artefacts and review patches must stay byte-exact. - id: end-of-file-fixer - exclude: ^dev/(research/|perf/.*/(results|review-[^/]+)/|reviews/|design/.*/experiments/) - id: trailing-whitespace - exclude: ^dev/(research/|perf/.*/(results|review-[^/]+)/|reviews/|design/.*/experiments/) - id: mixed-line-ending - id: debug-statements + # The revision matches the dev pin of Ruff (pyproject.toml). - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.11.13 hooks: @@ -19,9 +17,15 @@ repos: args: ["--fix"] - id: ruff-format - - repo: https://github.com/RobertCraigie/pyright-python - rev: v1.1.401 + # Pyright needs the compiled extension _netgraph_core to resolve the package's imports, so it + # runs as make lint runs it: the pinned Pyright of the project's venv (make dev), whose + # interpreter has the extension built, over the whole project (pyproject.toml's settings). + # An isolated hook environment cannot hold the extension. + - repo: local hooks: - id: pyright - additional_dependencies: ["numpy>=1.22"] - # Pyright reads configuration from pyproject.toml + name: pyright + entry: venv/bin/python -m pyright --pythonpath venv/bin/python + language: system + types_or: [python, pyi] + pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md index 917fa5f..7b39e83 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,13 +3,18 @@ - Setup: `bash .superset/workspace.sh setup`. Each worktree gets its own venv; setup does not replace the Git hooks shared by worktrees. - Superset **Run**: `bash .superset/workspace.sh check` rebuilds the Python - extension, then runs lint, types, C++ and Python tests. Editable Python imports - alone do not rebuild changed C++. -- For C++ changes, also run `make sanitize-test` (ASan/UBSan; errors must fail). + extension, then runs lint, types, the C++ tests with and without the private + hooks, and the Python tests. Editable Python imports alone do not rebuild + changed C++. +- For C++ changes, also run `make sanitize-test` (ASan/UBSan) and + `make tsan-test` (ThreadSanitizer); errors must fail. - For downstream changes, run NetGraph's `dev/check_core_integration.sh` with this worktree's absolute path. Test the chosen build, not the published wheel. Confirm defects with a reproducer. Max-flow tests need feasibility, conservation -and optimality/cut checks with nonuniform costs, masks and cost-factor cases. -Keep bindings and `_docs.py` consistent. Performance claims need correctness -checks and A/B/A on one quiet machine. Local checks do not replace CI. +and optimality/cut checks with nonuniform costs, excluded nodes and edges, and +cost ceilings (Absolute, Stretch). +A binding signature or docstring change needs the regenerated type stub +(`bash dev/stubs/run.sh generate`; `make stubs-check` checks drift, package +contents and typed clients). Performance claims need correctness checks and +A/B/A on one quiet machine. Local checks do not replace CI. diff --git a/CHANGELOG.md b/CHANGELOG.md index 30bdb5e..402430d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -The streamlined Core (breaking; integration branch `streamline`): one numeric domain (integer -units), one flow core, one placement engine. It replaces the 0.11 API. +A rebuilt Core (breaking): one numeric domain (integer units), one flow core, one placement +engine. It replaces the 0.11 API. ### Changed @@ -20,10 +20,9 @@ units), one flow core, one placement engine. It replaces the 0.11 API. unit values, the shapes, zero or negative costs, the remaining bounds including the cost-sum guard), on the exact integers also beyond int64. Accessors return read-only NumPy views that keep their graph alive, also when empty. - Construction uses stable counting passes: expected linear in edges plus nodes for bounded, - well-distributed distinct costs. It sorts all costs above 65,536 distinct costs or when the - cumulative hash-probe budget is exhausted. An edgeless graph needs validation and two - zero-filled offset arrays. Moving a graph no longer allocates. + Construction numbers the edges by counting sort, in expected time linear in edges plus nodes + for typical costs; above 65,536 distinct costs, or when the hash table that ranks the costs + examines too many slots, it sorts all costs instead. Moving a graph no longer allocates. `Eligibility` is built from excluded node ids and EdgeIds, with `Eligibility.intersect(a, b)`. `to_units` (round half up) and `as_float` are the only conversions between physical values and units. @@ -36,16 +35,16 @@ units), one flow core, one placement engine. It replaces the 0.11 API. ledger: a baseline in units (validated within `[0, capacity_units]`), records as snapshots in FlowId order (`records()`, `record(flow_id)` with `FlowId = (ledger uid, id)`, ids never reused), `capacity_units()`, `residual()`, `allocated()`, `bottleneck(sources, elig)`, - `reset()` and `copy()` with the lifetime table (bound and orphaned records; reset and copy - rejected while a policy is bound). `FlowRecord` carries the flow's endpoints as sets, + `reset()` and `copy()` (both rejected while a policy is bound; `reset()` clears the records a + destroyed policy left behind). `FlowRecord` carries the flow's endpoints as sets, placement, admission, bundle (`PredDAG`), route cost, `offered` (Lossy only), delivery and - per-edge allocations and drops; counters are 128-bit (exact Python ints). The ledger is - mutated only through policies. + per-edge allocations and drops; counters are 128-bit (exact Python ints). Policies place and + remove flows; `reset()` replaces an unbound ledger's baseline and clears its records. - **Kernels**: `FlowPlacement` (`MAX_FLOW`, `WEIGHTED`, `EQUAL_BALANCED`) and `Admission` - (`LOSSLESS`, `LOSSY`). MaxFlow solves the flow core on the flow's split graph over the residual - plus its own allocation; Weighted apportions by highest averages (D'Hondt, EdgeId ties) over - weights frozen at creation from `priority`; EqualBalanced by round robin from the lowest - EdgeId; Lossy splits each offer per call and records transit drops. + (`LOSSLESS`, `LOSSY`). `MAX_FLOW` re-solves the flow with the flow core on its route over the free + capacity plus its own allocation; `WEIGHTED` splits in proportion to weights fixed at creation + from `priority` (highest averages, D'Hondt, EdgeId ties); `EQUAL_BALANCED` splits equally by + round robin from the lowest EdgeId; Lossy splits each offer per call and records transit drops. - **Policies**: `FlowPolicy(ledger, sources, destinations, config, elig=None, cache=None)` is the one placement engine. `FlowPolicyConfig(routing=..., placement=..., admission=..., selection=...)` has four explicit axes; the routing is `CostOnly()`, `CapacityAware(...)` @@ -57,27 +56,31 @@ units), one flow core, one placement engine. It replaces the 0.11 API. the residual plus the policy's own allocation, and returns `PlacementResult` (`placed`, `remaining`, `termination`, `local_outcomes`, `routes_changed`, `weights_changed`, `drops`, Global's `cost_distribution`, and `edge_flows`, `residual`, `bottleneck` and Global's - `min_cut` when requested through `OutputOptions`). Validation precedes the zero-volume shortcut. + `min_cut` when requested through `OutputOptions`). A request of 0 is validated like any other. `remove()`, `records()`, `record(flow_id)` (the policy's own records), `placed_demand()` and `usable_bundles()` complete the policy. -- **Failure guarantee**: every public call is all-or-nothing up to its caller, an allocation - failure included, across worker threads and the Python result. Two limits: the pybind11 object - machinery (a construction or conversion outside the staged calls allocates through - `make_new_instance`, which does not check `tp_alloc`: a Python allocation failure there - crashes the process, Core state unchanged), and a supplied building `DagCache`, a memo that - keeps the complete entries and statistics of the lookups that succeeded before a failure. +- **Failure guarantee**: every public call either completes or raises and changes nothing, also + when memory runs out, a worker thread fails or the Python result cannot be built. Two limits: + running out of memory inside pybind11's own object construction (outside the library's checked + result construction) may crash the process; a `DagCache` passed to the call: a building cache + keeps the complete entries and statistics of the lookups that succeeded before the failure, and + a frozen cache keeps its entries and changes none of its statistics, except one miss when the + call raised `DagCacheMiss`. - **Search**: `spf(graph, roots, SpfOptions(direction, selection, targets=None, residual=None, min_residual=1, elig=None))` returns `SpfResult`. It searches from a root set (every root at - distance 0), `Direction.FORWARD` or `REVERSE`. Without targets it settles every reachable node - and returns `costs` (`UNREACHABLE` for the nodes not reached) and the full DAG of tight edges, + distance 0), `Direction.FORWARD` or `REVERSE`. Without targets it finds the distance of every reachable + node and returns `costs` (`UNREACHABLE` for the nodes not reached) and the full DAG of tight edges, the edges on some shortest path. With targets it stops at the nearest targets' cost and returns `target_cost` and the DAG of the walks between the roots and a nearest target (for a reverse search, from the target to a root). With - `multipath=False` it returns the canonical single path: the one walk that minimises cost, - then the bottleneck under `PREFER_HIGHER_PRIORITY`, then hops, then the edge-id sequence. A + `multipath=False` it returns the canonical single path: the walk of lowest cost, then (under + `PREFER_HIGHER_PRIORITY` only) of highest bottleneck, the smallest priority value along it, + then of fewest hops, then of lexicographically smallest edge-id sequence; with + `multi_edge=False` each node pair is first reduced to one parallel member. A residual adds residual gating, which keeps only the edges with at least `min_residual` - residual units. Every search runs on one traversal core, with a bucket queue when the cost - width allows and a heap otherwise. + residual units. Every search runs on one traversal core, with a bucket queue (nodes grouped by + distance) when the largest edge cost is at most 65,535 times the costs' greatest common + divisor, and a binary heap otherwise. - **KSP and paths**: `ksp(graph, src, dst, KspOptions(k, selection, max_path_cost=None, residual=None, elig=None))` returns the K shortest simple paths as `(cost, Path)` pairs. They come in increasing (cost, -bottleneck under `PREFER_HIGHER_PRIORITY`, hops, edge ids) order, @@ -86,26 +89,31 @@ units), one flow core, one placement engine. It replaces the 0.11 API. `resolve_to_paths(graph, dag, src, dst, split_parallel_edges=False, max_paths=None)` lists a DAG's walks in (cost, hops, edge ids) order. - **DagCache**: `DagCache(graph)` is keyed by the root set, the direction and the Eligibility. - Its values are the full static supports (immutable, shared, valid after `clear()`), and + Its values are the full static shortest-path DAGs (immutable, shared, valid after `clear()`), and `stats()` reports `hits`, `misses`, `entries`, `bytes` and `live_values`. - **Building mode:** one writer; a miss searches and inserts. - **Frozen mode:** `freeze()` makes the cache read-only and shareable by every worker of an evaluator. A frozen lookup miss raises `DagCacheMiss`, an exception the evaluators pass on - unchanged, never as a status. `clear()` raises when frozen, and `thaw()` returns to - building mode after the caller has joined every reader. + unchanged. `clear()` raises when frozen, and `thaw()` returns to building mode after the + caller has joined every reader. - **Evaluators**: `capacity(graph, sources, destinations, CapacityOptions(...))` (with Global - and MaxFlow the exact maximum flow, its marginal cost curve and its minimum cut), + and `MAX_FLOW` the exact maximum flow, its marginal cost curve and its minimum cut), `batch_capacity(graph, pairs, opts, per_pair_elig=None, workers=None)` (pair order; the lowest - failing index raises; a supplied cache needs `workers == 1`) and `sensitivity(graph, sources, - destinations, SensitivityOptions(...), workers=None)` (every candidate edge, the signed - unthresholded loss in units, unevaluable edges with their reason). The default worker counts - read `NGRAPH_CORE_BATCH_THREADS` and `NGRAPH_CORE_SENSITIVITY_THREADS` (1 when unset). -- **Flow core**: one integer minimum-cost flow (successive shortest paths with potentials, - Dinic phases per cost tier, every edge its own arc, arcs at a node in ascending EdgeId) serves - Global routing, the MaxFlow kernel and the evaluators (the exact sensitivity model re-solves - each deletion from the baseline solve's final state); the private entry - `_netgraph_core._place_global_units` returns the value, the marginal cost curve, edge flows, - the residual minimum cut, the potentials and six operation counters. + failing index raises) and `sensitivity(graph, sources, destinations, SensitivityOptions(...), + workers=None)` (every candidate edge and its signed unthresholded loss in units; a failed + deletion fails the call). A supplied building cache needs `workers == 1`; a frozen cache is + shared by every worker. With `workers=None` the count is 1 when a cache is supplied, otherwise + `NGRAPH_CORE_BATCH_THREADS` and `NGRAPH_CORE_SENSITIVITY_THREADS` (an integer in [1, 4096]; + 1 when unset or invalid). +- **Flow core**: one exact integer minimum-cost flow solver (successive shortest paths with + potentials, then Dinic phases for each cost level; each edge is its own arc, visited in + ascending EdgeId) serves Global routing, the `MAX_FLOW` kernel and the evaluators (sensitivity + repairs the baseline's solution for each removed edge instead of solving again); the private + entry `_netgraph_core._place_global_units` returns the value, the marginal cost curve, the edge + flows, the minimum cut, the potentials and six operation counters. The solver numbers its + network in `int32`, so a solve raises `ValueError`, before it sizes anything, on a network of + more than `INT32_MAX - 2` nodes or with more than `2^30 - 1` edges, sources and destinations + together; graphs of every accepted size still build and can be searched. - **C++ headers** (under `netgraph/core/`): `types.hpp`, `graph.hpp` (`Graph`, `to_units`, `as_float`; it includes `eligibility.hpp`), `eligibility.hpp`, `options.hpp` (the selection axes, `FlowPlacement`, `Admission`), `pred_dag.hpp`, `flow_record.hpp` (`FlowId`, @@ -114,6 +122,14 @@ units), one flow core, one placement engine. It replaces the 0.11 API. queue choice `SpfQueue { Auto, Heap }` is a C++-only trailing parameter of `spf`); each compiles alone and includes no `detail/` header. Python spells the creation cap `MAX_FLOWS_PER_CALL` (C++ `kMaxFlowsPerCall`). +- **Typing**: the package ships `__init__.pyi`, a type stub generated from the compiled module + (pybind11-stubgen with reviewed overrides: exact integer and array types, NumPy result + dtypes, optional values, and no constructor for the result classes the module refuses to + construct), next to `py.typed`. The stub carries the binding docstrings. `bash dev/stubs/run.sh + generate` regenerates it; `make stubs-check` checks it against a fresh generation, the wheel + and sdist contents, and typed client programs. +- **Error messages**: the error for a finite request after `float('inf')` within an epoch no + longer cites a rule number. ### Removed @@ -124,6 +140,15 @@ units), one flow core, one placement engine. It replaces the 0.11 API. batch max-flow and the previous sensitivity, the SPF memo and `StateStamp`, `kMinFlow`, `kEpsilon`, `constants.hpp`, and the profiling facility (the Python accessors, the C++ scopes and `NGRAPH_CORE_PROFILE`). +- The hand-written typing module `netgraph_core/_docs.py`, replaced by the generated stub. +- The sensitivity entry status (`SensitivityStatus`, `SensitivityEntry.status` and `.reason`): + Successful entries were always `EVALUATED`; + `SensitivityEntry.loss` is now always an int, and a failed deletion fails the call. + +### Fixed + +- Fixed double frees and leaks when native allocation fails while constructing Python result + wrappers for `spf`, `ksp` and `resolve_to_paths`; these failures now raise `MemoryError`. ## [0.11.0] - 2026-10-02 diff --git a/CMakeLists.txt b/CMakeLists.txt index 03c66aa..03bd37c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,72 +1,34 @@ cmake_minimum_required(VERSION 3.23) project(netgraph_core LANGUAGES CXX) -# Silence pybind11 FindPython compatibility warning by selecting the modern mode -set(PYBIND11_FINDPYTHON NEW CACHE STRING "Use modern FindPython in pybind11") -# Optional C++ tests + +# ============================================================================= +# Options. Every build states them on its command line (pyproject.toml for pip builds, +# dev/cpp-tests.sh for the C++ test builds), so a value cached by an earlier configure of the same +# build directory never decides a later build. +# ============================================================================= + option(NETGRAPH_CORE_BUILD_TESTS "Build C++ unit tests" OFF) -# The private hooks (test controls and prototype-parity diagnostics the contract does not name): -# ON for the editable development build (pyproject.toml) and the test suites (dev/cpp-tests.sh), -# OFF for an installed wheel. +# The private hooks (test controls, and diagnostics such as the placement trace that the tests +# compare with the reference under tests/reference/policy/; neither is public API): +# ON for the editable development build (pyproject.toml) and the hooks-on test suites +# (dev/cpp-tests.sh), OFF for a wheel and for the hooks-off test suite. option(NETGRAPH_CORE_PRIVATE_HOOKS "Build the private test hooks into the library and the extension" OFF) -if(NETGRAPH_CORE_BUILD_TESTS) - include(FetchContent) - FetchContent_Declare( - googletest - URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.zip - DOWNLOAD_EXTRACT_TIMESTAMP TRUE - ) - FetchContent_MakeAvailable(googletest) - enable_testing() -add_executable(netgraph_core_tests - tests/cpp/graph_tests.cpp - tests/cpp/graph_layout_tests.cpp - tests/cpp/graph_concurrency_tests.cpp - tests/cpp/flow_core_tests.cpp - tests/cpp/flow_core_deletion_tests.cpp - tests/cpp/eligibility_tests.cpp - tests/cpp/pred_dag_tests.cpp - tests/cpp/search_tests.cpp - tests/cpp/ksp_tests.cpp - tests/cpp/dag_cache_tests.cpp - tests/cpp/kernels_tests.cpp - tests/cpp/cost_only_creation_tests.cpp - tests/cpp/flow_ledger_tests.cpp - tests/cpp/flow_policy_fixture_tests.cpp - tests/cpp/flow_policy_tests.cpp -) - target_link_libraries(netgraph_core_tests PRIVATE netgraph_core GTest::gtest_main) - target_include_directories(netgraph_core_tests PRIVATE tests/cpp) - target_compile_definitions(netgraph_core_tests PRIVATE NETGRAPH_CORE_TEST_DATA="${CMAKE_CURRENT_SOURCE_DIR}/tests/data") - if(NETGRAPH_CORE_COVERAGE AND (CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")) - # Atomic counters: see the NETGRAPH_CORE_COVERAGE block below. - target_compile_options(netgraph_core_tests PRIVATE -O0 -g --coverage) - if(CMAKE_CXX_COMPILER_ID MATCHES "GNU") - target_compile_options(netgraph_core_tests PRIVATE -fprofile-update=atomic) - endif() - target_link_options(netgraph_core_tests PRIVATE --coverage) - endif() - # Allocation-failure tests replace the global operator new: a separate executable. - add_executable(netgraph_core_fault_tests tests/cpp/flow_ledger_fault_tests.cpp tests/cpp/flow_policy_fault_tests.cpp - tests/cpp/flow_core_deletion_fault_tests.cpp tests/cpp/search_fault_tests.cpp - tests/cpp/graph_fault_tests.cpp) - target_link_libraries(netgraph_core_fault_tests PRIVATE netgraph_core GTest::gtest_main) - target_include_directories(netgraph_core_fault_tests PRIVATE tests/cpp) - if(NETGRAPH_CORE_COVERAGE AND (CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")) - target_compile_options(netgraph_core_fault_tests PRIVATE -O0 -g --coverage) - if(CMAKE_CXX_COMPILER_ID MATCHES "GNU") - target_compile_options(netgraph_core_fault_tests PRIVATE -fprofile-update=atomic) - endif() - target_link_options(netgraph_core_fault_tests PRIVATE --coverage) - endif() - include(GoogleTest) - gtest_discover_tests(netgraph_core_tests) - gtest_discover_tests(netgraph_core_fault_tests) +option(NETGRAPH_CORE_COVERAGE "Enable C++ coverage instrumentation" OFF) +option(NETGRAPH_CORE_SANITIZE "Enable Address/Undefined sanitizers" OFF) +option(NETGRAPH_CORE_TSAN "Enable ThreadSanitizer" OFF) +option(NETGRAPH_CORE_NATIVE "Optimize for current CPU (not portable)" OFF) +if(NETGRAPH_CORE_SANITIZE AND NETGRAPH_CORE_TSAN) + # ThreadSanitizer cannot be combined with AddressSanitizer in one binary. + message(FATAL_ERROR "NETGRAPH_CORE_SANITIZE and NETGRAPH_CORE_TSAN are mutually exclusive") endif() +# Settings for every target: CMake applies them to targets created later, so they come first. set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_POSITION_INDEPENDENT_CODE ON) +# Silence pybind11 FindPython compatibility warning by selecting the modern mode +set(PYBIND11_FINDPYTHON NEW CACHE STRING "Use modern FindPython in pybind11") # macOS: enforce consistent deployment target and architecture for wheels/builds if(APPLE) @@ -84,20 +46,24 @@ if(APPLE) endif() endif() -# Dependencies -find_package(pybind11 3 CONFIG QUIET) +# Dependencies. pybind11 is exactly the version pinned in pyproject.toml's build requirements, +# whose licence notice the wheel ships (third_party/pybind11/LICENSE). +find_package(pybind11 3.1.0 EXACT CONFIG QUIET) if(NOT pybind11_FOUND) - message(STATUS "pybind11 not found via config; using FetchContent") + message(STATUS "pybind11 3.1.0 not found via config; using FetchContent") include(FetchContent) FetchContent_Declare( pybind11 - GIT_REPOSITORY https://github.com/pybind/pybind11.git - GIT_TAG v3.0.0 + URL https://github.com/pybind/pybind11/archive/refs/tags/v3.1.0.tar.gz + URL_HASH SHA256=ef712655692a2e9bf7bb7874c022564a45f91d847ddee987e720cd9e28849665 DOWNLOAD_EXTRACT_TIMESTAMP TRUE ) FetchContent_MakeAvailable(pybind11) endif() +# ============================================================================= +# Targets +# ============================================================================= add_library(netgraph_core STATIC src/graph.cpp @@ -117,56 +83,150 @@ target_include_directories(netgraph_core PUBLIC ) # PUBLIC: the library, the extension and the tests see one value (0 or 1). target_compile_definitions(netgraph_core PUBLIC NETGRAPH_CORE_PRIVATE_HOOKS=$) -## C++ standard is set globally above. target_compile_features(netgraph_core PUBLIC cxx_std_20) -# Warnings -if(MSVC) - target_compile_options(netgraph_core PRIVATE /W4 /permissive-) -else() - target_compile_options(netgraph_core PRIVATE -Wall -Wextra -Wpedantic) -endif() - -# Python extension -pybind11_add_module(_netgraph_core bindings/python/module.cpp) +# Python extension: the module entry and its registration files (bindings/python/), in the +# order the entry calls them; the shared helpers are headers there. +pybind11_add_module(_netgraph_core + bindings/python/module.cpp + bindings/python/axes.cpp + bindings/python/units.cpp + bindings/python/eligibility.cpp + bindings/python/graph.cpp + bindings/python/recorder.cpp + bindings/python/pred_dag.cpp + bindings/python/path.cpp + bindings/python/flow_record.cpp + bindings/python/flow_ledger.cpp + bindings/python/routing.cpp + bindings/python/policy_config.cpp + bindings/python/placement_result.cpp + bindings/python/dag_cache.cpp + bindings/python/flow_policy.cpp + bindings/python/evaluators.cpp + bindings/python/paths.cpp + bindings/python/search.cpp + bindings/python/hooks.cpp +) +# The library's PUBLIC usage requirements (C++20, the hooks flag, the libc++ settings below) reach +# the extension through this link. target_link_libraries(_netgraph_core PRIVATE netgraph_core) -target_compile_features(_netgraph_core PRIVATE cxx_std_20) if(APPLE) # Prefer maximum runtime compatibility for libc++ on macOS target_compile_definitions(netgraph_core PUBLIC _LIBCPP_DISABLE_AVAILABILITY=1 _LIBCPP_ABI_VERSION=1) - target_compile_definitions(_netgraph_core PRIVATE _LIBCPP_DISABLE_AVAILABILITY=1 _LIBCPP_ABI_VERSION=1) endif() +# The targets that get the project's warnings and either the link-time optimisation (where the +# compiler supports it) or, in a coverage or sanitizer build, the instrumentation: the library, +# the extension and, in a test build, the two test executables (added below). The header +# self-containment check below is not one of them. The extra code-generation flags go to the +# library and the extension only. +set(NETGRAPH_CORE_PROJECT_TARGETS netgraph_core _netgraph_core) + +if(NETGRAPH_CORE_BUILD_TESTS) + include(FetchContent) + FetchContent_Declare( + googletest + URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.zip + URL_HASH SHA256=1f357c27ca988c3f7c6b4bf68a9395005ac6761f034046e9dde0896e3aba00e4 + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + ) + # MSVC: link googletest against the same (shared) C runtime as the project. + set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) + FetchContent_MakeAvailable(googletest) + # googletest itself stays at C++17 (under C++20 its char8_t printer warns with Clang 21); the + # project's tests below are C++20 like the library. + set_target_properties(gtest gtest_main gmock gmock_main PROPERTIES CXX_STANDARD 17) + enable_testing() + add_executable(netgraph_core_tests + tests/cpp/graph_tests.cpp + tests/cpp/graph_layout_tests.cpp + tests/cpp/graph_concurrency_tests.cpp + tests/cpp/flow_core_tests.cpp + tests/cpp/flow_core_deletion_tests.cpp + tests/cpp/eligibility_tests.cpp + tests/cpp/pred_dag_tests.cpp + tests/cpp/search_tests.cpp + tests/cpp/ksp_tests.cpp + tests/cpp/dag_cache_tests.cpp + tests/cpp/kernels_tests.cpp + tests/cpp/cost_only_creation_tests.cpp + tests/cpp/flow_ledger_tests.cpp + tests/cpp/flow_policy_fixture_tests.cpp + tests/cpp/flow_policy_tests.cpp + ) + target_compile_definitions(netgraph_core_tests PRIVATE NETGRAPH_CORE_TEST_DATA="${CMAKE_CURRENT_SOURCE_DIR}/tests/data") + # Allocation-failure tests replace the global operator new: a separate executable. + add_executable(netgraph_core_fault_tests + tests/cpp/fault_injection.cpp + tests/cpp/flow_ledger_fault_tests.cpp + tests/cpp/flow_policy_fault_tests.cpp + tests/cpp/flow_core_deletion_fault_tests.cpp + tests/cpp/search_fault_tests.cpp + tests/cpp/graph_fault_tests.cpp + ) + foreach(tgt netgraph_core_tests netgraph_core_fault_tests) + target_link_libraries(${tgt} PRIVATE netgraph_core GTest::gtest_main) + target_include_directories(${tgt} PRIVATE tests/cpp) + endforeach() + # Every public header compiles on its own: one generated source file per header, holding only + # its include, built with the test executables. No public header names a private detail/ + # header in an #include (the configure stops on one). + file(GLOB NETGRAPH_CORE_PUBLIC_HEADERS RELATIVE ${CMAKE_CURRENT_SOURCE_DIR}/include + CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/include/netgraph/core/*.hpp) + set(NETGRAPH_CORE_HEADER_CHECKS) + foreach(header ${NETGRAPH_CORE_PUBLIC_HEADERS}) + file(STRINGS ${CMAKE_CURRENT_SOURCE_DIR}/include/${header} private_includes + REGEX "^[ \t]*#[ \t]*include[ \t]*[\"<][^\">]*detail/") + if(private_includes) + message(FATAL_ERROR "public header ${header} includes a private detail/ header: ${private_includes}") + endif() + string(MAKE_C_IDENTIFIER ${header} id) + file(GENERATE OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/header_checks/${id}.cpp CONTENT "#include \"${header}\"\n") + list(APPEND NETGRAPH_CORE_HEADER_CHECKS ${CMAKE_CURRENT_BINARY_DIR}/header_checks/${id}.cpp) + endforeach() + add_library(netgraph_core_header_checks OBJECT ${NETGRAPH_CORE_HEADER_CHECKS}) + target_link_libraries(netgraph_core_header_checks PRIVATE netgraph_core) + add_dependencies(netgraph_core_tests netgraph_core_header_checks) + list(APPEND NETGRAPH_CORE_PROJECT_TARGETS netgraph_core_tests netgraph_core_fault_tests) + include(GoogleTest) + gtest_discover_tests(netgraph_core_tests) + gtest_discover_tests(netgraph_core_fault_tests) +endif() + +# Warnings: the project's set on the library, the extension and the test executables (the list +# above); third-party code keeps its own. +if(MSVC) + set(NETGRAPH_CORE_WARNINGS /W4 /permissive-) +else() + set(NETGRAPH_CORE_WARNINGS -Wall -Wextra -Wpedantic) +endif() +foreach(tgt ${NETGRAPH_CORE_PROJECT_TARGETS}) + target_compile_options(${tgt} PRIVATE ${NETGRAPH_CORE_WARNINGS}) +endforeach() + # ============================================================================= # Performance optimizations (disabled when coverage/sanitizers are enabled) # ============================================================================= -option(NETGRAPH_CORE_COVERAGE "Enable C++ coverage instrumentation" OFF) -option(NETGRAPH_CORE_SANITIZE "Enable Address/Undefined sanitizers" OFF) -option(NETGRAPH_CORE_TSAN "Enable ThreadSanitizer" OFF) -if(NETGRAPH_CORE_SANITIZE AND NETGRAPH_CORE_TSAN) - # ThreadSanitizer cannot be combined with AddressSanitizer in one binary. - message(FATAL_ERROR "NETGRAPH_CORE_SANITIZE and NETGRAPH_CORE_TSAN are mutually exclusive") -endif() -option(NETGRAPH_CORE_NATIVE "Optimize for current CPU (not portable)" OFF) -option(NETGRAPH_CORE_FAST_MATH "Enable fast math (may affect precision)" OFF) -option(NETGRAPH_CORE_PGO_GENERATE "Instrument for PGO profile collection" OFF) -option(NETGRAPH_CORE_PGO_USE "Use PGO profile data (set NETGRAPH_CORE_PGO_DIR)" OFF) -set(NETGRAPH_CORE_PGO_DIR "${CMAKE_BINARY_DIR}/pgo" CACHE PATH "Directory for PGO profile data") - -# Only apply optimizations when not in debug/instrumentation mode +# Apply the extra optimization settings when coverage and sanitizers are off (whatever the build +# type: a Debug build gets them too). if(NOT NETGRAPH_CORE_COVERAGE AND NOT NETGRAPH_CORE_SANITIZE AND NOT NETGRAPH_CORE_TSAN) - # LTO (Link Time Optimization) - enables cross-module inlining + # LTO (Link Time Optimization) - enables cross-module inlining. LANGUAGES CXX: googletest + # enables C as well, and the check would otherwise require LTO support for C too and turn LTO + # off in every test build. The test executables are LTO-linked like the extension, so the C++ + # tests exercise the code generation that ships. include(CheckIPOSupported) - check_ipo_supported(RESULT ipo_supported OUTPUT ipo_error) + check_ipo_supported(RESULT ipo_supported OUTPUT ipo_error LANGUAGES CXX) if(ipo_supported) message(STATUS "Enabling LTO (Link Time Optimization)") - set_target_properties(netgraph_core PROPERTIES INTERPROCEDURAL_OPTIMIZATION TRUE) - set_target_properties(_netgraph_core PROPERTIES INTERPROCEDURAL_OPTIMIZATION TRUE) + set_target_properties(${NETGRAPH_CORE_PROJECT_TARGETS} PROPERTIES INTERPROCEDURAL_OPTIMIZATION TRUE) else() message(STATUS "LTO not supported: ${ipo_error}") endif() + # MSVC gets no extra flags: its build type's optimisation (/O2 in Release) and CMake's LTO (/GL + # with the matching /LTCG link option) suffice. if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") # GCC-only: allow aggressive inlining by promising symbols won't be interposed if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") @@ -188,77 +248,33 @@ if(NOT NETGRAPH_CORE_COVERAGE AND NOT NETGRAPH_CORE_SANITIZE AND NOT NETGRAPH_CO target_compile_options(netgraph_core PRIVATE -march=native) target_compile_options(_netgraph_core PRIVATE -march=native) endif() - - # Fast math - trades IEEE compliance for speed - if(NETGRAPH_CORE_FAST_MATH) - message(STATUS "Enabling fast math optimizations") - target_compile_options(netgraph_core PRIVATE -ffast-math) - target_compile_options(_netgraph_core PRIVATE -ffast-math) - endif() - - # PGO (Profile-Guided Optimization) - uses runtime profile to guide inlining decisions - # GCC: -fprofile-generate/-fprofile-use, Clang: -fprofile-instr-* - if(NETGRAPH_CORE_PGO_GENERATE) - message(STATUS "PGO: Instrumenting for profile generation -> ${NETGRAPH_CORE_PGO_DIR}") - file(MAKE_DIRECTORY ${NETGRAPH_CORE_PGO_DIR}) - if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") - target_compile_options(netgraph_core PRIVATE -fprofile-generate=${NETGRAPH_CORE_PGO_DIR}) - target_compile_options(_netgraph_core PRIVATE -fprofile-generate=${NETGRAPH_CORE_PGO_DIR}) - target_link_options(netgraph_core PRIVATE -fprofile-generate=${NETGRAPH_CORE_PGO_DIR}) - target_link_options(_netgraph_core PRIVATE -fprofile-generate=${NETGRAPH_CORE_PGO_DIR}) - else() - # Clang/AppleClang: use LLVM instrumentation PGO - target_compile_options(netgraph_core PRIVATE -fprofile-instr-generate=${NETGRAPH_CORE_PGO_DIR}/default.profraw) - target_compile_options(_netgraph_core PRIVATE -fprofile-instr-generate=${NETGRAPH_CORE_PGO_DIR}/default.profraw) - target_link_options(netgraph_core PRIVATE -fprofile-instr-generate=${NETGRAPH_CORE_PGO_DIR}/default.profraw) - target_link_options(_netgraph_core PRIVATE -fprofile-instr-generate=${NETGRAPH_CORE_PGO_DIR}/default.profraw) - endif() - elseif(NETGRAPH_CORE_PGO_USE) - message(STATUS "PGO: Using profile data from ${NETGRAPH_CORE_PGO_DIR}") - if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") - target_compile_options(netgraph_core PRIVATE -fprofile-use=${NETGRAPH_CORE_PGO_DIR} -fprofile-correction) - target_compile_options(_netgraph_core PRIVATE -fprofile-use=${NETGRAPH_CORE_PGO_DIR} -fprofile-correction) - else() - # Clang/AppleClang: use merged .profdata file - target_compile_options(netgraph_core PRIVATE -fprofile-instr-use=${NETGRAPH_CORE_PGO_DIR}/default.profdata) - target_compile_options(_netgraph_core PRIVATE -fprofile-instr-use=${NETGRAPH_CORE_PGO_DIR}/default.profdata) - endif() - endif() - elseif(MSVC) - # /O2: full optimization, /GL: whole program optimization (MSVC's LTO) - target_compile_options(netgraph_core PRIVATE /O2 /GL) - target_compile_options(_netgraph_core PRIVATE /O2 /GL) - if(NETGRAPH_CORE_FAST_MATH) - target_compile_options(netgraph_core PRIVATE /fp:fast) - target_compile_options(_netgraph_core PRIVATE /fp:fast) - endif() endif() endif() # ============================================================================= -# Debug/instrumentation options (mutually exclusive with optimizations above) +# Coverage and sanitizer options (mutually exclusive with the extra optimizations above). A request +# the compiler cannot honour stops the configure: a build that ran its tests without the requested +# checker would otherwise report success. # ============================================================================= if(NETGRAPH_CORE_COVERAGE) message(STATUS "Enabling coverage instrumentation (disables optimizations)") if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") - # The evaluators' worker threads corrupt the default non-atomic profile counters under - # concurrent updates (gcov then emits impossible values such as "branch 4 taken -1"). - set(COV_FLAGS -O0 -g --coverage) - if(CMAKE_CXX_COMPILER_ID MATCHES "GNU") - list(APPEND COV_FLAGS -fprofile-update=atomic) - endif() - foreach(tgt netgraph_core _netgraph_core) + # Atomic counters (GCC and Clang): the evaluators' worker threads corrupt the default + # non-atomic profile counters under concurrent updates (gcov then emits impossible values + # such as "branch 4 taken -1"). + set(COV_FLAGS -O0 -g --coverage -fprofile-update=atomic) + foreach(tgt ${NETGRAPH_CORE_PROJECT_TARGETS}) target_compile_options(${tgt} PRIVATE ${COV_FLAGS}) target_link_options(${tgt} PRIVATE --coverage) endforeach() else() - message(WARNING "NETGRAPH_CORE_COVERAGE requires GCC or Clang") + message(FATAL_ERROR "NETGRAPH_CORE_COVERAGE requires GCC or Clang") endif() endif() if(NETGRAPH_CORE_SANITIZE) - message(STATUS "Enabling sanitizers (disables optimizations)") + message(STATUS "Enabling sanitizers (without the extra optimizations)") if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") # Must be a CMake list: a single quoted string reaches the compiler as one # argument and clang rejects "-fsanitize=address,undefined -fno-omit-frame-pointer". @@ -269,23 +285,19 @@ if(NETGRAPH_CORE_SANITIZE) # and then silently ignored, which is indistinguishable from not checking at all. set(SAN_FLAGS -fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=undefined) - set(SAN_TARGETS netgraph_core _netgraph_core) - if(TARGET netgraph_core_tests) - # The test executables link the sanitized static library, so they must be - # built and linked with the same flags to pull in the sanitizer runtime. - list(APPEND SAN_TARGETS netgraph_core_tests netgraph_core_fault_tests) - endif() - foreach(tgt ${SAN_TARGETS}) + # The test executables link the sanitized static library, so they are built and linked with + # the same flags to pull in the sanitizer runtime. + foreach(tgt ${NETGRAPH_CORE_PROJECT_TARGETS}) target_compile_options(${tgt} PRIVATE ${SAN_FLAGS}) target_link_options(${tgt} PRIVATE ${SAN_FLAGS}) endforeach() else() - message(WARNING "NETGRAPH_CORE_SANITIZE requires GCC or Clang") + message(FATAL_ERROR "NETGRAPH_CORE_SANITIZE requires GCC or Clang") endif() endif() if(NETGRAPH_CORE_TSAN) - message(STATUS "Enabling ThreadSanitizer (disables optimizations)") + message(STATUS "Enabling ThreadSanitizer (without the extra optimizations)") if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") # The data-race check of the evaluators' worker threads and the shared DagCache. A CMake # list, as SAN_FLAGS above. A report makes the process exit non-zero (TSan's exit code 66, @@ -293,18 +305,14 @@ if(NETGRAPH_CORE_TSAN) # as the TSan documentation advises (the Debug build type adds -g); the instrumented # suites at -O0 would be several times slower for no extra coverage. set(TSAN_FLAGS -fsanitize=thread -fno-omit-frame-pointer -O1) - set(TSAN_TARGETS netgraph_core _netgraph_core) - if(TARGET netgraph_core_tests) - # The test executables link the instrumented static library: same compile and link - # flags, so they carry the TSan runtime and their own accesses are instrumented too. - list(APPEND TSAN_TARGETS netgraph_core_tests netgraph_core_fault_tests) - endif() - foreach(tgt ${TSAN_TARGETS}) + # The test executables link the instrumented static library: same compile and link flags, so + # they carry the TSan runtime and their own accesses are instrumented too. + foreach(tgt ${NETGRAPH_CORE_PROJECT_TARGETS}) target_compile_options(${tgt} PRIVATE ${TSAN_FLAGS}) target_link_options(${tgt} PRIVATE ${TSAN_FLAGS}) endforeach() else() - message(WARNING "NETGRAPH_CORE_TSAN requires GCC or Clang") + message(FATAL_ERROR "NETGRAPH_CORE_TSAN requires GCC or Clang") endif() endif() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6cd3577..79fed30 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ NetGraph-Core is a hybrid C++/Python project. You will need: - Python 3.11+ - C++20 compatible compiler (GCC 10+, Clang 12+, MSVC 2019+) - CMake 3.23+ -- Ninja (recommended) +- Ninja (the C++ test runner uses it; `make dev` installs CMake and Ninja into the venv) ### 1. Clone the repository @@ -37,7 +37,11 @@ This will: ### Running Tests -Run all checks (linting + C++ tests + Python tests): +Run all checks: rebuild the editable extension, then lint (ruff, pyright), the check that the +policy fixture data still equals its frozen reference (`tests/reference/policy/README.md`), the +C++ tests with and without the private test hooks, and the Python tests. It only verifies and +never edits a file; `make check-ci` is the same without the rebuild (CI installs the package +itself): ```bash make check @@ -46,8 +50,12 @@ make check Run specific test suites: ```bash -make cpp-test # C++ tests (GoogleTest) -make test # Python tests (pytest) +make cpp-test # C++ tests (GoogleTest), private hooks on +make cpp-test-off # C++ tests without the hooks (the shipped configuration) +make test # Python tests (pytest) +make sanitize-test # C++ tests under ASan/UBSan (required for C++ changes) +make tsan-test # C++ tests under ThreadSanitizer (required for C++ changes) +make cov # Coverage of the C++ and Python suites (its own venv and builds) ``` ### Code Style @@ -55,26 +63,43 @@ make test # Python tests (pytest) - **Python**: We use `ruff` for linting and formatting, and `pyright` for static type checking. - **C++**: We follow standard C++20 practices. -Auto-fix Python formatting: +Apply fixes (these edit files): ```bash -make format +make format # ruff formatting +make hooks # every pre-commit hook, including ruff --fix and whitespace fixers ``` +The commit hook and `make hooks` run Pyright as `make lint` does, from the `make dev` venv: it +needs the built extension to resolve the package's imports. + +### Type Stub + +`python/netgraph_core/__init__.pyi` is generated from the compiled module. After changing a +binding's signature or docstring, run `bash dev/stubs/run.sh generate` and commit the stub; +`make stubs-check` (also in CI) fails when the stub drifts or the typed client checks fail. + +### Behaviour Documentation + +[docs/semantics.md](docs/semantics.md) is the maintained statement of what the library computes. +A change to observable behaviour (a result, an order, an outcome, an error, a default) updates it +in the same change, and its examples must still print what the document shows: +`tests/py/test_docs_examples.py` runs them, and README's quick start, in the Python tests. + ## Release Process Releases are automated via GitHub Actions when a new tag is pushed. 1. Move the pending notes into a new `## [X.Y.Z] - YYYY-MM-DD` section in `CHANGELOG.md`, dated the day you tag. Mark anything that breaks consumers as **BREAKING**. -2. Bump `version` in `pyproject.toml` to the same `X.Y.Z`. CI does not cross-check the - tag against this value, so a mismatch publishes the wrong version silently. +2. Bump `version` in `pyproject.toml` to the same `X.Y.Z`. Release CI compares the tag + with this value and refuses to publish on a mismatch. 3. Commit and push. 4. Create and push a matching tag: ```bash - git tag v0.8.0 - git push origin v0.8.0 + git tag vX.Y.Z + git push origin vX.Y.Z ``` 5. The CI pipeline builds wheels and the sdist, tests them, and publishes to PyPI via diff --git a/Makefile b/Makefile index 01d4074..4488bdf 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +1,14 @@ # NetGraph-Core Development Makefile -.PHONY: help venv clean-venv dev install install-native install-pgo check check-ci lint format test qt build clean check-dist publish-test publish info hooks check-python cpp-test cov sanitize-test tsan-test rebuild +.PHONY: help venv clean-venv dev install install-native check check-ci lint format test build clean check-dist publish-test publish info hooks check-python cpp-test cpp-test-off cov sanitize-test tsan-test stubs-check rebuild .DEFAULT_GOAL := help # -------------------------------------------------------------------------- # Python interpreter detection # -------------------------------------------------------------------------- -# VENV_BIN: path to local virtualenv bin directory -VENV_BIN := $(PWD)/venv/bin +# VENV_BIN: the checkout's virtualenv bin directory (CURDIR is the directory make runs in, -C included) +VENV_BIN := $(CURDIR)/venv/bin # PY_BEST: scan for newest supported Python (used when creating new venvs) # Supports 3.11-3.14 to match requires-python >=3.11 @@ -41,23 +41,26 @@ help: @echo "Setup & Installation:" @echo " make venv - Create a local virtualenv (./venv)" @echo " make dev - Full development environment (package + dev deps + hooks)" - @echo " make install - Install package (default optimizations: LTO, loop unrolling)" + @echo " make install - Install package (editable, private hooks; LTO, loop unrolling)" @echo " make install-native - Install with CPU-specific optimizations (faster, not portable)" - @echo " make install-pgo - Profile-guided optimization (two-phase build, experimental)" @echo " make clean-venv - Remove virtual environment" @echo " make rebuild - Clean and rebuild (respects CMAKE_ARGS)" @echo "" @echo "Code Quality & Testing:" - @echo " make check - Run pre-commit (auto-fix) + C++/Python tests, then lint" - @echo " make check-ci - Run non-mutating lint + tests (CI entrypoint)" + @echo " make check - Rebuild the editable extension, then everything check-ci runs" + @echo " make check-ci - Lint, fixture data check, C++ tests (hooks on and off), Python tests" + @echo " (the CI entrypoint)" + @echo " (check and check-ci only verify: they never edit a file)" @echo " make lint - Run only linting (non-mutating: ruff + pyright)" - @echo " make format - Auto-format code with ruff" + @echo " make format - Auto-format code with ruff (edits files)" @echo " make test - Run Python tests (pytest)" - @echo " make qt - Run quick tests only (exclude slow/benchmark)" - @echo " make cpp-test - Build and run C++ tests" - @echo " make cov - Coverage summary + XML + single-page combined HTML" + @echo " make cpp-test - Build and run C++ tests (private hooks on)" + @echo " make cpp-test-off - Build and run C++ tests without the hooks (the shipped configuration)" + @echo " make cov - Coverage (own venv and builds) + XML + single-page combined HTML" @echo " make sanitize-test - Build and run C++ tests with sanitizers" @echo " make tsan-test - Build and run C++ tests with ThreadSanitizer" + @echo " make stubs-check - Type stub: drift, wheel/sdist contents, typed clients (own venvs)" + @echo " (regenerate the stub: bash dev/stubs/run.sh generate)" @echo "" @echo "Build & Package:" @echo " make build - Build distribution packages" @@ -68,7 +71,7 @@ help: @echo "" @echo "Utilities:" @echo " make info - Show project information" - @echo " make hooks - Run pre-commit on all files" + @echo " make hooks - Run pre-commit on all files (applies its fixes)" @echo " make check-python - Check if venv Python matches best available" # Allow callers to pass CMAKE_ARGS and MACOSX_DEPLOYMENT_TARGET consistently @@ -121,48 +124,33 @@ install: @echo "📦 Installing package (editable, default optimizations)" @$(DEV_ENV) $(PIP) install -e . +# The project options are stated in pyproject.toml for every build; a variant overrides one of them +# with -C (a config setting), so it cannot stay in the shared build directory's cache. install-native: @echo "📦 Installing with native CPU optimizations (-march=native)" @echo " Note: Binary will only work on this CPU architecture" - @$(ENV_MACOS) $(ENV_CC) $(ENV_CXX) CMAKE_ARGS="$(strip $(CMAKE_ARGS) -DNETGRAPH_CORE_NATIVE=ON)" $(PIP) install -e . - -# PGO profile stored outside build/ so it survives rebuild -PGO_DIR := $(PWD)/.pgo-profile - -install-pgo: - @echo "📦 PGO Build Phase 1/3: Instrumenting..." - @rm -rf build/ $(PGO_DIR) - @mkdir -p $(PGO_DIR) - @$(ENV_MACOS) $(ENV_CC) $(ENV_CXX) CMAKE_ARGS="$(strip $(CMAKE_ARGS) -DNETGRAPH_CORE_PGO_GENERATE=ON -DNETGRAPH_CORE_PGO_DIR=$(PGO_DIR) -DNETGRAPH_CORE_NATIVE=ON)" $(PIP) install -e . - @echo "📦 PGO Build Phase 2/3: Collecting profile (running the Python test suite)..." - @$(PYTEST) -q tests/py >/dev/null 2>&1 - @# Clang: merge raw profiles into .profdata (profile may be in cwd or PGO_DIR) - @if ls $(PGO_DIR)/*.profraw >/dev/null 2>&1; then \ - echo " Merging Clang profile data..."; \ - xcrun llvm-profdata merge -output=$(PGO_DIR)/default.profdata $(PGO_DIR)/*.profraw 2>/dev/null || \ - llvm-profdata merge -output=$(PGO_DIR)/default.profdata $(PGO_DIR)/*.profraw; \ - elif ls *.profraw >/dev/null 2>&1; then \ - mv *.profraw $(PGO_DIR)/; \ - xcrun llvm-profdata merge -output=$(PGO_DIR)/default.profdata $(PGO_DIR)/*.profraw 2>/dev/null || \ - llvm-profdata merge -output=$(PGO_DIR)/default.profdata $(PGO_DIR)/*.profraw; \ - fi - @echo "📦 PGO Build Phase 3/3: Rebuilding with profile data..." - @rm -rf build/ - @$(ENV_MACOS) $(ENV_CC) $(ENV_CXX) CMAKE_ARGS="$(strip $(CMAKE_ARGS) -DNETGRAPH_CORE_PGO_USE=ON -DNETGRAPH_CORE_PGO_DIR=$(PGO_DIR) -DNETGRAPH_CORE_NATIVE=ON)" $(PIP) install -e . - @echo "✅ PGO build complete" + @$(DEV_ENV) $(PIP) install -e . -C cmake.define.NETGRAPH_CORE_NATIVE=ON +# Verification only: neither target edits a file (make format and make hooks apply fixes). check: - @PYTHON=$(PYTHON) bash dev/run-checks.sh + @$(MAKE) install + @$(MAKE) check-ci +# The fixture data check: tests/data/policy_fixtures.json still equals what its frozen reference +# computes (tests/reference/policy/README.md; standard library only; -B writes no __pycache__). check-ci: @$(MAKE) lint + @$(PYTHON) -I -B tests/reference/policy/export_policy_fixtures.py --check @$(MAKE) cpp-test - @$(MAKE) test + @$(MAKE) cpp-test-off + @PYTHON=$(PYTHON) bash dev/run-checks.sh pytest on +# Pyright resolves imports (NumPy in the type stub, the extension) through the interpreter it +# is given; without --pythonpath it would take whichever python3 comes first on PATH. lint: @$(RUFF) format --check . @$(RUFF) check . - @$(PYTHON) -m pyright + @$(PYTHON) -m pyright --pythonpath "$$($(PYTHON) -c 'import sys; print(sys.executable)')" format: @$(RUFF) format . @@ -170,9 +158,6 @@ format: test: @$(PYTEST) -qt: - @$(PYTEST) --no-cov -m "not slow and not benchmark" - build: @echo "🏗️ Building distribution packages..." @if $(PYTHON) -c "import build" >/dev/null 2>&1; then \ @@ -212,8 +197,7 @@ publish: clean: @echo "🧹 Cleaning build artifacts and cache files..." @rm -rf build/ dist/ *.egg-info/ || true - @rm -rf .pytest_cache .ruff_cache .mypy_cache htmlcov htmlcov-python .coverage coverage.xml coverage-*.xml .benchmarks .pytest-benchmark || true - @rm -rf Testing CTestTestfile.cmake || true + @rm -rf .pytest_cache .ruff_cache || true @find . -path "./venv" -prune -o -type f -name "*.pyc" -delete 2>/dev/null || true @find . -path "./venv" -prune -o -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true @find . -path "./venv" -prune -o -type f -name "*.pyo" -delete 2>/dev/null || true @@ -247,34 +231,13 @@ hooks: cpp-test: @PATH="$(VENV_BIN):$$PATH" bash dev/cpp-tests.sh release +cpp-test-off: + @PATH="$(VENV_BIN):$$PATH" bash dev/cpp-tests.sh off + +# The summary script's own rules first (its refusals make a failed measurement fail; no build). cov: - @echo "📦 Reinstalling with C++ coverage instrumentation..." - @$(PIP) install -U scikit-build-core "pybind11>=2.11" - @PIP_NO_BUILD_ISOLATION=1 CMAKE_ARGS="-DNETGRAPH_CORE_COVERAGE=ON" $(PIP) install -e .'[dev]' - @echo "🧪 Running Python tests with coverage..." - @mkdir -p build/coverage - @$(PYTEST) --cov=netgraph_core --cov-report=term-missing --cov-report=xml:build/coverage/coverage-python.xml - @echo "🛠️ Building and running C++ tests with coverage..." - @BUILD_DIR="build/cpp-tests-cov"; \ - mkdir -p "$$BUILD_DIR"; \ - GEN_ARGS=""; \ - if command -v ninja >/dev/null 2>&1; then GEN_ARGS="-G Ninja"; fi; \ - cmake -S . -B "$$BUILD_DIR" -DNETGRAPH_CORE_BUILD_TESTS=ON -DNETGRAPH_CORE_PRIVATE_HOOKS=ON -DNETGRAPH_CORE_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug $$GEN_ARGS; \ - cmake --build "$$BUILD_DIR" --config Debug -j; \ - ctest --test-dir "$$BUILD_DIR" --output-on-failure || echo "⚠️ Some C++ tests failed (continuing for coverage)" - @echo "📈 Generating C++ coverage XML (gcovr)..." - @$(PYTHON) -m gcovr --root . \ - --object-directory build \ - --object-directory build/cpp-tests-cov \ - --filter 'include/netgraph' --filter 'src' --exclude 'tests' --exclude 'bindings/.*' --exclude '.*pybind11.*' --exclude '_deps/pybind11-src/.*' \ - --gcov-ignore-errors=all \ - --gcov-ignore-parse-errors=negative_hits.warn_once_per_file \ - --xml-pretty -o build/coverage/coverage-cpp.xml - @echo "" - @echo "================ Python + C++ coverage (summary) ================" - @$(PYTHON) dev/coverage_summary.py build/coverage/coverage-python.xml build/coverage/coverage-cpp.xml --html=build/coverage/coverage-combined.html - @echo "" - @echo "✅ Coverage ready in build/coverage/: coverage-python.xml, coverage-cpp.xml, coverage-combined.html" + @$(PYTHON) -I -B dev/test_coverage_summary.py + @PYTHON=$(PYTHON) bash dev/run-checks.sh cov sanitize-test: @PATH="$(VENV_BIN):$$PATH" bash dev/cpp-tests.sh sanitize @@ -282,6 +245,9 @@ sanitize-test: tsan-test: @PATH="$(VENV_BIN):$$PATH" bash dev/cpp-tests.sh tsan +stubs-check: + @PYTHON=$(PYTHON) bash dev/stubs/run.sh check + # Clean + reinstall in dev mode (respects CMAKE_ARGS and MACOSX_DEPLOYMENT_TARGET) # Uses active PYTHON (venv or PATH) to avoid environment mismatches rebuild: clean diff --git a/README.md b/README.md index 4cb505a..2f6faf3 100644 --- a/README.md +++ b/README.md @@ -2,47 +2,58 @@ C++ graph engine for network flow analysis, traffic engineering simulation, and capacity planning. -> **Streamline in progress.** The `streamline` branch rebuilds Core. This build carries the -> graph on integer units, the minimum-cost flow core, the route structures (`PredDAG`, `Path`), -> the integer ledger `FlowLedger` with the placement kernels, the policy engine `FlowPolicy`, the -> `DagCache` (building mode) and the evaluators `capacity`, `batch_capacity` and `sensitivity`. -> The public search API (shortest paths, k shortest paths, path resolution) and the frozen cache -> are not part of it yet. The previous API (0.11 and earlier) is in git history and on PyPI. +> **Unreleased, breaking.** This version replaces the 0.11 API (see the Unreleased section of +> [CHANGELOG.md](CHANGELOG.md)): the graph on integer units, the minimum-cost flow core, the route +> structures (`PredDAG`, `Path`), the integer ledger `FlowLedger` with the placement kernels, the +> policy engine `FlowPolicy`, the searches (`spf`, `ksp`, `resolve_to_paths`), the `DagCache` in +> building and frozen modes, and the evaluators `capacity`, `batch_capacity` and `sensitivity`. +> The previous API (0.11 and earlier) is in git history and on PyPI. ## Overview - **Exact integer amounts**: a graph carries an explicit resolution `q` (a power of two in `[2^-32, 2^32]`); capacities and flows are `int64` units of `q`. Physical values enter only through `to_units` (round half up) and leave through `as_float`. -- **Positive costs**: edge costs are integers `>= 1`, so the shortest-path DAG is unique. -- **Stable edge ids**: the public `EdgeId` is the rank of an edge in the order - `(src, cost, dst, input)`; `input_index()` maps ids back to input positions. -- **One flow core**: an integer minimum-cost flow (successive shortest paths with potentials, - Dinic phases per cost tier) returning the flow value, the marginal cost curve, per-edge - flows, the residual minimum cut and the dual potentials. +- **Positive costs**: edge costs are integers `>= 1`, so the shortest-path DAG (every edge on + some shortest path) has no cycle and does not depend on search order or tie rules. +- **Stable edge ids**: edges are numbered by sorting on `(src, cost, dst)`, ties kept in input + order; that number is the public `EdgeId`, and `input_index()` maps it back to the input position. +- **One flow core**: an exact integer minimum-cost flow solver (the most traffic, sent as cheaply + as possible). It returns the flow value, the marginal cost curve (the extra minimum total cost + per additional delivered unit, as `(cost, units)` segments), the flow on every edge, a minimum + cut (the full edges that separate the sources from the destinations) and the node potentials + (per-node prices that prove the flow is cheapest). It works one cost level at a time: shortest + paths with potentials find the next cheapest cost, then Dinic phases fill every path of that + cost. ## Core Features +The exact behaviour of every call (units and rounding, edge ids, which route a search returns, +how the kernels split traffic, outcomes and flags, failures, the cache and the evaluators) is +specified in [docs/semantics.md](docs/semantics.md). + ### Graph - **`Graph(num_nodes, src, dst, capacity_units, cost, resolution)`**: immutable - directed multigraph with forward and reverse CSR adjacency; parallel edges and self-loops are - allowed. Validation reports the first defect in a fixed order (the resolution, the unit + directed multigraph that stores each node's out-edges and in-edges as arrays; parallel edges and + self-loops are allowed. Validation reports the first defect in a fixed order (the resolution, the unit values, the shapes, zero or negative costs, the remaining bounds). Accessors (`capacity_units()`, `cost()`, `edge_src()`, `edge_dst()`, `input_index()`, `row_offsets()`, `in_row_offsets()`, `in_adj_edge_index()`) return read-only NumPy views that keep the graph alive. -- **`Eligibility`**: immutable, graph-bound static eligibility built by +- **`Eligibility`**: which nodes and edges of one graph may be used (immutable), built by `graph.eligibility(excluded_nodes, excluded_edges)`; `Eligibility.intersect(a, b)` returns an operand when the result equals it, otherwise a new value. ### Route structures -- **`PredDAG`**: immutable, graph-bound predecessor DAG, stored over its member nodes and - presented in CSR form (`parent_offsets()` built on demand, `parents()`, `via_edges()`), - entries in ascending via EdgeId within a node. +- **`PredDAG`**: the route structure a search returns, a predecessor DAG (directed, no cycle): + for each node, the edges that reach it on the route and the node each comes from. `parents()` + and `via_edges()` list these entries, in ascending EdgeId within a node; `parent_offsets()` + (built on each call) says where each node's entries start. `PredDAG.from_csr(graph, offsets, parents, via)` validates the shape, the ranges and endpoints, duplicates, acyclicity and the canonical order before accepting a DAG. -- **`Path(graph, hops)`**: an explicit walk whose hops may hold several parallel members; +- **`Path(graph, hops)`**: a route given hop by hop, where a hop may hold several parallel edges + between the same two nodes; `cost()` is the sum over hops of the minimum member cost and `to_dag()` gives the equivalent `PredDAG`. - **`PathSelection(tie_break, priority, multipath=True, multi_edge=True)`** with @@ -50,92 +61,110 @@ C++ graph engine for network flow analysis, traffic engineering simulation, and ### Ledger and kernels -- **`FlowLedger(graph, residual=None)`**: the integer ledger of one graph: an immutable baseline - in units and the records (`FlowRecord`: flow id, owner, endpoints, placement, admission, - bundle, route cost, offered, delivered, per-edge allocation and drops) of the flows placed on - it. `allocated()` is the exact sum of the records and `residual()` the baseline minus it; - `bottleneck(sources)` lists the saturated frontier; `reset()` and `copy()` follow the lifetime - rules (rejected while a policy is bound). The ledger is mutated only through policies. -- **Kernels** (`FlowPlacement`: `MAX_FLOW`, `WEIGHTED`, `EQUAL_BALANCED`; `Admission`: - `LOSSLESS`, `LOSSY`): MaxFlow is the flow core on the flow's DAG (a replacement on its own - allocation plus the residual), Weighted splits by highest averages over weights frozen at - creation, EqualBalanced by round robin; Lossy forwards hop by hop and records drops. +- **`FlowLedger(graph, residual=None)`**: the integer ledger of one graph: a baseline + in units (replaced by `reset()`) and the records (`FlowRecord`: flow id, owner, endpoints, + placement, admission, bundle, route cost, offered, delivered, per-edge allocation and drops) + of the flows placed on it. `allocated()` is the exact sum of the records and `residual()` + the baseline minus it; `bottleneck(sources)` lists the full edges leaving the set of nodes + the sources can still reach. `reset()` and `copy()` are rejected while a policy is bound + to the ledger. Policies place and remove flows; `reset()` replaces an unbound ledger's + baseline and clears its records. +- **Kernels**, the rules that spread one flow over its route (`FlowPlacement`: `MAX_FLOW`, + `WEIGHTED`, `EQUAL_BALANCED`; `Admission`: `LOSSLESS`, `LOSSY`): `MAX_FLOW` re-solves the flow + with the flow core on its DAG, over the free capacity plus its own allocation; `WEIGHTED` splits + at every node in proportion to weights fixed when the flow is created (highest averages: + whole-unit shares as close to proportional as possible); `EQUAL_BALANCED` splits equally (round + robin); `LOSSY` forwards hop by hop and records what it drops. ### Policies -- **`FlowPolicy(ledger, sources, destinations, config, elig=None, cache=None)`**: the one - placement engine, bound to its ledger. `FlowPolicyConfig(routing=..., placement=..., - admission=..., selection=...)` has four explicit axes; the routing is `CostOnly()` (static - shortest paths), `CapacityAware(flow_count=1, max_flow_count=None, max_path_cost=None, - min_flow_bandwidth=0, reoptimize=False, diminishing=None)` (residual-aware searches with an - `Absolute` or `Stretch` cost ceiling, a `DiminishingReturns(window, fraction)` guard, and at - most `MAX_FLOWS_PER_CALL` (10,000) creations per call), `Global()` (the minimum-cost flow of the policy's - sources to its destinations on the residual plus its own allocation) or `Pinned(bundles)` - (fixed `PredDAG`s). -- **`place(volume, outputs=None)`** runs one transaction per call (per source for CostOnly and - CapacityAware, cumulative on Global) and returns a `PlacementResult` (`placed`, `remaining`, - `termination`, `local_outcomes`, `routes_changed`, `weights_changed`, `drops` and the - optional `cost_distribution` (Global) and, when requested through `OutputOptions`, `edge_flows`, - `residual`, `bottleneck` and, on Global with `OutputOptions(bottleneck=True)`, `min_cut`). +- **`FlowPolicy(ledger, sources, destinations, config, elig=None, cache=None)`**: the placement + engine for one demand, bound to its ledger. `FlowPolicyConfig(routing=..., placement=..., + admission=..., selection=...)` sets four independent choices, all explicit. The routing is one of: + - `CostOnly()`: static shortest paths, as routers forward with an IGP such as OSPF; + - `CapacityAware(flow_count=1, max_flow_count=None, max_path_cost=None, min_flow_bandwidth=0, + reoptimize=False, diminishing=None)`: tunnels that each search the free capacity (CSPF), with + an `Absolute` or `Stretch` cost ceiling, a `DiminishingReturns(window, fraction)` guard, and at + most `MAX_FLOWS_PER_CALL` (10,000) new flows per call; + - `Global()`: an ideal controller: the minimum-cost flow from all the policy's sources to its + destinations, on the free capacity plus its own allocation; + - `Pinned(bundles)`: fixed routes given as `PredDAG`s. +- **`place(volume, outputs=None)`** adds `volume` to the policy's request as one all-or-nothing + step (CostOnly and CapacityAware place source by source; Global re-solves what it already + delivers plus this request) and returns a `PlacementResult`: `placed`, `remaining`, `termination`, `local_outcomes`, + `routes_changed`, `weights_changed`, `drops`, `cost_distribution` (Global only), and, when + requested through `OutputOptions`, `edge_flows`, `residual`, `bottleneck` and (Global only) + `min_cut`. `remove()` reverts the policy's flows; `records()`, `record(flow_id)`, `placed_demand()` and `usable_bundles()` read its state. -- **Failure guarantee**: every public call is all-or-nothing up to its caller: a failure - anywhere (validation, allocation, a worker thread, the construction of the Python result) - leaves every ledger, policy, cache and scratch buffer as it was and raises one exception. - Two limits: the pybind11 object machinery is outside the guarantee (constructing a bound - object from Python, or a conversion into a new bound object or container outside the staged - calls, allocates through pybind11's `make_new_instance`, which does not check `tp_alloc`, so a - Python allocation failure there crashes the process, with Core state unchanged); and a - supplied building `DagCache` is a memo that keeps the complete entries and statistics of the - lookups that succeeded before a later failure of the call. +- **Failure guarantee**: every public call either completes or raises one exception and leaves + every ledger, policy, cache and scratch buffer as it was, whatever fails (validation, memory, a + worker thread, building the Python result). Two limits: pybind11 does not check every + allocation when it constructs a bound object from Python or converts a returned value into a + new Python object outside the library's checked result construction, so running out of memory + exactly there may crash the process (Core state stays unchanged); and a `DagCache` passed to the + call: a building cache keeps the complete entries and statistics of the lookups that succeeded + before the failure, and a frozen cache keeps its entries and statistics unchanged, except that a + call raising `DagCacheMiss` counts one miss + ([docs/semantics.md](docs/semantics.md#8-failures-threads-and-lifetimes)). ### Search `spf(graph, roots, SpfOptions(direction, selection, targets=None, residual=None, min_residual=1, elig=None))` searches from a root set, forward or reverse. - **Without targets:** it returns `costs` (`UNREACHABLE` for nodes not reached) and the full - DAG of tight edges, the edges that lie on some shortest path. + DAG of tight edges, the edges that lie on some shortest path (one per node pair with + `multi_edge=False`). - **With targets:** it stops at the nearest targets and returns `target_cost` and the DAG of the walks between the roots and the nearest targets (for a reverse search, from a target to a root). -- **With `multipath=False`:** it returns the canonical single path, the one walk that minimises - cost, then bottleneck, then hops, then edge ids. +- **With `multipath=False`:** it returns the canonical single path: the walk of lowest cost; + among those, under `PREFER_HIGHER_PRIORITY` only, the one of highest bottleneck (the smallest + priority value along the walk); then the fewest hops; then the lexicographically smallest + sequence of EdgeIds ([docs/semantics.md](docs/semantics.md#the-canonical-single-path)). +- **With `multi_edge=False`** (any search): each node pair is first reduced to one parallel + member (lowest cost, then under `PREFER_HIGHER_PRIORITY` the highest priority, then the lowest + EdgeId). `ksp(graph, src, dst, KspOptions(k, selection, max_path_cost=None, residual=None, elig=None))` -returns the K shortest simple paths as `(cost, Path)` pairs, in the contract's order and up to +returns the K shortest simple paths as `(cost, Path)` pairs, in the order of +[docs/semantics.md](docs/semantics.md#k-shortest-paths) and up to an inclusive cost ceiling. `resolve_to_paths(graph, dag, src, dst, split_parallel_edges=False, max_paths=None)` lists a DAG's walks as `Path` values. ### DagCache -`DagCache(graph)` keeps the static shortest-path supports that policies build, keyed by the -root set, the direction and the Eligibility. Values are immutable and shared, and `stats()` -reports hits, misses, entries, retained bytes and live values. -- **Building mode (new caches):** one writer; a miss searches and inserts. -- **Frozen mode (`freeze()`):** read-only and shared by every worker of an evaluator; a miss - raises `DagCacheMiss`. `thaw()` returns to building mode once every reader has finished. +`DagCache(graph)` keeps the static shortest-path DAGs (from costs and the Eligibility, not +traffic) that policies build, keyed by the root set, the direction and the Eligibility. Values +are immutable and shared, and `stats()` reports hits, misses, entries, retained bytes and live +values. +- **Building mode (new caches):** one writer; a miss searches and inserts. An evaluator given a + building cache runs on one worker (an explicit `workers` above 1 is rejected). +- **Frozen mode (`freeze()`):** read-only and shared by every worker of an evaluator (pass + `workers` explicitly; with a cache the default is 1); a miss raises `DagCacheMiss`. `thaw()` + returns to building mode once every reader has finished. ### Evaluators `capacity(graph, sources, destinations, CapacityOptions(config, elig=None, cache=None, -outputs=None))` is `place(+inf)` of a policy on a fresh ledger; Global with MaxFlow gives the +outputs=None))` is `place(+inf)` of a policy on a fresh ledger; Global with MAX_FLOW gives the exact maximum flow with its marginal cost curve, and its minimum cut with `outputs=OutputOptions(bottleneck=True)`. `batch_capacity(graph, pairs, opts, per_pair_elig=None, workers=None)` evaluates pairs in pair order (the lowest failing index raises). `sensitivity(graph, sources, destinations, SensitivityOptions(...), workers=None)` reports, for every candidate edge, the signed loss in placed units when the -edge is removed. The default worker counts come from `NGRAPH_CORE_BATCH_THREADS` and -`NGRAPH_CORE_SENSITIVITY_THREADS`. +edge is removed. With `workers=None` the worker count is 1 when a cache is supplied, otherwise +it comes from `NGRAPH_CORE_BATCH_THREADS` and `NGRAPH_CORE_SENSITIVITY_THREADS`. ### Flow core -One native integer minimum-cost flow core serves Global routing, the MaxFlow kernel and the -evaluators: the minimum-cost flow of the maximum value attainable under a cap from a source set -to a destination set over the eligible edges, starting from zero flow; the exact sensitivity -model re-solves each deletion from the baseline solve's final state. Its private Python entry +One native integer minimum-cost flow core serves Global routing, the MAX_FLOW kernel and the +evaluators: the largest flow up to a cap from a source set to a destination set over the eligible +edges, sent at the lowest total cost, starting from zero flow. For sensitivity it repairs the +baseline's solution for each removed edge instead of solving again. Its private Python entry `_netgraph_core._place_global_units(graph, view, sources, destinations, elig, cap)` returns the payload `{placed, cost_distribution, edge_flows, min_cut, potentials, counters}` for the -equivalence recorder and the oracle checks. +flow checks of the test suites. ## Quick Start @@ -164,12 +193,15 @@ print(result.cost_distribution) # [(2, 40960), (4, 20480)] pip install netgraph-core ``` -Or from source: +Or from source (the shipped configuration; `pip install -e .` is the editable development build, +which carries the private test hooks): ```bash -pip install -e . +pip install . ``` +The package is typed: it ships `py.typed` and a type stub generated from the compiled module. + ### Build Optimizations Default builds include LTO and loop unrolling. For local development: @@ -187,24 +219,26 @@ bindings/python/ # pybind11 bindings python/netgraph_core/ # Python package tests/cpp/ # C++ tests (googletest) tests/py/ # Python tests (pytest) -dev/ # Developer scripts (not shipped): checks, coverage, -dev/perf/ # performance tooling and recorded A/B/A results, -dev/research/ # research prototypes and their evidence +dev/ # Developer scripts (not shipped): checks and coverage ``` ## Development ```bash -make dev # Setup: venv, dependencies, pre-commit hooks -make check # Run all tests and linting (auto-fix formatting) -make check-ci # Strict checks without auto-fix (for CI) -make test # Python tests -make cpp-test # C++ tests only -make cov # Combined coverage report (C++ + Python) +make dev # Setup: venv, dependencies, pre-commit hooks +make check # Rebuild the editable extension, then everything check-ci runs +make check-ci # Lint, types, C++ tests (hooks on and off), Python tests (CI entrypoint) +make test # Python tests +make cpp-test # C++ tests (private hooks on) +make cpp-test-off # C++ tests without the hooks (the shipped configuration) +make sanitize-test # C++ tests under ASan/UBSan +make tsan-test # C++ tests under ThreadSanitizer +make cov # Combined coverage report (C++ + Python; its own venv and builds) +make stubs-check # The generated type stub: drift, package contents, typed clients +make format # Apply ruff formatting (make hooks runs every pre-commit fixer) ``` -Performance work (harnesses, old/new/old protocol, recorded measurements) is -described in [dev/perf/README.md](dev/perf/README.md). +`make check` and `make check-ci` only verify; they never edit a file. The editable development build (`make install`, `make dev`) carries the private test hooks (CMake option `NETGRAPH_CORE_PRIVATE_HOOKS`; a wheel build does not); the test suites skip the @@ -214,7 +248,7 @@ tests that need them when they are absent. | Variable | Effect | | --- | --- | -| `NGRAPH_CORE_BATCH_THREADS` | Default worker count of `batch_capacity` (an integer in [1, 4096]; 1 when unset or invalid; an explicit `workers` wins). | +| `NGRAPH_CORE_BATCH_THREADS` | Default worker count of `batch_capacity` (an integer in [1, 4096]; 1 when unset or invalid; not read when a cache is supplied, which defaults to 1; an explicit `workers` wins). | | `NGRAPH_CORE_SENSITIVITY_THREADS` | Default worker count of `sensitivity` (as above). | ## Requirements diff --git a/bindings/python/axes.cpp b/bindings/python/axes.cpp new file mode 100644 index 0000000..ca26f56 --- /dev/null +++ b/bindings/python/axes.cpp @@ -0,0 +1,89 @@ +// The enumerations of the placement and selection axes, and PathSelection. +#include + +#include + +#include "netgraph/core/options.hpp" + +#include "registration.hpp" + +namespace py = pybind11; + +namespace netgraph::core::bindings { + +void register_axes(py::module_& m) { + py::enum_(m, "FlowPlacement", + "How one flow spreads over its route (its kernel). MAX_FLOW: the minimum-cost flow core, re-solved\n" + "on the flow's route over the free capacity plus the flow's own allocation (it may move its own\n" + "earlier placement, never another flow's). WEIGHTED (WCMP, weighted-cost multipath): every node\n" + "splits in proportion to weights fixed when the flow is created (highest averages: whole-unit\n" + "shares as close to proportional as possible). EQUAL_BALANCED (ECMP, equal-cost multipath): every\n" + "node splits equally, by round robin.") + .value("MAX_FLOW", FlowPlacement::MaxFlow) + .value("WEIGHTED", FlowPlacement::Weighted) + .value("EQUAL_BALANCED", FlowPlacement::EqualBalanced); + + py::enum_(m, "Admission", + "LOSSLESS admits only what fits; LOSSY forwards hop by hop without admission control and\n" + "drops what does not fit on each edge (EQUAL_BALANCED and WEIGHTED only).") + .value("LOSSLESS", Admission::Lossless) + .value("LOSSY", Admission::Lossy); + + py::enum_(m, "EdgeTieBreak", + "How a search breaks cost ties. Between parallel members of one (u, v) pair\n" + "(multi_edge = False): DETERMINISTIC keeps the lowest EdgeId, PREFER_HIGHER_PRIORITY the\n" + "higher priority value, then the lowest EdgeId. Between equal-cost walks (multipath =\n" + "False): PREFER_HIGHER_PRIORITY first takes the highest bottleneck (the smallest priority\n" + "value along the walk), DETERMINISTIC skips that step; then the fewest hops, then the\n" + "lexicographically smallest EdgeId sequence.") + .value("DETERMINISTIC", EdgeTieBreak::Deterministic) + .value("PREFER_HIGHER_PRIORITY", EdgeTieBreak::PreferHigherPriority); + + py::enum_(m, "EdgePriority", + "The per-edge value PREFER_HIGHER_PRIORITY reads and, with WEIGHTED, the value that decides which\n" + "edges a flow uses and their weights: RESIDUAL (residual units), CAPACITY (static capacity units),\n" + "HEADROOM (residual / capacity, compared exactly as a fraction; rejected with WEIGHTED).") + .value("RESIDUAL", EdgePriority::Residual) + .value("CAPACITY", EdgePriority::Capacity) + .value("HEADROOM", EdgePriority::Headroom); + + py::enum_(m, "Direction", + "The orientation of a search: FORWARD from the roots along the edges, or REVERSE toward the roots\n" + "against the edges (the DAG it returns keeps the edges' own direction, so its walks lead to the\n" + "roots).") + .value("FORWARD", Direction::Forward) + .value("REVERSE", Direction::Reverse); + + py::class_(m, "PathSelection", + "The selection of a search: tie_break and priority are explicit; multipath = True keeps\n" + "every equal-cost walk (the DAG), False the one canonical single path; multi_edge = True\n" + "keeps every eligible parallel member, False one per (u, v) pair. A tie_break or priority\n" + "outside its enumeration raises ValueError.") + // The constructor checks both enumerations (ValueError naming the field). + .def(py::init(), py::arg("tie_break"), py::arg("priority"), + py::arg("multipath") = true, py::arg("multi_edge") = true) + .def_readonly("tie_break", &PathSelection::tie_break, "the EdgeTieBreak") + .def_readonly("priority", &PathSelection::priority, "the EdgePriority") + .def_readonly("multipath", &PathSelection::multipath, + "True: every equal-cost walk (a DAG); False: the canonical single path") + .def_readonly("multi_edge", &PathSelection::multi_edge, + "True: every eligible parallel member; False: one member per (u, v) pair") + .def("__eq__", [](const PathSelection& a, const py::object& b) { + return py::isinstance(b) && a == b.cast(); + }) + .def("__hash__", [](const PathSelection& a) { + return py::hash(py::make_tuple(static_cast(a.tie_break), static_cast(a.priority), a.multipath, + a.multi_edge)); + }) + .def("__repr__", [](const PathSelection& a) { + return std::string("PathSelection(tie_break=") + + (a.tie_break == EdgeTieBreak::Deterministic ? "DETERMINISTIC" : "PREFER_HIGHER_PRIORITY") + + ", priority=" + + (a.priority == EdgePriority::Residual ? "RESIDUAL" + : (a.priority == EdgePriority::Capacity ? "CAPACITY" : "HEADROOM")) + + ", multipath=" + (a.multipath ? "True" : "False") + ", multi_edge=" + (a.multi_edge ? "True" : "False") + + ")"; + }); +} + +} // namespace netgraph::core::bindings diff --git a/bindings/python/dag_cache.cpp b/bindings/python/dag_cache.cpp new file mode 100644 index 0000000..1e5252b --- /dev/null +++ b/bindings/python/dag_cache.cpp @@ -0,0 +1,60 @@ +// DagCacheStats, DagCacheMiss and DagCache: the cache of static shortest-path DAGs. +#include +#include +#include + +#include + +#include "netgraph/core/dag_cache.hpp" +#include "netgraph/core/graph.hpp" +#include "registration.hpp" + +namespace py = pybind11; + +namespace netgraph::core::bindings { + +void register_dag_cache(py::module_& m) { + py::class_(m, "DagCacheStats", + "DagCache statistics (atomic per field, not a consistent snapshot): hits, misses,\n" + "entries, bytes (the entries' retained payload), live_values (values alive anywhere).") + .def_readonly("hits", &DagCache::Stats::hits, "lookups answered from an entry (they persist across clear())") + .def_readonly("misses", &DagCache::Stats::misses, + "lookups without an entry: a building cache searched, a frozen one raised DagCacheMiss") + .def_readonly("entries", &DagCache::Stats::entries, "the entries held") + .def_readonly("bytes", &DagCache::Stats::bytes, "the retained payload of the entries (their DAGs' stored arrays)") + .def_readonly("live_values", &DagCache::Stats::live_values, "the cached values alive anywhere, entries included"); + + py::register_exception(m, "DagCacheMiss", PyExc_Exception); + m.attr("DagCacheMiss").attr("__doc__") = + "A lookup in a frozen DagCache whose key is absent: a dedicated exception (not a\n" + "RuntimeError or ValueError); the evaluators propagate it unchanged."; + + py::class_>( + m, "DagCache", + "A cache of static shortest-path DAGs (from costs and the Eligibility only) for one graph, keyed by\n" + "(the root set, the direction, the Eligibility uid). It stores the full DAG and applies each\n" + "caller's selection afterwards, so the selection is never cached. CostOnly policies and the\n" + "evaluators read it.\n" + "Building mode (a new cache): one writer, no concurrent readers; a miss searches and inserts;\n" + "clear() empties it (the hit and miss counters persist), and values already handed out stay valid.\n" + "Frozen mode (freeze()): read-only and shareable by every worker; a miss counts one miss and raises\n" + "DagCacheMiss; clear() raises ValueError; thaw() returns to building once the caller has joined\n" + "every reader.\n" + "When a public call fails: a building cache passed to it keeps the complete entries and statistics\n" + "of the lookups that succeeded before the failure (never a partial entry; a kept entry never\n" + "changes a result); a frozen cache's statistics stay unchanged, except one miss when the call\n" + "raised DagCacheMiss. A frozen cache counts its hits only when the call succeeds, Python result\n" + "included.") + .def(py::init([](const std::shared_ptr& graph) { + if (!graph) throw py::value_error("graph must be a Graph"); + return std::make_shared(*graph); + }), + py::arg("graph"), "An empty cache in building mode, bound to graph (by its uid).") + .def("graph_uid", &DagCache::graph_uid, "uid of the graph this cache is bound to") + .def("freeze", &DagCache::freeze, "enter frozen mode (no effect when frozen)") + .def("thaw", &DagCache::thaw, "return to building mode (no effect when building); join every reader first") + .def("clear", &DagCache::clear, "empty a building cache; ValueError when frozen") + .def("stats", &DagCache::stats, "the current DagCacheStats (each field read atomically)"); +} + +} // namespace netgraph::core::bindings diff --git a/bindings/python/eligibility.cpp b/bindings/python/eligibility.cpp new file mode 100644 index 0000000..bcb96b7 --- /dev/null +++ b/bindings/python/eligibility.cpp @@ -0,0 +1,54 @@ +// Eligibility: the static node and edge flags of one graph. +#include +#include +#include + +#include +#include + +#include "netgraph/core/eligibility.hpp" +#include "values.hpp" +#include "registration.hpp" + +namespace py = pybind11; + +namespace netgraph::core::bindings { + +void register_eligibility(py::module_& m) { + py::class_>( + m, "Eligibility", + "Which nodes and edges of one graph may be used: one flag per node and per edge, fixed when built\n" + "(it does not depend on traffic). Built by Graph.eligibility(excluded_nodes, excluded_edges) and\n" + "Eligibility.intersect.") + .def("graph_uid", &Eligibility::graph_uid, "uid of the graph this value belongs to") + .def("uid", &Eligibility::uid, "0 for the all-allowed value, a fresh uid otherwise") + .def( + "node_allowed", + [](const py::object& self) { + const auto& el = self.cast(); + return readonly_view(el.node_allowed(), self, py::dtype("bool")); + }, + "bool[N] read-only view") + .def( + "edge_allowed", + [](const py::object& self) { + const auto& el = self.cast(); + return readonly_view(el.edge_allowed(), self, py::dtype("bool")); + }, + "bool[E] read-only view (not excluded and both endpoints allowed)") + .def_static( + "intersect", + [](const std::shared_ptr& a, const std::shared_ptr& b) -> std::shared_ptr { + if (!a || !b) throw py::value_error("Eligibility.intersect takes two Eligibility values"); + auto r = Eligibility::intersect(*a, *b); + if (r.uid() == a->uid()) return a; + if (r.uid() == b->uid()) return b; + return std::make_shared(std::move(r)); + }, + py::arg("a"), py::arg("b"), + "The intersection of two values of one graph (ValueError for values of different graphs):\n" + "an operand itself when the result equals it (a first, then b), otherwise a new value with a\n" + "fresh uid; equal intersections built separately get distinct uids."); +} + +} // namespace netgraph::core::bindings diff --git a/bindings/python/evaluators.cpp b/bindings/python/evaluators.cpp new file mode 100644 index 0000000..902e250 --- /dev/null +++ b/bindings/python/evaluators.cpp @@ -0,0 +1,241 @@ +// capacity, batch_capacity and sensitivity with their options and results. Each evaluator +// places on a new, empty ledger; a frozen DagCache counts its hits only when the whole Python +// call succeeds (detail::frozen_call). +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/dag_cache.hpp" +#include "netgraph/core/detail/evaluator_checks.hpp" +#include "netgraph/core/eligibility.hpp" +#include "netgraph/core/evaluators.hpp" +#include "netgraph/core/flow_policy.hpp" +#include "netgraph/core/graph.hpp" +#include "integers.hpp" +#include "publication.hpp" +#include "values.hpp" +#include "registration.hpp" + +namespace py = pybind11; + +namespace netgraph::core::bindings { + +namespace { + +// The Python form of CapacityOptions and SensitivityOptions: the Eligibility and the cache are +// held by reference count for the call (the C++ structs borrow raw pointers). +struct PyCapacityOptions { + FlowPolicyConfig config; + std::shared_ptr elig; + std::shared_ptr cache; + OutputOptions outputs; +}; +struct PySensitivityOptions { + FlowPolicyConfig config; + std::shared_ptr elig; + std::shared_ptr cache; + OutputOptions outputs; +}; + +// workers: an explicit integer is passed through (validated by the evaluator); None takes the +// environment default (detail::default_workers), and 1 whenever a cache is supplied (a building +// cache has one writer; a frozen one is shared only when the caller asks for workers). +std::int32_t workers_of(const py::object& workers, bool sensitivity, bool cache_given) { + if (!workers.is_none()) { + return static_cast(integer_value(workers, std::numeric_limits::min(), + std::numeric_limits::max(), "workers")); + } + if (cache_given) return 1; + return detail::default_workers(sensitivity); +} + +} // namespace + +void register_evaluators(py::module_& m) { + py::class_(m, "CapacityOptions", + "The configuration, the static Eligibility (None: all allowed), the DagCache (None:\n" + "private per worker) and the optional outputs of capacity() and batch_capacity().") + .def(py::init([](const FlowPolicyConfig& config, const std::shared_ptr& elig, + const std::shared_ptr& cache, const py::object& outputs) { + return PyCapacityOptions {config, elig, cache, outputs_of(outputs)}; + }), + py::arg("config"), py::kw_only(), py::arg("elig") = nullptr, py::arg("cache") = nullptr, + py::arg("outputs") = py::none()) + .def_readonly("config", &PyCapacityOptions::config, "the FlowPolicyConfig of every evaluated policy") + .def_readonly("elig", &PyCapacityOptions::elig, "the static Eligibility; None: everything allowed") + .def_readonly("cache", &PyCapacityOptions::cache, "the supplied DagCache; None: a private cache per worker") + .def_readonly("outputs", &PyCapacityOptions::outputs, "the OutputOptions of every result"); + + py::class_(m, "SensitivityOptions", + "The configuration, Eligibility, DagCache and baseline outputs of sensitivity().") + .def(py::init([](const FlowPolicyConfig& config, const std::shared_ptr& elig, + const std::shared_ptr& cache, const py::object& outputs) { + return PySensitivityOptions {config, elig, cache, outputs_of(outputs)}; + }), + py::arg("config"), py::kw_only(), py::arg("elig") = nullptr, py::arg("cache") = nullptr, + py::arg("outputs") = py::none()) + .def_readonly("config", &PySensitivityOptions::config, "the FlowPolicyConfig of the baseline and every deletion") + .def_readonly("elig", &PySensitivityOptions::elig, "the static Eligibility; None: everything allowed") + .def_readonly("cache", &PySensitivityOptions::cache, "the supplied DagCache; None: a private cache per worker") + .def_readonly("outputs", &PySensitivityOptions::outputs, "the OutputOptions of the baseline result"); + + py::class_(m, "SensitivityEntry", + "One candidate edge: edge and loss (int units: the baseline minus the capacity without the\n" + "edge).") + .def_readonly("edge", &SensitivityEntry::edge) + .def_readonly("loss", &SensitivityEntry::loss); + + py::class_(m, "SensitivityResult", + "baseline (the PlacementResult of capacity()) and entries (ascending EdgeId; an\n" + "absent edge has loss 0).") + .def_readonly("baseline", &SensitivityResult::baseline) + .def_readonly("entries", &SensitivityResult::entries); + + m.attr("MAX_FLOWS_PER_CALL") = py::int_(kMaxFlowsPerCall); + + m.def( + "capacity", + [](const std::shared_ptr& graph, const py::object& sources, const py::object& destinations, + const py::object& opts) { + if (!graph) throw py::value_error("graph must be a Graph"); + const auto o = options_of(opts, "CapacityOptions"); + const auto s = node_ids(sources, graph->num_nodes(), "sources"); + const auto d = node_ids(destinations, graph->num_nodes(), "destinations"); + const CapacityOptions c {o.config, o.elig.get(), o.cache.get(), o.outputs}; + // One public call on a frozen cache's statistics, its result object included: its hits count + // only when the Python result exists. + return detail::frozen_call(c.cache, [&](detail::FrozenTally& tally) { + PlacementResult r; + { + py::gil_scoped_release release; + r = detail::capacity_in_call(*graph, s, d, c, tally); + } + return object_of(std::move(r)); + }); + }, + py::arg("graph"), py::arg("sources"), py::arg("destinations"), py::arg("opts"), + "place(+inf) of a FlowPolicy with opts.config on a fresh ledger of the graph -> PlacementResult.\n" + "The exact maximum flow is the Global + MAX_FLOW configuration. A supplied frozen cache raises\n" + "DagCacheMiss for an absent key, unchanged.\n" + "A failed call raises; a supplied DagCache then behaves as the DagCache docstring states."); + + m.def( + "batch_capacity", + [](const std::shared_ptr& graph, const py::object& pairs, const py::object& opts, + const py::object& per_pair_elig, const py::object& workers) { + if (!graph) throw py::value_error("graph must be a Graph"); + // The arguments first, then each pair completely and in pair order (the same two parts + // as the C++ validation), so the lowest failing pair index is reported. + const auto o = options_of(opts, "CapacityOptions"); + const auto w = workers_of(workers, false, static_cast(o.cache)); + const CapacityOptions c {o.config, o.elig.get(), o.cache.get(), o.outputs}; + detail::check_batch_arguments(*graph, c, w); + if (not_a_sequence(pairs)) throw py::value_error("pairs must be a sequence of (sources, destinations)"); + const auto items = py::reinterpret_borrow(pairs); + const auto num_pairs = py::len(items); + std::optional eligs; + if (!per_pair_elig.is_none()) { + // An explicit sequence has one entry per pair (None entries allowed), also when empty. + if (not_a_sequence(per_pair_elig)) throw py::value_error("per_pair_elig must be a sequence of Eligibility or None"); + eligs = py::reinterpret_borrow(per_pair_elig); + if (py::len(*eligs) != num_pairs) { + throw py::value_error("per_pair_elig has " + std::to_string(py::len(*eligs)) + " entries for " + + std::to_string(num_pairs) + " pairs"); + } + } + std::vector, std::vector>> ps; + std::vector> keep; + std::vector per; + const auto n = graph->num_nodes(); + for (std::size_t i = 0; i < num_pairs; ++i) { + const py::object item = items[i]; + if (not_a_sequence(item) || py::len(item) != 2) { + throw py::value_error("each pair must be (sources, destinations)"); + } + auto t = py::reinterpret_borrow(item); + auto src = node_ids(t[0], n, "sources"); + auto dst = node_ids(t[1], n, "destinations"); + const Eligibility* e = nullptr; + if (eligs) { + const py::object entry = (*eligs)[i]; + if (!entry.is_none()) { + if (!py::isinstance(entry)) throw py::type_error("per_pair_elig entries must be Eligibility or None"); + keep.push_back(entry.cast>()); + e = keep.back().get(); + } + per.push_back(e); + } + detail::check_batch_pair(*graph, src, dst, e, i); + ps.emplace_back(std::move(src), std::move(dst)); + } + return detail::frozen_call(c.cache, [&](detail::FrozenTally& tally) { + std::vector rs; + { + py::gil_scoped_release release; + rs = detail::batch_capacity_in_call(*graph, ps, c, per, w, tally); + } + py::list out(rs.size()); + for (std::size_t i = 0; i < rs.size(); ++i) { + PyList_SET_ITEM(out.ptr(), static_cast(i), object_of(std::move(rs[i])).release().ptr()); + } + return py::object(std::move(out)); + }); + }, + py::arg("graph"), py::arg("pairs"), py::arg("opts"), py::arg("per_pair_elig") = py::none(), + py::arg("workers") = py::none(), + "capacity() for each (sources, destinations) pair, results in pair order. Pair i uses opts.elig\n" + "intersected with per_pair_elig[i] (None entries allowed; a given sequence has one entry per pair,\n" + "also when empty). workers >= 1; None: NGRAPH_CORE_BATCH_THREADS or 1, and 1 when a cache is\n" + "supplied. Everything is validated before a worker starts, the arguments first, then each pair\n" + "completely in pair order, so the error of the lowest failing pair is raised. Pinned is rejected;\n" + "an empty batch returns [].\n" + "Caches: a supplied building cache needs workers == 1; a frozen cache is shared by every worker\n" + "(pass workers explicitly), and its DagCacheMiss reaches the caller unchanged (the lowest failing\n" + "pair's). An intersection that differs from both operands gets a new Eligibility uid, which a\n" + "frozen cache cannot hold: pass the combined per-pair values with elig None, or a common value\n" + "that contains them.\n" + "A failed call raises; a supplied DagCache then behaves as the DagCache docstring states."); + + m.def( + "sensitivity", + [](const std::shared_ptr& graph, const py::object& sources, const py::object& destinations, + const py::object& opts, const py::object& workers) { + if (!graph) throw py::value_error("graph must be a Graph"); + const auto o = options_of(opts, "SensitivityOptions"); + const auto s = node_ids(sources, graph->num_nodes(), "sources"); + const auto d = node_ids(destinations, graph->num_nodes(), "destinations"); + const auto w = workers_of(workers, true, static_cast(o.cache)); + const SensitivityOptions c {o.config, o.elig.get(), o.cache.get(), o.outputs}; + return detail::frozen_call(c.cache, [&](detail::FrozenTally& tally) { + SensitivityResult r; + { + py::gil_scoped_release release; + r = detail::sensitivity_in_call(*graph, s, d, c, w, tally); + } + return object_of(std::move(r)); + }); + }, + py::arg("graph"), py::arg("sources"), py::arg("destinations"), py::arg("opts"), py::arg("workers") = py::none(), + "The baseline capacity() and one SensitivityEntry per candidate edge. Candidates: with Global, the\n" + "edges that carry flow in the baseline (removing any other edge cannot lower a maximum flow); with\n" + "the other routings, every allowed edge of at least 1 unit. loss = baseline.placed minus the placed\n" + "of capacity() without the edge, in signed units, never thresholded. Two shortcuts give exactly\n" + "that value: Global repairs a copy of the baseline's solve instead of solving again, and CostOnly\n" + "gives loss 0 without placing to an edge outside the static shortest-path support reachable from\n" + "the roster sources (no route can use it). workers as batch_capacity\n" + "(NGRAPH_CORE_SENSITIVITY_THREADS). Only CostOnly reads the cache, and each placed deletion uses a\n" + "new Eligibility uid, so a frozen cache raises DagCacheMiss there.\n" + "A failed deletion fails the call (the lowest failing candidate's exception, no partial result); a\n" + "supplied DagCache then behaves as the DagCache docstring states."); +} + +} // namespace netgraph::core::bindings diff --git a/bindings/python/flow_ledger.cpp b/bindings/python/flow_ledger.cpp new file mode 100644 index 0000000..cbfb0ec --- /dev/null +++ b/bindings/python/flow_ledger.cpp @@ -0,0 +1,103 @@ +// FlowLedger: the integer ledger of one graph. +#include +#include +#include + +#include + +#include "netgraph/core/flow_ledger.hpp" +#include "netgraph/core/graph.hpp" +#include "integers.hpp" +#include "values.hpp" +#if NETGRAPH_CORE_PRIVATE_HOOKS +#include "netgraph/core/detail/ledger_access.hpp" +#endif +#include "registration.hpp" + +namespace py = pybind11; + +namespace netgraph::core::bindings { + +void register_flow_ledger(py::module_& m) { + [[maybe_unused]] auto ledger_class = py::class_>( + m, "FlowLedger", + "The integer ledger of one graph: a baseline residual in units (replaced only by reset()) and the\n" + "records of the flows placed on it by policies (the ledger has no public placement or removal).\n" + "allocated() is the sum of every record's edges and residual() = baseline - allocated(), both\n" + "int64[E] snapshots. reset() and copy() are rejected while a policy is bound (a FlowPolicy built\n" + "on this ledger still exists).") + .def(py::init([](const std::shared_ptr& graph, const py::object& residual) { + if (!graph) throw py::value_error("graph must be a Graph"); + if (residual.is_none()) return std::make_shared(graph); + const auto r = edge_units(residual, *graph, "residual"); + return std::make_shared(graph, r); + }), + py::arg("graph"), py::arg("residual") = py::none(), + "residual: the baseline as integer units, one entry per edge in [0, capacity_units[e]] (a\n" + "physical baseline goes through to_units first); None: the capacities. ValueError otherwise.") + .def("uid", &FlowLedger::uid, "process-unique ledger uid (the first half of every FlowId)") + .def( + "graph", [](const FlowLedger& fg) { return std::const_pointer_cast(fg.graph()); }, + "the ledger's graph") + .def( + "capacity_units", + [](const py::object& self) { return readonly_view(self.cast().capacity_units(), self); }, + "int64[E] the graph's capacities in units (read-only view)") + .def( + "residual", [](const FlowLedger& fg) { return int64_array(fg.residual()); }, + "int64[E] snapshot: baseline - allocated") + .def( + "allocated", [](const FlowLedger& fg) { return int64_array(fg.allocated()); }, + "int64[E] snapshot: the sum of every record's edges") + .def("records", &FlowLedger::records, "live records as snapshots in FlowId order: those of bound policies and orphans (records left by a\n" + "policy destroyed without remove())") + .def( + "record", [](const FlowLedger& fg, const py::object& flow_id) { return fg.record(flow_id_of(flow_id)); }, + py::arg("flow_id"), + "one record by FlowId (ValueError for a FlowId of another ledger, a retired or an unknown id)") + .def( + "bottleneck", + [](const FlowLedger& fg, const py::object& sources, const std::shared_ptr& elig) { + const auto s = node_ids(sources, fg.graph()->num_nodes(), "sources"); + return fg.bottleneck(s, elig.get()); + }, + py::arg("sources"), py::arg("elig") = nullptr, + "Saturation report: the eligible edges with capacity >= 1 unit that leave the set of nodes the\n" + "allowed sources can reach (following eligible edges forward with residual >= 1 unit), in\n" + "ascending EdgeId. It ignores the destinations, so it is not a source-destination cut.") + .def( + "reset", + [](FlowLedger& fg, const py::object& residual) { + if (residual.is_none()) { + fg.reset(); + return; + } + const auto r = edge_units(residual, *fg.graph(), "residual"); + fg.reset(r); + }, + py::arg("residual") = py::none(), + "A new baseline (None: the capacities); orphaned records and their allocations are cleared\n" + "and their ids retired (ids are never reused). ValueError while a policy is bound.") + .def( + "copy", [](const FlowLedger& fg) { return std::make_shared(fg); }, + "a new ledger (new uid) with the same baseline and records, ids kept under the new uid;\n" + "ValueError while a policy is bound") + .def("__copy__", [](const FlowLedger& fg) { return std::make_shared(fg); }) + .def("__deepcopy__", [](const FlowLedger& fg, const py::object&) { return std::make_shared(fg); }, + py::arg("memo")); +#if NETGRAPH_CORE_PRIVATE_HOOKS + ledger_class + .def( + "_state", + [](const FlowLedger& fg) { + py::dict out; + out["next_flow_id"] = detail::LedgerAccess::next_flow_id(fg); + out["bound"] = detail::LedgerAccess::bound_owners(fg); + out["orphans"] = detail::LedgerAccess::orphan_owners(fg); + return out; + }, + "private: {next_flow_id, bound, orphans} (owner ids)"); +#endif +} + +} // namespace netgraph::core::bindings diff --git a/bindings/python/flow_policy.cpp b/bindings/python/flow_policy.cpp new file mode 100644 index 0000000..33394b9 --- /dev/null +++ b/bindings/python/flow_policy.cpp @@ -0,0 +1,190 @@ +// FlowPolicy: the one placement engine, bound to its ledger. +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/dag_cache.hpp" +#include "netgraph/core/eligibility.hpp" +#include "netgraph/core/flow_ledger.hpp" +#include "netgraph/core/flow_policy.hpp" +#include "integers.hpp" +#include "publication.hpp" +#include "values.hpp" +#if NETGRAPH_CORE_PRIVATE_HOOKS +#include +#include + +#include "netgraph/core/detail/pred_dag_builder.hpp" +#include "netgraph/core/detail/ledger_access.hpp" +#include "netgraph/core/detail/policy_engine.hpp" +#include "netgraph/core/detail/private_hooks.hpp" +#include "trace.hpp" +#endif +#include "registration.hpp" + +namespace py = pybind11; + +namespace netgraph::core::bindings { + +namespace { + +// A request: an integer number of units in [0, 2^62) or +inf (float('inf')); nullopt is +inf. +std::optional request_units(const py::object& volume) { + if (py::isinstance(volume) || py::isinstance(volume, py::module_::import("numpy").attr("floating"))) { + const double x = PyFloat_AsDouble(volume.ptr()); + if (x == -1.0 && PyErr_Occurred() != nullptr) throw py::error_already_set(); + if (std::isinf(x) && x > 0) return std::nullopt; + throw py::value_error("requests are integer units or +inf (a physical request goes through to_units first; got " + + std::string(py::str(volume)) + ")"); + } + const auto v = integer_value(volume, 0, kDomainLimit - 1, "request (units)"); + return static_cast(v); +} + +#if NETGRAPH_CORE_PRIVATE_HOOKS + +// The epoch state of a policy (roster, requested, +inf, per roster source the flows). +py::dict state_obj(const FlowPolicy& policy) { + const auto& pe = detail::PolicyAccess::engine(policy); + py::dict out; + const auto& r = pe.config.routing; + out["mode"] = std::holds_alternative(r) ? "CostOnly" + : std::holds_alternative(r) ? "CapacityAware" + : std::holds_alternative(r) ? "Global" + : "Pinned"; + out["roster"] = pe.roster; + out["requested"] = counter_int(pe.requested); + out["infinite"] = pe.infinite; + out["placed_demand"] = policy.placed_demand(); + py::dict cohorts; + for (std::size_t i = 0; i < pe.roster.size(); ++i) { + py::list flows; + for (const auto& s : pe.cohorts[i]) { + const FlowRecord& rec = detail::LedgerAccess::live(pe.fg, s.id); + py::dict f; + f["flow_id"] = py::make_tuple(rec.flow_id.first, rec.flow_id.second); + f["src"] = s.src; + if (s.plan) { + f["route"] = s.plan->route; + } else { + const auto edges = detail::PredDAGBuilder::ascending(*s.bundle); + f["route"] = std::vector(edges.begin(), edges.end()); + } + f["members"] = s.plan ? py::cast(s.plan->members) : py::object(py::none()); + f["weights"] = s.plan && s.plan->placement == FlowPlacement::Weighted ? py::cast(s.plan->weights) + : py::object(py::none()); + f["T"] = rec.delivered; + f["delivered"] = rec.delivered; + f["alloc"] = pairs_list(rec.edges); + f["bundle_index"] = s.bundle_index < 0 ? py::object(py::none()) : py::object(py::int_(s.bundle_index)); + flows.append(f); + } + cohorts[py::int_(pe.roster[i])] = flows; + } + out["cohorts"] = cohorts; + return out; +} + +#endif + +} // namespace + +void register_flow_policy(py::module_& m) { + [[maybe_unused]] auto flow_policy = py::class_( + m, "FlowPolicy", + "The placement engine for one demand, bound to its ledger at construction. place(volume, outputs)\n" + "is all-or-nothing: a failed call raises and leaves the ledger and the policy as they were (with\n" + "the two limits in the module docstring). remove() takes every flow of the policy back out and\n" + "starts a new epoch (the request total starts again from 0). A policy dropped without remove()\n" + "leaves its records in the ledger as orphans: readable, never changed again, cleared by\n" + "FlowLedger.reset(). sources and destinations are one NodeId or a sequence; the two sets must not\n" + "overlap. ValueError for invalid endpoints, configurations and requests (non-integer node ids and\n" + "requests included) and for objects of another graph, ledger or policy; TypeError for a ledger,\n" + "config, elig, cache or outputs of the wrong class (a ledger of None: ValueError) and for an\n" + "unknown keyword.") + .def(py::init([](const std::shared_ptr& ledger, const py::object& sources, + const py::object& destinations, const FlowPolicyConfig& config, + const std::shared_ptr& elig, const std::shared_ptr& cache) { + if (!ledger) throw py::value_error("ledger must be a FlowLedger"); + const auto n = ledger->graph()->num_nodes(); + const auto s = node_ids(sources, n, "sources"); + const auto d = node_ids(destinations, n, "destinations"); + return std::make_unique(*ledger, s, d, config, elig.get(), cache.get()); + }), + py::keep_alive<1, 2>(), py::keep_alive<1, 6>(), py::keep_alive<1, 7>(), py::arg("ledger"), + py::arg("sources"), py::arg("destinations"), py::arg("config"), py::arg("elig") = nullptr, + py::arg("cache") = nullptr, + "Bind a policy to ledger. elig (None: everything allowed) and cache (None: none; only CostOnly\n" + "routing reads it) belong to the ledger's graph; the policy keeps the ledger, elig and cache\n" + "alive. The roster (the sources that statically reach an allowed destination) and, for Pinned,\n" + "the usable bundles are fixed here. Everything is validated before the policy binds.") + .def( + "place", + [](FlowPolicy& p, const py::object& volume, const py::object& outputs) { + const auto v = request_units(volume); + const auto o = outputs_of(outputs); + // All or nothing across the boundary: the result's Python object exists before the + // call, and after the commit nothing allocates (the value moves into it). The GIL + // stays held: the call mutates the ledger (one writer per ledger). + auto [obj, slot] = new_object(); + *slot = p.place(v, o); + return std::move(obj); + }, + py::arg("volume"), py::arg("outputs") = py::none(), + "Place volume (int units in [0, 2^62), or float('inf')) all or nothing -> PlacementResult.\n" + "Requests add up within an epoch (from construction or the last remove()); after float('inf') any\n" + "finite request is rejected, 0 included. A request of 0 places nothing. outputs (OutputOptions;\n" + "None: none) requests edge_flows, residual and bottleneck. Everything is validated before any\n" + "change; a failed call raises with the ledger and the policy unchanged. A solve of the flow core\n" + "(Global, or a MAX_FLOW flow) on a network beyond its int32 numbering raises ValueError: more than\n" + "INT32_MAX - 2 nodes, or more than 2^30 - 1 edges, sources and destinations together.") + .def("remove", &FlowPolicy::remove, "revert every flow of the policy, retire the ids, open a new epoch") + .def("placed_demand", &FlowPolicy::placed_demand, "the sum of delivered over the policy's live records") + .def("records", &FlowPolicy::records, "the policy's live records as snapshots in FlowId order") + .def( + "record", [](const FlowPolicy& p, const py::object& flow_id) { return p.record(flow_id_of(flow_id)); }, + py::arg("flow_id"), + "one of the policy's live records by FlowId (ValueError for a FlowId of another ledger or policy, a\n" + "retired or an unknown id)") + .def("usable_bundles", &FlowPolicy::usable_bundles, + "Pinned: the bundles with a src -> dst walk over eligible edges of at least 1 unit of capacity (0\n" + "for the other modes)") + .def_property_readonly("config", &FlowPolicy::config, "the FlowPolicyConfig the policy was built with"); +#if NETGRAPH_CORE_PRIVATE_HOOKS + flow_policy + .def( + "_place_traced", + [](FlowPolicy& p, const py::object& volume, const py::object& outputs) { + const auto v = request_units(volume); + const auto o = outputs_of(outputs); + // As place(): every Python object exists before anything changes; the engine runs the + // `staged` callback, which converts the trace, after the work is done and before the + // commit, so a failed conversion changes nothing. + auto [obj, slot] = new_object(); + py::tuple pair(2); + py::object trace_py; + detail::Trace trace; + const std::function staged = [&] { trace_py = trace_obj(trace); }; + *slot = detail::place_traced(p, v, o, trace, &staged); + PyTuple_SET_ITEM(pair.ptr(), 0, obj.release().ptr()); + PyTuple_SET_ITEM(pair.ptr(), 1, trace_py.release().ptr()); + return pair; + }, + py::arg("volume"), py::arg("outputs") = py::none(), + "private: place() with the transaction's trace (the call's intermediate values, keyed as\n" + "the tests' model of the policy keys them)") + .def("_state", &state_obj, "private: the epoch state (roster, requested, +inf, cohorts)") + .def( + "_owner", [](const FlowPolicy& p) { return detail::PolicyAccess::engine(p).owner; }, + "private: the ledger-local owner id (FlowRecord.owner)"); +#endif +} + +} // namespace netgraph::core::bindings diff --git a/bindings/python/flow_record.cpp b/bindings/python/flow_record.cpp new file mode 100644 index 0000000..ff85348 --- /dev/null +++ b/bindings/python/flow_record.cpp @@ -0,0 +1,73 @@ +// FlowRecord: one flow of a ledger, as a snapshot. +#include +#include +#include + +#include +#include + +#include "netgraph/core/flow_record.hpp" +#include "netgraph/core/pred_dag.hpp" +#include "values.hpp" +#include "registration.hpp" + +namespace py = pybind11; + +namespace netgraph::core::bindings { + +namespace { + +py::object cost_or_none(Cost c) { return c == kUnreachable ? py::object(py::none()) : py::object(py::int_(c)); } + +} // namespace + +void register_flow_record(py::module_& m) { + py::class_( + m, "FlowRecord", + "One flow of a ledger, as a snapshot: flow_id (ledger uid, id), owner (the policy's id),\n" + "sources, destinations, placement, admission, bundle (PredDAG: the flow's route),\n" + "route_cost (its minimum walk cost; None when empty), offered (Lossy only, else None),\n" + "delivered, edges [(EdgeId, units)] and drops [(EdgeId, units)] (cumulative, ascending\n" + "EdgeId). offered and the drops units are exact Python ints (they may exceed 64 bits).") + .def_property_readonly( + "flow_id", [](const FlowRecord& r) { return py::make_tuple(r.flow_id.first, r.flow_id.second); }, + "(ledger uid, id): ids are assigned in creation order and never reused in the ledger") + .def_readonly("owner", &FlowRecord::owner, "the ledger-local id of the policy that created the flow") + .def_readonly("sources", &FlowRecord::sources, "the flow's source node ids, ascending") + .def_readonly("destinations", &FlowRecord::destinations, "the flow's destination node ids, ascending") + .def_readonly("placement", &FlowRecord::placement, "the FlowPlacement (kernel) that spreads the flow over its route") + .def_readonly("admission", &FlowRecord::admission, "LOSSLESS or LOSSY") + .def_property_readonly( + "bundle", [](const FlowRecord& r) { return std::const_pointer_cast(r.bundle); }, + "the flow's route (an immutable PredDAG shared with the ledger)") + .def_property_readonly( + "route_cost", [](const FlowRecord& r) { return cost_or_none(r.route_cost); }, + "the cost of the cheapest walk of the bundle from the sources to the destinations; None when\n" + "the bundle has no such walk") + .def_property_readonly( + "offered", + [](const FlowRecord& r) -> py::object { + return r.offered.has_value() ? counter_int(*r.offered) : py::object(py::none()); + }, + "Lossy: the units offered to the flow, summed over calls (an exact int, may exceed 64 bits);\n" + "None for a Lossless flow") + .def_readonly("delivered", &FlowRecord::delivered, "the units the flow delivers to its destinations (its total)") + .def_property_readonly( + "edges", [](const FlowRecord& r) { return pairs_list(r.edges); }, + "the flow's allocation [(EdgeId, units)], positive entries in ascending EdgeId") + .def_property_readonly( + "drops", + [](const FlowRecord& r) { + py::list out; + for (const auto& [e, x] : r.drops) out.append(py::make_tuple(e, counter_int(x))); + return out; + }, + "Lossy: the units dropped per edge [(EdgeId, units)], summed over calls, positive entries in\n" + "ascending EdgeId (exact ints); empty for a Lossless flow") + .def("__repr__", [](const FlowRecord& r) { + return "FlowRecord(flow_id=(" + std::to_string(r.flow_id.first) + ", " + std::to_string(r.flow_id.second) + + "), owner=" + std::to_string(r.owner) + ", delivered=" + std::to_string(r.delivered) + ")"; + }); +} + +} // namespace netgraph::core::bindings diff --git a/bindings/python/graph.cpp b/bindings/python/graph.cpp new file mode 100644 index 0000000..3db326e --- /dev/null +++ b/bindings/python/graph.cpp @@ -0,0 +1,216 @@ +// Graph: the immutable graph on integer units and its validating constructor. +#include +#include +#include + +#include +#include +#include +#include + +#include "netgraph/core/eligibility.hpp" +#include "netgraph/core/graph.hpp" +#include "integers.hpp" +#include "values.hpp" +#include "registration.hpp" + +namespace py = pybind11; + +namespace netgraph::core::bindings { + +namespace { + +// The unit-value step on the capacity argument whatever its shape (the shape step comes next): +// every integer entry it holds must lie in [0, 2^62). A NumPy array is read flattened; nested +// entries of a Python sequence and a non-integer non-sequence are left to the shape step. +void check_unit_values(const py::handle& capacity_units) { + const auto out_of_domain = [](const std::string& what, const std::string& value) { + return py::value_error(what + " = " + value + " is outside the units domain [0, 2^62)"); + }; + if (py::isinstance(capacity_units)) { + py::object flat = capacity_units.attr("ravel")(); + const auto w = wide_integers(flat, "capacity_units"); + for (std::size_t i = 0; i < w.size(); ++i) { + if (!w.in(i, 0, kDomainLimit - 1)) throw out_of_domain("capacity_units[" + std::to_string(i) + "]", w.text(i)); + } + return; + } + if (!not_a_sequence(capacity_units)) { + auto seq = py::reinterpret_borrow(capacity_units); + for (std::size_t i = 0; i < seq.size(); ++i) { + py::object item = seq[i]; + if (is_nested(item)) continue; + const std::string what = "capacity_units[" + std::to_string(i) + "]"; + py::object idx = python_int(item, what); + int overflow = 0; + const long long v = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); + if (overflow != 0 || v < 0 || v >= kDomainLimit) throw out_of_domain(what, std::string(py::str(idx))); + } + return; + } + if (!is_bool(capacity_units) && PyIndex_Check(capacity_units.ptr())) { + py::object idx = python_int(capacity_units, "capacity_units"); + int overflow = 0; + const long long v = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); + if (overflow != 0 || v < 0 || v >= kDomainLimit) throw out_of_domain("capacity_units", std::string(py::str(idx))); + } +} + +std::shared_ptr make_graph(const py::object& num_nodes, const py::object& src, + const py::object& dst, const py::object& capacity_units, + const py::object& cost, double resolution) { + // The constructor's validation order: the resolution, the unit values, the shapes, zero or + // negative costs, the remaining bounds (ids in range, then the sums); every value is read + // without narrowing, and the messages are the C++ constructor's, which re-checks the same order. + (void)resolution_exponent(resolution); + check_unit_values(capacity_units); + // The shapes: num_nodes, then every array argument rank 1 (in signature order), then equal + // lengths; no entry of src, dst or cost is read before the cost step. + const auto n = integer_value(num_nodes, 0, std::numeric_limits::max(), "num_nodes"); + const std::size_t m = sequence_length(src, "src"); + const std::size_t md = sequence_length(dst, "dst"); + const std::size_t mu = sequence_length(capacity_units, "capacity_units"); + const std::size_t mc = sequence_length(cost, "cost"); + if (md != m || mu != m || mc != m) { + throw py::value_error("src, dst, capacity_units and cost must have the same length (got " + + std::to_string(m) + ", " + std::to_string(md) + ", " + std::to_string(mu) + ", " + + std::to_string(mc) + ")"); + } + const auto cap = wide_integers(capacity_units, "capacity_units"); // rank 1, values checked + if (m > static_cast(std::numeric_limits::max())) { + throw py::value_error("the number of edges exceeds INT32_MAX (array length)"); + } + const auto w = wide_integers(cost, "cost"); + for (std::size_t i = 0; i < m; ++i) { + if (w.sign(i) < 0 || (w.sign(i) == 0 && w.value[i] < 1)) { + throw py::value_error("cost[" + std::to_string(i) + "] = " + w.text(i) + ": costs must be positive (cost >= 1)"); + } + } + const auto s = wide_integers(src, "src"); + const auto d = wide_integers(dst, "dst"); + for (std::size_t i = 0; i < m; ++i) { + if (!s.in(i, 0, n - 1) || !d.in(i, 0, n - 1)) { + throw py::value_error("edge " + std::to_string(i) + ": endpoint (" + s.text(i) + ", " + d.text(i) + + ") out of node range [0, " + std::to_string(n) + ")"); + } + } + constexpr auto kLimit = static_cast(kDomainLimit); + std::uint64_t cap_sum = 0; + std::uint64_t cost_sum = 0; + for (std::size_t i = 0; i < m; ++i) { + cap_sum += static_cast(cap.value[i]); // each below 2^62: no wrap before the check + if (cap_sum >= kLimit) { + throw py::value_error("the sum of all capacities reaches the bound of the units domain (2^62 units)"); + } + if (w.sign(i) > 0 || static_cast(w.value[i]) >= kLimit - cost_sum) { + throw py::value_error("the sum of all edge costs reaches the bound 2^62: path costs would overflow int64"); + } + cost_sum += static_cast(w.value[i]); + } + auto cap_v = cap.narrow(); + auto cost_v = w.narrow(); + auto src_v = s.narrow(); + auto dst_v = d.narrow(); + py::gil_scoped_release release; + return std::make_shared(static_cast(n), src_v, dst_v, cap_v, cost_v, resolution); +} + +} // namespace + +void register_graph(py::module_& m) { + py::class_>( + m, "Graph", + "Immutable directed multigraph on integer units (parallel edges and self-loops allowed). Edges are\n" + "given in input order and numbered by sorting on (src, cost, dst), ties kept in input order: that\n" + "number is the public EdgeId. input_index()[e] is the input position of edge e. Per-edge values\n" + "use EdgeId order; in_adj_edge_index() instead lists EdgeIds in incoming-slot order. Costs are\n" + "positive integers; capacities are int64 units of the explicit resolution q (a power of two in\n" + "[2^-32, 2^32]); physical capacities enter through to_units.") + .def(py::init(&make_graph), py::arg("num_nodes"), py::arg("src"), py::arg("dst"), + py::arg("capacity_units"), py::arg("cost"), py::arg("resolution"), + "Validates, in order, and reports the first defect as ValueError: the resolution (a power of\n" + "two in [2^-32, 2^32]), the unit values (every capacity in [0, 2^62)), the array shapes,\n" + "zero or negative costs, the remaining bounds (endpoints in range, then the\n" + "capacity and cost sums below 2^62). Integers are read without narrowing, so the error\n" + "does not depend on how the input is represented.") + .def("uid", &Graph::uid, "process-unique id (never reused)") + .def("resolution", &Graph::resolution, "the resolution q (units of capacity)") + .def("num_nodes", &Graph::num_nodes, "N: node ids are 0 .. N - 1") + .def("num_edges", &Graph::num_edges, "E: EdgeIds are 0 .. E - 1") + .def("max_cost", &Graph::max_cost, "largest edge cost (0 without edges)") + .def("cost_gcd", &Graph::cost_gcd, "gcd of the edge costs (0 without edges)") + .def( + "capacity_units", [](const py::object& self) { + const auto& g = self.cast(); + return readonly_view(g.capacity_units(), self); + }, + "int64[E] capacities in units, id order (read-only view)") + .def( + "cost", [](const py::object& self) { + const auto& g = self.cast(); + return readonly_view(g.cost(), self); + }, + "int64[E] costs, id order (read-only view)") + .def( + "edge_src", [](const py::object& self) { + const auto& g = self.cast(); + return readonly_view(g.edge_src(), self); + }, + "int32[E] source node per edge (read-only view)") + .def( + "edge_dst", [](const py::object& self) { + const auto& g = self.cast(); + return readonly_view(g.edge_dst(), self); + }, + "int32[E] destination node per edge (read-only view)") + .def( + "input_index", [](const py::object& self) { + const auto& g = self.cast(); + return readonly_view(g.input_index(), self); + }, + "int32[E]: EdgeId -> input position (read-only view); build the inverse yourself if needed") + .def( + "row_offsets", [](const py::object& self) { + const auto& g = self.cast(); + return readonly_view(g.row_offsets(), self); + }, + "int32[N + 1]: the out-edges of u are the ids [row_offsets[u], row_offsets[u + 1])") + .def( + "in_row_offsets", [](const py::object& self) { + const auto& g = self.cast(); + return readonly_view(g.in_row_offsets(), self); + }, + "int32[N + 1]: the in-edges of v are in_adj_edge_index()[in_row_offsets[v] : in_row_offsets[v + 1]]") + .def( + "in_adj_edge_index", [](const py::object& self) { + const auto& g = self.cast(); + return readonly_view(g.in_adj_edge_index(), self); + }, + "int32[E]: the in-edges of v in (cost, src, input) order, as EdgeIds") + .def( + "eligibility", + [](const Graph& g, const py::object& excluded_nodes, const py::object& excluded_edges) { + // Nodes, then edges, each range-checked before narrowing (the constructor's order). + const auto xn = wide_integers(excluded_nodes, "excluded_nodes"); + for (std::size_t i = 0; i < xn.size(); ++i) { + if (!xn.in(i, 0, static_cast(g.num_nodes()) - 1)) { + throw py::value_error("excluded node " + xn.text(i) + " out of node range [0, " + + std::to_string(g.num_nodes()) + ")"); + } + } + const auto xe = wide_integers(excluded_edges, "excluded_edges"); + for (std::size_t i = 0; i < xe.size(); ++i) { + if (!xe.in(i, 0, static_cast(g.num_edges()) - 1)) { + throw py::value_error("excluded edge " + xe.text(i) + " out of edge range [0, " + + std::to_string(g.num_edges()) + ")"); + } + } + return std::make_shared(g.eligibility(xn.narrow(), xe.narrow())); + }, + py::arg("excluded_nodes") = py::tuple(), py::arg("excluded_edges") = py::tuple(), + "Static eligibility from excluded node ids and EdgeIds (duplicates and unsorted lists\n" + "accepted, out-of-range ids rejected); an edge is allowed when it is not excluded and\n" + "both endpoints are allowed. Empty lists mean all allowed (uid 0)."); +} + +} // namespace netgraph::core::bindings diff --git a/bindings/python/hooks.cpp b/bindings/python/hooks.cpp new file mode 100644 index 0000000..ea2ff08 --- /dev/null +++ b/bindings/python/hooks.cpp @@ -0,0 +1,217 @@ +// The private hooks (NETGRAPH_CORE_PRIVATE_HOOKS builds only): the test controls +// of the creation cap and of Python and native allocation failures, and the apportionment +// kernels. +#include +#include +#include + +#include "registration.hpp" + +#if NETGRAPH_CORE_PRIVATE_HOOKS +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/detail/kernels.hpp" +#include "netgraph/core/detail/private_hooks.hpp" +#include "netgraph/core/flow_policy.hpp" +#include "netgraph/core/types.hpp" +#include "integers.hpp" + +namespace py = pybind11; + +namespace netgraph::core::bindings { + +namespace { + +// The allocation-failure hook: the k-th object-domain allocation fails (Python's allocator +// wrapped for one call, then restored). The allocator is process-wide while the call runs, so +// the hook serves one caller at a time (the tests); its counters are atomics because the module +// does not hold the GIL for every thread (mod_gil_not_used). +PyMemAllocatorEx g_saved_allocator {}; +std::atomic g_fail_countdown {-1}; +std::atomic g_fail_seen {0}; +std::atomic g_fail_hit {false}; + +bool fail_now() { + g_fail_seen.fetch_add(1); + if (g_fail_countdown.load() < 0) return false; + if (g_fail_countdown.fetch_sub(1) == 0) { + g_fail_hit.store(true); + return true; + } + return false; +} +void* failing_malloc(void*, std::size_t n) { return fail_now() ? nullptr : g_saved_allocator.malloc(g_saved_allocator.ctx, n); } +void* failing_calloc(void*, std::size_t n, std::size_t size) { + return fail_now() ? nullptr : g_saved_allocator.calloc(g_saved_allocator.ctx, n, size); +} +void* failing_realloc(void*, void* ptr, std::size_t n) { + return fail_now() ? nullptr : g_saved_allocator.realloc(g_saved_allocator.ctx, ptr, n); +} +void forwarding_free(void*, void* ptr) { g_saved_allocator.free(g_saved_allocator.ctx, ptr); } + +// The native allocation-failure hook: while armed for one call, the k-th allocation of +// the calling thread through the extension's global operator new (the bindings and the library +// linked into the extension) throws std::bad_alloc, and the thread's allocations and deletions +// are counted, so a test also sees what a failed call left allocated. Other threads and other +// images (libc++, Python) are not counted; the aligned forms are not replaced. The counters are +// atomics for the same reason as above. +std::atomic g_native_armed {false}; +std::atomic g_native_owner {}; +std::atomic g_native_countdown {-1}; +std::atomic g_native_seen {0}; +std::atomic g_native_live {0}; +std::atomic g_native_hit {false}; + +bool native_counted() noexcept { + return g_native_armed.load(std::memory_order_relaxed) && g_native_owner.load() == std::this_thread::get_id(); +} + +void* native_allocate(std::size_t n) { + if (native_counted()) { + g_native_seen.fetch_add(1); + if (g_native_countdown.load() >= 0 && g_native_countdown.fetch_sub(1) == 0) { + g_native_hit.store(true); + throw std::bad_alloc(); + } + } + void* p = std::malloc(n == 0 ? 1 : n); + if (p == nullptr) throw std::bad_alloc(); + if (native_counted()) g_native_live.fetch_add(1); + return p; +} + +void* native_allocate_nothrow(std::size_t n) noexcept { + try { + return native_allocate(n); + } catch (...) { + return nullptr; + } +} + +void native_free(void* p) noexcept { + if (p != nullptr && native_counted()) g_native_live.fetch_sub(1); + std::free(p); +} + +} // namespace + +void register_private_hooks(py::module_& m) { + m.def( + "_max_flows_per_call", + [](const py::object& value) { + auto& cap = detail::max_flows_per_call(); + const auto old = cap.load(); + if (!value.is_none()) cap.store(integer_value(value, 0, kMaxFlowsPerCall, "max_flows_per_call")); + return old; + }, + py::arg("value") = py::none(), + "private test hook: the call-wide creation cap (MAX_FLOWS_PER_CALL by default); returns the old value"); + + m.def( + "_fail_python_allocation", + [](const py::object& fn, const py::tuple& args, long k) { + // Calls fn(*args) with the k-th Python object allocation (from 0) failing, then + // restores the allocator. Returns (hit, allocations seen, result or None, exception or + // None). One caller at a time: the allocator is process-wide while the call runs. + PyMem_GetAllocator(PYMEM_DOMAIN_OBJ, &g_saved_allocator); + PyMemAllocatorEx hook {nullptr, failing_malloc, failing_calloc, failing_realloc, forwarding_free}; + g_fail_countdown.store(k); + g_fail_seen.store(0); + g_fail_hit.store(false); + PyMem_SetAllocator(PYMEM_DOMAIN_OBJ, &hook); + PyObject* r = PyObject_CallObject(fn.ptr(), args.ptr()); + PyMem_SetAllocator(PYMEM_DOMAIN_OBJ, &g_saved_allocator); + g_fail_countdown.store(-1); + py::object result = r != nullptr ? py::reinterpret_steal(r) : py::object(py::none()); + py::object error = py::none(); + if (r == nullptr) error = py::reinterpret_steal(PyErr_GetRaisedException()); + return py::make_tuple(g_fail_hit.load(), g_fail_seen.load(), result, error); + }, + py::arg("fn"), py::arg("args"), py::arg("k"), + "private test hook: fn(*args) with the k-th Python object allocation failing -> (hit, seen, result, error)"); + + m.def( + "_fail_native_allocation", + [](const py::object& fn, const py::tuple& args, long k) { + // Calls fn(*args) with the calling thread's k-th native allocation (from 0) failing. + // Returns (hit, allocations seen, result or None, exception or None, allocations of the + // call still live). One caller at a time. + g_native_countdown.store(k); + g_native_seen.store(0); + g_native_live.store(0); + g_native_hit.store(false); + g_native_owner.store(std::this_thread::get_id()); + g_native_armed.store(true); + PyObject* r = PyObject_CallObject(fn.ptr(), args.ptr()); + g_native_armed.store(false); + g_native_countdown.store(-1); + py::object result = r != nullptr ? py::reinterpret_steal(r) : py::object(py::none()); + py::object error = py::none(); + if (r == nullptr) error = py::reinterpret_steal(PyErr_GetRaisedException()); + return py::make_tuple(g_native_hit.load(), g_native_seen.load(), result, error, g_native_live.load()); + }, + py::arg("fn"), py::arg("args"), py::arg("k"), + "private test hook: fn(*args) with the k-th native allocation failing -> (hit, seen, result, error, live)"); + + m.def( + "_round_robin", + [](const py::object& total, const py::object& k) { + const auto t = integer_value(total, 0, kDomainLimit - 1, "total"); + const auto n = integer_value(k, 1, 1 << 20, "k"); + std::vector out(static_cast(n)); + detail::round_robin(t, out); + return out; + }, + py::arg("total"), py::arg("k"), + "private: round robin of total units over k members (the first total % k get one unit more)"); + + m.def( + "_highest_averages", + [](const py::object& total, const py::object& weights) { + const auto t = integer_value(total, 0, kDomainLimit - 1, "total"); + const auto w = wide_integers(weights, "weights"); + if (w.size() == 0) throw py::value_error("highest averages over no member"); + std::uint64_t sum = 0; + for (std::size_t i = 0; i < w.size(); ++i) { + if (!w.in(i, 1, kDomainLimit - 1)) { + throw py::value_error("weights[" + std::to_string(i) + "] = " + w.text(i) + " is outside [1, 2^62)"); + } + sum += static_cast(w.value[i]); + if (sum >= static_cast(kDomainLimit)) throw py::value_error("the weights sum to 2^62 or more"); + } + const auto wv = w.narrow(); + std::vector out(wv.size()); + detail::highest_averages(t, wv, out); + return out; + }, + py::arg("total"), py::arg("weights"), + "private: highest-averages (D'Hondt) apportionment of total units over positive weights, ties by\n" + "the lower index"); +} + +} // namespace netgraph::core::bindings + +// The extension's global allocation functions in a hooks build: the native allocation-failure +// hook above. Unarmed they allocate as the defaults do (malloc and free, which every image's +// operator delete accepts). +using netgraph::core::bindings::native_allocate; +using netgraph::core::bindings::native_allocate_nothrow; +using netgraph::core::bindings::native_free; + +void* operator new(std::size_t n) { return native_allocate(n); } +void* operator new[](std::size_t n) { return native_allocate(n); } +void* operator new(std::size_t n, const std::nothrow_t&) noexcept { return native_allocate_nothrow(n); } +void* operator new[](std::size_t n, const std::nothrow_t&) noexcept { return native_allocate_nothrow(n); } +void operator delete(void* p) noexcept { native_free(p); } +void operator delete[](void* p) noexcept { native_free(p); } +void operator delete(void* p, std::size_t) noexcept { native_free(p); } +void operator delete[](void* p, std::size_t) noexcept { native_free(p); } +void operator delete(void* p, const std::nothrow_t&) noexcept { native_free(p); } +void operator delete[](void* p, const std::nothrow_t&) noexcept { native_free(p); } + +#endif diff --git a/bindings/python/integers.hpp b/bindings/python/integers.hpp new file mode 100644 index 0000000..d5b7a21 --- /dev/null +++ b/bindings/python/integers.hpp @@ -0,0 +1,232 @@ +/* + Integer arguments from Python: Python ints, NumPy integer scalars and integer arrays, read + without narrowing (WideInts) and validated in the C++ constructors' order before any + conversion to the C++ types, so the reported error does not depend on how the input is + represented; bools and floats are rejected, never truncated. Also the shape step of sequence + arguments, the per-edge amount arrays and the node sets. +*/ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "netgraph/core/detail/endpoints.hpp" +#include "netgraph/core/graph.hpp" +#include "netgraph/core/types.hpp" + +namespace py = pybind11; + +namespace netgraph::core::bindings { + +inline bool is_bool(const py::handle& obj) { + return PyBool_Check(obj.ptr()) || py::isinstance(obj, py::module_::import("numpy").attr("bool_")); +} + +// The Python int of an integer-like object (a Python int or a NumPy integer scalar); bools and +// everything else are rejected, and an exception of the object's own __index__ propagates as +// raised. The result is an exact int, so PyLong_AsLongLongAndOverflow cannot fail on it. +inline py::object python_int(const py::handle& item, const std::string& what) { + if (is_bool(item) || !PyIndex_Check(item.ptr())) { + throw py::value_error(what + " must be an integer (got " + + std::string(py::str(py::type::of(item).attr("__name__"))) + ")"); + } + py::object idx = py::reinterpret_steal(PyNumber_Index(item.ptr())); + if (!idx) throw py::error_already_set(); + return idx; +} + +// One integer of [lo, hi] (scalar arguments). +inline std::int64_t integer_value(const py::handle& item, std::int64_t lo, std::int64_t hi, const std::string& what) { + py::object idx = python_int(item, what); + int overflow = 0; + const long long v = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); + if (overflow != 0 || v < lo || v > hi) { + throw py::value_error(what + " = " + std::string(py::str(idx)) + " is out of range [" + + std::to_string(lo) + ", " + std::to_string(hi) + "]"); + } + return static_cast(v); +} + +// A 1-D integer sequence read without narrowing: every entry's int64 value where it fits, and +// the sign of the overflow where it does not (+1 above INT64_MAX, -1 below INT64_MIN). Only type +// defects raise here (a non-integer, a bool, a float array, a non-sequence); every value bound +// is checked by the caller in the C++ constructor's order, so the reported error does not depend +// on the representation of the input. +struct WideInts { + py::object source; + std::vector value; + std::vector overflow; // empty when no entry overflows + [[nodiscard]] std::size_t size() const noexcept { return value.size(); } + [[nodiscard]] int sign(std::size_t i) const noexcept { return overflow.empty() ? 0 : overflow[i]; } + // The entry as Python prints it (exact also beyond int64). + [[nodiscard]] std::string text(std::size_t i) const { + if (sign(i) == 0) return std::to_string(value[i]); + return std::string(py::str(source[py::int_(i)])); + } + [[nodiscard]] bool in(std::size_t i, std::int64_t lo, std::int64_t hi) const noexcept { + return sign(i) == 0 && value[i] >= lo && value[i] <= hi; + } + // The entry as an exact Python int (an overflowing entry compared with another). The entry is + // converted again here, so its __index__ may now fail: that exception propagates as raised. + [[nodiscard]] py::object exact(std::size_t i) const { + py::object item = source[py::int_(i)]; + py::object idx = py::reinterpret_steal(PyNumber_Index(item.ptr())); + if (!idx) throw py::error_already_set(); + return idx; + } + template + [[nodiscard]] std::vector narrow() const { // after every bound was checked + return std::vector(value.begin(), value.end()); + } +}; + +inline bool not_a_sequence(const py::handle& obj) { + return py::isinstance(obj) || py::isinstance(obj) || !py::isinstance(obj); +} + +inline WideInts wide_integers(const py::handle& obj, const char* name) { + WideInts out; + out.source = py::reinterpret_borrow(obj); + if (py::isinstance(obj)) { + auto arr = py::reinterpret_borrow(obj); + if (arr.ndim() != 1) { + throw py::value_error(std::string(name) + " must be a 1-D array (got " + std::to_string(arr.ndim()) + + " dimensions)"); + } + const char kind = arr.dtype().kind(); + if (kind != 'i' && kind != 'u') { + throw py::value_error(std::string(name) + " entries must be integers (got dtype " + + std::string(py::str(arr.dtype())) + ")"); + } + out.value.reserve(static_cast(arr.size())); + if (kind == 'u' && arr.itemsize() == 8) { + auto u = py::array_t::ensure(arr); + auto r = u.unchecked<1>(); + constexpr auto kMax = static_cast(std::numeric_limits::max()); + for (py::ssize_t i = 0; i < r.shape(0); ++i) { + if (r(i) > kMax) { + if (out.overflow.empty()) out.overflow.assign(static_cast(r.shape(0)), 0); + out.overflow[static_cast(i)] = 1; + out.value.push_back(std::numeric_limits::max()); + } else { + out.value.push_back(static_cast(r(i))); + } + } + return out; + } + auto a = py::array_t::ensure(arr); + auto r = a.unchecked<1>(); + for (py::ssize_t i = 0; i < r.shape(0); ++i) out.value.push_back(r(i)); + return out; + } + if (not_a_sequence(obj)) throw py::value_error(std::string(name) + " must be a sequence of integers"); + auto seq = py::reinterpret_borrow(obj); + const std::size_t n = seq.size(); + out.value.reserve(n); + for (std::size_t i = 0; i < n; ++i) { + py::object idx = python_int(seq[i], std::string(name) + "[" + std::to_string(i) + "]"); + int overflow = 0; + const long long v = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); + if (overflow != 0) { + if (out.overflow.empty()) out.overflow.assign(n, 0); + out.overflow[i] = static_cast(overflow); + out.value.push_back(overflow > 0 ? std::numeric_limits::max() + : std::numeric_limits::min()); + } else { + out.value.push_back(static_cast(v)); + } + } + return out; +} + +// An entry that is itself a sequence or an array of rank >= 1 (a shape defect of its argument). +inline bool is_nested(const py::handle& item) { + if (py::isinstance(item)) return py::reinterpret_borrow(item).ndim() >= 1; + return !py::isinstance(item) && !py::isinstance(item) && PySequence_Check(item.ptr()) != 0; +} + +// The shape step: the length of a rank-1 integer sequence argument. A NumPy array of another +// rank (0-D included), a nested sequence or a non-sequence is a shape defect. Entry types and +// values are not read here; their steps come later. +inline std::size_t sequence_length(const py::handle& obj, const char* name) { + if (py::isinstance(obj)) { + auto arr = py::reinterpret_borrow(obj); + if (arr.ndim() != 1) { + throw py::value_error(std::string(name) + " must be a 1-D array (got " + std::to_string(arr.ndim()) + + " dimensions)"); + } + return static_cast(arr.size()); + } + if (not_a_sequence(obj)) { + throw py::value_error(std::string(name) + " must be a 1-D sequence of integers (got " + + std::string(py::str(py::type::of(obj).attr("__name__"))) + ")"); + } + auto seq = py::reinterpret_borrow(obj); + const std::size_t n = seq.size(); + for (std::size_t i = 0; i < n; ++i) { + if (is_nested(seq[i])) { + throw py::value_error(std::string(name) + " must be a 1-D sequence of integers (entry " + std::to_string(i) + + " is a sequence)"); + } + } + return n; +} + +// A per-edge integer array in [0, capacity_units[e]] (a ledger baseline, a search residual, +// the capacities `view` of _place_global_units), validated on the unnarrowed values: the length +// (shape step), then every entry in EdgeId order. `label` names the entries in a type error +// ("residual (units)", "view (capacity units)"). The checked values come back unnarrowed: a +// caller narrows them later (_place_global_units, inside its call). +inline WideInts edge_amounts(const py::object& obj, const Graph& g, const char* name, const char* label) { + const auto num_edges = static_cast(g.num_edges()); + const std::size_t len = sequence_length(obj, name); + if (len != num_edges) { + throw py::value_error(std::string(name) + " has length " + std::to_string(len) + ", expected " + + std::to_string(num_edges) + " (one entry per edge)"); + } + auto v = wide_integers(obj, label); + const auto capacity = g.capacity_units(); + for (std::size_t e = 0; e < num_edges; ++e) { + if (!v.in(e, 0, capacity[e])) { + throw py::value_error(std::string(name) + "[" + std::to_string(e) + "] = " + v.text(e) + " is outside [0, capacity_units[" + + std::to_string(e) + "] = " + std::to_string(capacity[e]) + "]"); + } + } + return v; +} + +// A per-edge integer array in [0, capacity_units[e]] (a ledger baseline, a search residual), +// validated on the unnarrowed values: the length (shape step), then every entry. +inline std::vector edge_units(const py::object& obj, const Graph& g, const char* name) { + return edge_amounts(obj, g, name, (std::string(name) + " (units)").c_str()).narrow(); +} + +// A node set's ids, non-empty and range-checked before narrowing (detail::node_set's messages). +inline std::vector checked_ids(const WideInts& ids, std::int32_t num_nodes, const char* name) { + if (ids.size() == 0) throw py::value_error(detail::empty_set_error(name)); + for (std::size_t i = 0; i < ids.size(); ++i) { + if (!ids.in(i, 0, static_cast(num_nodes) - 1)) { + throw py::value_error(detail::node_range_error(name, ids.text(i), num_nodes)); + } + } + return ids.narrow(); +} + +// A node set: one NodeId or a sequence of them. +inline std::vector node_ids(const py::object& obj, std::int32_t num_nodes, const char* name) { + if (!is_bool(obj) && PyIndex_Check(obj.ptr()) && !py::isinstance(obj)) { + py::list one; + one.append(obj); + return checked_ids(wide_integers(one, name), num_nodes, name); + } + return checked_ids(wide_integers(obj, name), num_nodes, name); +} + +} // namespace netgraph::core::bindings diff --git a/bindings/python/module.cpp b/bindings/python/module.cpp index 1a29525..394f3a5 100644 --- a/bindings/python/module.cpp +++ b/bindings/python/module.cpp @@ -1,2181 +1,65 @@ /* Pybind11 module `_netgraph_core`: the graph on integer units with Eligibility and the unit conversions, the route structures (PredDAG, Path), the selection and placement axes, the - ledger with its records, the policy engine with its configuration and results, the DagCache - , the evaluators (capacity, batch_capacity, sensitivity) and the public search (spf); and one - private entry: the flow core's _place_global_units (the recorder's entry, APPROACH 2.2). + ledger with its records, the policy engine with its configuration and results, the DagCache, + the evaluators (capacity, batch_capacity, sensitivity) and the public search (spf, ksp, + resolve_to_paths); and one private entry: the flow core's _place_global_units. A build with the private hooks (CMake NETGRAPH_CORE_PRIVATE_HOOKS: the editable development build) adds the test controls and the parity diagnostics. - Graph accessors return read-only NumPy views that keep the graph alive (no per-call copy). - Integer inputs (capacities, costs, ids, views, caps) must be integers: Python ints, NumPy integer scalars or integer arrays; floats and bools are rejected with ValueError, never - truncated. Values are read without narrowing (WideInts) and validated in the contract's - order before any conversion to the C++ types (APPROACH 3, revision 25). Physical values + truncated. Values are read without narrowing (WideInts) and validated in the C++ + constructors' order before any conversion to the C++ types. Physical values enter only through to_units; the resolution is validated before anything else. - std::invalid_argument from Core maps to ValueError; enumeration values outside their - enumeration are rejected at construction (rule 13). - - _place_global_units is the recorder's private entry point (APPROACH 2.2): it returns the - payload as a dict of Python ints and lists. + enumeration are rejected at construction (ValueError). + - _place_global_units is a private entry point for the flow checks: it returns the flow + core's payload as a dict of Python ints and lists. - Amounts and counters cross as Python ints (exact, also beyond 64 bits); per-edge arrays as int64 NumPy arrays in EdgeId order; +inf is accepted only as a request (float('inf')). - Python spells constants in UPPER_SNAKE_CASE: MAX_FLOWS_PER_CALL is the C++ kMaxFlowsPerCall. */ #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "netgraph/core/dag_cache.hpp" -#include "netgraph/core/detail/endpoints.hpp" -#include "netgraph/core/detail/enum_domain.hpp" -#include "netgraph/core/detail/evaluator_checks.hpp" -#include "netgraph/core/detail/flow_core.hpp" -#include "netgraph/core/detail/pred_dag_builder.hpp" -#include "netgraph/core/detail/private_hooks.hpp" -#include "netgraph/core/detail/traversal.hpp" -#include "netgraph/core/eligibility.hpp" -#include "netgraph/core/evaluators.hpp" -#include "netgraph/core/flow_ledger.hpp" -#include "netgraph/core/flow_policy.hpp" -#include "netgraph/core/flow_record.hpp" -#include "netgraph/core/graph.hpp" -#include "netgraph/core/options.hpp" -#include "netgraph/core/pred_dag.hpp" -#include "netgraph/core/search.hpp" -#include "netgraph/core/types.hpp" -#if NETGRAPH_CORE_PRIVATE_HOOKS -#include - -#include "netgraph/core/detail/kernels.hpp" -#include "netgraph/core/detail/ledger_access.hpp" -#include "netgraph/core/detail/policy_engine.hpp" -#include "netgraph/core/detail/trace.hpp" -#endif +#include "registration.hpp" namespace py = pybind11; using namespace netgraph::core; -namespace { - -// Read-only NumPy view of a span owned by `owner`. -template -py::array readonly_view(std::span s, const py::handle& owner, const py::dtype& dtype) { - static constexpr T empty_element {}; - py::array arr(dtype, {static_cast(s.size())}, {static_cast(sizeof(T))}, - s.empty() ? &empty_element : s.data(), owner); - py::detail::array_proxy(arr.ptr())->flags &= ~py::detail::npy_api::NPY_ARRAY_WRITEABLE_; - return arr; -} - -template -py::array readonly_view(std::span s, const py::handle& owner) { - return readonly_view(s, owner, py::dtype::of()); -} - -bool is_bool(const py::handle& obj) { - return PyBool_Check(obj.ptr()) || py::isinstance(obj, py::module_::import("numpy").attr("bool_")); -} - -// The Python int of an integer-like object (a Python int or a NumPy integer scalar); bools and -// everything else are rejected. -py::object python_int(const py::handle& item, const std::string& what) { - if (is_bool(item) || !PyIndex_Check(item.ptr())) { - throw py::value_error(what + " must be an integer (got " + - std::string(py::str(py::type::of(item).attr("__name__"))) + ")"); - } - py::object idx = py::reinterpret_steal(PyNumber_Index(item.ptr())); - if (!idx) throw py::error_already_set(); - return idx; -} - -// One integer of [lo, hi] (scalar arguments). -std::int64_t integer_value(const py::handle& item, std::int64_t lo, std::int64_t hi, const std::string& what) { - py::object idx = python_int(item, what); - int overflow = 0; - const long long v = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); - if (overflow != 0 || v < lo || v > hi) { - throw py::value_error(what + " = " + std::string(py::str(idx)) + " is out of range [" + - std::to_string(lo) + ", " + std::to_string(hi) + "]"); - } - return static_cast(v); -} - -// A 1-D integer sequence read without narrowing: every entry's int64 value where it fits, and -// the sign of the overflow where it does not (+1 above INT64_MAX, -1 below INT64_MIN). Only type -// defects raise here (a non-integer, a bool, a float array, a non-sequence); every value bound -// is checked by the caller in the contract's order, so the reported error does not depend on -// the representation of the input (APPROACH 3, revision 25). -struct WideInts { - py::object source; - std::vector value; - std::vector overflow; // empty when no entry overflows - [[nodiscard]] std::size_t size() const noexcept { return value.size(); } - [[nodiscard]] int sign(std::size_t i) const noexcept { return overflow.empty() ? 0 : overflow[i]; } - // The entry as Python prints it (exact also beyond int64). - [[nodiscard]] std::string text(std::size_t i) const { - if (sign(i) == 0) return std::to_string(value[i]); - return std::string(py::str(source[py::int_(i)])); - } - [[nodiscard]] bool in(std::size_t i, std::int64_t lo, std::int64_t hi) const noexcept { - return sign(i) == 0 && value[i] >= lo && value[i] <= hi; - } - // The entry as an exact Python int (an overflowing entry compared with another). - [[nodiscard]] py::object exact(std::size_t i) const { - py::object item = source[py::int_(i)]; - return py::reinterpret_steal(PyNumber_Index(item.ptr())); - } - template - [[nodiscard]] std::vector narrow() const { // after every bound was checked - return std::vector(value.begin(), value.end()); - } -}; - -bool not_a_sequence(const py::handle& obj) { - return py::isinstance(obj) || py::isinstance(obj) || !py::isinstance(obj); -} - -WideInts wide_integers(const py::handle& obj, const char* name) { - WideInts out; - out.source = py::reinterpret_borrow(obj); - if (py::isinstance(obj)) { - auto arr = py::reinterpret_borrow(obj); - if (arr.ndim() != 1) { - throw py::value_error(std::string(name) + " must be a 1-D array (got " + std::to_string(arr.ndim()) + - " dimensions)"); - } - const char kind = arr.dtype().kind(); - if (kind != 'i' && kind != 'u') { - throw py::value_error(std::string(name) + " entries must be integers (got dtype " + - std::string(py::str(arr.dtype())) + ")"); - } - out.value.reserve(static_cast(arr.size())); - if (kind == 'u' && arr.itemsize() == 8) { - auto u = py::array_t::ensure(arr); - auto r = u.unchecked<1>(); - constexpr auto kMax = static_cast(std::numeric_limits::max()); - for (py::ssize_t i = 0; i < r.shape(0); ++i) { - if (r(i) > kMax) { - if (out.overflow.empty()) out.overflow.assign(static_cast(r.shape(0)), 0); - out.overflow[static_cast(i)] = 1; - out.value.push_back(std::numeric_limits::max()); - } else { - out.value.push_back(static_cast(r(i))); - } - } - return out; - } - auto a = py::array_t::ensure(arr); - auto r = a.unchecked<1>(); - for (py::ssize_t i = 0; i < r.shape(0); ++i) out.value.push_back(r(i)); - return out; - } - if (not_a_sequence(obj)) throw py::value_error(std::string(name) + " must be a sequence of integers"); - auto seq = py::reinterpret_borrow(obj); - const std::size_t n = seq.size(); - out.value.reserve(n); - for (std::size_t i = 0; i < n; ++i) { - py::object idx = python_int(seq[i], std::string(name) + "[" + std::to_string(i) + "]"); - int overflow = 0; - const long long v = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); - if (overflow != 0) { - if (out.overflow.empty()) out.overflow.assign(n, 0); - out.overflow[i] = static_cast(overflow); - out.value.push_back(overflow > 0 ? std::numeric_limits::max() - : std::numeric_limits::min()); - } else { - out.value.push_back(static_cast(v)); - } - } - return out; -} - -// An entry that is itself a sequence or an array of rank >= 1 (a shape defect of its argument). -bool is_nested(const py::handle& item) { - if (py::isinstance(item)) return py::reinterpret_borrow(item).ndim() >= 1; - return !py::isinstance(item) && !py::isinstance(item) && PySequence_Check(item.ptr()) != 0; -} - -// The shape step: the length of a rank-1 integer sequence argument. A NumPy array of another -// rank (0-D included), a nested sequence or a non-sequence is a shape defect. Entry types and -// values are not read here; their steps come later. -std::size_t sequence_length(const py::handle& obj, const char* name) { - if (py::isinstance(obj)) { - auto arr = py::reinterpret_borrow(obj); - if (arr.ndim() != 1) { - throw py::value_error(std::string(name) + " must be a 1-D array (got " + std::to_string(arr.ndim()) + - " dimensions)"); - } - return static_cast(arr.size()); - } - if (not_a_sequence(obj)) { - throw py::value_error(std::string(name) + " must be a 1-D sequence of integers (got " + - std::string(py::str(py::type::of(obj).attr("__name__"))) + ")"); - } - auto seq = py::reinterpret_borrow(obj); - const std::size_t n = seq.size(); - for (std::size_t i = 0; i < n; ++i) { - if (is_nested(seq[i])) { - throw py::value_error(std::string(name) + " must be a 1-D sequence of integers (entry " + std::to_string(i) + - " is a sequence)"); - } - } - return n; -} - -// The unit-value step on the capacity argument whatever its shape (the shape step comes next): -// every integer entry it holds must lie in [0, 2^62). A NumPy array is read flattened; nested -// entries of a Python sequence and a non-integer non-sequence are left to the shape step. -void check_unit_values(const py::handle& capacity_units) { - const auto out_of_domain = [](const std::string& what, const std::string& value) { - return py::value_error(what + " = " + value + " is outside the units domain [0, 2^62)"); - }; - if (py::isinstance(capacity_units)) { - py::object flat = capacity_units.attr("ravel")(); - const auto w = wide_integers(flat, "capacity_units"); - for (std::size_t i = 0; i < w.size(); ++i) { - if (!w.in(i, 0, kDomainLimit - 1)) throw out_of_domain("capacity_units[" + std::to_string(i) + "]", w.text(i)); - } - return; - } - if (!not_a_sequence(capacity_units)) { - auto seq = py::reinterpret_borrow(capacity_units); - for (std::size_t i = 0; i < seq.size(); ++i) { - py::object item = seq[i]; - if (is_nested(item)) continue; - const std::string what = "capacity_units[" + std::to_string(i) + "]"; - py::object idx = python_int(item, what); - int overflow = 0; - const long long v = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); - if (overflow != 0 || v < 0 || v >= kDomainLimit) throw out_of_domain(what, std::string(py::str(idx))); - } - return; - } - if (!is_bool(capacity_units) && PyIndex_Check(capacity_units.ptr())) { - py::object idx = python_int(capacity_units, "capacity_units"); - int overflow = 0; - const long long v = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); - if (overflow != 0 || v < 0 || v >= kDomainLimit) throw out_of_domain("capacity_units", std::string(py::str(idx))); - } -} - -// Validates a resolution (a power of two in [2^-32, 2^32]) and returns its exponent. -int resolution_exponent(double resolution) { - (void)as_float(0, resolution); // throws the resolution message - return std::ilogb(resolution); -} - -// Physical numbers as a float64 array (or a 0-d array for a scalar); bools and strings rejected. -py::array_t physical_array(const py::handle& x) { - if (is_bool(x) || py::isinstance(x) || py::isinstance(x)) { - throw py::value_error("to_units takes physical numbers (a float or an array of floats)"); - } - auto np = py::module_::import("numpy"); - py::array raw = np.attr("asarray")(x); - const char kind = raw.dtype().kind(); - if (kind != 'f' && kind != 'i' && kind != 'u') { - throw py::value_error("to_units takes physical numbers (got dtype " + std::string(py::str(raw.dtype())) + ")"); - } - return py::array_t::ensure(raw); -} - -// x * 2^k for an unbounded Python int, correctly rounded: Python's int shift and int true -// division are exact or correctly rounded, so only a result beyond the finite doubles fails. -double scaled_python_int(const py::object& x, int k) { - try { - py::object r; - if (k >= 0) { - r = py::float_(x.attr("__lshift__")(py::int_(k))); - } else { - r = x.attr("__truediv__")(py::int_(1).attr("__lshift__")(py::int_(-k))); - } - return r.cast(); - } catch (py::error_already_set& e) { - if (e.matches(PyExc_OverflowError)) { - throw py::value_error("as_float: " + std::string(py::str(x)) + - " units times the resolution exceed the largest finite double"); - } - throw; - } -} - -// One Amount to a physical double; the resolution's exponent k is already validated. -double amount_as_float(const py::handle& item, int k, double resolution) { - py::object idx = python_int(item, "as_float: x"); - int overflow = 0; - const long long v = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); - if (overflow == 0) return as_float(static_cast(v), resolution); - return scaled_python_int(idx, k); -} - -std::shared_ptr make_graph(const py::object& num_nodes, const py::object& src, - const py::object& dst, const py::object& capacity_units, - const py::object& cost, double resolution) { - // APPROACH 3 (revision 25): the resolution, the unit values, the shapes, zero or negative - // costs, the remaining bounds (ids in range, then the sums); every value is read without - // narrowing, and the messages are the constructor's, which re-checks the same order. - (void)resolution_exponent(resolution); - check_unit_values(capacity_units); - // The shapes: num_nodes, then every array argument rank 1 (in signature order), then equal - // lengths; no entry of src, dst or cost is read before the cost step. - const auto n = integer_value(num_nodes, 0, std::numeric_limits::max(), "num_nodes"); - const std::size_t m = sequence_length(src, "src"); - const std::size_t md = sequence_length(dst, "dst"); - const std::size_t mu = sequence_length(capacity_units, "capacity_units"); - const std::size_t mc = sequence_length(cost, "cost"); - if (md != m || mu != m || mc != m) { - throw py::value_error("src, dst, capacity_units and cost must have the same length (got " + - std::to_string(m) + ", " + std::to_string(md) + ", " + std::to_string(mu) + ", " + - std::to_string(mc) + ")"); - } - const auto cap = wide_integers(capacity_units, "capacity_units"); // rank 1, values checked - if (m > static_cast(std::numeric_limits::max())) { - throw py::value_error("the number of edges exceeds INT32_MAX (array length)"); - } - const auto w = wide_integers(cost, "cost"); - for (std::size_t i = 0; i < m; ++i) { - if (w.sign(i) < 0 || (w.sign(i) == 0 && w.value[i] < 1)) { - throw py::value_error("cost[" + std::to_string(i) + "] = " + w.text(i) + ": costs must be positive (cost >= 1)"); - } - } - const auto s = wide_integers(src, "src"); - const auto d = wide_integers(dst, "dst"); - for (std::size_t i = 0; i < m; ++i) { - if (!s.in(i, 0, n - 1) || !d.in(i, 0, n - 1)) { - throw py::value_error("edge " + std::to_string(i) + ": endpoint (" + s.text(i) + ", " + d.text(i) + - ") out of node range [0, " + std::to_string(n) + ")"); - } - } - constexpr auto kLimit = static_cast(kDomainLimit); - std::uint64_t cap_sum = 0; - std::uint64_t cost_sum = 0; - for (std::size_t i = 0; i < m; ++i) { - cap_sum += static_cast(cap.value[i]); // each below 2^62: no wrap before the check - if (cap_sum >= kLimit) { - throw py::value_error("the sum of all capacities reaches the bound of the units domain (2^62 units)"); - } - if (w.sign(i) > 0 || static_cast(w.value[i]) >= kLimit - cost_sum) { - throw py::value_error("the sum of all edge costs reaches the bound 2^62: path costs would overflow int64"); - } - cost_sum += static_cast(w.value[i]); - } - auto cap_v = cap.narrow(); - auto cost_v = w.narrow(); - auto src_v = s.narrow(); - auto dst_v = d.narrow(); - py::gil_scoped_release release; - return std::make_shared(static_cast(n), src_v, dst_v, cap_v, cost_v, resolution); -} - - -// ---------------------------------------------------------------- ledger, records, requests - -// The exact order of wide integers: a positive overflow above every int64, a negative one -// below, and two overflows on the same side by their exact values (review-1b D2). -bool wide_less(const WideInts& w, std::size_t i, std::size_t j) { - if (w.sign(i) != w.sign(j)) return w.sign(i) < w.sign(j); - if (w.sign(i) == 0) return w.value[i] < w.value[j]; - const int less = PyObject_RichCompareBool(w.exact(i).ptr(), w.exact(j).ptr(), Py_LT); - if (less < 0) throw py::error_already_set(); - return less == 1; -} - -// A per-edge integer array in [0, capacity_units[e]] (a ledger baseline, a search residual), -// validated on the unnarrowed values: the length (shape step), then every entry. -std::vector edge_units(const py::object& obj, const Graph& g, const char* name) { - const auto num_edges = static_cast(g.num_edges()); - const std::size_t len = sequence_length(obj, name); - if (len != num_edges) { - throw py::value_error(std::string(name) + " has length " + std::to_string(len) + ", expected " + - std::to_string(num_edges) + " (one entry per edge)"); - } - const auto v = wide_integers(obj, (std::string(name) + " (units)").c_str()); - const auto capacity = g.capacity_units(); - for (std::size_t e = 0; e < num_edges; ++e) { - if (!v.in(e, 0, capacity[e])) { - throw py::value_error(std::string(name) + "[" + std::to_string(e) + "] = " + v.text(e) + " is outside [0, capacity_units[" + - std::to_string(e) + "] = " + std::to_string(capacity[e]) + "]"); - } - } - return v.narrow(); -} - -// A node set's ids, non-empty and range-checked before narrowing (detail::node_set's messages). -std::vector checked_ids(const WideInts& ids, std::int32_t num_nodes, const char* name) { - if (ids.size() == 0) throw py::value_error(detail::empty_set_error(name)); - for (std::size_t i = 0; i < ids.size(); ++i) { - if (!ids.in(i, 0, static_cast(num_nodes) - 1)) { - throw py::value_error(detail::node_range_error(name, ids.text(i), num_nodes)); - } - } - return ids.narrow(); -} - -// A node set: one NodeId or a sequence of them. -std::vector node_ids(const py::object& obj, std::int32_t num_nodes, const char* name) { - if (!is_bool(obj) && PyIndex_Check(obj.ptr()) && !py::isinstance(obj)) { - py::list one; - one.append(obj); - return checked_ids(wide_integers(one, name), num_nodes, name); - } - return checked_ids(wide_integers(obj, name), num_nodes, name); -} - -// One NodeId of [0, num_nodes) (a single endpoint); the range message of detail/endpoints.hpp. -NodeId endpoint(const py::object& obj, std::int32_t num_nodes, const char* name) { - const py::object idx = python_int(obj, name); - int overflow = 0; - const long long v = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); - if (overflow != 0 || v < 0 || v >= num_nodes) { - throw py::value_error(detail::node_range_error(name, std::string(py::str(idx)), num_nodes)); - } - return static_cast(v); -} - -// A count of at least 1 (KSP's k, the resolver's max_paths): the rule-13 message below 1; a count -// above INT64_MAX means the same as INT64_MAX (no list reaches it). -std::int64_t count_of(const py::object& obj, const char* name) { - const py::object idx = python_int(obj, name); - if (PyObject_RichCompareBool(idx.ptr(), py::int_(1).ptr(), Py_LT) == 1) { - throw py::value_error(std::string(name) + " must be >= 1 (got " + std::string(py::str(idx)) + ")"); - } - int overflow = 0; - const long long v = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); - return overflow != 0 ? std::numeric_limits::max() : static_cast(v); -} - -// A request: an integer number of units in [0, 2^62) or +inf (float('inf')); nullopt is +inf. -std::optional request_units(const py::object& volume) { - if (py::isinstance(volume) || py::isinstance(volume, py::module_::import("numpy").attr("floating"))) { - const double x = volume.cast(); - if (std::isinf(x) && x > 0) return std::nullopt; - throw py::value_error("requests are integer units or +inf (a physical request goes through to_units first; got " + - std::string(py::str(volume)) + ")"); - } - const auto v = integer_value(volume, 0, kDomainLimit - 1, "request (units)"); - return static_cast(v); -} - -py::object counter_int(const Counter128& c) { - py::int_ hi(c.hi); - py::int_ lo(c.lo); - return hi.attr("__lshift__")(py::int_(64)).attr("__or__")(lo); -} - -py::object cost_or_none(Cost c) { return c == kUnreachable ? py::object(py::none()) : py::object(py::int_(c)); } - -py::array_t int64_array(const std::vector& v) { - py::array_t out(static_cast(v.size())); - std::copy(v.begin(), v.end(), out.mutable_data()); - return out; -} - -FlowId flow_id_of(const py::object& obj) { - if (!py::isinstance(obj) || py::len(obj) != 2) { - throw py::value_error("a FlowId is a tuple (ledger uid, id)"); - } - auto t = py::reinterpret_borrow(obj); - py::object uid = python_int(t[0], "FlowId ledger uid"); - py::object id = python_int(t[1], "FlowId id"); - int o1 = 0, o2 = 0; - const unsigned long long u = PyLong_AsUnsignedLongLong(uid.ptr()); - if (PyErr_Occurred()) { - PyErr_Clear(); - o1 = 1; - } - const long long i = PyLong_AsLongLongAndOverflow(id.ptr(), &o2); - if (o1 != 0 || o2 != 0 || i < 0) { - throw py::value_error("FlowId (" + std::string(py::str(uid)) + ", " + std::string(py::str(id)) + ") is retired or unknown"); - } - return FlowId {static_cast(u), static_cast(i)}; -} - -py::list pairs_list(const std::vector>& v) { - py::list out; - for (const auto& [e, x] : v) out.append(py::make_tuple(e, x)); - return out; -} - -py::list int_list(const std::vector& v) { - py::list out(v.size()); - for (std::size_t i = 0; i < v.size(); ++i) out[i] = py::int_(v[i]); - return out; -} - - -// The Python form of CapacityOptions and SensitivityOptions: the Eligibility and the cache are -// held by reference count for the call (the C++ structs borrow raw pointers). -struct PyCapacityOptions { - FlowPolicyConfig config; - std::shared_ptr elig; - std::shared_ptr cache; - OutputOptions outputs; -}; -struct PySensitivityOptions { - FlowPolicyConfig config; - std::shared_ptr elig; - std::shared_ptr cache; - OutputOptions outputs; -}; - -// The Python form of SpfOptions: the arguments as given (targets, residual and min_residual are -// validated at the call, in the contract's order, on their unnarrowed values); the Eligibility is -// held by reference count. -struct PySpfOptions { - Direction direction; - PathSelection selection; - py::object targets; - py::object residual; - py::object min_residual; - std::shared_ptr elig; -}; - -// The Python form of KspOptions: the arguments as given (k, max_path_cost and residual are -// validated at the call, in the contract's order, on their unnarrowed values). -struct PyKspOptions { - py::object k; - PathSelection selection; - py::object max_path_cost; - py::object residual; - std::shared_ptr elig; -}; - -// The Python form of SpfResult: every field already a Python object (made by the call through -// the checked allocations), so reading one allocates nothing. -struct PySpfResult { - py::object costs; // a read-only int64 array (full searches) or None - py::object dag; // PredDAG - py::object target_cost; // an int, or None (no target reached, or a full search) -}; - -// ---------------------------------------------------------------- policies and evaluators - -// An optional int field of [lo, hi] (None allowed when `optional`); bools and floats rejected. -std::optional int_field(const py::object& obj, std::int64_t lo, std::int64_t hi, const std::string& what, - bool optional) { - if (obj.is_none()) { - if (optional) return std::nullopt; - throw py::value_error(what + " must be an integer (got None)"); - } - return integer_value(obj, lo, hi, what); -} - -double float_field(const py::object& obj, const std::string& what) { - if (is_bool(obj) || !(py::isinstance(obj) || PyIndex_Check(obj.ptr()) || - py::isinstance(obj, py::module_::import("numpy").attr("floating")))) { - throw py::value_error(what + " must be a number (got " + std::string(py::str(py::type::of(obj).attr("__name__"))) + - ")"); - } - return obj.cast(); -} - -py::object remaining_obj(const std::optional& r) { - return r.has_value() ? py::object(py::int_(*r)) : py::object(py::float_(std::numeric_limits::infinity())); -} - -Routing routing_of(const py::object& obj) { - if (py::isinstance(obj)) return obj.cast(); - if (py::isinstance(obj)) return obj.cast(); - if (py::isinstance(obj)) return obj.cast(); - if (py::isinstance(obj)) return obj.cast(); - throw py::type_error("routing must be CostOnly, CapacityAware, Global or Pinned (got " + - std::string(py::str(py::type::of(obj).attr("__name__"))) + ")"); -} - -py::object routing_obj(const Routing& r) { - return std::visit([](const auto& x) { return py::cast(x); }, r); -} - -// workers: an explicit integer is passed through (validated by the evaluator); None takes the -// environment default (detail::default_workers), and 1 whenever a cache is supplied (the -// building cache's one writer; APPROACH 10, 11). -std::int32_t workers_of(const py::object& workers, bool sensitivity, bool cache_given) { - if (!workers.is_none()) { - return static_cast(integer_value(workers, std::numeric_limits::min(), - std::numeric_limits::max(), "workers")); - } - if (cache_given) return 1; - return detail::default_workers(sensitivity); -} - -OutputOptions outputs_of(const py::object& obj) { - if (obj.is_none()) return OutputOptions {}; - if (!py::isinstance(obj)) { - throw py::type_error("outputs must be an OutputOptions (got " + std::string(py::str(py::type::of(obj).attr("__name__"))) + - ")"); - } - return obj.cast(); -} - -template -Options options_of(const py::object& obj, const char* name) { - if (!py::isinstance(obj)) { - throw py::type_error(std::string(name) + " expected (got " + std::string(py::str(py::type::of(obj).attr("__name__"))) + - ")"); - } - return obj.cast(); -} - -// A new Python object of the bound class T owning a default-constructed T, made as pybind11's -// generic move-cast makes one (type_caster_generic::cast) but with the object allocation -// checked: pybind11's make_new_instance uses the result of tp_alloc unchecked, so a failed -// Python allocation there is a crash rather than a MemoryError. Every failure here raises, and -// the half-built object is released on unwinding (its value is deleted, nothing registered). -// The policy calls make their result object with this before anything is published. -template -std::pair new_object() { - const py::detail::type_info* tinfo = py::detail::get_type_info(typeid(T), /*throw_if_missing=*/true); - PyTypeObject* type = tinfo->type; - PyObject* raw = type->tp_alloc(type, 0); - if (raw == nullptr) throw py::error_already_set(); - auto obj = py::reinterpret_steal(raw); - auto* inst = reinterpret_cast(raw); - inst->allocate_layout(); - inst->owned = false; - auto* value = new T(); - py::detail::values_and_holders(inst).begin()->value_ptr() = value; - inst->owned = true; - tinfo->init_instance(inst, nullptr); - return {std::move(obj), value}; -} - -// The value moved into its result object after the commit: nothing may allocate there. -static_assert(std::is_nothrow_move_assignable_v); -static_assert(std::is_nothrow_move_assignable_v); -static_assert(std::is_nothrow_move_assignable_v); - -template -py::object object_of(T value) { - auto [obj, slot] = new_object(); - *slot = std::move(value); - return std::move(obj); -} - -// A new Python object of the bound class T (held by std::shared_ptr) owning `value`, through the -// same checked allocation as new_object: for the values without a default constructor (PredDAG, -// Path) that a search returns. A failure raises and releases everything made so far. -template -py::object object_from(T value) { - const py::detail::type_info* tinfo = py::detail::get_type_info(typeid(T), /*throw_if_missing=*/true); - PyTypeObject* type = tinfo->type; - PyObject* raw = type->tp_alloc(type, 0); - if (raw == nullptr) throw py::error_already_set(); - auto obj = py::reinterpret_steal(raw); - auto* inst = reinterpret_cast(raw); - inst->allocate_layout(); - inst->owned = false; - auto* held = new T(std::move(value)); - py::detail::values_and_holders(inst).begin()->value_ptr() = held; - inst->owned = true; - tinfo->init_instance(inst, nullptr); - return obj; -} - - -// ---------------------------------------------------------------- private hooks - -#if NETGRAPH_CORE_PRIVATE_HOOKS - -// A trace value as Python objects (map keys that are decimal integers become ints: the maps -// keyed by source NodeId). -py::object trace_obj(const detail::Trace& t) { - using K = detail::Trace::Kind; - switch (t.kind) { - case K::Null: return py::none(); - case K::Bool: return py::bool_(t.b); - case K::Int: return py::int_(t.i); - case K::Str: return py::str(t.s); - case K::List: { - py::list out; - for (const auto& x : t.items) out.append(trace_obj(x)); - return out; - } - case K::Map: { - py::dict out; - for (const auto& [k, x] : t.fields) { - const bool digits = !k.empty() && std::all_of(k.begin(), k.end(), [](char ch) { return ch >= '0' && ch <= '9'; }); - if (digits) { - out[py::int_(std::stoll(k))] = trace_obj(x); - } else { - out[py::str(k)] = trace_obj(x); - } - } - return out; - } - } - return py::none(); -} - -// The epoch state of a policy (roster, requested, +inf, per roster source the flows). -py::dict state_obj(const FlowPolicy& policy) { - const auto& pe = detail::PolicyAccess::engine(policy); - py::dict out; - const auto& r = pe.config.routing; - out["mode"] = std::holds_alternative(r) ? "CostOnly" - : std::holds_alternative(r) ? "CapacityAware" - : std::holds_alternative(r) ? "Global" - : "Pinned"; - out["roster"] = pe.roster; - out["requested"] = counter_int(pe.requested); - out["infinite"] = pe.infinite; - out["placed_demand"] = policy.placed_demand(); - py::dict cohorts; - for (std::size_t i = 0; i < pe.roster.size(); ++i) { - py::list flows; - for (const auto& s : pe.cohorts[i]) { - const FlowRecord& rec = detail::LedgerAccess::live(pe.fg, s.id); - py::dict f; - f["flow_id"] = py::make_tuple(rec.flow_id.first, rec.flow_id.second); - f["src"] = s.src; - if (s.plan) { - f["route"] = s.plan->route; - } else { - const auto edges = detail::PredDAGBuilder::ascending(*s.bundle); - f["route"] = std::vector(edges.begin(), edges.end()); - } - f["members"] = s.plan ? py::cast(s.plan->members) : py::object(py::none()); - f["weights"] = s.plan && s.plan->placement == FlowPlacement::Weighted ? py::cast(s.plan->weights) - : py::object(py::none()); - f["T"] = rec.delivered; - f["delivered"] = rec.delivered; - f["alloc"] = pairs_list(rec.edges); - f["bundle_index"] = s.bundle_index < 0 ? py::object(py::none()) : py::object(py::int_(s.bundle_index)); - flows.append(f); - } - cohorts[py::int_(pe.roster[i])] = flows; - } - out["cohorts"] = cohorts; - return out; -} - -// The allocation-failure hook: the k-th object-domain allocation fails (Python's allocator -// wrapped for one call, then restored). The allocator is process-wide while the call runs, so -// the hook serves one caller at a time (the tests); its counters are atomics because the module -// does not hold the GIL for every thread (mod_gil_not_used). -PyMemAllocatorEx g_saved_allocator {}; -std::atomic g_fail_countdown {-1}; -std::atomic g_fail_seen {0}; -std::atomic g_fail_hit {false}; - -bool fail_now() { - g_fail_seen.fetch_add(1); - if (g_fail_countdown.load() < 0) return false; - if (g_fail_countdown.fetch_sub(1) == 0) { - g_fail_hit.store(true); - return true; - } - return false; -} -void* failing_malloc(void*, std::size_t n) { return fail_now() ? nullptr : g_saved_allocator.malloc(g_saved_allocator.ctx, n); } -void* failing_calloc(void*, std::size_t n, std::size_t size) { - return fail_now() ? nullptr : g_saved_allocator.calloc(g_saved_allocator.ctx, n, size); -} -void* failing_realloc(void*, void* ptr, std::size_t n) { - return fail_now() ? nullptr : g_saved_allocator.realloc(g_saved_allocator.ctx, ptr, n); -} -void forwarding_free(void*, void* ptr) { g_saved_allocator.free(g_saved_allocator.ctx, ptr); } - -#endif - -} // namespace - PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { m.doc() = "NetGraph-Core: the graph on integer units, the route structures, the integer ledger, the policy\n" - "engine FlowPolicy, the DagCache and the evaluators (capacity, batch_capacity, sensitivity).\n" - "Every public call is all-or-nothing up to its caller (APPROACH 13), with two limits: (1) the pybind11\n" - "object machinery is outside the guarantee: constructing a bound object from Python, or reading an\n" - "attribute or method value converted into a new bound object or container outside the staged calls,\n" - "allocates through pybind11's make_new_instance, which does not check the result of tp_alloc, so a\n" - "Python allocation failure at that point crashes the process, with Core state unchanged; (2) a supplied\n" - "building DagCache is a memo: it keeps the complete entries and statistics of the lookups that\n" - "succeeded before a later failure of the call."; - - py::enum_(m, "FlowPlacement", - "The kernel of one flow: MAX_FLOW (the minimum-cost flow core on the flow's split graph,\n" - "a replacement on V = W + own), WEIGHTED (hop-by-hop WCMP: highest averages over frozen\n" - "weights at every node), EQUAL_BALANCED (ECMP: round robin at every node).") - .value("MAX_FLOW", FlowPlacement::MaxFlow) - .value("WEIGHTED", FlowPlacement::Weighted) - .value("EQUAL_BALANCED", FlowPlacement::EqualBalanced); - - py::enum_(m, "Admission", - "LOSSLESS admits only what fits; LOSSY forwards hop by hop without admission control and\n" - "drops what does not fit on each edge (EQUAL_BALANCED and WEIGHTED only).") - .value("LOSSLESS", Admission::Lossless) - .value("LOSSY", Admission::Lossy); - - py::enum_(m, "EdgeTieBreak", - "Ties between equal-cost candidates (parallel members and equal-cost walks):\n" - "DETERMINISTIC (lowest EdgeId; capacities ignored) or PREFER_HIGHER_PRIORITY (the higher\n" - "priority value first, then the lowest EdgeId).") - .value("DETERMINISTIC", EdgeTieBreak::Deterministic) - .value("PREFER_HIGHER_PRIORITY", EdgeTieBreak::PreferHigherPriority); - - py::enum_(m, "EdgePriority", - "The per-edge value PREFER_HIGHER_PRIORITY reads, and the Weighted kernel's membership and\n" - "weights: RESIDUAL (residual units), CAPACITY (static capacity units), HEADROOM (the exact\n" - "rational residual / capacity; rejected with WEIGHTED).") - .value("RESIDUAL", EdgePriority::Residual) - .value("CAPACITY", EdgePriority::Capacity) - .value("HEADROOM", EdgePriority::Headroom); - - py::enum_(m, "Direction", - "The orientation of a search: FORWARD from the roots, or REVERSE (rooted at the roots over\n" - "reversed arcs; the DAG it returns is forward-oriented, toward the roots).") - .value("FORWARD", Direction::Forward) - .value("REVERSE", Direction::Reverse); - - py::class_(m, "PathSelection", - "The selection of a search: tie_break and priority are explicit; multipath = True keeps\n" - "every equal-cost walk (the DAG), False the one canonical single path; multi_edge = True\n" - "keeps every eligible parallel member, False one per (u, v) pair. A tie_break or priority\n" - "outside its enumeration raises ValueError.") - // The constructor checks both enumerations (ValueError naming the field). - .def(py::init(), py::arg("tie_break"), py::arg("priority"), - py::arg("multipath") = true, py::arg("multi_edge") = true) - .def_readonly("tie_break", &PathSelection::tie_break) - .def_readonly("priority", &PathSelection::priority) - .def_readonly("multipath", &PathSelection::multipath) - .def_readonly("multi_edge", &PathSelection::multi_edge) - .def("__eq__", [](const PathSelection& a, const py::object& b) { - return py::isinstance(b) && a == b.cast(); - }) - .def("__hash__", [](const PathSelection& a) { - return py::hash(py::make_tuple(static_cast(a.tie_break), static_cast(a.priority), a.multipath, - a.multi_edge)); - }) - .def("__repr__", [](const PathSelection& a) { - return std::string("PathSelection(tie_break=") + - (a.tie_break == EdgeTieBreak::Deterministic ? "DETERMINISTIC" : "PREFER_HIGHER_PRIORITY") + - ", priority=" + - (a.priority == EdgePriority::Residual ? "RESIDUAL" - : (a.priority == EdgePriority::Capacity ? "CAPACITY" : "HEADROOM")) + - ", multipath=" + (a.multipath ? "True" : "False") + ", multi_edge=" + (a.multi_edge ? "True" : "False") + - ")"; - }); - - m.def( - "to_units", - [](const py::object& x, double resolution) -> py::object { - (void)resolution_exponent(resolution); // before the shape: an empty array is no exemption - auto arr = physical_array(x); - if (arr.ndim() == 0) return py::int_(to_units(*arr.data(), resolution)); - if (arr.ndim() != 1) throw py::value_error("to_units takes a float or a 1-D array of floats"); - py::array_t out(arr.size()); - auto in = arr.unchecked<1>(); - auto o = out.mutable_unchecked<1>(); - for (py::ssize_t i = 0; i < in.shape(0); ++i) o(i) = to_units(in(i), resolution); - return std::move(out); - }, - py::arg("x"), py::arg("resolution"), - "Physical value(s) to units of the resolution q, rounding half up: an int for a scalar, an\n" - "int64 array for an array. NaN, infinity, negative values and results at or above 2^62\n" - "units raise ValueError; q must be a power of two in [2^-32, 2^32]."); - - m.def( - "as_float", - [](const py::object& x, double resolution) -> py::object { - const int k = resolution_exponent(resolution); // before the shape (review-1a D3) - const bool array = py::isinstance(x) && py::reinterpret_borrow(x).ndim() > 0; - if (array || !not_a_sequence(x)) { - const auto v = wide_integers(x, "as_float: x"); - py::array_t out(static_cast(v.size())); - auto o = out.mutable_unchecked<1>(); - for (std::size_t i = 0; i < v.size(); ++i) { - o(static_cast(i)) = v.sign(i) == 0 ? as_float(v.value[i], resolution) - : amount_as_float(v.source[py::int_(i)], k, resolution); - } - return std::move(out); - } - return py::float_(amount_as_float(x, k, resolution)); - }, - py::arg("x"), py::arg("resolution"), - "Units to physical value(s): x * q, correctly rounded (exact below 2^53 units). An int, of\n" - "any size, gives a float, an integer array a float64 array; a result beyond the finite\n" - "doubles raises ValueError; q must be a power of two in [2^-32, 2^32]."); - - py::class_>( - m, "Eligibility", - "Immutable, graph-bound static eligibility: dense node and edge flags. Built by\n" - "Graph.eligibility(excluded_nodes, excluded_edges).") - .def("graph_uid", &Eligibility::graph_uid, "uid of the graph this value belongs to") - .def("uid", &Eligibility::uid, "0 for the all-allowed value, a fresh uid otherwise") - .def( - "node_allowed", - [](const py::object& self) { - const auto& el = self.cast(); - return readonly_view(el.node_allowed(), self, py::dtype("bool")); - }, - "bool[N] read-only view") - .def( - "edge_allowed", - [](const py::object& self) { - const auto& el = self.cast(); - return readonly_view(el.edge_allowed(), self, py::dtype("bool")); - }, - "bool[E] read-only view (not excluded and both endpoints allowed)") - .def_static( - "intersect", - [](const std::shared_ptr& a, const std::shared_ptr& b) -> std::shared_ptr { - if (!a || !b) throw py::value_error("Eligibility.intersect takes two Eligibility values"); - auto r = Eligibility::intersect(*a, *b); - if (r.uid() == a->uid()) return a; - if (r.uid() == b->uid()) return b; - return std::make_shared(std::move(r)); - }, - py::arg("a"), py::arg("b"), - "The intersection of two values of one graph (ValueError for values of different graphs):\n" - "an operand itself when the result equals it (a first, then b), otherwise a new value with a\n" - "fresh uid; equal intersections built separately get distinct uids."); - - py::class_>( - m, "Graph", - "Immutable directed multigraph on integer units. Edges are given in input order; the\n" - "public EdgeId is the rank in the stable order (src, cost, dst, input), input_index()[e]\n" - "is the input position of e, and every other per-edge array is in id order. Costs are\n" - "positive integers; capacities are int64 units of the explicit resolution q (a power of\n" - "two in [2^-32, 2^32]); physical capacities enter through to_units.") - .def(py::init(&make_graph), py::arg("num_nodes"), py::arg("src"), py::arg("dst"), - py::arg("capacity_units"), py::arg("cost"), py::arg("resolution"), - "Validates, in order, and reports the first defect as ValueError: the resolution (a power of\n" - "two in [2^-32, 2^32]), the unit values (every capacity in [0, 2^62)), the array shapes,\n" - "zero or negative costs (rule 28), the remaining bounds (endpoints in range, then the\n" - "capacity and cost sums below 2^62). Integers are read without narrowing, so the error\n" - "does not depend on how the input is represented (APPROACH 3).") - .def("uid", &Graph::uid, "process-unique id (never reused)") - .def("resolution", &Graph::resolution, "the resolution q (units of capacity)") - .def("num_nodes", &Graph::num_nodes) - .def("num_edges", &Graph::num_edges) - .def("max_cost", &Graph::max_cost, "largest edge cost (0 without edges)") - .def("cost_gcd", &Graph::cost_gcd, "gcd of the edge costs (0 without edges)") - .def( - "capacity_units", [](const py::object& self) { - const auto& g = self.cast(); - return readonly_view(g.capacity_units(), self); - }, - "int64[E] capacities in units, id order (read-only view)") - .def( - "cost", [](const py::object& self) { - const auto& g = self.cast(); - return readonly_view(g.cost(), self); - }, - "int64[E] costs, id order (read-only view)") - .def( - "edge_src", [](const py::object& self) { - const auto& g = self.cast(); - return readonly_view(g.edge_src(), self); - }, - "int32[E] source node per edge (read-only view)") - .def( - "edge_dst", [](const py::object& self) { - const auto& g = self.cast(); - return readonly_view(g.edge_dst(), self); - }, - "int32[E] destination node per edge (read-only view)") - .def( - "input_index", [](const py::object& self) { - const auto& g = self.cast(); - return readonly_view(g.input_index(), self); - }, - "int32[E]: id -> input position (read-only view); the inverse is the caller's") - .def( - "row_offsets", [](const py::object& self) { - const auto& g = self.cast(); - return readonly_view(g.row_offsets(), self); - }, - "int32[N + 1]: the out-edges of u are the ids [row_offsets[u], row_offsets[u + 1])") - .def( - "in_row_offsets", [](const py::object& self) { - const auto& g = self.cast(); - return readonly_view(g.in_row_offsets(), self); - }, - "int32[N + 1] reverse CSR offsets") - .def( - "in_adj_edge_index", [](const py::object& self) { - const auto& g = self.cast(); - return readonly_view(g.in_adj_edge_index(), self); - }, - "int32[E]: the in-edges of v in (cost, src, input) order, as EdgeIds") - .def( - "eligibility", - [](const Graph& g, const py::object& excluded_nodes, const py::object& excluded_edges) { - // Nodes, then edges, each range-checked before narrowing (the constructor's order). - const auto xn = wide_integers(excluded_nodes, "excluded_nodes"); - for (std::size_t i = 0; i < xn.size(); ++i) { - if (!xn.in(i, 0, static_cast(g.num_nodes()) - 1)) { - throw py::value_error("excluded node " + xn.text(i) + " out of node range [0, " + - std::to_string(g.num_nodes()) + ")"); - } - } - const auto xe = wide_integers(excluded_edges, "excluded_edges"); - for (std::size_t i = 0; i < xe.size(); ++i) { - if (!xe.in(i, 0, static_cast(g.num_edges()) - 1)) { - throw py::value_error("excluded edge " + xe.text(i) + " out of edge range [0, " + - std::to_string(g.num_edges()) + ")"); - } - } - return std::make_shared(g.eligibility(xn.narrow(), xe.narrow())); - }, - py::arg("excluded_nodes") = py::tuple(), py::arg("excluded_edges") = py::tuple(), - "Static eligibility from excluded node ids and EdgeIds (duplicates and unsorted lists\n" - "accepted, out-of-range ids rejected); an edge is allowed when it is not excluded and\n" - "both endpoints are allowed. Empty lists mean all allowed (uid 0)."); - - m.def( - "_place_global_units", - [](const std::shared_ptr& graph, const py::object& view, const py::object& sources, - const py::object& destinations, const std::shared_ptr& elig, - const py::object& cap) -> py::dict { - if (!graph) throw py::value_error("graph must be a Graph"); - // The core's order (view length, view entries, sources, destinations, overlap, cap, - // Eligibility), every value checked before narrowing; the core re-checks the same order. - const auto num_edges = static_cast(graph->num_edges()); - const std::size_t view_len = sequence_length(view, "view"); - if (view_len != num_edges) { - throw py::value_error("view has length " + std::to_string(view_len) + ", expected " + - std::to_string(num_edges) + " (one entry per edge)"); - } - const auto v = wide_integers(view, "view (capacity units)"); - const auto capacity = graph->capacity_units(); - for (std::size_t e = 0; e < num_edges; ++e) { - if (!v.in(e, 0, capacity[e])) { - throw py::value_error("view[" + std::to_string(e) + "] = " + v.text(e) + " is outside [0, capacity_units[" + - std::to_string(e) + "] = " + std::to_string(capacity[e]) + "]"); - } - } - const auto s = checked_ids(wide_integers(sources, "sources node ids"), graph->num_nodes(), "sources"); - const auto d = checked_ids(wide_integers(destinations, "destinations node ids"), graph->num_nodes(), "destinations"); - detail::check_disjoint(detail::node_set(s, graph->num_nodes(), "sources"), - detail::node_set(d, graph->num_nodes(), "destinations"), "sources", "destinations"); - std::optional c; - if (!cap.is_none()) { - if (is_bool(cap) || !PyIndex_Check(cap.ptr())) { - throw py::value_error("cap must be an integer number of units in [0, 2^63 - 2] or None"); - } - py::object idx = python_int(cap, "cap"); - int overflow = 0; - const long long value = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); - if (overflow != 0 || value < 0 || value > detail::kMaxFlowCap) { - throw py::value_error("cap " + std::string(py::str(idx)) + " is outside [0, 2^63 - 2]"); - } - c = static_cast(value); - } - detail::FlowCoreResult r; - { - py::gil_scoped_release release; - const auto view_units = v.narrow(); - r = detail::place_global_units(*graph, view_units, s, d, elig.get(), c); - } - py::dict out; - out["placed"] = py::int_(r.placed); - py::list curve; - for (const auto& [cost, units] : r.cost_distribution) { - py::list entry; - entry.append(py::int_(cost)); - entry.append(py::int_(units)); - curve.append(entry); - } - out["cost_distribution"] = curve; - out["edge_flows"] = int_list(r.edge_flows); - if (r.min_cut.has_value()) { - py::list cut; - for (EdgeId e : *r.min_cut) cut.append(py::int_(e)); - out["min_cut"] = cut; - } else { - out["min_cut"] = py::none(); - } - out["potentials"] = int_list(r.potentials); - py::dict counters; - counters["dijkstra_runs"] = py::int_(r.counters.dijkstra_runs); - counters["dijkstra_pops"] = py::int_(r.counters.dijkstra_pops); - counters["settled"] = py::int_(r.counters.settled); - counters["arc_scans"] = py::int_(r.counters.arc_scans); - counters["dinic_phases"] = py::int_(r.counters.dinic_phases); - counters["augmentations"] = py::int_(r.counters.augmentations); - out["counters"] = counters; - return out; - }, - py::arg("graph"), py::arg("view"), py::arg("sources"), py::arg("destinations"), py::arg("elig"), - py::arg("cap"), - "Private recorder entry (APPROACH 2.2): the minimum-cost flow of the maximum value under\n" - "cap (None: uncapped) from the source set to the destination set over the eligible edges,\n" - "on the capacities `view` (int64 units, id order), from zero flow. Returns {placed,\n" - "cost_distribution: [[cost, units]], edge_flows, min_cut (None while the destinations stay\n" - "connected, and on cap 0), potentials (-1 = never reached), counters}. Invalid inputs raise\n" - "ValueError before any work."); - - // ------------------------------------------------------------------ route structures - - py::class_>( - m, "PredDAG", - "Immutable, graph-bound predecessor DAG: the entries of node v are parents()[k], via_edges()[k]\n" - "for k in [parent_offsets()[v], parent_offsets()[v + 1]), within a node in strictly ascending\n" - "via EdgeId (one canonical form). Equality is structural. parents() and via_edges() are\n" - "read-only views that keep the DAG alive; parent_offsets() is a fresh read-only array built on\n" - "demand (the DAG is stored over its member nodes). Built by PredDAG.from_csr, Path.to_dag()\n" - "and the searches.") - .def_static( - "from_csr", - [](const std::shared_ptr& graph, const py::object& offsets, const py::object& parents, - const py::object& via) { - if (!graph) throw py::value_error("graph must be a Graph"); - // APPROACH 5's order on unnarrowed values; the C++ constructor re-checks it and adds the - // structural checks (endpoints, duplicates, acyclic, ascending). - const auto n = static_cast(graph->num_nodes()); - const auto m = static_cast(graph->num_edges()); - const std::size_t lo = sequence_length(offsets, "offsets"); - const std::size_t lp = sequence_length(parents, "parents"); - const std::size_t lv = sequence_length(via, "via"); - if (lo != n + 1) { - throw py::value_error("PredDAG.from_csr: offsets has length " + std::to_string(lo) + - ", expected N + 1 = " + std::to_string(n + 1)); - } - const auto o = wide_integers(offsets, "offsets"); - if (!o.in(0, 0, 0)) throw py::value_error("PredDAG.from_csr: offsets[0] = " + o.text(0) + ", expected 0"); - for (std::size_t v = 0; v < n; ++v) { - if (wide_less(o, v + 1, v)) { - throw py::value_error("PredDAG.from_csr: offsets are not monotone at " + std::to_string(v + 1) + " (" + - o.text(v) + " > " + o.text(v + 1) + ")"); - } - } - if (!o.in(n, static_cast(lp), static_cast(lp)) || lp != lv) { - throw py::value_error("PredDAG.from_csr: offsets[N] = " + o.text(n) + " but parents has " + - std::to_string(lp) + " and via has " + std::to_string(lv) + " entries"); - } - const auto p = wide_integers(parents, "parents"); - const auto w = wide_integers(via, "via"); - for (std::size_t k = 0; k < lp; ++k) { - if (!p.in(k, 0, static_cast(n) - 1)) { - throw py::value_error("PredDAG.from_csr: parents[" + std::to_string(k) + "] = " + p.text(k) + - " out of node range [0, " + std::to_string(n) + ")"); - } - if (!w.in(k, 0, m - 1)) { - throw py::value_error("PredDAG.from_csr: via[" + std::to_string(k) + "] = " + w.text(k) + - " out of edge range [0, " + std::to_string(m) + ")"); - } - } - const auto ov = o.narrow(); - const auto pv = p.narrow(); - const auto wv = w.narrow(); - return std::make_shared(PredDAG::from_csr(*graph, ov, pv, wv)); - }, - py::arg("graph"), py::arg("offsets"), py::arg("parents"), py::arg("via"), - "Validates, in order, before any traversal (ValueError on the first defect): offsets of\n" - "length N + 1, offsets[0] == 0, monotone, offsets[N] == len(parents) == len(via); then every\n" - "parent and via in range, every via joining (parent, child), no duplicate (child, via),\n" - "acyclic; within a node the entries in ascending via EdgeId.") - .def("graph_uid", &PredDAG::graph_uid, "uid of the graph this DAG belongs to") - .def( - "parent_offsets", - [](const PredDAG& d) { - const auto off = d.parent_offsets(); - py::array_t arr(static_cast(off.size())); - std::copy(off.begin(), off.end(), arr.mutable_data()); - py::detail::array_proxy(arr.ptr())->flags &= ~py::detail::npy_api::NPY_ARRAY_WRITEABLE_; - return arr; - }, - "int32[N + 1] CSR offsets, presented on demand from the sparse storage (a read-only array)") - .def( - "parents", [](const py::object& self) { return readonly_view(self.cast().parents(), self); }, - "int32 parent NodeId per entry (read-only view)") - .def( - "via_edges", - [](const py::object& self) { return readonly_view(self.cast().via_edges(), self); }, - "int32 via EdgeId per entry (read-only view)") - .def("__eq__", [](const PredDAG& a, const py::object& b) { - return py::isinstance(b) && a == b.cast(); - }) - .def("__hash__", [](const PredDAG& a) { - return py::hash(py::make_tuple(a.graph_uid(), py::tuple(py::cast(std::vector(a.via_edges().begin(), - a.via_edges().end()))))); - }) - .def("__repr__", [](const PredDAG& a) { - return "PredDAG(graph_uid=" + std::to_string(a.graph_uid()) + ", entries=" + - std::to_string(a.via_edges().size()) + ")"; - }); - - py::class_>( - m, "Path", - "Immutable, graph-bound explicit walk of hops; a hop lists one or more parallel members of one\n" - "(u, v) pair in the caller's order. cost = sum over hops of the minimum member cost.") - .def(py::init([](const std::shared_ptr& graph, const py::object& hops) { - if (!graph) throw py::value_error("graph must be a Graph"); - if (not_a_sequence(hops)) throw py::value_error("Path: hops must be a sequence of hops (sequences of EdgeIds)"); - auto seq = py::reinterpret_borrow(hops); - std::vector raw; - for (std::size_t h = 0; h < seq.size(); ++h) { - py::object hop = seq[h]; - raw.push_back(wide_integers(hop, ("hops[" + std::to_string(h) + "]").c_str())); - } - // Every member in range before anything else (APPROACH 5), then C++'s order. - const auto m = static_cast(graph->num_edges()); - for (std::size_t h = 0; h < raw.size(); ++h) { - for (std::size_t i = 0; i < raw[h].size(); ++i) { - if (!raw[h].in(i, 0, m - 1)) { - throw py::value_error("Path: hop " + std::to_string(h) + " member " + raw[h].text(i) + - " out of edge range [0, " + std::to_string(m) + ")"); - } - } - } - std::vector> narrowed; - narrowed.reserve(raw.size()); - for (const auto& r : raw) narrowed.push_back(r.narrow()); - return std::make_shared(*graph, std::move(narrowed)); - }), - py::arg("graph"), py::arg("hops"), - "Validates, in order (ValueError on the first defect): every member id in range, no duplicate\n" - "member, a non-empty hop list, every hop non-empty, the members of a hop sharing (u, v),\n" - "contiguous hops, no repeated node.") - .def("graph_uid", &Path::graph_uid, "uid of the graph this path belongs to") - .def("hops", [](const Path& p) { return p.hops(); }, "the hops as lists of EdgeIds (the caller's member order)") - .def("src", &Path::src) - .def("dst", &Path::dst) - .def("cost", &Path::cost, "sum over hops of the minimum member cost") - .def( - "to_dag", [](const Path& p) { return std::make_shared(p.to_dag()); }, - "the equivalent PredDAG (every member of every hop)") - .def("__eq__", [](const Path& a, const py::object& b) { - return py::isinstance(b) && a == b.cast(); - }) - .def("__hash__", - [](const Path& a) { - py::list hops; - for (const auto& hop : a.hops()) hops.append(py::tuple(py::cast(hop))); - return py::hash(py::make_tuple(a.graph_uid(), py::tuple(hops))); - }) - .def("__repr__", [](const Path& a) { - return "Path(src=" + std::to_string(a.src()) + ", dst=" + std::to_string(a.dst()) + - ", hops=" + std::to_string(a.hops().size()) + ", cost=" + std::to_string(a.cost()) + ")"; - }); - - // ------------------------------------------------------------------ the ledger - - py::class_( - m, "FlowRecord", - "One flow of a ledger, as a snapshot: flow_id (ledger uid, id), owner (the policy's id),\n" - "sources, destinations, placement, admission, bundle (PredDAG: the flow's route),\n" - "route_cost (its minimum walk cost; None when empty), offered (Lossy only, else None),\n" - "delivered, edges [(EdgeId, units)] and drops [(EdgeId, units)] (cumulative, ascending\n" - "EdgeId). Counters are exact Python ints (they may exceed 64 bits).") - .def_property_readonly("flow_id", [](const FlowRecord& r) { return py::make_tuple(r.flow_id.first, r.flow_id.second); }) - .def_readonly("owner", &FlowRecord::owner) - .def_readonly("sources", &FlowRecord::sources) - .def_readonly("destinations", &FlowRecord::destinations) - .def_readonly("placement", &FlowRecord::placement) - .def_readonly("admission", &FlowRecord::admission) - .def_property_readonly("bundle", [](const FlowRecord& r) { return std::const_pointer_cast(r.bundle); }) - .def_property_readonly("route_cost", [](const FlowRecord& r) { return cost_or_none(r.route_cost); }) - .def_property_readonly("offered", [](const FlowRecord& r) -> py::object { - return r.offered.has_value() ? counter_int(*r.offered) : py::object(py::none()); - }) - .def_readonly("delivered", &FlowRecord::delivered) - .def_property_readonly("edges", [](const FlowRecord& r) { return pairs_list(r.edges); }) - .def_property_readonly("drops", [](const FlowRecord& r) { - py::list out; - for (const auto& [e, x] : r.drops) out.append(py::make_tuple(e, counter_int(x))); - return out; - }) - .def("__repr__", [](const FlowRecord& r) { - return "FlowRecord(flow_id=(" + std::to_string(r.flow_id.first) + ", " + std::to_string(r.flow_id.second) + - "), owner=" + std::to_string(r.owner) + ", delivered=" + std::to_string(r.delivered) + ")"; - }); - - auto ledger_class = py::class_>( - m, "FlowLedger", - "The integer ledger of one graph: an immutable baseline residual in units and the records of\n" - "the flows placed on it by policies (the ledger has no public placement or removal).\n" - "allocated() is the sum of every record's edges and residual() = baseline - allocated(), both\n" - "int64[E] snapshots. reset() and copy() are rejected while a policy is bound.") - .def(py::init([](const std::shared_ptr& graph, const py::object& residual) { - if (!graph) throw py::value_error("graph must be a Graph"); - if (residual.is_none()) return std::make_shared(graph); - const auto r = edge_units(residual, *graph, "residual"); - return std::make_shared(graph, r); - }), - py::arg("graph"), py::arg("residual") = py::none(), - "residual: the baseline as integer units, one entry per edge in [0, capacity_units[e]] (a\n" - "physical baseline goes through to_units first); None: the capacities. ValueError otherwise.") - .def("uid", &FlowLedger::uid, "process-unique ledger uid (the first half of every FlowId)") - .def( - "graph", [](const FlowLedger& fg) { return std::const_pointer_cast(fg.graph()); }, - "the ledger's graph") - .def( - "capacity_units", - [](const py::object& self) { return readonly_view(self.cast().capacity_units(), self); }, - "int64[E] the graph's capacities in units (read-only view)") - .def( - "residual", [](const FlowLedger& fg) { return int64_array(fg.residual()); }, - "int64[E] snapshot: baseline - allocated") - .def( - "allocated", [](const FlowLedger& fg) { return int64_array(fg.allocated()); }, - "int64[E] snapshot: the sum of every record's edges") - .def("records", &FlowLedger::records, "live records (bound and orphaned) as snapshots in FlowId order") - .def( - "record", [](const FlowLedger& fg, const py::object& flow_id) { return fg.record(flow_id_of(flow_id)); }, - py::arg("flow_id"), - "one record by FlowId (ValueError for a FlowId of another ledger, a retired or an unknown id)") - .def( - "bottleneck", - [](const FlowLedger& fg, const py::object& sources, const std::shared_ptr& elig) { - const auto s = node_ids(sources, fg.graph()->num_nodes(), "sources"); - return fg.bottleneck(s, elig.get()); - }, - py::arg("sources"), py::arg("elig") = nullptr, - "Saturation diagnostic: the edges (statically eligible, capacity >= 1 unit) leaving the set\n" - "reachable from the allowed sources over eligible forward arcs with residual >= 1 unit, in\n" - "ascending EdgeId (not a cut).") - .def( - "reset", - [](FlowLedger& fg, const py::object& residual) { - if (residual.is_none()) { - fg.reset(); - return; - } - const auto r = edge_units(residual, *fg.graph(), "residual"); - fg.reset(r); - }, - py::arg("residual") = py::none(), - "A new baseline (None: the capacities); orphaned records and their allocations are cleared\n" - "and their ids retired (ids are never reused). ValueError while a policy is bound.") - .def( - "copy", [](const FlowLedger& fg) { return std::make_shared(fg); }, - "a new ledger (new uid) with the same baseline and records, ids kept under the new uid;\n" - "ValueError while a policy is bound") - .def("__copy__", [](const FlowLedger& fg) { return std::make_shared(fg); }) - .def("__deepcopy__", [](const FlowLedger& fg, const py::object&) { return std::make_shared(fg); }, - py::arg("memo")); + "engine FlowPolicy, the DagCache, the evaluators (capacity, batch_capacity, sensitivity) and the\n" + "searches (spf, ksp, resolve_to_paths).\n" + "Every public call either completes or raises and leaves Core state as it was, with two limits:\n" + "(1) pybind11 does not check every allocation when it constructs a bound object from Python, or\n" + "converts an attribute or a returned value into a new Python object outside the library's checked\n" + "result construction; running out of memory exactly there may crash the process, with Core state\n" + "unchanged. (2) A DagCache passed to the call: a building one keeps the complete entries and\n" + "statistics of the lookups that succeeded before the call failed; a frozen one counts one miss\n" + "when the call raised DagCacheMiss."; + + // In this order: a later binding's defaults and conversions need the earlier types. + bindings::register_axes(m); + bindings::register_units(m); + bindings::register_eligibility(m); + bindings::register_graph(m); + bindings::register_recorder(m); + bindings::register_pred_dag(m); + bindings::register_path(m); + bindings::register_flow_record(m); + bindings::register_flow_ledger(m); + bindings::register_routing(m); + bindings::register_policy_config(m); + bindings::register_placement_result(m); + bindings::register_dag_cache(m); + bindings::register_flow_policy(m); + bindings::register_evaluators(m); + bindings::register_paths(m); + bindings::register_search(m); #if NETGRAPH_CORE_PRIVATE_HOOKS - ledger_class - .def( - "_state", - [](const FlowLedger& fg) { - py::dict out; - out["next_flow_id"] = detail::LedgerAccess::next_flow_id(fg); - out["bound"] = detail::LedgerAccess::bound_owners(fg); - out["orphans"] = detail::LedgerAccess::orphan_owners(fg); - return out; - }, - "private: {next_flow_id, bound, orphans} (owner ids)"); -#else - (void)ledger_class; -#endif - - // ------------------------------------------------------------------ the policy engine - - py::class_(m, "Absolute", "CostCeiling: an absolute, inclusive path-cost budget (cost >= 0).") - .def(py::init([](const py::object& cost) { - return Absolute {*int_field(cost, std::numeric_limits::min(), std::numeric_limits::max(), - "Absolute cost", false)}; - }), - py::arg("cost")) - .def_readonly("cost", &Absolute::cost) - .def("__eq__", [](const Absolute& a, const py::object& b) { return py::isinstance(b) && a == b.cast(); }) - .def("__hash__", [](const Absolute& a) { return py::hash(py::make_tuple("Absolute", a.cost)); }) - .def("__repr__", [](const Absolute& a) { return "Absolute(" + std::to_string(a.cost) + ")"; }); - - py::class_(m, "Stretch", - "CostCeiling: a factor in [1, inf) of the static shortest cost under the policy's\n" - "Eligibility, read as the exact dyadic rational it is (compared without rounding).") - .def(py::init([](const py::object& factor) { return Stretch {float_field(factor, "Stretch factor")}; }), - py::arg("factor")) - .def_readonly("factor", &Stretch::factor) - .def("__eq__", [](const Stretch& a, const py::object& b) { return py::isinstance(b) && a == b.cast(); }) - .def("__hash__", [](const Stretch& a) { return py::hash(py::make_tuple("Stretch", a.factor)); }) - .def("__repr__", [](const Stretch& a) { return "Stretch(" + std::string(py::str(py::float_(a.factor))) + ")"; }); - - py::class_(m, "DiminishingReturns", - "The diminishing-returns guard of CapacityAware growth: stop once at least `window`\n" - "growth steps were accepted in the call and the last `window` gains sum below\n" - "`fraction` times the source's (finite) offer. window >= 1, fraction in (0, 1).") - .def(py::init([](const py::object& window, const py::object& fraction) { - DiminishingReturns d; - d.window = static_cast(*int_field(window, std::numeric_limits::min(), - std::numeric_limits::max(), - "diminishing window", false)); - d.fraction = float_field(fraction, "diminishing fraction"); - return d; - }), - py::arg("window"), py::arg("fraction")) - .def_readonly("window", &DiminishingReturns::window) - .def_readonly("fraction", &DiminishingReturns::fraction) - .def("__repr__", [](const DiminishingReturns& d) { - return "DiminishingReturns(window=" + std::to_string(d.window) + ", fraction=" + std::string(py::str(py::float_(d.fraction))) + ")"; - }); - - py::class_(m, "CostOnly", - "Routing: IGP forwarding. One flow per roster source on the static shortest-path DAG\n" - "(the DagCache value; selection projected at creation), never refreshed, grown or\n" - "reoptimised.") - .def(py::init<>()) - .def("__repr__", [](const CostOnly&) { return std::string("CostOnly()"); }); - - py::class_( - m, "CapacityAware", - "Routing: distributed CSPF tunnels. flow_count flows created by the canonical search on the\n" - "selection view (residual >= max(1, min_flow_bandwidth) units, the max_path_cost ceiling),\n" - "grown up to max_flow_count (None: no ceiling), optionally reoptimised (reroute with retention\n" - "fallback) and guarded by `diminishing` (None: off).") - .def(py::init([](const py::object& flow_count, const py::object& max_flow_count, const py::object& max_path_cost, - const py::object& min_flow_bandwidth, bool reoptimize, const py::object& diminishing) { - CapacityAware ca; - constexpr auto lo32 = std::numeric_limits::min(); - constexpr auto hi32 = std::numeric_limits::max(); - ca.flow_count = static_cast(*int_field(flow_count, lo32, hi32, "flow_count", false)); - if (auto mx = int_field(max_flow_count, lo32, hi32, "max_flow_count", true)) { - ca.max_flow_count = static_cast(*mx); - } - if (!max_path_cost.is_none()) { - if (py::isinstance(max_path_cost)) { - ca.max_path_cost = max_path_cost.cast(); - } else if (py::isinstance(max_path_cost)) { - ca.max_path_cost = max_path_cost.cast(); - } else { - throw py::type_error("max_path_cost must be Absolute, Stretch or None"); - } - } - ca.min_flow_bandwidth = *int_field(min_flow_bandwidth, std::numeric_limits::min(), - std::numeric_limits::max(), "min_flow_bandwidth (units)", false); - ca.reoptimize = reoptimize; - if (!diminishing.is_none()) { - if (!py::isinstance(diminishing)) throw py::type_error("diminishing must be DiminishingReturns or None"); - ca.diminishing = diminishing.cast(); - } - return ca; - }), - py::kw_only(), py::arg("flow_count") = 1, py::arg("max_flow_count") = py::none(), - py::arg("max_path_cost") = py::none(), py::arg("min_flow_bandwidth") = 0, py::arg("reoptimize") = false, - py::arg("diminishing") = py::none()) - .def_readonly("flow_count", &CapacityAware::flow_count) - .def_readonly("max_flow_count", &CapacityAware::max_flow_count) - .def_property_readonly("max_path_cost", - [](const CapacityAware& ca) -> py::object { - if (!ca.max_path_cost.has_value()) return py::none(); - return std::visit([](const auto& x) { return py::cast(x); }, *ca.max_path_cost); - }) - .def_readonly("min_flow_bandwidth", &CapacityAware::min_flow_bandwidth) - .def_readonly("reoptimize", &CapacityAware::reoptimize) - .def_readonly("diminishing", &CapacityAware::diminishing) - .def("__repr__", [](const CapacityAware& ca) { - return "CapacityAware(flow_count=" + std::to_string(ca.flow_count) + ", max_flow_count=" + - (ca.max_flow_count ? std::to_string(*ca.max_flow_count) : std::string("None")) + ")"; - }); - - py::class_(m, "Global", - "Routing: the omniscient controller. Every call re-solves the minimum-cost flow of the\n" - "cumulative value over the whole eligible graph (placement MaxFlow only).") - .def(py::init<>()) - .def("__repr__", [](const Global&) { return std::string("Global()"); }); - - py::class_(m, "Pinned", - "Routing: explicit route structures, one flow per usable bundle in supply order (single\n" - "endpoints; a Path converts with to_dag()). The list is copied at construction.") - .def(py::init([](const py::object& bundles) { - if (not_a_sequence(bundles)) throw py::type_error("Pinned bundles must be a sequence of PredDAG"); - Pinned p; - for (const auto& b : py::reinterpret_borrow(bundles)) { - if (!py::isinstance(b)) { - throw py::type_error("Pinned bundles must be PredDAG values (a Path converts with to_dag())"); - } - p.bundles.push_back(b.cast>()); - } - return p; - }), - py::arg("bundles")) - .def_property_readonly("bundles", - [](const Pinned& p) { - py::list out; - for (const auto& b : p.bundles) out.append(std::const_pointer_cast(b)); - return out; - }) - .def("__repr__", [](const Pinned& p) { return "Pinned(bundles=" + std::to_string(p.bundles.size()) + ")"; }); - - py::class_( - m, "FlowPolicyConfig", - "The four explicit axes of a policy: routing (CostOnly, CapacityAware, Global or Pinned), placement\n" - "(FlowPlacement), admission (Admission) and selection (PathSelection). A placement or admission\n" - "outside its enumeration raises ValueError.") - .def(py::init([](const py::object& routing, FlowPlacement placement, Admission admission, - const PathSelection& selection) { - // The routing's structure first (TypeError), then the constructor's enumeration checks. - auto r = routing_of(routing); - return FlowPolicyConfig {std::move(r), placement, admission, selection}; - }), - py::kw_only(), py::arg("routing"), py::arg("placement"), py::arg("admission"), py::arg("selection")) - .def_property_readonly("routing", [](const FlowPolicyConfig& c) { return routing_obj(c.routing); }) - .def_readonly("placement", &FlowPolicyConfig::placement) - .def_readonly("admission", &FlowPolicyConfig::admission) - .def_readonly("selection", &FlowPolicyConfig::selection) - .def("__repr__", [](const FlowPolicyConfig& c) { - return "FlowPolicyConfig(routing=" + std::string(py::repr(routing_obj(c.routing))) + ")"; - }); - - py::class_(m, "OutputOptions", - "Optional outputs of a placement (all false by default; absent outputs are None):\n" - "edge_flows (the policy's own allocation per edge after the call), residual and\n" - "bottleneck (the ledger's after the call); on Global, bottleneck adds min_cut.") - .def(py::init(), py::kw_only(), py::arg("edge_flows") = false, py::arg("residual") = false, - py::arg("bottleneck") = false) - .def_readonly("edge_flows", &OutputOptions::edge_flows) - .def_readonly("residual", &OutputOptions::residual) - .def_readonly("bottleneck", &OutputOptions::bottleneck); - - py::enum_(m, "Termination", - "The local outcome of a source and a call's termination (APPROACH 9.4): COMPLETE (the\n" - "offer is served), LIMIT_FLOWS, LIMIT_DIMINISHING, NO_ROUTE, EXHAUSTED.") - .value("COMPLETE", Termination::Complete) - .value("LIMIT_FLOWS", Termination::LimitFlows) - .value("LIMIT_DIMINISHING", Termination::LimitDiminishing) - .value("NO_ROUTE", Termination::NoRoute) - .value("EXHAUSTED", Termination::Exhausted); - - py::class_( - m, "PlacementResult", - "One placement call: placed (units), remaining (request - placed; float('inf') for +inf),\n" - "termination, local_outcomes [(source, Termination)] per roster source, routes_changed,\n" - "weights_changed, drops [(EdgeId, units)] of this call; cost_distribution [(cost, units)] on\n" - "Global (the marginal curve of the policy's cumulative flow; a zero call reports it unchanged), else\n" - "None; the optional outputs\n" - "edge_flows, residual (int64[E]), bottleneck, min_cut (Global with bottleneck; None when the\n" - "solve was not maximal), None unless requested.") - .def_readonly("placed", &PlacementResult::placed) - .def_property_readonly("remaining", [](const PlacementResult& r) { return remaining_obj(r.remaining); }) - .def_readonly("termination", &PlacementResult::termination) - .def_readonly("local_outcomes", &PlacementResult::local_outcomes) - .def_readonly("routes_changed", &PlacementResult::routes_changed) - .def_readonly("weights_changed", &PlacementResult::weights_changed) - .def_property_readonly("drops", [](const PlacementResult& r) { return pairs_list(r.drops); }) - .def_readonly("cost_distribution", &PlacementResult::cost_distribution) - .def_property_readonly("edge_flows", - [](const PlacementResult& r) -> py::object { - return r.edge_flows ? py::object(int64_array(*r.edge_flows)) : py::object(py::none()); - }) - .def_property_readonly("residual", - [](const PlacementResult& r) -> py::object { - return r.residual ? py::object(int64_array(*r.residual)) : py::object(py::none()); - }) - .def_readonly("bottleneck", &PlacementResult::bottleneck) - .def_readonly("min_cut", &PlacementResult::min_cut) - .def("__repr__", [](const PlacementResult& r) { - return "PlacementResult(placed=" + std::to_string(r.placed) + ", termination=" + - std::string(py::str(py::cast(r.termination).attr("name"))) + ")"; - }); - - py::class_(m, "DagCacheStats", - "DagCache statistics (atomic per field, not a consistent snapshot): hits, misses,\n" - "entries, bytes (the entries' retained payload), live_values (values alive anywhere).") - .def_readonly("hits", &DagCache::Stats::hits) - .def_readonly("misses", &DagCache::Stats::misses) - .def_readonly("entries", &DagCache::Stats::entries) - .def_readonly("bytes", &DagCache::Stats::bytes) - .def_readonly("live_values", &DagCache::Stats::live_values); - - py::register_exception(m, "DagCacheMiss", PyExc_Exception); - m.attr("DagCacheMiss").attr("__doc__") = - "A lookup in a frozen DagCache whose key is absent (APPROACH 10): a dedicated exception (not a\n" - "RuntimeError or ValueError); the evaluators propagate it unchanged, never as a status."; - - py::class_>( - m, "DagCache", - "A cache of static shortest-path supports bound to one graph, keyed (roots as a set, direction,\n" - "Eligibility uid). Building mode (a new cache): one writer, no concurrent readers; a miss searches\n" - "and inserts. Frozen mode (freeze()): read-only and shareable by every worker; a miss counts one miss\n" - "and raises DagCacheMiss; clear() raises ValueError; thaw() returns to building once the caller has\n" - "joined every reader. CostOnly policies and the evaluators read it; selection is projected at\n" - "creation, never cached. clear() empties a building cache (the hit and miss counters persist); held\n" - "values stay valid. A supplied building cache is a memo (APPROACH 13, limit 2): a public call that\n" - "fails keeps the complete entries and statistics of the lookups that succeeded before; never a\n" - "partial entry, and a retained entry never changes a result. A frozen cache's statistics follow the\n" - "public call that reads it: its hits count when the call succeeds, its Python result included; a\n" - "failed call changes no statistic, except that a call raising DagCacheMiss counts one miss.") - .def(py::init([](const std::shared_ptr& graph) { - if (!graph) throw py::value_error("graph must be a Graph"); - return std::make_shared(*graph); - }), - py::arg("graph")) - .def("graph_uid", &DagCache::graph_uid, "uid of the graph this cache is bound to") - .def("freeze", &DagCache::freeze, "enter frozen mode (no effect when frozen)") - .def("thaw", &DagCache::thaw, "return to building mode (no effect when building); join every reader first") - .def("clear", &DagCache::clear, "empty a building cache; ValueError when frozen") - .def("stats", &DagCache::stats); - - auto flow_policy = py::class_( - m, "FlowPolicy", - "The one placement engine (APPROACH 9), bound to its ledger at construction: place(volume,\n" - "outputs) runs the transaction and commits one atomic exchange (a failed call raises with the ledger\n" - "and the policy as they were, its Python result included; limits in APPROACH 13: pybind11's\n" - "unchecked tp_alloc in object construction and conversions outside the staged calls, and a supplied\n" - "building DagCache, a memo that keeps its successful lookups); remove() reverts every flow and\n" - "opens a new epoch; dropping the policy without remove() leaves its records as orphans.\n" - "sources and destinations are one NodeId or a sequence (disjoint sets). ValueError for invalid\n" - "endpoints, configurations, foreign objects and requests; TypeError for wrong argument types.") - .def(py::init([](const std::shared_ptr& ledger, const py::object& sources, - const py::object& destinations, const FlowPolicyConfig& config, - const std::shared_ptr& elig, const std::shared_ptr& cache) { - if (!ledger) throw py::value_error("ledger must be a FlowLedger"); - const auto n = ledger->graph()->num_nodes(); - const auto s = node_ids(sources, n, "sources"); - const auto d = node_ids(destinations, n, "destinations"); - return std::make_unique(*ledger, s, d, config, elig.get(), cache.get()); - }), - py::keep_alive<1, 2>(), py::keep_alive<1, 6>(), py::keep_alive<1, 7>(), py::arg("ledger"), - py::arg("sources"), py::arg("destinations"), py::arg("config"), py::arg("elig") = nullptr, - py::arg("cache") = nullptr) - .def( - "place", - [](FlowPolicy& p, const py::object& volume, const py::object& outputs) { - const auto v = request_units(volume); - const auto o = outputs_of(outputs); - // All or nothing across the boundary: the result's Python object exists before the - // call, and after the commit nothing allocates (the value moves into it). The GIL - // stays held: the call mutates the ledger (one writer per ledger). - auto [obj, slot] = new_object(); - *slot = p.place(v, o); - return std::move(obj); - }, - py::arg("volume"), py::arg("outputs") = py::none(), - "place(volume: int units or float('inf'), outputs: OutputOptions | None) -> PlacementResult") - .def("remove", &FlowPolicy::remove, "revert every flow of the policy, retire the ids, open a new epoch") - .def("placed_demand", &FlowPolicy::placed_demand, "the sum of delivered over the policy's live records") - .def("records", &FlowPolicy::records, "the policy's live records as snapshots in FlowId order") - .def( - "record", [](const FlowPolicy& p, const py::object& flow_id) { return p.record(flow_id_of(flow_id)); }, - py::arg("flow_id"), - "one of the policy's live records by FlowId (ValueError for a FlowId of another ledger or policy, a\n" - "retired or an unknown id)") - .def("usable_bundles", &FlowPolicy::usable_bundles, - "Pinned: the bundles with a src -> dst walk after static pruning (0 for the other modes)") - .def_property_readonly("config", &FlowPolicy::config); -#if NETGRAPH_CORE_PRIVATE_HOOKS - flow_policy - .def( - "_place_traced", - [](FlowPolicy& p, const py::object& volume, const py::object& outputs) { - const auto v = request_units(volume); - const auto o = outputs_of(outputs); - // As place(): every Python object exists before anything is published; the trace is - // converted at the engine's staged point, after the call is complete and before the - // commit, so a failed conversion changes nothing. - auto [obj, slot] = new_object(); - py::tuple pair(2); - py::object trace_py; - detail::Trace trace; - const std::function staged = [&] { trace_py = trace_obj(trace); }; - *slot = detail::place_traced(p, v, o, trace, &staged); - PyTuple_SET_ITEM(pair.ptr(), 0, obj.release().ptr()); - PyTuple_SET_ITEM(pair.ptr(), 1, trace_py.release().ptr()); - return pair; - }, - py::arg("volume"), py::arg("outputs") = py::none(), - "private: place() with the transaction's trace (prototype 2's keys)") - .def("_state", &state_obj, "private: the epoch state (roster, requested, +inf, cohorts)") - .def( - "_owner", [](const FlowPolicy& p) { return detail::PolicyAccess::engine(p).owner; }, - "private: the ledger-local owner id (FlowRecord.owner)"); -#else - (void)flow_policy; -#endif - - - py::class_(m, "CapacityOptions", - "The configuration, the static Eligibility (None: all allowed), the DagCache (None:\n" - "private per worker) and the optional outputs of capacity() and batch_capacity().") - .def(py::init([](const FlowPolicyConfig& config, const std::shared_ptr& elig, - const std::shared_ptr& cache, const py::object& outputs) { - return PyCapacityOptions {config, elig, cache, outputs_of(outputs)}; - }), - py::arg("config"), py::kw_only(), py::arg("elig") = nullptr, py::arg("cache") = nullptr, - py::arg("outputs") = py::none()) - .def_readonly("config", &PyCapacityOptions::config) - .def_readonly("elig", &PyCapacityOptions::elig) - .def_readonly("cache", &PyCapacityOptions::cache) - .def_readonly("outputs", &PyCapacityOptions::outputs); - - py::class_(m, "SensitivityOptions", - "The configuration, Eligibility, DagCache and baseline outputs of sensitivity().") - .def(py::init([](const FlowPolicyConfig& config, const std::shared_ptr& elig, - const std::shared_ptr& cache, const py::object& outputs) { - return PySensitivityOptions {config, elig, cache, outputs_of(outputs)}; - }), - py::arg("config"), py::kw_only(), py::arg("elig") = nullptr, py::arg("cache") = nullptr, - py::arg("outputs") = py::none()) - .def_readonly("config", &PySensitivityOptions::config) - .def_readonly("elig", &PySensitivityOptions::elig) - .def_readonly("cache", &PySensitivityOptions::cache) - .def_readonly("outputs", &PySensitivityOptions::outputs); - - py::enum_(m, "SensitivityStatus", - "EVALUATED (the loss is the baseline minus the capacity without the edge) or\n" - "UNEVALUABLE (the deletion raised a model exception: loss None, reason the message).") - .value("EVALUATED", SensitivityStatus::Evaluated) - .value("UNEVALUABLE", SensitivityStatus::Unevaluable); - - py::class_(m, "SensitivityEntry", "One candidate edge: edge, loss (int units or None), status, reason.") - .def_readonly("edge", &SensitivityEntry::edge) - .def_readonly("loss", &SensitivityEntry::loss) - .def_readonly("status", &SensitivityEntry::status) - .def_property_readonly("reason", [](const SensitivityEntry& e) -> py::object { - return e.status == SensitivityStatus::Unevaluable ? py::object(py::str(e.reason)) : py::object(py::none()); - }); - - py::class_(m, "SensitivityResult", - "baseline (the PlacementResult of capacity()) and entries (ascending EdgeId; an\n" - "absent edge has loss 0).") - .def_readonly("baseline", &SensitivityResult::baseline) - .def_readonly("entries", &SensitivityResult::entries); - - m.attr("MAX_FLOWS_PER_CALL") = py::int_(kMaxFlowsPerCall); - - m.def( - "capacity", - [](const std::shared_ptr& graph, const py::object& sources, const py::object& destinations, - const py::object& opts) { - if (!graph) throw py::value_error("graph must be a Graph"); - const auto o = options_of(opts, "CapacityOptions"); - const auto s = node_ids(sources, graph->num_nodes(), "sources"); - const auto d = node_ids(destinations, graph->num_nodes(), "destinations"); - const CapacityOptions c {o.config, o.elig.get(), o.cache.get(), o.outputs}; - // One public call on a frozen cache's statistics, its result object included (NOTES R27). - return detail::frozen_call(c.cache, [&](detail::FrozenTally& tally) { - PlacementResult r; - { - py::gil_scoped_release release; - r = detail::capacity_in_call(*graph, s, d, c, tally); - } - return object_of(std::move(r)); - }); - }, - py::arg("graph"), py::arg("sources"), py::arg("destinations"), py::arg("opts"), - "place(+inf) of a FlowPolicy with opts.config on a fresh ledger of the graph -> PlacementResult.\n" - "The exact maximum flow is the Global + MaxFlow configuration. A supplied frozen cache raises\n" - "DagCacheMiss for an absent key, unchanged.\n" - "A failed call raises; a supplied building cache keeps the entries and statistics of the lookups\n" - "that succeeded before the failure (a memo: APPROACH 13, limit 2); a supplied frozen cache's\n" - "statistics are unchanged, except one miss when the call raises DagCacheMiss."); - - m.def( - "batch_capacity", - [](const std::shared_ptr& graph, const py::object& pairs, const py::object& opts, - const py::object& per_pair_elig, const py::object& workers) { - if (!graph) throw py::value_error("graph must be a Graph"); - // The arguments first, then each pair completely and in pair order (the same two parts - // as the C++ validation), so the lowest failing pair index is reported. - const auto o = options_of(opts, "CapacityOptions"); - const auto w = workers_of(workers, false, static_cast(o.cache)); - const CapacityOptions c {o.config, o.elig.get(), o.cache.get(), o.outputs}; - detail::check_batch_arguments(*graph, c, w); - if (not_a_sequence(pairs)) throw py::value_error("pairs must be a sequence of (sources, destinations)"); - const auto items = py::reinterpret_borrow(pairs); - const auto num_pairs = py::len(items); - std::optional eligs; - if (!per_pair_elig.is_none()) { - // An explicit sequence has one entry per pair (None entries allowed), also when empty. - if (not_a_sequence(per_pair_elig)) throw py::value_error("per_pair_elig must be a sequence of Eligibility or None"); - eligs = py::reinterpret_borrow(per_pair_elig); - if (py::len(*eligs) != num_pairs) { - throw py::value_error("per_pair_elig has " + std::to_string(py::len(*eligs)) + " entries for " + - std::to_string(num_pairs) + " pairs"); - } - } - std::vector, std::vector>> ps; - std::vector> keep; - std::vector per; - const auto n = graph->num_nodes(); - for (std::size_t i = 0; i < num_pairs; ++i) { - const py::object item = items[i]; - if (not_a_sequence(item) || py::len(item) != 2) { - throw py::value_error("each pair must be (sources, destinations)"); - } - auto t = py::reinterpret_borrow(item); - auto src = node_ids(t[0], n, "sources"); - auto dst = node_ids(t[1], n, "destinations"); - const Eligibility* e = nullptr; - if (eligs) { - const py::object entry = (*eligs)[i]; - if (!entry.is_none()) { - if (!py::isinstance(entry)) throw py::type_error("per_pair_elig entries must be Eligibility or None"); - keep.push_back(entry.cast>()); - e = keep.back().get(); - } - per.push_back(e); - } - detail::check_batch_pair(*graph, src, dst, e, i); - ps.emplace_back(std::move(src), std::move(dst)); - } - return detail::frozen_call(c.cache, [&](detail::FrozenTally& tally) { - std::vector rs; - { - py::gil_scoped_release release; - rs = detail::batch_capacity_in_call(*graph, ps, c, per, w, tally); - } - py::list out(rs.size()); - for (std::size_t i = 0; i < rs.size(); ++i) { - PyList_SET_ITEM(out.ptr(), static_cast(i), object_of(std::move(rs[i])).release().ptr()); - } - return py::object(std::move(out)); - }); - }, - py::arg("graph"), py::arg("pairs"), py::arg("opts"), py::arg("per_pair_elig") = py::none(), - py::arg("workers") = py::none(), - "capacity() per (sources, destinations) pair in pair order, opts.elig intersected with\n" - "per_pair_elig[i] (None entries allowed; a given sequence has one entry per pair, also when\n" - "empty); workers >= 1 (None: NGRAPH_CORE_BATCH_THREADS or 1; 1 with a supplied cache).\n" - "Everything is validated before a worker starts, the arguments first, then each pair\n" - "completely in pair order, so the error of the lowest failing pair index is raised; Pinned is\n" - "rejected; an empty batch returns []. A supplied building cache needs workers == 1; a frozen cache\n" - "is shared by every worker (pass workers explicitly) and its DagCacheMiss reaches the caller\n" - "unchanged (the lowest failing pair's); a per-pair intersection that differs from both operands\n" - "has a new uid, absent from a frozen cache (pass effective per-pair values with elig None, or a\n" - "common value containing them).\n" - "A failed call raises; a supplied building cache keeps the entries and statistics of the lookups\n" - "that succeeded before the failure (a memo: APPROACH 13, limit 2); a supplied frozen cache's\n" - "statistics are unchanged, except one miss when the call raises DagCacheMiss."); - - m.def( - "sensitivity", - [](const std::shared_ptr& graph, const py::object& sources, const py::object& destinations, - const py::object& opts, const py::object& workers) { - if (!graph) throw py::value_error("graph must be a Graph"); - const auto o = options_of(opts, "SensitivityOptions"); - const auto s = node_ids(sources, graph->num_nodes(), "sources"); - const auto d = node_ids(destinations, graph->num_nodes(), "destinations"); - const auto w = workers_of(workers, true, static_cast(o.cache)); - const SensitivityOptions c {o.config, o.elig.get(), o.cache.get(), o.outputs}; - return detail::frozen_call(c.cache, [&](detail::FrozenTally& tally) { - SensitivityResult r; - { - py::gil_scoped_release release; - r = detail::sensitivity_in_call(*graph, s, d, c, w, tally); - } - return object_of(std::move(r)); - }); - }, - py::arg("graph"), py::arg("sources"), py::arg("destinations"), py::arg("opts"), py::arg("workers") = py::none(), - "The baseline capacity() and one entry per candidate edge (Global: the baseline's positive-flow\n" - "edges; other models: every allowed edge of at least 1 unit): the loss is the baseline's placed\n" - "minus capacity() without the edge, in signed units, never thresholded (Global evaluates it by the\n" - "flow core's deletion re-solve from the baseline's final state, and CostOnly gives edges no route\n" - "uses loss 0 without a placement, result-equivalent forms of that definition); a placed deletion\n" - "raising a model exception is UNEVALUABLE with the message as reason.\n" - "workers as batch_capacity (NGRAPH_CORE_SENSITIVITY_THREADS); a CostOnly deletion (the one\n" - "model reading the cache) looks up a new Eligibility uid, so a frozen cache raises DagCacheMiss\n" - "there (unchanged, never UNEVALUABLE); other models read no cache.\n" - "A failed call raises; a supplied building cache keeps the entries and statistics of the lookups\n" - "that succeeded before the failure (a memo: APPROACH 13, limit 2); a supplied frozen cache's\n" - "statistics are unchanged, except one miss when the call raises DagCacheMiss."); - - py::class_( - m, "KspOptions", - "The options of ksp (APPROACH 6, rule 10): k (>= 1) and selection are explicit (its multi_edge is\n" - "the member rule, Expand or Collapse; multipath is ignored); max_path_cost (None, Absolute(c) with\n" - "c >= 0, or Stretch(f) with f finite in [1, inf) times the static shortest cost under the\n" - "Eligibility, residual ignored; inclusive); residual (int64 units per EdgeId, each in\n" - "[0, capacity_units]: gating at 1 unit and the priority values); elig (None: everything allowed).\n" - "The fields are validated by ksp, in the contract's order.") - .def(py::init([](const py::object& k, const PathSelection& selection, const py::object& max_path_cost, - const py::object& residual, const std::shared_ptr& elig) { - return PyKspOptions {k, selection, max_path_cost, residual, elig}; - }), - py::arg("k"), py::arg("selection"), py::kw_only(), py::arg("max_path_cost") = py::none(), - py::arg("residual") = py::none(), py::arg("elig") = nullptr) - .def_readonly("k", &PyKspOptions::k) - .def_readonly("selection", &PyKspOptions::selection) - .def_readonly("max_path_cost", &PyKspOptions::max_path_cost) - .def_readonly("residual", &PyKspOptions::residual) - .def_readonly("elig", &PyKspOptions::elig); - - m.def( - "ksp", - [](const std::shared_ptr& graph, const py::object& src, const py::object& dst, const py::object& opts) { - if (!graph) throw py::value_error("graph must be a Graph"); - const auto o = options_of(opts, "KspOptions"); - const auto n = graph->num_nodes(); - // APPROACH 6's order on unnarrowed values (Core repeats every check): the selection, src, - // dst, src == dst, k, the ceiling, the residual, then a foreign Eligibility. - detail::check_selection(o.selection); - const NodeId s = endpoint(src, n, "src"); - const NodeId t = endpoint(dst, n, "dst"); - if (s == t) throw py::value_error(detail::overlap_error(s, "src", "dst")); - KspOptions c(count_of(o.k, "k"), o.selection); - if (!o.max_path_cost.is_none()) { - if (py::isinstance(o.max_path_cost)) { - c.max_path_cost = o.max_path_cost.cast(); - } else if (py::isinstance(o.max_path_cost)) { - c.max_path_cost = o.max_path_cost.cast(); - } else { - throw py::type_error("max_path_cost must be Absolute, Stretch or None (got " + - std::string(py::str(py::type::of(o.max_path_cost).attr("__name__"))) + ")"); - } - detail::check_ceiling(c.max_path_cost); - } - std::optional> res; - if (!o.residual.is_none()) res = edge_units(o.residual, *graph, "residual"); - if (res.has_value()) c.residual = std::span(*res); - c.elig = o.elig.get(); - std::vector> paths; - { - py::gil_scoped_release release; - paths = ksp(*graph, s, t, c); - } - // Every object through a checked allocation: a failure raises. - py::list out(paths.size()); - for (std::size_t i = 0; i < paths.size(); ++i) { - py::tuple item(2); - py::int_ cost(paths[i].first); - py::object path = object_from(std::move(paths[i].second)); - PyTuple_SET_ITEM(item.ptr(), 0, cost.release().ptr()); - PyTuple_SET_ITEM(item.ptr(), 1, path.release().ptr()); - PyList_SET_ITEM(out.ptr(), static_cast(i), item.release().ptr()); - } - return out; - }, - py::arg("graph"), py::arg("src"), py::arg("dst"), py::arg("opts"), - "The K shortest simple src -> dst paths of the member graph (APPROACH 6, rule 10) -> [(cost, Path)],\n" - "in increasing (cost, -bottleneck under PREFER_HIGHER_PRIORITY, hops, edge-id sequence), the\n" - "bottleneck being the minimum priority value along a path: the minimal prefix of that order,\n" - "truncated at k and at the inclusive ceiling; each Path has single-member hops. Validated in this\n" - "order before any work (ValueError): src and dst in range, src == dst, k >= 1, the ceiling, the\n" - "residual (length, then every entry), a foreign Eligibility. An excluded endpoint, no static route\n" - "under Stretch, or no path gives []."); - - m.def( - "resolve_to_paths", - [](const std::shared_ptr& graph, const std::shared_ptr& dag, const py::object& src, - const py::object& dst, bool split_parallel_edges, const py::object& max_paths) { - if (!graph) throw py::value_error("graph must be a Graph"); - if (!dag) throw py::value_error("dag must be a PredDAG"); - if (dag->graph_uid() != graph->uid()) throw py::value_error("foreign PredDAG: built for another graph"); - const auto n = graph->num_nodes(); - const NodeId s = endpoint(src, n, "src"); - const NodeId t = endpoint(dst, n, "dst"); - if (s == t) throw py::value_error(detail::overlap_error(s, "src", "dst")); - std::optional mp; - if (!max_paths.is_none()) mp = count_of(max_paths, "max_paths"); - std::vector paths; - { - py::gil_scoped_release release; - paths = resolve_to_paths(*graph, *dag, s, t, split_parallel_edges, mp); - } - py::list out(paths.size()); - for (std::size_t i = 0; i < paths.size(); ++i) { - PyList_SET_ITEM(out.ptr(), static_cast(i), object_from(std::move(paths[i])).release().ptr()); - } - return out; - }, - py::arg("graph"), py::arg("dag"), py::arg("src"), py::arg("dst"), py::arg("split_parallel_edges") = false, - py::arg("max_paths") = py::none(), - "The src -> dst walks of a DAG (APPROACH 5) -> [Path], in increasing (cost, hops, edge-id sequence):\n" - "a hop costs its cheapest member and is keyed by its lowest member id. split_parallel_edges = False\n" - "keeps every parallel member of a (u, v) pair in one hop (members ascending); True gives one path\n" - "per member combination in the same order. max_paths (>= 1) truncates; None lists every walk.\n" - "ValueError: a DAG of another graph, src or dst out of range, src == dst, max_paths < 1. An\n" - "endpoint outside the DAG, or no walk, gives []."); - - // ------------------------------------------------------------------ private recorder entries - - // ------------------------------------------------------------------ the public search - - m.attr("UNREACHABLE") = py::int_(kUnreachable); - - py::class_( - m, "SpfOptions", - "The options of spf (APPROACH 6): direction and selection are explicit; targets (one NodeId or a\n" - "sequence; None: a full search) stops the search at the nearest targets (anycast); residual (int64\n" - "units per EdgeId, each in [0, capacity_units]) adds residual gating at min_residual units (an\n" - "integer >= 1; it gates a supplied residual only) and supplies the priority values; elig (None:\n" - "everything allowed). A direction outside its enumeration raises ValueError here; the other fields\n" - "are validated by spf, in the contract's order.") - .def(py::init([](Direction direction, const PathSelection& selection, const py::object& targets, - const py::object& residual, const py::object& min_residual, - const std::shared_ptr& elig) { - detail::check_member(direction, "direction"); - return PySpfOptions {direction, selection, targets, residual, min_residual, elig}; - }), - py::arg("direction"), py::arg("selection"), py::kw_only(), py::arg("targets") = py::none(), - py::arg("residual") = py::none(), py::arg("min_residual") = 1, py::arg("elig") = nullptr) - .def_readonly("direction", &PySpfOptions::direction) - .def_readonly("selection", &PySpfOptions::selection) - .def_readonly("targets", &PySpfOptions::targets) - .def_readonly("residual", &PySpfOptions::residual) - .def_readonly("min_residual", &PySpfOptions::min_residual) - .def_readonly("elig", &PySpfOptions::elig); - - py::class_(m, "SpfResult", - "The result of spf: costs (full searches: a read-only int64 array, UNREACHABLE for the\n" - "nodes not reached; None with targets), dag (PredDAG: the unique DAG of the edges tight in\n" - "the traversal direction, in their original orientation; with targets pruned to the walks\n" - "between the roots and a nearest target, from the target to a root for REVERSE, or the\n" - "canonical single path for multipath = False) and target_cost (with targets: the nearest\n" - "targets' cost, None when no target is reached; None for a full search).") - .def_property_readonly("costs", [](const PySpfResult& r) { return r.costs ? r.costs : py::object(py::none()); }) - .def_property_readonly("dag", [](const PySpfResult& r) { return r.dag; }) - .def_property_readonly("target_cost", - [](const PySpfResult& r) { return r.target_cost ? r.target_cost : py::object(py::none()); }) - .def("__repr__", [](const PySpfResult& r) { - std::string tc = r.target_cost && !r.target_cost.is_none() ? std::string(py::str(r.target_cost)) : "None"; - return "SpfResult(costs=" + std::string(r.costs ? "array" : "None") + - ", dag_entries=" + std::to_string(r.dag.cast().via_edges().size()) + - ", target_cost=" + tc + ")"; - }); - - m.def( - "spf", - [](const std::shared_ptr& graph, const py::object& roots, const py::object& opts) { - if (!graph) throw py::value_error("graph must be a Graph"); - const auto o = options_of(opts, "SpfOptions"); - const auto n = graph->num_nodes(); - // APPROACH 6's order on unnarrowed values (Core repeats every check): the enumerations, - // the roots, the targets, their overlap, a single path without targets, the residual, then - // min_residual and a foreign Eligibility. - detail::check_member(o.direction, "direction"); - detail::check_selection(o.selection); - const auto r = node_ids(roots, n, "roots"); - std::optional> t; - if (!o.targets.is_none()) { - t = node_ids(o.targets, n, "targets"); - detail::check_disjoint(detail::node_set(r, n, "roots"), detail::node_set(*t, n, "targets"), "roots", - "targets"); - } else if (!o.selection.multipath) { - throw py::value_error("multipath = false requires targets (the single path joins a root to a target)"); - } - std::optional> res; - if (!o.residual.is_none()) res = edge_units(o.residual, *graph, "residual"); - const auto minr = python_int(o.min_residual, "min_residual"); - if (PyObject_RichCompareBool(minr.ptr(), py::int_(1).ptr(), Py_LT) == 1) { - throw py::value_error("min_residual must be at least 1 unit (got " + std::string(py::str(minr)) + ")"); - } - int overflow = 0; - const long long mv = PyLong_AsLongLongAndOverflow(minr.ptr(), &overflow); - SpfOptions c(o.direction, o.selection); - c.targets = std::move(t); - if (res.has_value()) c.residual = std::span(*res); - // Above INT64_MAX it gates every residual edge exactly as INT64_MAX does (residuals < 2^62). - c.min_residual = overflow != 0 ? std::numeric_limits::max() : static_cast(mv); - c.elig = o.elig.get(); - std::optional result; - { - py::gil_scoped_release release; - result.emplace(spf(*graph, r, c)); - } - // The result's Python objects, each through a checked allocation: a failure raises. - auto [obj, slot] = new_object(); - if (result->costs.has_value()) { - py::array_t costs(static_cast(result->costs->size())); - std::copy(result->costs->begin(), result->costs->end(), costs.mutable_data()); - py::detail::array_proxy(costs.ptr())->flags &= ~py::detail::npy_api::NPY_ARRAY_WRITEABLE_; - slot->costs = std::move(costs); - } - slot->dag = object_from(std::move(result->dag)); - if (result->target_cost.has_value() && *result->target_cost != kUnreachable) { - slot->target_cost = py::int_(*result->target_cost); - } - return std::move(obj); - }, - py::arg("graph"), py::arg("roots"), py::arg("opts"), - "Shortest paths from the roots (one NodeId or a sequence; every root at distance 0), FORWARD or\n" - "REVERSE (APPROACH 6) -> SpfResult. Without targets every reachable node is settled: costs and the\n" - "full DAG of tight edges. With targets the search stops at the nearest targets' cost: target_cost\n" - "and the DAG pruned to the walks between the roots and a nearest target (anycast; for REVERSE, from\n" - "the target to a root in the edges' orientation), or for multipath = False\n" - "the canonical single path, the walk minimising (cost, -bottleneck under PREFER_HIGHER_PRIORITY,\n" - "hops, edge-id sequence). Validated in this order before any work (ValueError): the roots (non-\n" - "empty, ids in range), the targets (the same), a vertex in both sets, multipath = False without\n" - "targets, the residual (length, then every entry in [0, capacity_units]), min_residual >= 1, a\n" - "foreign Eligibility. Excluded roots and targets are skipped; all roots excluded gives an empty\n" - "result."); - - // ------------------------------------------------------------------ private hooks - -#if NETGRAPH_CORE_PRIVATE_HOOKS - - m.def( - "_max_flows_per_call", - [](const py::object& value) { - auto& cap = detail::max_flows_per_call(); - const auto old = cap.load(); - if (!value.is_none()) cap.store(integer_value(value, 0, kMaxFlowsPerCall, "max_flows_per_call")); - return old; - }, - py::arg("value") = py::none(), - "private test hook: the call-wide creation cap (MAX_FLOWS_PER_CALL by default); returns the old value"); - - m.def( - "_sensitivity_fail_edge", - [](const py::object& edge) { - auto& hook = detail::sensitivity_fail_edge(); - const auto old = hook.load(); - hook.store(edge.is_none() ? -1 : integer_value(edge, -1, std::numeric_limits::max(), "edge")); - return old; - }, - py::arg("edge") = py::none(), - "private test hook: the candidate whose deletion raises a model exception (None: off)"); - - m.def( - "_fail_python_allocation", - [](const py::object& fn, const py::tuple& args, long k) { - // Calls fn(*args) with the k-th Python object allocation (from 0) failing, then - // restores the allocator. Returns (hit, allocations seen, result or None, exception or - // None). One caller at a time: the allocator is process-wide while the call runs. - PyMem_GetAllocator(PYMEM_DOMAIN_OBJ, &g_saved_allocator); - PyMemAllocatorEx hook {nullptr, failing_malloc, failing_calloc, failing_realloc, forwarding_free}; - g_fail_countdown.store(k); - g_fail_seen.store(0); - g_fail_hit.store(false); - PyMem_SetAllocator(PYMEM_DOMAIN_OBJ, &hook); - PyObject* r = PyObject_CallObject(fn.ptr(), args.ptr()); - PyMem_SetAllocator(PYMEM_DOMAIN_OBJ, &g_saved_allocator); - g_fail_countdown.store(-1); - py::object result = r != nullptr ? py::reinterpret_steal(r) : py::object(py::none()); - py::object error = py::none(); - if (r == nullptr) error = py::reinterpret_steal(PyErr_GetRaisedException()); - return py::make_tuple(g_fail_hit.load(), g_fail_seen.load(), result, error); - }, - py::arg("fn"), py::arg("args"), py::arg("k"), - "private test hook: fn(*args) with the k-th Python object allocation failing -> (hit, seen, result, error)"); - - m.def( - "_round_robin", - [](const py::object& total, const py::object& k) { - const auto t = integer_value(total, 0, kDomainLimit - 1, "total"); - const auto n = integer_value(k, 1, 1 << 20, "k"); - std::vector out(static_cast(n)); - detail::round_robin(t, out); - return out; - }, - py::arg("total"), py::arg("k"), "private: round robin of total units over k members (APPROACH 2.1)"); - - m.def( - "_highest_averages", - [](const py::object& total, const py::object& weights) { - const auto t = integer_value(total, 0, kDomainLimit - 1, "total"); - const auto w = wide_integers(weights, "weights"); - if (w.size() == 0) throw py::value_error("highest averages over no member"); - std::uint64_t sum = 0; - for (std::size_t i = 0; i < w.size(); ++i) { - if (!w.in(i, 1, kDomainLimit - 1)) { - throw py::value_error("weights[" + std::to_string(i) + "] = " + w.text(i) + " is outside [1, 2^62)"); - } - sum += static_cast(w.value[i]); - if (sum >= static_cast(kDomainLimit)) throw py::value_error("the weights sum to 2^62 or more"); - } - const auto wv = w.narrow(); - std::vector out(wv.size()); - detail::highest_averages(t, wv, out); - return out; - }, - py::arg("total"), py::arg("weights"), - "private: highest-averages (D'Hondt) apportionment of total units over positive weights, ties by\n" - "the lower index (APPROACH 2.1)"); + bindings::register_private_hooks(m); #endif } diff --git a/bindings/python/path.cpp b/bindings/python/path.cpp new file mode 100644 index 0000000..5039a25 --- /dev/null +++ b/bindings/python/path.cpp @@ -0,0 +1,78 @@ +// Path: an explicit walk of hops of one graph. +#include +#include +#include + +#include +#include +#include +#include + +#include "netgraph/core/graph.hpp" +#include "netgraph/core/pred_dag.hpp" +#include "integers.hpp" +#include "registration.hpp" + +namespace py = pybind11; + +namespace netgraph::core::bindings { + +void register_path(py::module_& m) { + py::class_>( + m, "Path", + "Immutable route of one graph, hop by hop: each hop lists one or more parallel edges (members)\n" + "from the same u to the same v, in the caller's order. cost = the sum over hops of the cost of\n" + "each hop's cheapest member.") + .def(py::init([](const std::shared_ptr& graph, const py::object& hops) { + if (!graph) throw py::value_error("graph must be a Graph"); + if (not_a_sequence(hops)) throw py::value_error("Path: hops must be a sequence of hops (sequences of EdgeIds)"); + auto seq = py::reinterpret_borrow(hops); + std::vector raw; + for (std::size_t h = 0; h < seq.size(); ++h) { + py::object hop = seq[h]; + raw.push_back(wide_integers(hop, ("hops[" + std::to_string(h) + "]").c_str())); + } + // Every member in range before anything else (no id is narrowed unchecked), then the + // C++ constructor's order. + const auto edge_count = static_cast(graph->num_edges()); + for (std::size_t h = 0; h < raw.size(); ++h) { + for (std::size_t i = 0; i < raw[h].size(); ++i) { + if (!raw[h].in(i, 0, edge_count - 1)) { + throw py::value_error("Path: hop " + std::to_string(h) + " member " + raw[h].text(i) + + " out of edge range [0, " + std::to_string(edge_count) + ")"); + } + } + } + std::vector> narrowed; + narrowed.reserve(raw.size()); + for (const auto& r : raw) narrowed.push_back(r.narrow()); + return std::make_shared(*graph, std::move(narrowed)); + }), + py::arg("graph"), py::arg("hops"), + "Validates, in order (ValueError on the first defect): every member id in range, no duplicate\n" + "member, a non-empty hop list, every hop non-empty, the members of a hop sharing (u, v),\n" + "contiguous hops, no repeated node.") + .def("graph_uid", &Path::graph_uid, "uid of the graph this path belongs to") + .def("hops", [](const Path& p) { return p.hops(); }, "the hops as lists of EdgeIds (the caller's member order)") + .def("src", &Path::src, "the first node of the walk") + .def("dst", &Path::dst, "the last node of the walk") + .def("cost", &Path::cost, "sum over hops of the minimum member cost") + .def( + "to_dag", [](const Path& p) { return std::make_shared(p.to_dag()); }, + "the equivalent PredDAG (every member of every hop)") + .def("__eq__", [](const Path& a, const py::object& b) { + return py::isinstance(b) && a == b.cast(); + }) + .def("__hash__", + [](const Path& a) { + py::list hops; + for (const auto& hop : a.hops()) hops.append(py::tuple(py::cast(hop))); + return py::hash(py::make_tuple(a.graph_uid(), py::tuple(hops))); + }) + .def("__repr__", [](const Path& a) { + return "Path(src=" + std::to_string(a.src()) + ", dst=" + std::to_string(a.dst()) + + ", hops=" + std::to_string(a.hops().size()) + ", cost=" + std::to_string(a.cost()) + ")"; + }); +} + +} // namespace netgraph::core::bindings diff --git a/bindings/python/paths.cpp b/bindings/python/paths.cpp new file mode 100644 index 0000000..ba8b2c3 --- /dev/null +++ b/bindings/python/paths.cpp @@ -0,0 +1,183 @@ +// The path listings: ksp with KspOptions, and resolve_to_paths. +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/detail/enum_domain.hpp" +#include "netgraph/core/detail/endpoints.hpp" +#include "netgraph/core/detail/traversal.hpp" +#include "netgraph/core/eligibility.hpp" +#include "netgraph/core/graph.hpp" +#include "netgraph/core/options.hpp" +#include "netgraph/core/pred_dag.hpp" +#include "netgraph/core/search.hpp" +#include "integers.hpp" +#include "publication.hpp" +#include "values.hpp" +#include "registration.hpp" + +namespace py = pybind11; + +namespace netgraph::core::bindings { + +namespace { + +// One NodeId of [0, num_nodes) (a single endpoint); the range message of detail/endpoints.hpp. +NodeId endpoint(const py::object& obj, std::int32_t num_nodes, const char* name) { + const py::object idx = python_int(obj, name); + int overflow = 0; + const long long v = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); + if (overflow != 0 || v < 0 || v >= num_nodes) { + throw py::value_error(detail::node_range_error(name, std::string(py::str(idx)), num_nodes)); + } + return static_cast(v); +} + +// A count of at least 1 (KSP's k, the resolver's max_paths): the validation message below 1; a count +// above INT64_MAX means the same as INT64_MAX (no list reaches it). +std::int64_t count_of(const py::object& obj, const char* name) { + const py::object idx = python_int(obj, name); + int overflow = 0; + const long long v = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); + if (overflow < 0 || (overflow == 0 && v < 1)) { + throw py::value_error(std::string(name) + " must be >= 1 (got " + std::string(py::str(idx)) + ")"); + } + return overflow != 0 ? std::numeric_limits::max() : static_cast(v); +} + +// The Python form of KspOptions: the arguments as given (k, max_path_cost and residual are +// validated at the call, in ksp's validation order, on their unnarrowed values). +struct PyKspOptions { + py::object k; + PathSelection selection; + py::object max_path_cost; + py::object residual; + std::shared_ptr elig; +}; + +} // namespace + +void register_paths(py::module_& m) { + py::class_( + m, "KspOptions", + "The options of ksp: k (>= 1) and selection are explicit (multi_edge = True lists each parallel\n" + "edge as a separate path, False keeps one per (u, v) pair as EdgeTieBreak describes; multipath is\n" + "ignored); max_path_cost (None, Absolute(c) with c >= 0, or Stretch(f) with f finite in [1, inf)\n" + "times the static shortest cost under the Eligibility, residual ignored; inclusive); residual\n" + "(int64 units per EdgeId, each in [0, capacity_units]: edges with less than 1 unit are skipped, and\n" + "the values feed the priority); elig (None: everything allowed). The fields are validated by ksp,\n" + "in the order its docstring gives.") + .def(py::init([](const py::object& k, const PathSelection& selection, const py::object& max_path_cost, + const py::object& residual, const std::shared_ptr& elig) { + return PyKspOptions {k, selection, max_path_cost, residual, elig}; + }), + py::arg("k"), py::arg("selection"), py::kw_only(), py::arg("max_path_cost") = py::none(), + py::arg("residual") = py::none(), py::arg("elig") = nullptr) + .def_readonly("k", &PyKspOptions::k, "the number of paths as given (validated by ksp: an integer >= 1)") + .def_readonly("selection", &PyKspOptions::selection, "the PathSelection (multipath is ignored)") + .def_readonly("max_path_cost", &PyKspOptions::max_path_cost, + "the inclusive cost ceiling as given: None, Absolute or Stretch (validated by ksp)") + .def_readonly("residual", &PyKspOptions::residual, + "the residual units per EdgeId as given (validated by ksp); None: static capacities, no\n" + "residual gating (edges below 1 unit of capacity are still skipped)") + .def_readonly("elig", &PyKspOptions::elig, "the static Eligibility; None: everything allowed"); + + m.def( + "ksp", + [](const std::shared_ptr& graph, const py::object& src, const py::object& dst, const py::object& opts) { + if (!graph) throw py::value_error("graph must be a Graph"); + const auto o = options_of(opts, "KspOptions"); + const auto n = graph->num_nodes(); + // ksp's validation order on unnarrowed values (Core repeats every check): the selection, src, + // dst, src == dst, k, the ceiling, the residual, then a foreign Eligibility. + detail::check_selection(o.selection); + const NodeId s = endpoint(src, n, "src"); + const NodeId t = endpoint(dst, n, "dst"); + if (s == t) throw py::value_error(detail::overlap_error(s, "src", "dst")); + KspOptions c(count_of(o.k, "k"), o.selection); + if (!o.max_path_cost.is_none()) { + if (py::isinstance(o.max_path_cost)) { + c.max_path_cost = o.max_path_cost.cast(); + } else if (py::isinstance(o.max_path_cost)) { + c.max_path_cost = o.max_path_cost.cast(); + } else { + throw py::type_error("max_path_cost must be Absolute, Stretch or None (got " + + std::string(py::str(py::type::of(o.max_path_cost).attr("__name__"))) + ")"); + } + detail::check_ceiling(c.max_path_cost); + } + std::optional> res; + if (!o.residual.is_none()) res = edge_units(o.residual, *graph, "residual"); + if (res.has_value()) c.residual = std::span(*res); + c.elig = o.elig.get(); + std::vector> paths; + { + py::gil_scoped_release release; + paths = ksp(*graph, s, t, c); + } + // Every object through a checked allocation: a failure raises. + py::list out(paths.size()); + for (std::size_t i = 0; i < paths.size(); ++i) { + py::tuple item(2); + py::int_ cost(paths[i].first); + py::object path = object_from(std::move(paths[i].second)); + PyTuple_SET_ITEM(item.ptr(), 0, cost.release().ptr()); + PyTuple_SET_ITEM(item.ptr(), 1, path.release().ptr()); + PyList_SET_ITEM(out.ptr(), static_cast(i), item.release().ptr()); + } + return out; + }, + py::arg("graph"), py::arg("src"), py::arg("dst"), py::arg("opts"), + "The k shortest simple src -> dst paths over the eligible edges of at least 1 unit, as the options\n" + "further limit them -> [(cost, Path)], each hop a single edge. Order: lowest cost; then, under\n" + "PREFER_HIGHER_PRIORITY only, highest bottleneck (the smallest priority value along the path); then\n" + "fewest hops; then the smallest EdgeId sequence. The result is the first k paths of that order\n" + "within the inclusive cost ceiling. Validated in this order before any work (ValueError): src and\n" + "dst in range, src == dst, k >= 1, the ceiling, the residual (length, then every entry), a foreign\n" + "Eligibility. An excluded endpoint, no static route under Stretch, or no path gives []."); + + m.def( + "resolve_to_paths", + [](const std::shared_ptr& graph, const std::shared_ptr& dag, const py::object& src, + const py::object& dst, bool split_parallel_edges, const py::object& max_paths) { + if (!graph) throw py::value_error("graph must be a Graph"); + if (!dag) throw py::value_error("dag must be a PredDAG"); + if (dag->graph_uid() != graph->uid()) throw py::value_error("foreign PredDAG: built for another graph"); + const auto n = graph->num_nodes(); + const NodeId s = endpoint(src, n, "src"); + const NodeId t = endpoint(dst, n, "dst"); + if (s == t) throw py::value_error(detail::overlap_error(s, "src", "dst")); + std::optional mp; + if (!max_paths.is_none()) mp = count_of(max_paths, "max_paths"); + std::vector paths; + { + py::gil_scoped_release release; + paths = resolve_to_paths(*graph, *dag, s, t, split_parallel_edges, mp); + } + py::list out(paths.size()); + for (std::size_t i = 0; i < paths.size(); ++i) { + PyList_SET_ITEM(out.ptr(), static_cast(i), object_from(std::move(paths[i])).release().ptr()); + } + return out; + }, + py::arg("graph"), py::arg("dag"), py::arg("src"), py::arg("dst"), py::arg("split_parallel_edges") = false, + py::arg("max_paths") = py::none(), + "The src -> dst walks of a DAG -> [Path], in increasing (cost, hops, edge-id sequence):\n" + "a hop costs its cheapest member and is keyed by its lowest member id. split_parallel_edges = False\n" + "keeps every parallel member of a (u, v) pair in one hop (members ascending); True gives one path\n" + "per member combination, sorted by that path's own (cost, hops, edge-id sequence). max_paths\n" + "(>= 1) truncates; None lists every walk.\n" + "ValueError: a DAG of another graph, src or dst out of range, src == dst, max_paths < 1. An\n" + "endpoint outside the DAG, or no walk, gives []."); +} + +} // namespace netgraph::core::bindings diff --git a/bindings/python/placement_result.cpp b/bindings/python/placement_result.cpp new file mode 100644 index 0000000..e579dfb --- /dev/null +++ b/bindings/python/placement_result.cpp @@ -0,0 +1,107 @@ +// Termination and PlacementResult: the outcome of one placement call. +#include +#include +#include + +#include +#include +#include + +#include "netgraph/core/flow_policy.hpp" +#include "values.hpp" +#include "registration.hpp" + +namespace py = pybind11; + +namespace netgraph::core::bindings { + +namespace { + +py::object remaining_obj(const std::optional& r) { + return r.has_value() ? py::object(py::int_(*r)) : py::object(py::float_(std::numeric_limits::infinity())); +} + +} // namespace + +void register_placement_result(py::module_& m) { + py::enum_(m, "Termination", + "How a call ended, per source (local_outcomes) and for the whole call (termination).\n" + "A source's offer is served when it is 0, when (LOSSLESS) the delivery equals it, or when (LOSSY,\n" + "finite offer) all of it was forwarded, drops included. A source's outcome is the first that\n" + "applies:\n" + " COMPLETE: served.\n" + " LIMIT_FLOWS: creating or adding flows was stopped by MAX_FLOWS_PER_CALL, or adding flows by a\n" + " CapacityAware max_flow_count above flow_count.\n" + " LIMIT_DIMINISHING: the diminishing guard stopped adding flows.\n" + " NO_ROUTE: the source has no flows after the call (Global: the maximum flow is 0 and nothing\n" + " was ever placed).\n" + " EXHAUSTED: any other unserved case (every flow full and no new route helps, a full fixed-size\n" + " set of flows, Global at the maximum flow, a LOSSY infinite offer).\n" + "Global and Pinned act as one source. The call's termination is COMPLETE for a request of 0 or\n" + "when every source is served, NO_ROUTE for a positive request with an empty roster, else the\n" + "highest outcome of the unserved sources in the order LIMIT_FLOWS > LIMIT_DIMINISHING >\n" + "EXHAUSTED > NO_ROUTE.") + .value("COMPLETE", Termination::Complete) + .value("LIMIT_FLOWS", Termination::LimitFlows) + .value("LIMIT_DIMINISHING", Termination::LimitDiminishing) + .value("NO_ROUTE", Termination::NoRoute) + .value("EXHAUSTED", Termination::Exhausted); + + py::class_( + m, "PlacementResult", + "One placement call: placed (units), remaining (request - placed; float('inf') for an infinite\n" + "request), termination, local_outcomes [(source, Termination)] per roster source, routes_changed,\n" + "weights_changed, and this call's drops [(EdgeId, units)]. cost_distribution: Global only, the\n" + "marginal cost curve [(cost, units)] of the policy's flow so far, else None: in ascending cost,\n" + "delivering `units` more raised the minimum total flow cost by `cost` per unit (more delivery can\n" + "reroute earlier flow, so these are not the costs of the final paths); a zero call leaves it\n" + "unchanged. Optional outputs, None unless requested:\n" + "edge_flows and residual (int64[E]), bottleneck, and min_cut (Global with bottleneck; None when the\n" + "solve did not reach the maximum flow).") + .def_readonly("placed", &PlacementResult::placed, "the units placed by this call") + .def_property_readonly( + "remaining", [](const PlacementResult& r) { return remaining_obj(r.remaining); }, + "the request minus placed, in units (an amount, not a status); float('inf') for an infinite request") + .def_readonly("termination", &PlacementResult::termination, "the call's outcome (Termination)") + .def_readonly("local_outcomes", &PlacementResult::local_outcomes, + "[(source, Termination)] for every roster source, ascending node id") + .def_readonly("routes_changed", &PlacementResult::routes_changed, + "True when this call committed a route change: a flow created (also at zero delivery), rerouted\n" + "or added, or a change in the edges a Global source's share uses; a candidate equal to the\n" + "current route is not a change") + .def_readonly("weights_changed", &PlacementResult::weights_changed, + "True when this call committed new weights for a rerouted WEIGHTED flow") + .def_property_readonly( + "drops", [](const PlacementResult& r) { return pairs_list(r.drops); }, + "this call's transit drops [(EdgeId, units)], ascending EdgeId (empty unless Lossy; records\n" + "keep the cumulative drops)") + .def_readonly("cost_distribution", &PlacementResult::cost_distribution, + "Global: the marginal cost curve [(cost, units)] of the policy's cumulative flow (a zero\n" + "call reports it unchanged); None for the other routing modes") + .def_property_readonly( + "edge_flows", + [](const PlacementResult& r) -> py::object { + return r.edge_flows ? py::object(int64_array(*r.edge_flows)) : py::object(py::none()); + }, + "if requested: the policy's own allocation per edge after the call (int64[E], EdgeId order),\n" + "else None") + .def_property_readonly( + "residual", + [](const PlacementResult& r) -> py::object { + return r.residual ? py::object(int64_array(*r.residual)) : py::object(py::none()); + }, + "if requested: the ledger's residual after the call (int64[E], EdgeId order), else None") + .def_readonly("bottleneck", &PlacementResult::bottleneck, + "if requested: the ledger's saturation report after the call (FlowLedger.bottleneck of the\n" + "policy's sources and Eligibility), else None") + .def_readonly("min_cut", &PlacementResult::min_cut, + "Global with bottleneck requested: a minimum cut (edges whose removal separates the sources from\n" + "the destinations, with the smallest total capacity) of the policy's view (free capacity plus its\n" + "own allocation), when the solve reached the maximum flow; None otherwise (also on a zero call)") + .def("__repr__", [](const PlacementResult& r) { + return "PlacementResult(placed=" + std::to_string(r.placed) + ", termination=" + + std::string(py::str(py::cast(r.termination).attr("name"))) + ")"; + }); +} + +} // namespace netgraph::core::bindings diff --git a/bindings/python/policy_config.cpp b/bindings/python/policy_config.cpp new file mode 100644 index 0000000..370b91d --- /dev/null +++ b/bindings/python/policy_config.cpp @@ -0,0 +1,68 @@ +// FlowPolicyConfig and OutputOptions: the configuration of a placement. +#include + +#include +#include +#include + +#include "netgraph/core/flow_policy.hpp" +#include "netgraph/core/options.hpp" +#include "registration.hpp" + +namespace py = pybind11; + +namespace netgraph::core::bindings { + +namespace { + +Routing routing_of(const py::object& obj) { + if (py::isinstance(obj)) return obj.cast(); + if (py::isinstance(obj)) return obj.cast(); + if (py::isinstance(obj)) return obj.cast(); + if (py::isinstance(obj)) return obj.cast(); + throw py::type_error("routing must be CostOnly, CapacityAware, Global or Pinned (got " + + std::string(py::str(py::type::of(obj).attr("__name__"))) + ")"); +} + +py::object routing_obj(const Routing& r) { + return std::visit([](const auto& x) { return py::cast(x); }, r); +} + +} // namespace + +void register_policy_config(py::module_& m) { + py::class_( + m, "FlowPolicyConfig", + "The four explicit axes (independent settings) of a policy: routing (CostOnly, CapacityAware,\n" + "Global or Pinned), placement (FlowPlacement), admission (Admission) and selection\n" + "(PathSelection). A placement or admission outside its enumeration raises ValueError.") + .def(py::init([](const py::object& routing, FlowPlacement placement, Admission admission, + const PathSelection& selection) { + // The routing's structure first (TypeError), then the constructor's enumeration checks. + auto r = routing_of(routing); + return FlowPolicyConfig {std::move(r), placement, admission, selection}; + }), + py::kw_only(), py::arg("routing"), py::arg("placement"), py::arg("admission"), py::arg("selection")) + .def_property_readonly( + "routing", [](const FlowPolicyConfig& c) { return routing_obj(c.routing); }, + "the routing mode: CostOnly, CapacityAware, Global or Pinned") + .def_readonly("placement", &FlowPolicyConfig::placement, "how every flow spreads over its route (FlowPlacement)") + .def_readonly("admission", &FlowPolicyConfig::admission, "LOSSLESS or LOSSY") + .def_readonly("selection", &FlowPolicyConfig::selection, "the PathSelection the routing's searches read") + .def("__repr__", [](const FlowPolicyConfig& c) { + return "FlowPolicyConfig(routing=" + std::string(py::repr(routing_obj(c.routing))) + ")"; + }); + + py::class_(m, "OutputOptions", + "Optional outputs of a placement (all false by default; absent outputs are None):\n" + "edge_flows (the policy's own allocation per edge after the call), residual and\n" + "bottleneck (the ledger's after the call); on Global, bottleneck adds min_cut.") + .def(py::init(), py::kw_only(), py::arg("edge_flows") = false, py::arg("residual") = false, + py::arg("bottleneck") = false) + .def_readonly("edge_flows", &OutputOptions::edge_flows, "request PlacementResult.edge_flows") + .def_readonly("residual", &OutputOptions::residual, "request PlacementResult.residual") + .def_readonly("bottleneck", &OutputOptions::bottleneck, + "request PlacementResult.bottleneck (and min_cut on Global)"); +} + +} // namespace netgraph::core::bindings diff --git a/bindings/python/pred_dag.cpp b/bindings/python/pred_dag.cpp new file mode 100644 index 0000000..a388868 --- /dev/null +++ b/bindings/python/pred_dag.cpp @@ -0,0 +1,127 @@ +// PredDAG: the immutable predecessor DAG of one graph. +#include +#include +#include + +#include +#include +#include + +#include "netgraph/core/graph.hpp" +#include "netgraph/core/pred_dag.hpp" +#include "integers.hpp" +#include "values.hpp" +#include "registration.hpp" + +namespace py = pybind11; + +namespace netgraph::core::bindings { + +namespace { + +// The exact order of wide integers: a positive overflow above every int64, a negative one +// below, and two overflows on the same side by their exact values. +bool wide_less(const WideInts& w, std::size_t i, std::size_t j) { + if (w.sign(i) != w.sign(j)) return w.sign(i) < w.sign(j); + if (w.sign(i) == 0) return w.value[i] < w.value[j]; + const int less = PyObject_RichCompareBool(w.exact(i).ptr(), w.exact(j).ptr(), Py_LT); + if (less < 0) throw py::error_already_set(); + return less == 1; +} + +} // namespace + +void register_pred_dag(py::module_& m) { + py::class_>( + m, "PredDAG", + "Immutable route structure of one graph, a predecessor DAG (directed, no cycle): for each node v,\n" + "the edges that reach v on the route and the node each comes from (its parent). The entries of v\n" + "are parents()[k], via_edges()[k] for k in [parent_offsets()[v], parent_offsets()[v + 1]), in\n" + "strictly ascending EdgeId, so two DAGs of the same graph with the same entries have equal arrays\n" + "and compare equal; DAGs of different graphs never compare equal.\n" + "parents() and via_edges() are read-only views that keep the DAG alive; parent_offsets() is a new\n" + "read-only array on each call (the DAG stores only the nodes that have entries). Built by\n" + "PredDAG.from_csr, Path.to_dag(), the searches and the policies (FlowRecord.bundle).") + .def_static( + "from_csr", + [](const std::shared_ptr& graph, const py::object& offsets, const py::object& parents, + const py::object& via) { + if (!graph) throw py::value_error("graph must be a Graph"); + // from_csr's validation order on unnarrowed values (the shape, then the ranges); the C++ + // constructor re-checks it and adds the structural checks (endpoints, duplicates, + // acyclic, ascending). + const auto n = static_cast(graph->num_nodes()); + const auto edge_count = static_cast(graph->num_edges()); + const std::size_t lo = sequence_length(offsets, "offsets"); + const std::size_t lp = sequence_length(parents, "parents"); + const std::size_t lv = sequence_length(via, "via"); + if (lo != n + 1) { + throw py::value_error("PredDAG.from_csr: offsets has length " + std::to_string(lo) + + ", expected N + 1 = " + std::to_string(n + 1)); + } + const auto o = wide_integers(offsets, "offsets"); + if (!o.in(0, 0, 0)) throw py::value_error("PredDAG.from_csr: offsets[0] = " + o.text(0) + ", expected 0"); + for (std::size_t v = 0; v < n; ++v) { + if (wide_less(o, v + 1, v)) { + throw py::value_error("PredDAG.from_csr: offsets are not monotone at " + std::to_string(v + 1) + " (" + + o.text(v) + " > " + o.text(v + 1) + ")"); + } + } + if (!o.in(n, static_cast(lp), static_cast(lp)) || lp != lv) { + throw py::value_error("PredDAG.from_csr: offsets[N] = " + o.text(n) + " but parents has " + + std::to_string(lp) + " and via has " + std::to_string(lv) + " entries"); + } + const auto p = wide_integers(parents, "parents"); + const auto w = wide_integers(via, "via"); + for (std::size_t k = 0; k < lp; ++k) { + if (!p.in(k, 0, static_cast(n) - 1)) { + throw py::value_error("PredDAG.from_csr: parents[" + std::to_string(k) + "] = " + p.text(k) + + " out of node range [0, " + std::to_string(n) + ")"); + } + if (!w.in(k, 0, edge_count - 1)) { + throw py::value_error("PredDAG.from_csr: via[" + std::to_string(k) + "] = " + w.text(k) + + " out of edge range [0, " + std::to_string(edge_count) + ")"); + } + } + const auto ov = o.narrow(); + const auto pv = p.narrow(); + const auto wv = w.narrow(); + return std::make_shared(PredDAG::from_csr(*graph, ov, pv, wv)); + }, + py::arg("graph"), py::arg("offsets"), py::arg("parents"), py::arg("via"), + "Validates, in order, before any traversal (ValueError on the first defect): offsets of\n" + "length N + 1, offsets[0] == 0, monotone, offsets[N] == len(parents) == len(via); then every\n" + "parent and via in range, every via joining (parent, child), no duplicate (child, via),\n" + "acyclic; within a node the entries in ascending via EdgeId.") + .def("graph_uid", &PredDAG::graph_uid, "uid of the graph this DAG belongs to") + .def( + "parent_offsets", + [](const PredDAG& d) { + const auto off = d.parent_offsets(); + py::array_t arr(static_cast(off.size())); + std::copy(off.begin(), off.end(), arr.mutable_data()); + py::detail::array_proxy(arr.ptr())->flags &= ~py::detail::npy_api::NPY_ARRAY_WRITEABLE_; + return arr; + }, + "int32[N + 1]: the entries of node v are [parent_offsets[v], parent_offsets[v + 1]); a new read-only array per call") + .def( + "parents", [](const py::object& self) { return readonly_view(self.cast().parents(), self); }, + "int32 parent NodeId per entry (read-only view)") + .def( + "via_edges", + [](const py::object& self) { return readonly_view(self.cast().via_edges(), self); }, + "int32 via EdgeId per entry (read-only view)") + .def("__eq__", [](const PredDAG& a, const py::object& b) { + return py::isinstance(b) && a == b.cast(); + }) + .def("__hash__", [](const PredDAG& a) { + return py::hash(py::make_tuple(a.graph_uid(), py::tuple(py::cast(std::vector(a.via_edges().begin(), + a.via_edges().end()))))); + }) + .def("__repr__", [](const PredDAG& a) { + return "PredDAG(graph_uid=" + std::to_string(a.graph_uid()) + ", entries=" + + std::to_string(a.via_edges().size()) + ")"; + }); +} + +} // namespace netgraph::core::bindings diff --git a/bindings/python/publication.hpp b/bindings/python/publication.hpp new file mode 100644 index 0000000..4809b01 --- /dev/null +++ b/bindings/python/publication.hpp @@ -0,0 +1,89 @@ +/* + The checked construction of result objects: the Python object of a bound class is allocated + through a checked tp_alloc, so an allocation failure raises instead of crashing, and a policy + call makes its result object before it changes anything. +*/ +#pragma once + +#include +#include +#include + +#include +#include +#include + +#include "netgraph/core/evaluators.hpp" +#include "netgraph/core/flow_policy.hpp" + +namespace py = pybind11; + +namespace netgraph::core::bindings { + +// A new Python object of the bound class T owning a T made from `args`, made like pybind11's +// generic move-cast (type_caster_generic::cast) but with the object allocation checked: +// pybind11's make_new_instance uses the result of tp_alloc unchecked, so a failed Python +// allocation there is a crash rather than a MemoryError. Holder is the class's holder type +// (std::unique_ptr by default, std::shared_ptr for PredDAG and Path); another type is +// refused before anything is made. +// The steps, in order (new_object and object_from both use them): (1) allocate the object and +// check for null; (2) hold it in `obj` at once, so any later failure releases it; (3) allocate +// its layout; (4) make the value inside a local holder; (5) register the value and let the +// object take the holder; (6) mark the object as owner. Every failure raises and frees what was +// made exactly once. Until step 5 succeeds the object owns nothing: the local holder frees the +// value (std::shared_ptr also deletes it when its control block cannot be allocated), and the +// object is released without touching it. Taking the holder never allocates (pybind11 copies or +// moves it). +template +std::pair emplace_object(Args&&... args) { + static_assert(std::is_same_v> || std::is_same_v>); + const py::detail::type_info* tinfo = py::detail::get_type_info(typeid(T), /*throw_if_missing=*/true); + constexpr auto holder_kind = std::is_same_v> ? py::detail::holder_enum_t::std_shared_ptr + : py::detail::holder_enum_t::std_unique_ptr; + if (tinfo->holder_enum_v != holder_kind) { + py::pybind11_fail("emplace_object: Holder is not the holder of the bound class"); + } + PyTypeObject* type = tinfo->type; + PyObject* raw = type->tp_alloc(type, 0); + if (raw == nullptr) throw py::error_already_set(); + auto obj = py::reinterpret_steal(raw); + auto* inst = reinterpret_cast(raw); + inst->allocate_layout(); + inst->owned = false; + Holder holder(new T(std::forward(args)...)); + T* value = holder.get(); + py::detail::values_and_holders(inst).begin()->value_ptr() = value; + tinfo->init_instance(inst, &holder); + inst->owned = true; + return {std::move(obj), value}; +} + +// A new Python object of the bound class T (default holder) owning a default-constructed T +// (emplace_object). The policy calls make their result object with this before they change +// anything. +template +std::pair new_object() { + return emplace_object>(); +} + +// The value moved into its result object after the commit: nothing may allocate there. +static_assert(std::is_nothrow_move_assignable_v); +static_assert(std::is_nothrow_move_assignable_v); +static_assert(std::is_nothrow_move_assignable_v); + +template +py::object object_of(T value) { + auto [obj, slot] = new_object(); + *slot = std::move(value); + return std::move(obj); +} + +// A new Python object of the bound class T (held by std::shared_ptr) owning `value`, through the +// same checked allocation as new_object: for the values without a default constructor (PredDAG, +// Path) that a search returns. A failure raises and releases everything made so far. +template +py::object object_from(T value) { + return emplace_object>(std::move(value)).first; +} + +} // namespace netgraph::core::bindings diff --git a/bindings/python/recorder.cpp b/bindings/python/recorder.cpp new file mode 100644 index 0000000..0faea8a --- /dev/null +++ b/bindings/python/recorder.cpp @@ -0,0 +1,109 @@ +// The private entry _place_global_units, registered in both builds: the flow core's +// minimum-cost flow from zero on a given view, with its full payload for the flow checks. +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "netgraph/core/detail/endpoints.hpp" +#include "netgraph/core/detail/flow_core.hpp" +#include "netgraph/core/eligibility.hpp" +#include "netgraph/core/graph.hpp" +#include "integers.hpp" +#include "registration.hpp" + +namespace py = pybind11; + +namespace netgraph::core::bindings { + +namespace { + +py::list int_list(const std::vector& v) { + py::list out(v.size()); + for (std::size_t i = 0; i < v.size(); ++i) out[i] = py::int_(v[i]); + return out; +} + +} // namespace + +void register_recorder(py::module_& m) { + m.def( + "_place_global_units", + [](const std::shared_ptr& graph, const py::object& view, const py::object& sources, + const py::object& destinations, const std::shared_ptr& elig, + const py::object& cap) -> py::dict { + if (!graph) throw py::value_error("graph must be a Graph"); + // The core's order (view length, view entries, sources, destinations, overlap, cap, + // Eligibility), every value checked before narrowing; the core re-checks the same order. + const auto v = edge_amounts(view, *graph, "view", "view (capacity units)"); + const auto s = checked_ids(wide_integers(sources, "sources node ids"), graph->num_nodes(), "sources"); + const auto d = checked_ids(wide_integers(destinations, "destinations node ids"), graph->num_nodes(), "destinations"); + detail::check_disjoint(detail::node_set(s, graph->num_nodes(), "sources"), + detail::node_set(d, graph->num_nodes(), "destinations"), "sources", "destinations"); + std::optional c; + if (!cap.is_none()) { + if (is_bool(cap) || !PyIndex_Check(cap.ptr())) { + throw py::value_error("cap must be an integer number of units in [0, 2^63 - 2] or None"); + } + py::object idx = python_int(cap, "cap"); + int overflow = 0; + const long long value = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); + if (overflow != 0 || value < 0 || value > detail::kMaxFlowCap) { + throw py::value_error("cap " + std::string(py::str(idx)) + " is outside [0, 2^63 - 2]"); + } + c = static_cast(value); + } + detail::FlowCoreResult r; + { + py::gil_scoped_release release; + const auto view_units = v.narrow(); + r = detail::place_global_units(*graph, view_units, s, d, elig.get(), c); + } + py::dict out; + out["placed"] = py::int_(r.placed); + py::list curve; + for (const auto& [cost, units] : r.cost_distribution) { + py::list entry; + entry.append(py::int_(cost)); + entry.append(py::int_(units)); + curve.append(entry); + } + out["cost_distribution"] = curve; + out["edge_flows"] = int_list(r.edge_flows); + if (r.min_cut.has_value()) { + py::list cut; + for (EdgeId e : *r.min_cut) cut.append(py::int_(e)); + out["min_cut"] = cut; + } else { + out["min_cut"] = py::none(); + } + out["potentials"] = int_list(r.potentials); + py::dict counters; + counters["dijkstra_runs"] = py::int_(r.counters.dijkstra_runs); + counters["dijkstra_pops"] = py::int_(r.counters.dijkstra_pops); + counters["settled"] = py::int_(r.counters.settled); + counters["arc_scans"] = py::int_(r.counters.arc_scans); + counters["dinic_phases"] = py::int_(r.counters.dinic_phases); + counters["augmentations"] = py::int_(r.counters.augmentations); + out["counters"] = counters; + return out; + }, + py::arg("graph"), py::arg("view"), py::arg("sources"), py::arg("destinations"), py::arg("elig"), + py::arg("cap"), + "Private flow-core entry: the largest flow up to cap (None: no cap) from the source set to the\n" + "destination set over the eligible edges, sent at the lowest total cost, on the capacities `view`\n" + "(int64 units, EdgeId order), starting from zero flow. Returns {placed, cost_distribution:\n" + "[[cost, units]], edge_flows, min_cut (None while the destinations are still reachable, because\n" + "the cap stopped the solve, and on cap 0), potentials (per-node prices from the shortest-path runs\n" + "that prove the flow is cheapest; -1 = never reached), counters}. Invalid inputs raise ValueError\n" + "before any work. A solve (cap not 0) then raises ValueError, before the solver sizes anything, on\n" + "a network beyond the flow core's int32 numbering: more than INT32_MAX - 2 nodes, or more than\n" + "2^30 - 1 eligible edges, eligible sources and eligible destinations together."); +} + +} // namespace netgraph::core::bindings diff --git a/bindings/python/registration.hpp b/bindings/python/registration.hpp new file mode 100644 index 0000000..82020f2 --- /dev/null +++ b/bindings/python/registration.hpp @@ -0,0 +1,40 @@ +/* + The registration functions of the module _netgraph_core, one file each under bindings/python/. + The module entry (module.cpp) calls them in this order, which is the order of the module's + attributes: a default argument or a conversion of a later binding needs the types registered + before it. The private recorder entry (_place_global_units) is registered + in both builds; only register_private_hooks and the private members of FlowLedger and + FlowPolicy depend on NETGRAPH_CORE_PRIVATE_HOOKS. +*/ +#pragma once + +#include +#include +#include + +#include "netgraph/core/detail/hooks_flag.hpp" + +namespace netgraph::core::bindings { + +void register_axes(pybind11::module_& m); // the axes' enumerations, PathSelection +void register_units(pybind11::module_& m); // to_units, as_float +void register_eligibility(pybind11::module_& m); // Eligibility +void register_graph(pybind11::module_& m); // Graph +void register_recorder(pybind11::module_& m); // _place_global_units (both builds) +void register_pred_dag(pybind11::module_& m); // PredDAG +void register_path(pybind11::module_& m); // Path +void register_flow_record(pybind11::module_& m); // FlowRecord +void register_flow_ledger(pybind11::module_& m); // FlowLedger +void register_routing(pybind11::module_& m); // Absolute, Stretch, DiminishingReturns, the four routings +void register_policy_config(pybind11::module_& m); // FlowPolicyConfig, OutputOptions +void register_placement_result(pybind11::module_& m); // Termination, PlacementResult +void register_dag_cache(pybind11::module_& m); // DagCacheStats, DagCacheMiss, DagCache +void register_flow_policy(pybind11::module_& m); // FlowPolicy +void register_evaluators(pybind11::module_& m); // capacity, batch_capacity, sensitivity and their types +void register_paths(pybind11::module_& m); // KspOptions, ksp, resolve_to_paths +void register_search(pybind11::module_& m); // UNREACHABLE, SpfOptions, SpfResult, spf +#if NETGRAPH_CORE_PRIVATE_HOOKS +void register_private_hooks(pybind11::module_& m); // the test controls and diagnostics +#endif + +} // namespace netgraph::core::bindings diff --git a/bindings/python/routing.cpp b/bindings/python/routing.cpp new file mode 100644 index 0000000..807c558 --- /dev/null +++ b/bindings/python/routing.cpp @@ -0,0 +1,231 @@ +// The routing modes of a policy (CostOnly, CapacityAware, Global, Pinned) and their parameters: +// the cost ceilings Absolute and Stretch, the DiminishingReturns guard. +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/flow_policy.hpp" +#include "netgraph/core/options.hpp" +#include "netgraph/core/pred_dag.hpp" +#include "integers.hpp" +#include "registration.hpp" + +namespace py = pybind11; + +namespace netgraph::core::bindings { + +namespace { + +// An optional int field of [lo, hi] (None allowed when `optional`); bools and floats rejected. +std::optional int_field(const py::object& obj, std::int64_t lo, std::int64_t hi, const std::string& what, + bool optional) { + if (obj.is_none()) { + if (optional) return std::nullopt; + throw py::value_error(what + " must be an integer (got None)"); + } + return integer_value(obj, lo, hi, what); +} + +// A float field: a Python float, an integer or a NumPy floating scalar; bools and other types +// rejected. The conversion order is Python's float(): a float as it is, then the object's own +// __float__, then its __index__. A Python integer beyond the finite doubles (the int itself, or the +// one its __index__ returned) is outside the field's domain (ValueError); every exception raised by +// the object's own __float__ or __index__, OverflowError included, propagates as raised. +double float_field(const py::object& obj, const std::string& what) { + if (is_bool(obj) || !(py::isinstance(obj) || PyIndex_Check(obj.ptr()) || + py::isinstance(obj, py::module_::import("numpy").attr("floating")))) { + throw py::value_error(what + " must be a number (got " + std::string(py::str(py::type::of(obj).attr("__name__"))) + + ")"); + } + if (PyFloat_Check(obj.ptr())) return PyFloat_AS_DOUBLE(obj.ptr()); + const PyNumberMethods* nb = Py_TYPE(obj.ptr())->tp_as_number; + const bool own_float = nb != nullptr && nb->nb_float != nullptr && nb->nb_float != PyLong_Type.tp_as_number->nb_float; + if (own_float) { // the object's own __float__ (a NumPy scalar, a class defining it) + const double x = PyFloat_AsDouble(obj.ptr()); + if (x == -1.0 && PyErr_Occurred() != nullptr) throw py::error_already_set(); + return x; + } + // A Python int (int's own __float__ only narrows it), or an object converted by its __index__. + py::object idx = py::reinterpret_borrow(obj); + if (!PyLong_Check(obj.ptr())) { + idx = py::reinterpret_steal(PyNumber_Index(obj.ptr())); + if (!idx) throw py::error_already_set(); + } + const double x = PyLong_AsDouble(idx.ptr()); + if (x == -1.0 && PyErr_Occurred() != nullptr) { + if (!PyErr_ExceptionMatches(PyExc_OverflowError)) throw py::error_already_set(); + PyErr_Clear(); + throw py::value_error(what + " must be a number in the float range (got an integer beyond the largest finite float)"); + } + return x; +} + +} // namespace + +void register_routing(py::module_& m) { + py::class_(m, "Absolute", "Cost ceiling: an absolute, inclusive path-cost budget (cost >= 0).") + .def(py::init([](const py::object& cost) { + return Absolute {*int_field(cost, std::numeric_limits::min(), std::numeric_limits::max(), + "Absolute cost", false)}; + }), + py::arg("cost"), "Absolute(cost): a 64-bit integer budget in cost units (checked >= 0 where it is used)") + .def_readonly("cost", &Absolute::cost, "the budget: a path qualifies when its cost is at most this") + .def("__eq__", [](const Absolute& a, const py::object& b) { return py::isinstance(b) && a == b.cast(); }) + .def("__hash__", [](const Absolute& a) { return py::hash(py::make_tuple("Absolute", a.cost)); }) + .def("__repr__", [](const Absolute& a) { return "Absolute(" + std::to_string(a.cost) + ")"; }); + + py::class_(m, "Stretch", + "Cost ceiling: a route qualifies when its cost is at most factor times the static shortest cost\n" + "under the Eligibility; factor is in [1, inf), and the float is compared exactly, without rounding.") + .def(py::init([](const py::object& factor) { return Stretch {float_field(factor, "Stretch factor")}; }), + py::arg("factor"), + "Stretch(factor): a number, checked finite and >= 1 where it is used; a path qualifies when its\n" + "cost is at most factor times the static shortest cost. Here, an integer beyond the largest finite\n" + "float raises ValueError.") + .def_readonly("factor", &Stretch::factor, "the factor as a float") + .def("__eq__", [](const Stretch& a, const py::object& b) { return py::isinstance(b) && a == b.cast(); }) + .def("__hash__", [](const Stretch& a) { return py::hash(py::make_tuple("Stretch", a.factor)); }) + .def("__repr__", [](const Stretch& a) { return "Stretch(" + std::string(py::str(py::float_(a.factor))) + ")"; }); + + py::class_(m, "DiminishingReturns", + "The diminishing-returns guard of CapacityAware growth: stop once at least `window`\n" + "growth steps were accepted in the call and the last `window` gains sum below\n" + "`fraction` times the source's (finite) offer. window >= 1, fraction in (0, 1).") + .def(py::init([](const py::object& window, const py::object& fraction) { + DiminishingReturns d; + d.window = static_cast(*int_field(window, std::numeric_limits::min(), + std::numeric_limits::max(), + "diminishing window", false)); + d.fraction = float_field(fraction, "diminishing fraction"); + return d; + }), + py::arg("window"), py::arg("fraction"), + "DiminishingReturns(window, fraction): a 32-bit integer and a number, checked (window >= 1,\n" + "fraction in (0, 1)) when a policy is built with them. Here, a window outside 32 bits or a fraction\n" + "that is an integer beyond the largest finite float raises ValueError.") + .def_readonly("window", &DiminishingReturns::window, "the number of recent accepted growth steps compared") + .def_readonly("fraction", &DiminishingReturns::fraction, + "the share of the source's offer those steps must gain together to continue") + .def("__repr__", [](const DiminishingReturns& d) { + return "DiminishingReturns(window=" + std::to_string(d.window) + ", fraction=" + std::string(py::str(py::float_(d.fraction))) + ")"; + }); + + py::class_(m, "CostOnly", + "Routing like IGP forwarding (OSPF, IS-IS routers): one flow per roster source on the static\n" + "shortest-path DAG (read from the DagCache; the selection is applied when the flow is created),\n" + "never refreshed, grown or reoptimised.") + .def(py::init<>(), "CostOnly routing (no parameters)") + .def("__repr__", [](const CostOnly&) { return std::string("CostOnly()"); }); + + py::class_( + m, "CapacityAware", + "Routing like distributed CSPF tunnels (each tunnel head picks its own shortest path among edges\n" + "with enough free capacity). Each source creates flow_count flows, one search at a time, on edges\n" + "with residual >= max(1, min_flow_bandwidth) units and within max_path_cost; while its offer is\n" + "unmet it adds flows up to max_flow_count (None: no ceiling). With reoptimize, a flow moves to a\n" + "new route only when it keeps its whole amount there. `diminishing` (None: off) stops adding flows\n" + "once the recent ones gain too little.") + .def(py::init([](const py::object& flow_count, const py::object& max_flow_count, const py::object& max_path_cost, + const py::object& min_flow_bandwidth, bool reoptimize, const py::object& diminishing) { + CapacityAware ca; + constexpr auto lo32 = std::numeric_limits::min(); + constexpr auto hi32 = std::numeric_limits::max(); + ca.flow_count = static_cast(*int_field(flow_count, lo32, hi32, "flow_count", false)); + if (auto mx = int_field(max_flow_count, lo32, hi32, "max_flow_count", true)) { + ca.max_flow_count = static_cast(*mx); + } + if (!max_path_cost.is_none()) { + if (py::isinstance(max_path_cost)) { + ca.max_path_cost = max_path_cost.cast(); + } else if (py::isinstance(max_path_cost)) { + ca.max_path_cost = max_path_cost.cast(); + } else { + throw py::type_error("max_path_cost must be Absolute, Stretch or None"); + } + } + ca.min_flow_bandwidth = *int_field(min_flow_bandwidth, std::numeric_limits::min(), + std::numeric_limits::max(), "min_flow_bandwidth (units)", false); + ca.reoptimize = reoptimize; + if (!diminishing.is_none()) { + if (!py::isinstance(diminishing)) throw py::type_error("diminishing must be DiminishingReturns or None"); + ca.diminishing = diminishing.cast(); + } + return ca; + }), + py::kw_only(), py::arg("flow_count") = 1, py::arg("max_flow_count") = py::none(), + py::arg("max_path_cost") = py::none(), py::arg("min_flow_bandwidth") = 0, py::arg("reoptimize") = false, + py::arg("diminishing") = py::none(), + "The fields are checked when a policy is built with them (flow_count >= 1, max_flow_count >=\n" + "flow_count, min_flow_bandwidth in [0, 2^62) units, the ceiling, the guard). Here, an integer field\n" + "of the wrong type or outside its integer type (32-bit flow_count and max_flow_count, 64-bit\n" + "min_flow_bandwidth) raises ValueError; a ceiling or guard of the wrong class, a reoptimize that is\n" + "not a bool, a number or None (a number or None counts by its truth value), and an unknown keyword\n" + "raise TypeError.") + .def_readonly("flow_count", &CapacityAware::flow_count, + "the flows a source creates on its first positive request") + .def_readonly("max_flow_count", &CapacityAware::max_flow_count, + "the most flows growth may reach per source; None: no ceiling") + .def_property_readonly( + "max_path_cost", + [](const CapacityAware& ca) -> py::object { + if (!ca.max_path_cost.has_value()) return py::none(); + return std::visit([](const auto& x) { return py::cast(x); }, *ca.max_path_cost); + }, + "the inclusive route cost ceiling (Absolute or Stretch); None: no ceiling") + .def_readonly("min_flow_bandwidth", &CapacityAware::min_flow_bandwidth, + "units in [0, 2^62): every search keeps only edges with at least max(1, this) residual units") + .def_readonly("reoptimize", &CapacityAware::reoptimize, + "True: a retained flow moves to a newly found route when it keeps its whole amount there") + .def_readonly("diminishing", &CapacityAware::diminishing, "the growth guard; None: off") + .def("__repr__", [](const CapacityAware& ca) { + return "CapacityAware(flow_count=" + std::to_string(ca.flow_count) + ", max_flow_count=" + + (ca.max_flow_count ? std::to_string(*ca.max_flow_count) : std::string("None")) + ")"; + }); + + py::class_(m, "Global", + "Routing: an ideal central controller that sees the whole network. Every positive call re-solves\n" + "the minimum-cost flow of what the policy already delivers plus this call's request\n" + "(placed_demand() + volume; what an earlier call could not place is not requested again) over\n" + "the eligible graph, on the free capacity plus its own allocation (it may move its own traffic,\n" + "never another's). Placement MAX_FLOW only. A positive call on a network beyond the flow core's\n" + "int32 numbering raises ValueError (see FlowPolicy.place).") + .def(py::init<>(), "Global routing (no parameters)") + .def("__repr__", [](const Global&) { return std::string("Global()"); }); + + py::class_(m, "Pinned", + "Routing on routes the caller gives: one flow per usable bundle (a PredDAG with at least one\n" + "src -> dst walk over eligible edges of at least 1 unit of capacity), in the given order; one\n" + "source and one destination. A Path converts with to_dag(). The list is copied at construction.") + .def(py::init([](const py::object& bundles) { + if (not_a_sequence(bundles)) throw py::type_error("Pinned bundles must be a sequence of PredDAG"); + Pinned p; + for (const auto& b : py::reinterpret_borrow(bundles)) { + if (!py::isinstance(b)) { + throw py::type_error("Pinned bundles must be PredDAG values (a Path converts with to_dag())"); + } + p.bundles.push_back(b.cast>()); + } + return p; + }), + py::arg("bundles"), + "Pinned(bundles): a sequence of PredDAG of one graph, copied; the policy checks them when it is\n" + "built (non-empty, no duplicates, each with a src -> dst walk)") + .def_property_readonly( + "bundles", + [](const Pinned& p) { + py::list out; + for (const auto& b : p.bundles) out.append(std::const_pointer_cast(b)); + return out; + }, + "the bundles in supply order (a new list of the shared, immutable PredDAG values)") + .def("__repr__", [](const Pinned& p) { return "Pinned(bundles=" + std::to_string(p.bundles.size()) + ")"; }); +} + +} // namespace netgraph::core::bindings diff --git a/bindings/python/search.cpp b/bindings/python/search.cpp new file mode 100644 index 0000000..d4039d2 --- /dev/null +++ b/bindings/python/search.cpp @@ -0,0 +1,182 @@ +// spf with SpfOptions and SpfResult, the public search, and UNREACHABLE. +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/detail/endpoints.hpp" +#include "netgraph/core/detail/enum_domain.hpp" +#include "netgraph/core/eligibility.hpp" +#include "netgraph/core/graph.hpp" +#include "netgraph/core/options.hpp" +#include "netgraph/core/pred_dag.hpp" +#include "netgraph/core/search.hpp" +#include "netgraph/core/types.hpp" +#include "integers.hpp" +#include "publication.hpp" +#include "values.hpp" +#include "registration.hpp" + +namespace py = pybind11; + +namespace netgraph::core::bindings { + +namespace { + +// The Python form of SpfOptions: the arguments as given (targets, residual and min_residual are +// validated at the call, in spf's validation order, on their unnarrowed values); the Eligibility is +// held by reference count. +struct PySpfOptions { + Direction direction; + PathSelection selection; + py::object targets; + py::object residual; + py::object min_residual; + std::shared_ptr elig; +}; + +// The Python form of SpfResult: every field already a Python object (made by the call through +// the checked allocations), so reading one allocates nothing. +struct PySpfResult { + py::object costs; // a read-only int64 array (full searches) or None + py::object dag; // PredDAG + py::object target_cost; // an int, or None (no target reached, or a full search) +}; + +} // namespace + +void register_search(py::module_& m) { + m.attr("UNREACHABLE") = py::int_(kUnreachable); + + py::class_( + m, "SpfOptions", + "The options of spf: direction and selection are explicit; targets (one NodeId or a sequence;\n" + "None: a full search) stops the search at the nearest targets (anycast: any nearest target will\n" + "do); residual (int64 units per EdgeId, each in [0, capacity_units]) makes the search skip edges\n" + "with fewer than min_residual residual units (an integer >= 1; without a residual it has no\n" + "effect) and supplies the priority values; elig (None: everything allowed). A direction outside its\n" + "enumeration raises ValueError here; the other fields are validated by spf, in the order its\n" + "docstring gives.") + .def(py::init([](Direction direction, const PathSelection& selection, const py::object& targets, + const py::object& residual, const py::object& min_residual, + const std::shared_ptr& elig) { + detail::check_member(direction, "direction"); + return PySpfOptions {direction, selection, targets, residual, min_residual, elig}; + }), + py::arg("direction"), py::arg("selection"), py::kw_only(), py::arg("targets") = py::none(), + py::arg("residual") = py::none(), py::arg("min_residual") = 1, py::arg("elig") = nullptr) + .def_readonly("direction", &PySpfOptions::direction, "FORWARD or REVERSE") + .def_readonly("selection", &PySpfOptions::selection, "the PathSelection the search reads") + .def_readonly("targets", &PySpfOptions::targets, "the targets as given (validated by spf); None: a full search") + .def_readonly("residual", &PySpfOptions::residual, + "the residual units per EdgeId as given (validated by spf); None: static capacities, no\n" + "residual gating (edges below 1 unit of capacity are still skipped)") + .def_readonly("min_residual", &PySpfOptions::min_residual, + "the gating threshold in units as given (validated by spf: an integer >= 1)") + .def_readonly("elig", &PySpfOptions::elig, "the static Eligibility; None: everything allowed"); + + py::class_(m, "SpfResult", + "The result of spf: costs (full searches: a read-only int64 array, UNREACHABLE for the nodes not\n" + "reached; None with targets), dag (PredDAG of the tight edges, the edges on some shortest walk,\n" + "one per (u, v) pair with multi_edge = False, each in its own direction; with targets only the\n" + "walks between the roots and a nearest target, for REVERSE from the target to a root; for\n" + "multipath = False the canonical single path) and\n" + "target_cost (with targets: the nearest targets' cost, None when no target is reached; None for a\n" + "full search).") + .def_property_readonly( + "costs", [](const PySpfResult& r) { return r.costs ? r.costs : py::object(py::none()); }, + "full search: each node's distance from the roots for FORWARD, or to the roots for REVERSE\n" + "(read-only int64[N], UNREACHABLE for nodes not reached); None with targets") + .def_property_readonly( + "dag", [](const PySpfResult& r) { return r.dag; }, + "the PredDAG of the result, in the edges' original orientation") + .def_property_readonly( + "target_cost", [](const PySpfResult& r) { return r.target_cost ? r.target_cost : py::object(py::none()); }, + "with targets: the cost of the nearest targets, None when no target is reached; None for a\n" + "full search") + .def("__repr__", [](const PySpfResult& r) { + std::string tc = r.target_cost && !r.target_cost.is_none() ? std::string(py::str(r.target_cost)) : "None"; + return "SpfResult(costs=" + std::string(r.costs ? "array" : "None") + + ", dag_entries=" + std::to_string(r.dag.cast().via_edges().size()) + + ", target_cost=" + tc + ")"; + }); + + m.def( + "spf", + [](const std::shared_ptr& graph, const py::object& roots, const py::object& opts) { + if (!graph) throw py::value_error("graph must be a Graph"); + const auto o = options_of(opts, "SpfOptions"); + const auto n = graph->num_nodes(); + // spf's validation order on unnarrowed values (Core repeats every check): the enumerations, + // the roots, the targets, their overlap, a single path without targets, the residual, then + // min_residual and a foreign Eligibility. + detail::check_member(o.direction, "direction"); + detail::check_selection(o.selection); + const auto r = node_ids(roots, n, "roots"); + std::optional> t; + if (!o.targets.is_none()) { + t = node_ids(o.targets, n, "targets"); + detail::check_disjoint(detail::node_set(r, n, "roots"), detail::node_set(*t, n, "targets"), "roots", + "targets"); + } else if (!o.selection.multipath) { + throw py::value_error("multipath = false requires targets (the single path joins a root to a target)"); + } + std::optional> res; + if (!o.residual.is_none()) res = edge_units(o.residual, *graph, "residual"); + const auto minr = python_int(o.min_residual, "min_residual"); + int overflow = 0; + const long long mv = PyLong_AsLongLongAndOverflow(minr.ptr(), &overflow); + if (overflow < 0 || (overflow == 0 && mv < 1)) { + throw py::value_error("min_residual must be at least 1 unit (got " + std::string(py::str(minr)) + ")"); + } + SpfOptions c(o.direction, o.selection); + c.targets = std::move(t); + if (res.has_value()) c.residual = std::span(*res); + // Above INT64_MAX it gates every residual edge exactly as INT64_MAX does (residuals < 2^62). + c.min_residual = overflow != 0 ? std::numeric_limits::max() : static_cast(mv); + c.elig = o.elig.get(); + std::optional result; + { + py::gil_scoped_release release; + result.emplace(spf(*graph, r, c)); + } + // The result's Python objects, each through a checked allocation: a failure raises. + auto [obj, slot] = new_object(); + if (result->costs.has_value()) { + py::array_t costs(static_cast(result->costs->size())); + std::copy(result->costs->begin(), result->costs->end(), costs.mutable_data()); + py::detail::array_proxy(costs.ptr())->flags &= ~py::detail::npy_api::NPY_ARRAY_WRITEABLE_; + slot->costs = std::move(costs); + } + slot->dag = object_from(std::move(result->dag)); + if (result->target_cost.has_value() && *result->target_cost != kUnreachable) { + slot->target_cost = py::int_(*result->target_cost); + } + return std::move(obj); + }, + py::arg("graph"), py::arg("roots"), py::arg("opts"), + "Shortest paths from the roots for FORWARD, or to the roots for REVERSE (one NodeId or a sequence;\n" + "every root at distance 0), over the eligible edges of at least 1 unit of capacity (and of residual\n" + ">= min_residual with a residual) -> SpfResult. Without targets every reachable node gets its\n" + "distance: costs and the full DAG of tight edges (the edges on some shortest walk; one per (u, v)\n" + "pair with multi_edge = False). With targets the search stops at the nearest targets' cost:\n" + "target_cost and the DAG of the walks between the roots and a nearest target (for REVERSE, from\n" + "the target to a root, in the edges' own direction);\n" + "with multipath = False, instead the canonical single path, the walk minimising (cost, -bottleneck\n" + "under PREFER_HIGHER_PRIORITY, hops, EdgeId sequence). Validated in this order before any work\n" + "(ValueError): the roots (non-empty, ids in range), the targets (the same), a node in both sets,\n" + "multipath = False without targets, the residual (length, then every entry in [0, capacity_units]),\n" + "min_residual >= 1, a foreign Eligibility. Excluded roots and targets are skipped; all roots\n" + "excluded gives an empty result."); +} + +} // namespace netgraph::core::bindings diff --git a/bindings/python/trace.hpp b/bindings/python/trace.hpp new file mode 100644 index 0000000..33eef25 --- /dev/null +++ b/bindings/python/trace.hpp @@ -0,0 +1,54 @@ +/* + The trace of a traced placement as Python values (the private hooks only). +*/ +#pragma once + +#include +#include +#include + +#include "netgraph/core/detail/hooks_flag.hpp" + +#if NETGRAPH_CORE_PRIVATE_HOOKS +#include +#include + +#include "netgraph/core/detail/trace.hpp" + +namespace py = pybind11; + +namespace netgraph::core::bindings { + +// A trace value as Python objects (map keys that are decimal integers become ints: the maps +// keyed by source NodeId). +inline py::object trace_obj(const detail::Trace& t) { + using K = detail::Trace::Kind; + switch (t.kind) { + case K::Null: return py::none(); + case K::Bool: return py::bool_(t.b); + case K::Int: return py::int_(t.i); + case K::Str: return py::str(t.s); + case K::List: { + py::list out; + for (const auto& x : t.items) out.append(trace_obj(x)); + return out; + } + case K::Map: { + py::dict out; + for (const auto& [k, x] : t.fields) { + const bool digits = !k.empty() && std::all_of(k.begin(), k.end(), [](char ch) { return ch >= '0' && ch <= '9'; }); + if (digits) { + out[py::int_(std::stoll(k))] = trace_obj(x); + } else { + out[py::str(k)] = trace_obj(x); + } + } + return out; + } + } + return py::none(); +} + +} // namespace netgraph::core::bindings + +#endif diff --git a/bindings/python/units.cpp b/bindings/python/units.cpp new file mode 100644 index 0000000..251de4b --- /dev/null +++ b/bindings/python/units.cpp @@ -0,0 +1,108 @@ +// to_units and as_float: physical values to integer units and back. +#include +#include +#include + +#include +#include +#include + +#include "netgraph/core/graph.hpp" +#include "integers.hpp" +#include "values.hpp" +#include "registration.hpp" + +namespace py = pybind11; + +namespace netgraph::core::bindings { + +namespace { + +// Physical numbers as a float64 array (or a 0-d array for a scalar); bools and strings rejected. +py::array_t physical_array(const py::handle& x) { + if (is_bool(x) || py::isinstance(x) || py::isinstance(x)) { + throw py::value_error("to_units takes physical numbers (a float or an array of floats)"); + } + auto np = py::module_::import("numpy"); + py::array raw = np.attr("asarray")(x); + const char kind = raw.dtype().kind(); + if (kind != 'f' && kind != 'i' && kind != 'u') { + throw py::value_error("to_units takes physical numbers (got dtype " + std::string(py::str(raw.dtype())) + ")"); + } + return py::array_t::ensure(raw); +} + +// x * 2^k for an unbounded Python int, correctly rounded: Python's int shift and int true +// division are exact or correctly rounded, so only a result beyond the finite doubles fails. +double scaled_python_int(const py::object& x, int k) { + try { + py::object r; + if (k >= 0) { + r = py::float_(x.attr("__lshift__")(py::int_(k))); + } else { + r = x.attr("__truediv__")(py::int_(1).attr("__lshift__")(py::int_(-k))); + } + return r.cast(); + } catch (py::error_already_set& e) { + if (e.matches(PyExc_OverflowError)) { + throw py::value_error("as_float: " + std::string(py::str(x)) + + " units times the resolution exceed the largest finite double"); + } + throw; + } +} + +// One Amount to a physical double; the resolution's exponent k is already validated. +double amount_as_float(const py::handle& item, int k, double resolution) { + py::object idx = python_int(item, "as_float: x"); + int overflow = 0; + const long long v = PyLong_AsLongLongAndOverflow(idx.ptr(), &overflow); + if (overflow == 0) return as_float(static_cast(v), resolution); + return scaled_python_int(idx, k); +} + +} // namespace + +void register_units(py::module_& m) { + m.def( + "to_units", + [](const py::object& x, double resolution) -> py::object { + (void)resolution_exponent(resolution); // before the shape: an empty array is no exemption + auto arr = physical_array(x); + if (arr.ndim() == 0) return py::int_(to_units(*arr.data(), resolution)); + if (arr.ndim() != 1) throw py::value_error("to_units takes a float or a 1-D array of floats"); + py::array_t out(arr.size()); + auto in = arr.unchecked<1>(); + auto o = out.mutable_unchecked<1>(); + for (py::ssize_t i = 0; i < in.shape(0); ++i) o(i) = to_units(in(i), resolution); + return std::move(out); + }, + py::arg("x"), py::arg("resolution"), + "Physical value(s) to units of the resolution q, rounding half up: an int for a scalar, an\n" + "int64 array for an array. NaN, infinity, negative values and results at or above 2^62\n" + "units raise ValueError; q must be a power of two in [2^-32, 2^32]."); + + m.def( + "as_float", + [](const py::object& x, double resolution) -> py::object { + const int k = resolution_exponent(resolution); // the resolution before the shape + // A sequence, a NumPy array of any rank included: wide_integers refuses a rank other than 1. + if (!not_a_sequence(x)) { + const auto v = wide_integers(x, "as_float: x"); + py::array_t out(static_cast(v.size())); + auto o = out.mutable_unchecked<1>(); + for (std::size_t i = 0; i < v.size(); ++i) { + o(static_cast(i)) = v.sign(i) == 0 ? as_float(v.value[i], resolution) + : amount_as_float(v.source[py::int_(i)], k, resolution); + } + return std::move(out); + } + return py::float_(amount_as_float(x, k, resolution)); + }, + py::arg("x"), py::arg("resolution"), + "Units to physical value(s): x * q, correctly rounded (exact below 2^53 units). An int, of\n" + "any size, gives a float, an integer array a float64 array; a result beyond the finite\n" + "doubles raises ValueError; q must be a power of two in [2^-32, 2^32]."); +} + +} // namespace netgraph::core::bindings diff --git a/bindings/python/values.hpp b/bindings/python/values.hpp new file mode 100644 index 0000000..7ee09f0 --- /dev/null +++ b/bindings/python/values.hpp @@ -0,0 +1,111 @@ +/* + Small conversions shared by the registration files: read-only NumPy views that + keep their owner alive, the resolution's check, exact counters, int64 arrays, FlowIds, + (EdgeId, units) pairs and the type checks of option arguments. +*/ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "netgraph/core/flow_record.hpp" +#include "netgraph/core/flow_policy.hpp" +#include "netgraph/core/graph.hpp" +#include "netgraph/core/types.hpp" +#include "integers.hpp" + +namespace py = pybind11; + +namespace netgraph::core::bindings { + +// Read-only NumPy view of a span owned by `owner`. +template +py::array readonly_view(std::span s, const py::handle& owner, const py::dtype& dtype) { + static constexpr T empty_element {}; + py::array arr(dtype, {static_cast(s.size())}, {static_cast(sizeof(T))}, + s.empty() ? &empty_element : s.data(), owner); + py::detail::array_proxy(arr.ptr())->flags &= ~py::detail::npy_api::NPY_ARRAY_WRITEABLE_; + return arr; +} + +template +py::array readonly_view(std::span s, const py::handle& owner) { + return readonly_view(s, owner, py::dtype::of()); +} + +// Validates a resolution (a power of two in [2^-32, 2^32]) and returns its exponent. +inline int resolution_exponent(double resolution) { + (void)as_float(0, resolution); // throws the resolution message + return std::ilogb(resolution); +} + +inline py::object counter_int(const Counter128& c) { + py::int_ hi(c.hi); + py::int_ lo(c.lo); + return hi.attr("__lshift__")(py::int_(64)).attr("__or__")(lo); +} + +inline py::array_t int64_array(const std::vector& v) { + py::array_t out(static_cast(v.size())); + std::copy(v.begin(), v.end(), out.mutable_data()); + return out; +} + +inline FlowId flow_id_of(const py::object& obj) { + if (!py::isinstance(obj) || py::len(obj) != 2) { + throw py::value_error("a FlowId is a tuple (ledger uid, id)"); + } + auto t = py::reinterpret_borrow(obj); + py::object uid = python_int(t[0], "FlowId ledger uid"); + py::object id = python_int(t[1], "FlowId id"); + int o1 = 0, o2 = 0; + const unsigned long long u = PyLong_AsUnsignedLongLong(uid.ptr()); + if (PyErr_Occurred()) { + // Only the range error means no such ledger; another failure (memory running out while that + // error is raised) propagates as raised. + if (!PyErr_ExceptionMatches(PyExc_OverflowError)) throw py::error_already_set(); + PyErr_Clear(); + o1 = 1; + } + const long long i = PyLong_AsLongLongAndOverflow(id.ptr(), &o2); + if (o1 != 0 || o2 != 0 || i < 0) { + throw py::value_error("FlowId (" + std::string(py::str(uid)) + ", " + std::string(py::str(id)) + ") is retired or unknown"); + } + return FlowId {static_cast(u), static_cast(i)}; +} + +inline py::list pairs_list(const std::vector>& v) { + py::list out; + for (const auto& [e, x] : v) out.append(py::make_tuple(e, x)); + return out; +} + +inline OutputOptions outputs_of(const py::object& obj) { + if (obj.is_none()) return OutputOptions {}; + if (!py::isinstance(obj)) { + throw py::type_error("outputs must be an OutputOptions (got " + std::string(py::str(py::type::of(obj).attr("__name__"))) + + ")"); + } + return obj.cast(); +} + +template +Options options_of(const py::object& obj, const char* name) { + if (!py::isinstance(obj)) { + throw py::type_error(std::string(name) + " expected (got " + std::string(py::str(py::type::of(obj).attr("__name__"))) + + ")"); + } + return obj.cast(); +} + +} // namespace netgraph::core::bindings diff --git a/dev/coverage_summary.py b/dev/coverage_summary.py index ecd1b33..ae18ad9 100644 --- a/dev/coverage_summary.py +++ b/dev/coverage_summary.py @@ -10,46 +10,93 @@ Defaults: PY_XML = build/coverage/coverage-python.xml CPP_XML = build/coverage/coverage-cpp.xml + +A missing, unreadable or malformed report is an error (exit 1), never zero coverage, and so is a +report that counts no line, which would otherwise show as 100%: gcovr writes one, and exits 0, +when it reads no profile data (the profiles are not where it looks, or the filters match no +file), and coverage.py when the files it measured hold no statement. A report carries both +branch totals or neither, and covers at most what it counts. Each total is a count written in +decimal digits, optionally with a zero fraction ("12" or "12.0"), and at most 2**63 - 1 (far +beyond any real report); anything else (a sign, a nonzero fraction, an exponent, NaN or +infinity) is malformed. The counts are read and compared exactly. """ from __future__ import annotations +import argparse import html import os +import re import sys import xml.etree.ElementTree as ET from typing import Optional, Tuple +# A count: decimal digits, optionally with a zero fraction (some tools write "12.0"). +_COUNT = re.compile(r"([0-9]+)(?:\.0*)?") +_MAX_COUNT = 2**63 - 1 + + +class ReportError(Exception): + """A coverage report is missing, unreadable or malformed.""" + def parse_cobertura_totals( xml_path: str, ) -> Tuple[int, int, Optional[int], Optional[int]]: """Return (lines_valid, lines_covered, branches_valid, branches_covered). - Missing values are returned as None. If the file is missing, returns zeros/None. + The line totals are required; the branch totals are both None when the report has none. + Raises ReportError when the file is missing or unreadable, is not a Cobertura report, + holds a total that is not a count (the module docstring's spelling and bound), has one + branch total without the other, covers more lines or branches than it counts, or counts + no line (it measured nothing). """ - if not os.path.exists(xml_path): - return 0, 0, None, None - try: root = ET.parse(xml_path).getroot() - except Exception: - return 0, 0, None, None + except (OSError, ET.ParseError) as exc: + raise ReportError(f"{xml_path}: cannot read the report: {exc}") from exc + if root.tag != "coverage": + raise ReportError(f"{xml_path}: not a Cobertura report (root <{root.tag}>)") - def _to_int(value: Optional[str]) -> Optional[int]: + def _to_int(name: str, required: bool) -> Optional[int]: + value = root.attrib.get(name) if value is None: + if required: + raise ReportError(f"{xml_path}: no {name} total") return None - try: - # Some tools may write floats; coerce safely - return int(float(value)) - except Exception: - return None - - lines_valid = _to_int(root.attrib.get("lines-valid")) or 0 - lines_covered = _to_int(root.attrib.get("lines-covered")) or 0 - branches_valid = _to_int(root.attrib.get("branches-valid")) - branches_covered = _to_int(root.attrib.get("branches-covered")) - + match = _COUNT.fullmatch(value) + if match is None: + raise ReportError( + f"{xml_path}: {name}={value!r} is not a count (a nonnegative integer)" + ) + digits = match.group(1).lstrip("0") or "0" + if len(digits) > len(str(_MAX_COUNT)) or int(digits) > _MAX_COUNT: + raise ReportError( + f"{xml_path}: {name}={value!r} is above the largest count, 2**63 - 1" + ) + return int(digits) + + lines_valid = _to_int("lines-valid", True) + lines_covered = _to_int("lines-covered", True) + branches_valid = _to_int("branches-valid", False) + branches_covered = _to_int("branches-covered", False) + assert lines_valid is not None and lines_covered is not None + if lines_covered > lines_valid: + raise ReportError(f"{xml_path}: {lines_covered} lines covered of {lines_valid}") + if lines_valid == 0: + raise ReportError( + f"{xml_path}: the report counts no line (no coverage data was read, or the" + " measured files hold no statement)" + ) + if branches_valid is None or branches_covered is None: + if branches_valid is not None or branches_covered is not None: + raise ReportError( + f"{xml_path}: branches-valid and branches-covered must both be present or both absent" + ) + elif branches_covered > branches_valid: + raise ReportError( + f"{xml_path}: {branches_covered} branches covered of {branches_valid}" + ) return lines_valid, lines_covered, branches_valid, branches_covered @@ -70,10 +117,8 @@ def format_text_table( total_lines_valid = py_lines_valid + cpp_lines_valid total_lines_cov = py_lines_cov + cpp_lines_cov - # Branch totals only if present in both - have_branches = (py_br_valid is not None and py_br_cov is not None) or ( - cpp_br_valid is not None and cpp_br_cov is not None - ) + # Branch columns when either report has branch totals; a report without them counts 0 of 0. + have_branches = py_br_valid is not None or cpp_br_valid is not None total_br_valid = (py_br_valid or 0) + (cpp_br_valid or 0) if have_branches else 0 total_br_cov = (py_br_cov or 0) + (cpp_br_cov or 0) if have_branches else 0 @@ -108,31 +153,40 @@ def format_html_table(text_table: str, title: str = "Combined Coverage") -> str:

{html.escape(title)}

{pre}
""" -def main() -> int: - py_xml = ( - sys.argv[1] - if len(sys.argv) > 1 and not sys.argv[1].startswith("--") - else "build/coverage/coverage-python.xml" +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser( + description="Combine the Python and C++ Cobertura reports into one summary." ) - cpp_xml = ( - sys.argv[2] - if len(sys.argv) > 2 and not sys.argv[2].startswith("--") - else "build/coverage/coverage-cpp.xml" + parser.add_argument( + "py_xml", nargs="?", default="build/coverage/coverage-python.xml" ) + parser.add_argument("cpp_xml", nargs="?", default="build/coverage/coverage-cpp.xml") + parser.add_argument( + "--html", metavar="OUT_HTML", help="also write the table as HTML" + ) + args = parser.parse_args(argv) - html_out: Optional[str] = None - for arg in sys.argv[1:]: - if arg.startswith("--html="): - html_out = arg.split("=", 1)[1] - - py_totals = parse_cobertura_totals(py_xml) - cpp_totals = parse_cobertura_totals(cpp_xml) + try: + py_totals = parse_cobertura_totals(args.py_xml) + cpp_totals = parse_cobertura_totals(args.cpp_xml) + except ReportError as exc: + print(f"coverage_summary.py: {exc}", file=sys.stderr) + return 1 text = format_text_table(py_totals, cpp_totals) print(text) + html_out: Optional[str] = args.html if html_out: - os.makedirs(os.path.dirname(html_out), exist_ok=True) - with open(html_out, "w", encoding="utf-8") as f: - f.write(format_html_table(text)) + out_dir = os.path.dirname(html_out) + try: + if out_dir: + os.makedirs(out_dir, exist_ok=True) + with open(html_out, "w", encoding="utf-8") as f: + f.write(format_html_table(text)) + except OSError as exc: + print( + f"coverage_summary.py: cannot write {html_out}: {exc}", file=sys.stderr + ) + return 1 return 0 diff --git a/dev/cpp-tests.sh b/dev/cpp-tests.sh index fdf03df..82ea132 100644 --- a/dev/cpp-tests.sh +++ b/dev/cpp-tests.sh @@ -1,36 +1,108 @@ #!/usr/bin/env bash -# Propagate configuration, build, and test failures; bound local CPU use. +# The C++ test runner (make cpp-test, cpp-test-off, sanitize-test, tsan-test, check-ci, cov). +# +# bash dev/cpp-tests.sh MODE +# +# MODE private hooks build type build directory +# release ON Release (LTO) build/cpp-tests +# off OFF Release (LTO) build/cpp-tests-off the shipped configuration +# sanitize ON Debug, ASan + UBSan build/cpp-sanitize +# tsan ON Debug, TSan build/cpp-tsan +# coverage ON Debug, --coverage build/cpp-tests-cov used by make cov +# +# Each mode has its own build directory and states every project option on the command line, so a +# value cached by an earlier configure never decides this one. One generator (Ninja), parallelism +# bounded by CMAKE_BUILD_PARALLEL_LEVEL (default 4), ctest fails when it finds no test, and every +# configuration, build or test failure fails the script. The skipped tests are checked: none in a +# hooks-on mode, exactly EXPECTED_OFF_SKIPS (the tests that need a hook) in mode off, so a test that +# silently became hooks-only is caught. set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/.." + +# The C++ tests that need the private hooks and skip without them (GTEST_SKIP). Inside tests that +# still run, three checks are weaker without them: the helper check_deletion +# (flow_core_deletion_tests.cpp) checks each re-solve's final result, but observes each repair step +# only through the probe hook (see that file's header); PolicyFixtures.EveryFixtureReplaysIdentically +# compares no trace and replays only the 58 fixtures that keep the default creation cap, and +# PolicyFixtures.ACorruptedExpectationFails corrupts only what that replay compares, in those 58 +# fixtures (see flow_policy_fixture_tests.cpp's header). +EXPECTED_OFF_SKIPS=" +FlowCoreDeletion.CheckpointsEqualTheIterativeUpdate +FlowCoreDeletion.RerunningTheFailedSearchAddsExactlyOneSearch +GraphLayout.CombinedKeyPassesOnlyBelowNTimesKEqualsE +GraphLayout.HashAndFallbackBoundaryAt65536DistinctCosts +GraphLayout.ProbeBudgetFallbackInCollectionAndGrowth +GraphLayout.ProbeBudgetIsCumulativeAcrossTheLargeTableGrowths +GraphLayout.RadixFallbackDigitsOnBothSidesOfTheCombinedKeyBoundary +GraphLayout.RunsOfEqualCostsSkipTheLookup +Search.QueueDispatchAtTheWidthLimit +" + +die() { echo "dev/cpp-tests.sh: $*" >&2; exit 1; } + jobs=${CMAKE_BUILD_PARALLEL_LEVEL:-4} case "$jobs" in ''|*[!0-9]*|0) echo 'CMAKE_BUILD_PARALLEL_LEVEL must be positive.' >&2; exit 2 ;; esac -tsan=OFF -case "${1:-release}" in - release) build_dir=build/cpp-tests; build_type=Release; sanitize=OFF ;; +mode=${1:-} +hooks=ON; sanitize=OFF; tsan=OFF; coverage=OFF +case "$mode" in + release) build_dir=build/cpp-tests; build_type=Release ;; + off) build_dir=build/cpp-tests-off; build_type=Release; hooks=OFF ;; sanitize) build_dir=build/cpp-sanitize; build_type=Debug; sanitize=ON ;; - # ThreadSanitizer (data races of the worker threads and the shared DagCache), its own build - # directory; the longest instrumented test takes about 30 s, inside the common timeout. - tsan) build_dir=build/cpp-tsan; build_type=Debug; sanitize=OFF; tsan=ON ;; - *) echo 'Expected release, sanitize or tsan.' >&2; exit 2 ;; + # ThreadSanitizer (data races of the worker threads and the shared DagCache); the longest + # instrumented test takes about 30 s, inside the common timeout. + tsan) build_dir=build/cpp-tsan; build_type=Debug; tsan=ON ;; + coverage) build_dir=build/cpp-tests-cov; build_type=Debug; coverage=ON ;; + *) echo 'Usage: bash dev/cpp-tests.sh release|off|sanitize|tsan|coverage' >&2; exit 2 ;; esac + +command -v cmake >/dev/null 2>&1 || die "cmake not found on PATH (make dev installs it into venv/bin)" +command -v ctest >/dev/null 2>&1 || die "ctest not found on PATH (make dev installs it into venv/bin)" +command -v ninja >/dev/null 2>&1 || die "ninja not found on PATH (make dev installs it into venv/bin)" +cache="$build_dir/CMakeCache.txt" +if [[ -f "$cache" ]] && ! grep -qx 'CMAKE_GENERATOR:INTERNAL=Ninja' "$cache"; then + die "$build_dir was configured by another generator ($(sed -n 's/^CMAKE_GENERATOR:INTERNAL=//p' "$cache")); remove that directory and rerun" +fi + if [[ "$(uname -s)" == Darwin ]]; then - export CC="$(xcrun --find clang)" - export CXX="$(xcrun --find clang++)" + CC=$(xcrun --find clang) + CXX=$(xcrun --find clang++) + export CC CXX export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-15.0}" leak_detection=0 else leak_detection=1 fi -cmake -S . -B "$build_dir" -DNETGRAPH_CORE_BUILD_TESTS=ON -DNETGRAPH_CORE_PRIVATE_HOOKS=ON \ - -DNETGRAPH_CORE_SANITIZE="$sanitize" -DNETGRAPH_CORE_TSAN="$tsan" -DCMAKE_BUILD_TYPE="$build_type" -cmake --build "$build_dir" --config "$build_type" --parallel "$jobs" if [[ "$sanitize" == ON ]]; then - export ASAN_OPTIONS="${ASAN_OPTIONS:-detect_leaks=$leak_detection}" + # A sanitizer report must fail, including during test discovery at build time. Append + # the failure settings after the caller's options; keep the platform's leak policy. + export ASAN_OPTIONS="${ASAN_OPTIONS:-detect_leaks=$leak_detection}:halt_on_error=1:exitcode=1" + export UBSAN_OPTIONS="${UBSAN_OPTIONS:+$UBSAN_OPTIONS:}halt_on_error=1:exitcode=1" fi if [[ "$tsan" == ON ]]; then - # A report aborts the test (halt_on_error=1; TSan's own default is exit code 66 at exit): - # ctest fails on any race. - export TSAN_OPTIONS="${TSAN_OPTIONS:-halt_on_error=1 second_deadlock_stack=1}" + export TSAN_OPTIONS="${TSAN_OPTIONS:-second_deadlock_stack=1}:halt_on_error=1:exitcode=1" +fi +cmake -S . -B "$build_dir" -G Ninja -DCMAKE_BUILD_TYPE="$build_type" \ + -DNETGRAPH_CORE_BUILD_TESTS=ON -DNETGRAPH_CORE_PRIVATE_HOOKS="$hooks" \ + -DNETGRAPH_CORE_SANITIZE="$sanitize" -DNETGRAPH_CORE_TSAN="$tsan" \ + -DNETGRAPH_CORE_COVERAGE="$coverage" -DNETGRAPH_CORE_NATIVE=OFF +cmake --build "$build_dir" --parallel "$jobs" +if [[ "$coverage" == ON ]]; then + # Test discovery runs each binary at build time and writes profiles that are no test's coverage. + find "$build_dir" -name '*.gcda' -delete +fi +log="$build_dir/ctest.log" +ctest --test-dir "$build_dir" --output-on-failure --parallel "$jobs" --timeout 120 --no-tests=error \ + 2>&1 | tee "$log" + +# ctest reports a GTEST_SKIP as "***Skipped" (gtest_discover_tests' skip pattern). +skipped=$(sed -n 's/^ *[0-9]*\/[0-9]* Test *#[0-9]*: \([^ ]*\) .*\*\*\*Skipped.*/\1/p' "$log" | LC_ALL=C sort) +if [[ "$hooks" == OFF ]]; then + expected=$(echo "$EXPECTED_OFF_SKIPS" | sed '/^$/d' | LC_ALL=C sort) + if [[ "$skipped" != "$expected" ]]; then + diff <(echo "$expected") <(echo "$skipped") >&2 || true + die "the skipped tests differ from the expected hook-only tests (< expected, > skipped)" + fi + echo "hooks off: exactly the $(echo "$expected" | wc -l | tr -d ' ') expected hook-only tests skipped" +elif [[ -n "$skipped" ]]; then + die "tests skipped in a hooks-on build: $(echo "$skipped" | tr '\n' ' ')" fi -ctest --test-dir "$build_dir" --build-config "$build_type" \ - --output-on-failure --parallel "$jobs" --timeout 120 --no-tests=error diff --git a/dev/perf/README.md b/dev/perf/README.md deleted file mode 100644 index d9c6cc0..0000000 --- a/dev/perf/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Performance tooling - -Developer-only tools for measuring NetGraph-Core. Nothing here ships in the -wheel or the sdist, and nothing here is run by CI; the repository gates -(`make check-ci`, `make sanitize-test`) remain the acceptance tests. - -- [`spf_queue/`](spf_queue/): the shortest-path frontier queue and workspace - work (plan, parity harness, old-versus-new bit-gate, NetGraph-level - benchmark, recorded A/B/A results and the independent review). Start with - [`spf_queue/PLAN.md`](spf_queue/PLAN.md). -- [`../benchmark_profiling_overhead.py`](../benchmark_profiling_overhead.py): - SPF and placement micro-benchmark used for the PGO training run - (`make install-pgo`) and for measuring profiling overhead. - -Conventions for performance claims (see `AGENTS.md`): verify correctness -first (bit-identical outputs where the contract is exact, certificates for -flows), then measure old/new/old in fresh processes on one quiet machine, -record load, hashes and raw samples, and treat differences inside the -run-to-run band as inconclusive. The research record behind the queue work, -including the Apple GPU investigation that preceded it, is in -[`../research/`](../research/). diff --git a/dev/perf/spf_queue/PLAN.md b/dev/perf/spf_queue/PLAN.md deleted file mode 100644 index 1696f0c..0000000 --- a/dev/perf/spf_queue/PLAN.md +++ /dev/null @@ -1,581 +0,0 @@ -# CPU optimisation implementation plan - -Status: implemented in the working tree (P0–P2 and the P3 fallback review -below); not yet committed or released. -Consolidated on 27 September 2026 against NetGraph-Core `fbab119`, revised the -same day after the [queue-substitution experiment](./), then updated -with the implementation and its old/new/old measurements. This is the current -CPU plan, superseding the implementation-order guidance in the two Metal -research reports. Those reports and their raw measurements remain the historical -evidence; the earlier BFS/Dial prototypes are now understood as special cases of -one queue. - -## Implementation status (27 September 2026) - -What landed, as one change on top of `fbab119`: - -- `StrictMultiDiGraph::from_arrays` records `max_cost()` and `cost_gcd()` in - its existing cost scan; they follow copies and moves as members. -- `shortest_paths_core` and `shortest_paths_to_core` share one relaxation loop - templated on the queue. `HeapQueue` is the reference binary heap on - `(cost, -bottleneck, node)`; `BucketQueue` is the ordered bucket queue - (`W = max_cost / gcd + 1` cyclic buckets, 64-bit occupancy bitmap, active - bucket kept as a heap on `(-bottleneck, node)`). Dispatch is - `W <= 65536`; `SpfQueue::Heap` or `NGRAPH_CORE_SPF_QUEUE=heap` forces the - reference route, `Bucket`/`bucket` prefers the bucket queue when eligible. -- Scratch state is a thread-local workspace per direction with touched-node - tracking and a hybrid reset (sparse when fewer than a quarter of the nodes - were touched, wholesale otherwise; the same predicate chooses how outputs are - materialised). An RAII lease restores the invariant on every exit path, - including the reverse search's fanout exceptions. Arrays shrink when a much - smaller graph is searched; entry arrays and buckets keep their capacity. -- Tests: `ShortestPaths.QueueDifferential_Forward` / `_Reverse` (forced heap - against forced bucket over ten random graph families, the option matrix, - five destinations, twice on the reused workspace; more than 25,000 - comparisons), `_ComparisonHasTeeth`, `BucketQueueKeepsCapacityOrderedPredecessors`, - `WorkspaceIsResetAfterFanoutThrow`, `WorkspaceReuseAcrossGraphSizesAndEmptyGraphs`, - `StrictMultiDiGraph.CostSummaryMaxAndGcd`, and a Python test that runs the - same script under both `NGRAPH_CORE_SPF_QUEUE` values and unset. -- Documentation: README environment table and CHANGELOG (Unreleased). No - Python API change; `_docs.py` is unchanged. - -Gates passed on the final code (logs in [cpu_queue/](./)): - -- Old-versus-new bit-gate ([bitgate/](bitgate/)): a C++ program that - exercises forward and reverse SPF with fanout, `calc_max_flow` in every - placement mode, `batch_max_flow`, `sensitivity_analysis`, `FlowPolicy` - place/rebalance/remove sequences and KSP through the public API, built once - against the `fbab119` sources and once against the working tree. 6,226 - output lines, identical SHA-256, also with the new build forced onto the heap. -- `bash .superset/workspace.sh check`: lint, types, 177 C++ tests, 466 Python - tests. `make sanitize-test`: 177 C++ tests under ASan/UBSan. -- NetGraph `dev/check_core_integration.sh` against this worktree: a wheel built - in a fresh environment, 1,278 NetGraph tests passed. -- The research harness, rebuilt against the new production sources, still - matches its copies of the old loop across 1,351,168 comparisons on both - routes. - -One finding from the first old/new/old pass that the earlier experiment could -not show: the new code's *heap* route (the fallback for `W > 65536`) was 8–15% -slower than the old heap. Two causes were fixed: the scratch arrays were reached -through the thread-local struct, so their base pointers were reloaded around -every store (now hoisted to raw pointers for the search), and the heap -comparator was a static function that decays to a function pointer inside -`std::push_heap`/`pop_heap` (now a functor, as the old lambda was). The -measured tables below are from the final code. - -A second finding concerns measurement, not Core: NetGraph's failure Monte Carlo -results differ between processes of the *same* build unless `PYTHONHASHSEED` -is fixed (set-iteration order), so the NetGraph driver pins it; with the seed -fixed, old and new digests agree ([hashseed-determinism.txt](results/netgraph/hashseed-determinism.txt)). - -## Independent review (2 October 2026) - -An independent reviewer (the `gpt-6-astra` model through the `codex` CLI, brief -and full report in [cpu_queue/review-astra/](review-2026-10/)) was -asked whether all easily achievable, reasonable optimisations had been -considered and verified. Its verdict: the algorithm choice is sound and the -weighted-graph and small-query gains reproduce independently, but several -small changes were missed, one exception path was wrong, and the retained -memory statement was false. Each finding was re-verified here before adoption -(harness parity on both routes, the old-versus-new bit-gate, the repository -gates and a fresh old/new/old sequence; tables below are from the final code). - -Adopted: - -- **Exception safety (reviewer finding 1, reproduced).** The source or target - node's distance was written before its `touched.push_back`; a throwing - allocation there left an unrecorded modification that the next same-sized - search would see. Nodes are now recorded before their entries are written, - and `WorkspaceLease` documents that rule. -- **Skip settled neighbours before scanning their parallel edges (finding - 3).** A settled node cannot be improved with non-negative costs and no longer - accepts equal-cost or capacity-tie updates, so its group is skipped before - any edge or bottleneck is examined. Dense unit fabrics, which the earlier - passes recorded as neutral, now gain about 1.3x. -- **One-pass predecessor output (finding 4).** Implemented as a live per-node - count maintained by the search (exact sizing, no over-reservation) rather - than the reviewer's reserve-and-append variant; the second list walk is gone. -- **Inline single-item frontier (finding 5).** A frontier of one node is held - inline and promoted into the buckets on the next push; chain graphs improve - from about 2.1x slower to about 1.6x slower than the old heap. -- **Retention bound (finding 6, counterexample reproduced).** Bucket item - storage accumulated the high-water marks of every bucket ever used. The queue - now drops its item storage when it exceeds four times the last search's peak - frontier plus 4,096 items, the growable buffers are released whenever the - node arrays shrink, and the code comment and this plan describe the actual - policy instead of "at most the largest graph". -- **Bitmap bookkeeping (finding 8).** `begin`/`end` touched every occupancy - word (O(W/64) per search, measurable for tiny searches at large `W`); the - words set during a search are now listed and only those are cleared. -- **Fixed-routing callers (finding 2).** `calc_max_flow` and - `FlowState::place_max_flow` with `require_capacity=false` recomputed an - identical cost-only search on every tier; the result is now computed once per - invocation. Outputs are bit-identical (bit-gate). -- **Two redundant copies (findings 10 and 13).** `get_path_bundle` moves its - DAG into the returned pair, and the `spf`/`spf_to` bindings move the DAG into - the Python tuple instead of copying it. -- **Measurement corrections.** Reverse-search rows are now part of the Core - sequence (P2's gate was previously marked done without them); the unit-cost - rows are described as "no demonstrated regression" rather than "within - drift"; the heap-route delta is recorded as unexplained (the reviewer's - ablations of touched tracking, item layout and front-copy pop all showed no - gain); and the earlier claim that a distance-only API would remove the O(N) - output floor was wrong, since a distance vector still has N entries. - -Recorded as follow-ups, not done: - -- A narrower internal result for callers that only inspect `dist[dst]` or the - DAG (max-flow, FlowPolicy, KSP spur searches), which is the only lever left - for tiny searches on large graphs (finding 9). -- KSP's per-spur O(N+E) mask allocations and copies (finding 11), and - `place_on_dag`'s quadratic parent grouping on wide ECMP DAGs (finding 14), - both outside the search. -- `W_max` on a second platform, a two-level occupancy summary for very wide - ranges, and the reviewer's boundary rows (at `W` = 65,535–65,536 full - searches still gain about 1.4x while first-neighbour queries pay 9–17% - against the new heap route), which argue against raising the limit from the - full-search sweep alone (finding 8). -- A zero-copy int64 distance result in the bindings (finding 13), and the - stale `has_min_flow` memo-key discriminator in `FlowPolicy` (finding 10). -- Refuted by the reviewer: a gcd early-out in graph construction (finding 12). - -### Measured against the previous production build - -Both sequences ran one fresh process per phase in the order old (A1), new, -new forced onto the heap route, old (A2), on the final code (after the -independent review). The old build is the `fbab119` sources; the harness -binaries and the NetGraph extension modules are hashed in each -`environment.txt`. Load averages were 3.8–5.9 during both sequences because -other sessions were active; the desktop was not isolated, so differences under -about 5% are within run-to-run noise here. Earlier sequences on the -pre-review code are kept in the same directories' history -(the reviewer's own runs). - -Core level ([results-core](results/core/), harness `ref` = the -production function of each build, sequential, nine samples, medians in ms): - -| Workload: case / nodes / edges / queries / destination | W | Old A1 / A2 | New | New, forced heap | Old / new | -|---|---:|---:|---:|---:|---:| -| random / 1,024 / 8,192 / 1 / none | 32 | 0.107 / 0.102 | 0.067 | 0.089 | 1.52–1.60 | -| random / 4,096 / 32,768 / 256 / none | 32 | 211.6 / 207.6 | 132.6 | 221.3 | 1.57–1.60 | -| random / 16,384 / 131,072 / 64 / none | 32 | 254.4 / 250.6 | 150.9 | 266.1 | 1.66–1.69 | -| random / 65,536 / 524,288 / 1 / none | 32 | 21.21 / 19.03 | 11.51 | 23.14 | 1.65–1.84 | -| random, degree 32 / 4,096 / 131,072 / 64 / none | 32 | 112.0 / 110.6 | 77.1 | 115.4 | 1.43–1.45 | -| random, masked / 16,384 / 131,072 / 64 / none | 32 | 230.0 / 229.2 | 140.9 | 236.7 | 1.63 | -| random / 262,144 / 2,097,152 / 1 / none | 32 | 136.6 / 133.8 | 96.2 | 130.3 | 1.39–1.42 | -| random / 1,024 / 8,192 / 64 / farthest | 32 | 10.81 / 10.65 | 7.09 | 11.21 | 1.50–1.52 | -| random / 16,384 / 131,072 / 64 / farthest | 32 | 251.6 / 250.5 | 149.8 | 264.0 | 1.67–1.68 | -| random / 65,536 / 524,288 / 16 / farthest | 32 | 324.3 / 330.2 | 201.2 | 336.0 | 1.61–1.64 | -| uniform cost 31 / 16,384 / 131,072 / 64 / none | 2 | 181.4 / 177.1 | 146.3 | 178.6 | 1.21–1.24 | -| unit grid / 10,000 / 39,600 / 1 / none | 2 | 0.614 / 0.618 | 0.459 | 0.635 | 1.34–1.35 | -| unit fabric / 800 / 320,000 / 64 / none | 2 | 143.9 / 144.3 | 110.2 | 111.1 | 1.31 | -| pseudo src/sink fabric / 802 / 320,200 / 64 / fixed pair | 2 | 143.4 / 143.9 | 108.0 | 110.3 | 1.33 | -| chain / 2,048 / 2,047 / 1 / none | 32 | 0.021 / 0.021 | 0.027 | 0.029 | 0.77–0.78 | -| random / 1,024 / 8,192 / 64 / first neighbour | 32 | 0.116 / 0.115 | 0.043 | 0.038 | 2.70–2.72 | -| random / 16,384 / 131,072 / 64 / first neighbour | 32 | 1.160 / 1.158 | 0.402 | 0.392 | 2.88 | -| random / 65,536 / 524,288 / 64 / first neighbour | 32 | 4.351 / 4.254 | 1.506 | 1.517 | 2.83–2.89 | -| random / 262,144 / 2,097,152 / 16 / first neighbour | 32 | 4.509 / 4.053 | 1.491 | 1.471 | 2.72–3.02 | -| reverse: random / 4,096 / 32,768 / 64 | 32 | 55.85 / 55.34 | 34.23 | 58.82 | 1.62–1.63 | -| reverse: random / 16,384 / 131,072 / 16 | 32 | 66.55 / 66.34 | 38.67 | 69.33 | 1.72 | -| reverse: unit fabric / 800 / 320,000 / 16 | 2 | 45.43 / 45.72 | 37.15 | 36.90 | 1.22–1.23 | -| reverse: chain / 2,048 / 2,047 / 1 | 32 | 0.002 / 0.003 | 0.001 | 0.001 | 2.57–2.61 | - -Cost-range sweep (random 16,384 / 131,072, 16 full-source queries, costs in -`1..K`), same sequence: - -| K | W | Old A1 / A2 | New | New, forced heap | Old / new | -|---:|---:|---:|---:|---:|---:| -| 1 | 2 | 44.4 / 45.3 | 36.5 | 44.9 | 1.22–1.24 | -| 31 | 32 | 64.5 / 62.6 | 37.2 | 65.9 | 1.68–1.73 | -| 1,023 | 1,024 | 59.2 / 58.9 | 35.3 | 64.5 | 1.67–1.68 | -| 32,767 | 32,768 | 51.3 / 51.2 | 42.7 | 53.3 | 1.20 | -| 1,048,575 | heap fallback | 49.7 / 49.5 | 52.8 | 51.6 | 0.94 | -| 33,554,431 | heap fallback | 51.0 / 49.6 | 51.2 | 52.8 | 0.97–1.00 | - -NetGraph level ([results-netgraph](results/netgraph/), -[bench_netgraph.py](bench_netgraph.py) run by NetGraph's interpreter -against each build with `PYTHONHASHSEED=0`, five samples, medians in ms, every -workload's result digest identical across all four phases): - -| Workload | Old A1 / A2 | New | New, forced heap | Old / new | -|---|---:|---:|---:|---:| -| full-source `spf`, 2-tier Clos 200×200 (400 sources) | 214.2 / 212.7 | 163.4 | 164.6 | 1.30–1.31 | -| full-source `spf`, weighted grid 100×100 (50 sources) | 220.8 / 220.3 | 181.7 | 245.4 | 1.21–1.22 | -| bound-context `max_flow`, Clos 200×200, combine, ×50 | 52.6 / 51.9 | 47.1 | 46.8 | 1.10–1.12 | -| `max_flow` with 100 link-exclusion sets, Clos 100×100 | 44.3 / 43.0 | 40.4 | 39.3 | 1.06–1.10 | -| bound-context `max_flow`, weighted grid, combine, ×20 | 202.9 / 202.1 | 174.6 | 219.1 | 1.16 | -| `max_flow`, weighted grid, pairwise corners, ×5 | 204.0 / 200.6 | 208.2 | 212.9 | 0.96–0.98 | -| demand placement, backbone_clos matrix, ×50 | 69.2 / 67.4 | 69.0 | 68.7 | 0.98–1.00 | -| placement Monte Carlo, backbone_clos, 300 iterations | 74.5 / 75.3 | 67.8 | 71.4 | 1.10–1.11 | -| max-flow Monte Carlo, backbone_clos, 300 iterations | 29.3 / 29.1 | 27.6 | 29.0 | 1.06 | -| KSP k=8, 3-tier Clos, 56 leaf pairs | 54.7 / 53.4 | 46.3 | 46.2 | 1.15–1.18 | -| `max_flow`, 3-tier Clos, 56 pod pairs, combine | 167.5 / 166.1 | 168.2 | 171.8 | 0.99–1.00 | -| placement, 3-tier Clos, 64 WCMP + 8 ECMP demands | 366.4 / 363.3 | 376.3 | 368.9 | 0.97 | -| placement with 20 link-failure sets, 3-tier Clos | 7,646 / 7,548 | 7,640 | 7,571 | 0.99–1.00 | -| scenario square_mesh.yaml, 100 iterations, serial | 10.3 / 10.3 | 9.9 | 9.9 | 1.04 | -| scenario nsfnet.yaml, 200 iterations, serial | 958.2 / 939.1 | 929.6 | 912.2 | 1.01–1.03 | -| scenario backbone_clos.yml, 100 iterations, serial | 82.3 / 81.1 | 78.3 | 77.6 | 1.04–1.05 | - -Reading of the two sequences: - -- **Weighted graphs** (`W` between 32 and 32,768) gain 1.4–1.8x at the Core - level, forward and reverse, and 1.16–1.22x end to end on the weighted grid - workloads in NetGraph, where graph preparation, Python and placement share - the time. -- **Unit-cost fabrics** (`W = 2`), the class NetGraph's Clos scenarios and - pseudo source/sink max-flow graphs belong to, now gain 1.2–1.35x at the Core - level (settled-neighbour skip and one-pass output, adopted from the review) - and 1.06–1.3x through NetGraph's SPF, max-flow, KSP and Monte Carlo paths. - The wide-ECMP placement rows (0.97–1.00) are not a demonstrated regression: - they lie inside the run-to-run band and are dominated by placement, whose - quadratic parent grouping is a recorded follow-up. -- **Small destination-limited searches** gain 2.7–3.0x from the workspace - regardless of the queue (the forced-heap column matches the bucket column). -- **Fallback class** (`W > 65,536`): the two fallback rows are 0.94 and - 0.97–1.00 of the old heap; the forced-heap column on eligible graphs is - 0.82–0.97, which is the same unexplained route penalty. The reviewer's - ablations (touched tracking, item layout, front-copy pop) showed no gain, - so its cause remains open; it is recorded, not rationalised. -- **Chain graphs** are now 0.77x (about +6 µs on a 21 µs query) on the bucket - route after the inline single-item frontier, from 0.47x before. -- **Correctness**: every NetGraph workload digest was identical across old, - new and the forced heap; the Core parity, differential tests and the - old-versus-new bit-gate remain the semantic evidence, since these digests - only summarise distances and parent counts. - -## What the experiment established (27 September 2026) - -Harness: [cpu_queue/](./) is plain C++ (no Objective-C++ or Metal) and -compiles the production `shortest_paths.cpp` beside a copy of its forward loop -with the queue and workspace injected ([spf_variants.hpp](spf_variants.hpp), -[harness.cpp](harness.cpp)). Results are in -[cpu_queue/results](results/first/) and [cpu_queue/results-short](results/short/); -the earlier research results are untouched. - -**Parity.** 1,351,168 comparisons against the production `shortest_paths`, -bit-exact on distances, `parent_offsets`, `parents` and `via_edges`, over: twelve -masked random seeds, six graphs with zero-cost cycles, int64-offset costs (which -exercise the heap fallback), a unit grid, uniform cost 31, costs to 100,000, a -fabric with NetGraph-style zero-cost pseudo source/sink attachments, a chain, a -dense fabric and a 12-node multigraph with self-loops, parallel edges and -residuals at multiples of `kMinCap`; times `{multipath, single-path}` × -`{multi-edge, single-edge}` × `{Deterministic, PreferHigherResidual}` × -`{require_capacity}` × `{residual}` × `{masks}` × destinations `{none, first -neighbour, farthest, self, arbitrary}`, each run twice on a reused workspace. -Zero mismatches for every queue/workspace variant. The checker has teeth: -dropping the bottleneck key from the bucket comparator produces 167,636 -mismatches. ASan/UBSan runs of the same matrix are clean -([plain](results/first/correctness.txt), -[sanitized](results/first/correctness-sanitized.txt), -[teeth](results/first/correctness-teeth.txt)). - -**Timing.** Sequential, single thread, one process per variant, production -reference first and last (A1/A2), nine samples after two checked warmups, -medians in milliseconds for the whole query set, every timed result verified -against the reference outside the timed interval. `bucket` is the ordered bucket -queue; `fresh` allocates scratch per query as production does; `sparse` reuses a -workspace and resets only touched nodes; `hybrid` picks full or sparse reset -from the touched count after the search. Load average was 2.9–4.1 on 14 cores -throughout; the desktop was not isolated. - -| Workload: case / nodes / edges / queries / destination | W | Production A1 / A2 | bucket, fresh | bucket, sparse | bucket, hybrid | -|---|---:|---:|---:|---:|---:| -| random / 1,024 / 8,192 / 1 / none | 32 | 0.101 / 0.104 | 0.078 | 0.069 | — | -| random / 4,096 / 32,768 / 256 / none | 32 | 198.7 / 205.8 | 117.7 | 122.1 | — | -| random / 16,384 / 131,072 / 64 / none | 32 | 238.8 / 245.6 | 133.4 | 140.3 | 142.5 † | -| random / 65,536 / 524,288 / 1 / none | 32 | 18.38 / 19.63 | 10.96 | 10.68 | 10.80 † | -| random, degree 32 / 4,096 / 131,072 / 64 / none | 32 | 106.7 / 109.0 | 64.1 | 65.8 | — | -| random, masked / 16,384 / 131,072 / 64 / none | 32 | 219.5 / 225.3 | 130.6 | 138.4 | — | -| random / 262,144 / 2,097,152 / 1 / none | 32 | 119.7 / 125.2 | 83.6 | 87.0 | 84.5 † | -| random / 1,024 / 8,192 / 64 / farthest | 32 | 10.13 / 10.43 | 6.26 | 6.55 | — | -| random / 16,384 / 131,072 / 64 / farthest | 32 | 240.0 / 246.3 | 134.8 | 140.8 | — | -| random / 65,536 / 524,288 / 16 / farthest | 32 | 289.4 / 299.9 | 162.7 | 176.1 | — | -| uniform cost 31 / 16,384 / 131,072 / 64 / none | 2 | 170.3 / 175.4 | 142.4 | 140.7 | — | -| unit grid / 10,000 / 39,600 / 1 / none | 2 | 0.614 / 0.622 | 0.466 | 0.497 | — | -| unit fabric / 800 / 320,000 / 64 / none | 2 | 142.1 / 143.6 | 126.1 | 127.9 | 142.1 † | -| pseudo src/sink fabric / 802 / 320,200 / 64 / fixed pair | 2 | 140.6 / 142.2 | 125.5 | 127.2 | — | -| chain / 2,048 / 2,047 / 1 / none | 32 | 0.021 / 0.021 | 0.045 | 0.045 | 0.041 † | -| random / 1,024 / 8,192 / 64 / first neighbour | 32 | 0.105 / 0.120 | 0.111 | 0.043 | — | -| random / 16,384 / 131,072 / 64 / first neighbour | 32 | 1.044 / 1.171 | 1.190 | 0.393 | 0.390 † | -| random / 65,536 / 524,288 / 64 / first neighbour | 32 | 4.034 / 4.207 | 4.332 | 1.489 | 1.484 † | -| random / 262,144 / 2,097,152 / 16 / first neighbour | 32 | 3.872 / 3.997 | 4.090 | 1.449 | 1.442 † | - -† from the [second, shorter sequence](results/short/), whose own -production A1/A2 were 240.6 / 245.4, 18.51 / 18.77, 137.8 / 141.6, 0.021 / 0.023, -1.047 / 1.104, 4.206 / 4.305, 3.913 / 19.193 and 119.5 / 124.2 for the rows marked. - -Cost-range sweep, random 16,384 nodes / 131,072 edges, 16 full-source queries, -costs uniform in `1..K` ([sweep files](results/first/)): - -| K | W | Production | bucket, sparse | Ratio | -|---:|---:|---:|---:|---:| -| 1 | 2 | 43.7 | 35.2 | 1.24 | -| 31 | 32 | 62.1 | 35.8 | 1.74 | -| 1,023 | 1,024 | 58.2 | 34.1 | 1.71 | -| 32,767 | 32,768 | 50.0 | 35.0 | 1.43 | -| 1,048,575 | 1,048,576 | 48.6 | 46.8 | 1.04 | -| 33,554,431 | fallback | 48.9 | 56.5 (harness heap) | — | - -Interpretation to carry into implementation: - -- **Weighted graphs, `W` ≤ 32,768: 1.4–1.8x** over the production heap under - the full contract, sequentially, whether the query is full-source, farthest - destination or masked. Production A1/A2 drift was 2–7% on these rows (up to - 14% on sub-millisecond rows), so these clear the decision gate on this - machine. The 1,024-node single query improves 1.3–1.5x. -- **Uniform-cost graphs: 1.1–1.3x, and inconclusive on the dense fabric.** With - `W = 2` the queue is a small share of the work; edge scanning dominates the - 800-node fabric with 320,000 edges (0.97–1.13x across the two sequences). The - unit grid improves 1.32x, uniform cost 31 improves 1.2x. -- **Destination-limited queries that touch few nodes are bounded by O(N) - per-query overhead, not by the queue.** Production spends about 1.6 µs at - 1,024 nodes, 16 µs at 16,384, 63 µs at 65,536 and 242 µs at 262,144 per - first-neighbour query; the queue variant alone does not change this. Reusing - the workspace with a full reset changes nothing (consistent with the earlier - thread-local measurement recorded in the perf notes), while resetting only - touched nodes removes about 60% (2.4–3.0x). The remaining floor is the - N-sized `dist` and `parent_offsets` outputs that the API returns. -- **Hybrid reset is the right default:** it matches sparse on small queries and - full reset on full-graph queries, where sparse tracking costs 3–5%. -- **Chain graphs regress 1.7–2.1x (+15–24 µs on a 21 µs query)** in this - pre-implementation experiment (later reduced to 1.3x by the inline - single-item frontier adopted from the review). Every pop - advances the active bucket, so the per-pop bookkeeping (occupancy bitmap, - heap rebuild of a one-element bucket) exceeds a one-element binary heap. - Replacing the modulo by a power-of-two mask did not remove it. This is the - smallest absolute regression in the matrix and no production caller runs on - a path graph, but it is repeatable and must be recorded, not hidden. -- **Sorting each bucket once instead of heaping it is slower** on weighted - graphs (up to 8%) and equal on uniform graphs, except the unit grid (0.393 vs - 0.466). It is not worth a second queue. -- **The harness's own heap copy runs 7–14% slower than production** on the - random workloads. The cause is not isolated (candidates: the touched-node - branch per relaxation, runtime mode checks, scratch arrays reached through a - struct instead of locals). Production work must therefore be measured against - production, not against the harness heap, and P1 must check that its - workspace integration does not carry this penalty. The bucket gain is far - larger than the penalty. -- **Decomposition of the earlier prototype gain.** The research Dial package - reached 3.6x over native on the 16k/64 batch with ten workers and a relaxed - contract (no capacity ordering, canonical incoming-CSR DAG, cached eligibility - bitmap). The contract-preserving queue alone gives 1.8x sequentially. Roughly - half of the prototype's advantage came from dropping the contract and is not - available to production; the other half is. -- **One unattributed transient.** In two of twelve fresh-allocation phases the - 262,144-node first-neighbour set took 19 ms instead of 4 ms (once in the - harness heap copy, once in the production reference A2); none of fourteen - reused-workspace phases did. A page-fault probe in a fresh process showed - neither faults nor slowness. It is recorded as environmental variance, not as - a property of either implementation. -- Timings are sequential and cover synthetic graphs; ten-worker batch timings - from the research are not comparable row for row. No end-to-end flow - improvement is measured yet. - -## What the first consolidation overlooked - -1. **Every production caller is destination-limited.** `calc_max_flow`, - `FlowState::place_max_flow`, `FlowPolicy::get_path_bundle` and the KSP spur - searches all pass `dst`; only NetGraph's cost/DAG-cache paths call `spf` - without one. The earlier dispatch table sent destination-limited searches - to the existing implementation, which would have excluded the callers that - dominate placement time. Early-exit parity is now verified, so - destination-limited queries are in scope from the first enablement. -2. **Zero-cost edges are the normal case downstream.** NetGraph attaches pseudo - source/sink nodes with cost-0 augmentation edges, so "any zero-cost graph - stays on the heap" would have excluded every max-flow analysis. Zero-cost - pushes land in the active bucket's heap and are verified against the native - settled-node acyclicity rule; the dispatch condition is only `W`. -3. **The dominant fixed cost of small queries is O(N) scratch initialisation**, - which no queue change touches; touched-node reset addresses it and was not - in the previous plan (which specified a complete reset). -4. **Uniform non-unit costs** are covered by the gcd stride rather than by a - separate BFS; they were previously flagged as untested. -5. **Reverse SPF** (`shortest_paths_to_core`) has the same queue and takes the - same substitution mechanically; it is not yet measured. KSP's private - `dijkstra_single` is a cost-only search on a different key and is out of - scope for the first change. -6. **Ordered parity is not an open research question**; it follows from queue - monotonicity. The plan no longer needs "investigate per-layer ordering" or - "a LIFO vector is not sufficient" as open items. - -## Contract and fallback rules - -Keep `Cost=int64_t`, `Cap=double`, the unreachable sentinel and graph cost-sum -bound. Preserve validation, empty graphs, invalid/blocked endpoints, masks, -reverse DAG orientation, parallel-edge IDs, multipath selection, single-path -capacity ties, destination early exit and `fanout_edges` behaviour. The queue -key stays `(distance, -bottleneck_capacity, node_id)` with the existing -`kEpsilon` stale-entry and re-push semantics; boolean eligibility cannot replace -the capacity values used for ordering. SPF excludes `rem < kMinCap` when -capacity gating applies; a supplied residual forces that gate. - -Dispatch is a graph property, decided before the search starts: - -| Condition | Route | -|---|---| -| `W = max_cost / gcd + 1 ≤ W_max` (any mix of zero and positive costs, any selection mode, any destination, forward or reverse once P2 lands) | Ordered bucket queue | -| `W > W_max` (wide or int64-offset costs) | Existing heap | -| Forced reference (tests, benchmarks) | Existing heap | - -`W_max` starts at 65,536 (2^16): the sweep shows 1.4x at `W = 32,768` and -break-even near `W = 2^20`. Memory per workspace is `W` empty vector headers -plus a `W`-bit occupancy bitmap, about 1.6 MB at the limit. Widening the range -(two-level occupancy summary, or a radix heap whose last level is ordered the -same way) is later work with its own sweep. The graph-level `max_cost` is an -upper bound for every masked query, so no per-query rescan is needed; a -masked-out wide edge merely wastes empty buckets. - -## Implementation sequence - -### P0 — Parity oracle and forced route (done) - -The harness is the parity oracle. Before production work, add the equivalent -differential test to [tests/cpp](../../../tests/cpp/shortest_paths_tests.cpp): -the forced-heap route against the dispatched route over random graphs, the -option matrix and the destination set above, bit-exact on all four output -arrays, plus a guard test proving the comparison rejects a wrong ordering. -Forcing must be reachable from tests and benchmarks without a public API -(an internal function parameter or a build/env switch used only there). - -Keep the measured originals and hashes intact; write new results to new -directories. Dynamic profiling of NetGraph callers (full versus -destination-limited counts, touched fraction, weight range) remains useful for -the downstream gate but no longer blocks the first enablement, because both -call classes are covered. - -**Exit:** differential test with teeth in the C++ suite; forced route available. - -### P1 — Queue and workspace in the forward search (done) - -Work in [shortest_paths.cpp](../../../src/shortest_paths.cpp) and -[strict_multidigraph.cpp](../../../src/strict_multidigraph.cpp): - -- Record `max_cost` and the cost gcd in `from_arrays`'s existing cost scan as - members, so they follow copies and moves; no process-global cache keyed by a - graph address. -- Replace the priority queue in `shortest_paths_core` with the ordered bucket - queue when `W ≤ W_max`; keep the heap code path intact for the fallback and - the forced route. Keep the relaxation loop shared between routes (one loop, - two queue types), as the harness does, so parity is structural. -- Give the search a reusable workspace (distances, bottlenecks, predecessor - heads/tails, settled flags, entry arrays, queue buckets) with touched-node - tracking and the hybrid reset. Concurrency: `batch_max_flow` and - `sensitivity_analysis` run on `std::async` threads and Python callers release - the GIL, so the workspace must be per thread (thread-local) or caller-owned; - it must hold no pointer into a graph, residual or mask. Bound retained memory: - one workspace per thread sized to the largest graph seen, and state the bound - (or add a shrink rule) in the code comment and in `_docs.py` if it is - observable. Returned vectors must own their data. -- Early exit leaves items in buckets; clear only occupied buckets. - -**Exit:** the existing positive-cost SPF output hash and the flow output hash -from the August review are unchanged; the differential test passes; the full -suite and `make sanitize-test` pass; A/B/A against `main` on the harness -workloads reproduces the table above within drift, including the recorded -chain regression; no new implicit threads. - -### P2 — Reverse search and KSP (done for the reverse search) - -Apply the same substitution to `shortest_paths_to_core` (same key, in-adjacency) -and measure it with reverse workloads added to the harness. Leave KSP's -`dijkstra_single` alone unless profiling shows it matters. - -**Exit:** parity for reverse results including `fanout_edges`, and a measured -result either way. - -### P3 — Limits, regressions and dispatch review (fallback reviewed; second machine class pending) - -Confirm `W_max` on a second machine class (Linux/x86 CI-class hardware), and -decide the chain-like regime: the inline single-item frontier cut the recorded -regression to about 6 µs on a 21 µs query; either accept that residual on -frontier-of-one searches or reduce the per-pop advance cost and re-measure. Do -not choose the queue per query retrospectively; the rule stays a graph property. - -**Exit:** dispatch rule frozen with a held-out workload pass. - -### P4 — Downstream validation, then batching and GPU reconsideration (validation done; batching/GPU unchanged) - -Run NetGraph's profiles and representative demand/flow runs with the chosen -Core build, including residual updates, mask changes, cache hit/miss cases, -all placement modes and path-cost constraints. For max-flow behaviour, check -feasibility, conservation and optimality/cut certificates with nonuniform -costs, masks and cost-factor cases; SPF distance agreement alone does not -satisfy this gate. - -Explicit CPU batching stays conditional on demonstrated independent-query -callers: deterministic result order, bounded worker count, per-worker scratch -and bounded output memory. Sequential flow augmentations with changing -residuals cannot be batched as if independent. Any public batch or -distance-only extension must preserve current defaults and update bindings and -`_docs.py` together; a distance-only result would also remove the O(N) output -floor measured above, which is the only remaining lever for tiny queries on -large graphs. It is not a prerequisite for shipping this change. - -Revisit GPU only against the accepted CPU implementation and matching result -contracts; this plan does not require Metal, GPU frontier queues or automatic -CPU/GPU selection. - -## Acceptance and validation - -Correctness gates precede performance claims. Cover forward/reverse results, -both edge tie-break policies, multipath/single-path, multi-edge/single-edge, -parallel edges, self-loops, zero-cost cycles, isolated vertices, empty graphs, -invalid and blocked endpoints, all-masked graphs and malformed spans. Include -costs above `2^53`, near the constructor's bound, `W` at and just above -`W_max`, long chains, duplicate/stale queue entries, uniform non-unit weights, -and capacity values at/around `kMinCap` and the tie epsilon. Mutate masks and -residual contents in place between calls, including changes that leave -eligibility unchanged. Verify graph lifetime, workspace reuse across graphs of -different sizes, concurrent callers, repeated determinism, fanout validation, -target early exit and capped enumeration. - -Relevant existing suites include [C++ SPF](../../../tests/cpp/shortest_paths_tests.cpp), -[masking](../../../tests/cpp/masking_tests.cpp), -[Python edge selection](../../../tests/py/test_spf_edge_select.py), -[path enumeration](../../../tests/py/test_paths_resolve.py), -[reverse SPF](../../../tests/py/test_spf_to.py), -[memoisation](../../../tests/py/test_flow_policy_spf_memo.py), -[thread safety](../../../tests/py/test_thread_safety.py), and -[lifetime safety](../../../tests/py/test_lifetime_safety.py). - -For each change, run correctness-checked old/new/old in fresh processes on one -quiet machine, same build settings, with builds/tests excluded from the timed -run. Record raw samples, hashes, workload parameters, load, fallback counts and -peak memory. Keep warm reused-query timing, first-query timing and -changing-input end-to-end timing separate. Ship a class only when the new median -improves beyond both A1/A2 drift and sample variability, with no unrecorded -regression in its supported classes or reference fallback. A single M4 result -is insufficient to set `W_max` for every supported platform. - -Reproduce the experiment from the repository root: - -```sh -bash dev/perf/spf_queue/build.sh -build/perf/cpu-queue correctness -bash dev/perf/spf_queue/run_aba.sh -python3 dev/perf/spf_queue/summarize.py -bash dev/perf/spf_queue/build.sh sanitize -ASAN_OPTIONS=detect_leaks=0 build/perf/cpu-queue-sanitize correctness -``` - -For implementation changes, run: - -```sh -bash .superset/workspace.sh setup # when the worktree environment needs setup -bash .superset/workspace.sh check # rebuild extension, lint, types, C++ and Python -make sanitize-test # required for C++ changes -bash /Users/networmix/ws/NetGraph/dev/check_core_integration.sh \ - /absolute/path/to/the/chosen/NetGraph-Core-worktree -``` - -Use the actual worktree's absolute path for downstream verification. Local -checks do not replace CI. The prior research passed 170 C++ and 465 Python -tests plus sanitizers; that is baseline evidence, not completion of any -implementation gate. - -**Immediate next step:** review and commit the working-tree change (a -version bump and CHANGELOG date at release time), then confirm `W_max` on a -Linux/x86 CI-class machine before treating the chain-graph and wide-cost -fallback numbers as portable. Batching and GPU work remain later, conditional -items. diff --git a/dev/perf/spf_queue/README.md b/dev/perf/spf_queue/README.md deleted file mode 100644 index fe7286a..0000000 --- a/dev/perf/spf_queue/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# Ordered bucket queue experiment (CPU only) - -Plain C++ harness (no Objective-C++ or Metal) behind the -[CPU optimisation plan](PLAN.md). It compiles the production -`src/shortest_paths.cpp` next to a copy of its forward search loop in which only -the priority queue and the scratch workspace are pluggable, then checks every -variant bit-exactly against the production function and times them. - -- `spf_variants.hpp`: `HeapQueue` (production key), `BucketHeapQueueT` (monotone - cyclic buckets, active bucket kept as a heap on `(-bottleneck, node)`; the - `` instantiation masks instead of taking a modulo), `BucketSortQueue` - (sorted active bucket plus a side heap), `Workspace` (fresh, full reset, - sparse reset, hybrid reset) and `spf_variant`, the shared loop. -- `harness.cpp`: workload generators (the research generator verbatim, plus - uniform, wide, zero-cost-cycle and pseudo source/sink cases), the parity - matrix, timing and the cost-range sweep. Variant names are - `{ref, heap, bucket, bucket2, bsort}-{fresh, full, sparse, hybrid}`. -- `run_aba.sh`: one fresh process per variant, production reference first and - last, plus the sweep. `summarize.py` prints Markdown tables. -- `results/first/`: the first sequence (nine samples), sweep files, load readings, - source hashes and the parity outputs (plain, sanitized and the teeth check - built with `-DSPFX_BAD_ORDER`, which must fail). `results/short/`: the second - sequence for the hybrid and power-of-two variants. - -```sh -bash dev/perf/spf_queue/build.sh -build/perf/cpu-queue correctness -build/perf/cpu-queue time bucket-hybrid 9 -build/perf/cpu-queue sweep bucket-sparse 9 2097152 -bash dev/perf/spf_queue/run_aba.sh -python3 dev/perf/spf_queue/summarize.py -``` - -Timings are sequential and single-threaded; they are not comparable row for row -with the ten-worker batch timings in the research reports. - -## Production verification (after the change landed in `src/shortest_paths.cpp`) - -- `bitgate/dump.cpp`: exercises forward/reverse SPF, max-flow, batch max-flow, - sensitivity, `FlowPolicy` sequences and KSP through the public C++ API; built - once against the previous sources (`git archive` of the base commit) and once - against the working tree. `bitgate/sha256.txt` records the identical digests - of `old.txt` and `new.txt` (and of the new build forced onto the heap route). -- `run_core_aba.sh` + `results/core/` (+ `summarize_core.py`): old/new/old on - the harness workloads, where `ref` is the production function of the sources - each binary was built from (`cpu-queue-old` is built from a `git archive` of - the base commit); `b-new-heap` forces the heap route in the new binary as an - in-binary control. -- `results/priority/`: old/new/old for the `EdgeSelection::priority` addition - (default route against 0.11.0, plus the opt-in `capacity` and `headroom` - priorities timed in single-path `PreferHigherResidual` mode via - `SPFX_PRIORITY=capacity|headroom`). -- `results/dag/`: old/new/old for `shortest_paths_dag` (the DAG-only - destination search used by max-flow, FlowPolicy and KSP), timed via - `SPFX_DAG=1` on the destination rows: about 1.25x over 0.11.0 on - first-neighbour queries, unchanged elsewhere. -- `bench_netgraph.py` + `run_netgraph_aba.sh` + `results/netgraph/`: old/new/old - on NetGraph workloads run through NetGraph's own API and interpreter (full - SPF, bound-context max-flow with pseudo nodes, failure exclusions, demand - placement with WCMP and ECMP policies, failure Monte Carlo, KSP, and three of - the shipped scenarios end to end with `parallelism: 1`). Each workload - returns a checksum that must agree across builds; `summarize_netgraph.py` - tabulates medians and checks the checksums. The driver pins - `PYTHONHASHSEED=0`: NetGraph's Monte Carlo digests otherwise differ between - two processes of the same build (`results/netgraph/hashseed-determinism.txt`). -- `review-2026-10/`: brief, full report, patches, experiment sources and - raw results of the independent review (2 October 2026); what was adopted - from it is listed in the plan. Its full-source experimental copies, session - log and 10 MB bit-gate dumps were not kept (they are reproducible from the - patches against the base commit). `bash build.sh old` builds the - old-source harness binary from `git archive` of the base commit. -- Timing modes now include reverse-search rows (`dst` = `reverse`), which are - production-only (the harness variants implement the forward loop). diff --git a/dev/perf/spf_queue/bench_netgraph.py b/dev/perf/spf_queue/bench_netgraph.py deleted file mode 100644 index 3424d75..0000000 --- a/dev/perf/spf_queue/bench_netgraph.py +++ /dev/null @@ -1,393 +0,0 @@ -#!/usr/bin/env python3 -"""Realistic NetGraph workloads for the Core old/new A/B/A. - -Run with NetGraph's interpreter and PYTHONPATH pointing at the Core build under -test (see run_netgraph_aba.sh). Prints CSV rows: - workload,label,median_s,samples,checksum,samples_s -Every workload returns a checksum of its results that must be identical across -builds; the driver compares them. -""" - -from __future__ import annotations - -import hashlib -import json -import random -import statistics -import sys -import time -from pathlib import Path - -NETGRAPH = Path.home() / "ws" / "NetGraph" -sys.path.insert(0, str(NETGRAPH)) - -import numpy as np # noqa: E402 -from ngraph.analysis import FailureManager # noqa: E402 -from ngraph.analysis.context import AnalysisContext # noqa: E402 -from ngraph.analysis.functions import demand_placement_analysis # noqa: E402 -from ngraph.model.network import Link, Network, Node # noqa: E402 -from ngraph.scenario import Scenario # noqa: E402 -from ngraph.types.base import Mode # noqa: E402 - -import netgraph_core # noqa: E402 - - -def norm(obj): - """JSON-safe, order-stable view of nested results (tuple keys become strings).""" - if isinstance(obj, dict): - skip = { - "metadata", - "execution_time", - "duration", - "timestamp", - "started_at", - "finished_at", - "run_id", - "id", - } - return { - str(k): norm(v) - for k, v in sorted(obj.items(), key=lambda kv: str(kv[0])) - if str(k) not in skip - } - if isinstance(obj, (list, tuple, set, frozenset)): - items = [norm(v) for v in obj] - return sorted(items, key=str) if isinstance(obj, (set, frozenset)) else items - if isinstance(obj, float): - return repr(obj) - if isinstance(obj, (str, int, bool)) or obj is None: - return obj - if hasattr(obj, "to_dict"): - return norm(obj.to_dict()) - return str(obj) - - -def digest(obj) -> str: - return hashlib.sha256(json.dumps(norm(obj), sort_keys=True).encode()).hexdigest()[ - :16 - ] - - -def clos(leaves: int, spines: int, cap: float = 100.0) -> Network: - net = Network() - for i in range(leaves): - net.add_node(Node(f"leaf/leaf{i:03d}")) - for j in range(spines): - net.add_node(Node(f"spine/spine{j:03d}")) - for i in range(leaves): - for j in range(spines): - net.add_link( - Link(f"leaf/leaf{i:03d}", f"spine/spine{j:03d}", capacity=cap, cost=1.0) - ) - return net - - -def grid(rows: int, cols: int, seed: int = 3) -> Network: - rng = random.Random(seed) - net = Network() - for r in range(rows): - for c in range(cols): - net.add_node(Node(f"g/r{r:03d}c{c:03d}")) - for r in range(rows): - for c in range(cols): - if c + 1 < cols: - net.add_link( - Link( - f"g/r{r:03d}c{c:03d}", - f"g/r{r:03d}c{c + 1:03d}", - capacity=10.0, - cost=float(rng.randint(1, 20)), - ) - ) - if r + 1 < rows: - net.add_link( - Link( - f"g/r{r:03d}c{c:03d}", - f"g/r{r + 1:03d}c{c:03d}", - capacity=10.0, - cost=float(rng.randint(1, 20)), - ) - ) - return net - - -def clos3(pods: int, leaves: int, spines: int, supers: int) -> Network: - """Three-tier Clos: per pod a leaf/spine mesh, spines meshed to super-spines.""" - net = Network() - for k in range(supers): - net.add_node(Node(f"ss/ss{k:03d}")) - for p in range(pods): - for i in range(leaves): - net.add_node(Node(f"pod{p:02d}/leaf/leaf{i:03d}")) - for j in range(spines): - net.add_node(Node(f"pod{p:02d}/spine/spine{j:03d}")) - for i in range(leaves): - for j in range(spines): - net.add_link( - Link( - f"pod{p:02d}/leaf/leaf{i:03d}", - f"pod{p:02d}/spine/spine{j:03d}", - capacity=100.0, - cost=1.0, - ) - ) - for j in range(spines): - for k in range(supers): - net.add_link( - Link( - f"pod{p:02d}/spine/spine{j:03d}", - f"ss/ss{k:03d}", - capacity=400.0, - cost=1.0, - ) - ) - return net - - -def scenario_text(name: str, iterations: int) -> str: - path = NETGRAPH / "scenarios" / name - text = path.read_text() - text = text.replace("parallelism: auto", "parallelism: 1").replace( - "parallelism: 8", "parallelism: 1" - ) - text = text.replace("iterations: 1000", f"iterations: {iterations}") - return text - - -def results_digest(scenario: Scenario) -> str: - out = {} - for step in scenario.results.get_steps_by_execution_order(): - data = scenario.results.get_step(step) - out[step] = {k: v for k, v in data.items() if k not in ("metadata",)} - return digest(out) - - -def build_workloads() -> dict[str, tuple[callable, int]]: - """Return name -> (fn returning checksum, inner repetitions).""" - wl: dict[str, tuple[callable, int]] = {} - sel = netgraph_core.EdgeSelection( - multi_edge=True, - require_capacity=False, - tie_break=netgraph_core.EdgeTieBreak.DETERMINISTIC, - ) - - clos200 = clos(200, 200) - ctx_clos200 = AnalysisContext.from_network(clos200) - n_clos = ctx_clos200.multidigraph.num_nodes() - - def spf_clos(): - acc = [] - for s in range(n_clos): - dist, dag = ctx_clos200.algorithms.spf(ctx_clos200.handle, s, selection=sel) - acc.append((int(np.asarray(dist).sum()), int(len(dag.parents)))) - return digest(acc) - - wl["spf_full_clos_200x200"] = (spf_clos, 1) - - grid100 = grid(100, 100) - ctx_grid = AnalysisContext.from_network(grid100) - - def spf_grid(): - acc = [] - for s in range(0, 10000, 200): - dist, dag = ctx_grid.algorithms.spf(ctx_grid.handle, s, selection=sel) - d = np.asarray(dist) - acc.append((int(d[d < 2**62].sum()), int(len(dag.parents)))) - return digest(acc) - - wl["spf_full_grid_100x100_weighted"] = (spf_grid, 5) - - ctx_mf_clos = AnalysisContext.from_network( - clos200, - source="^leaf/leaf00[0-9]$", - sink="^leaf/leaf19[0-9]$", - mode=Mode.COMBINE, - ) - wl["maxflow_clos_200x200_combine"] = (lambda: digest(ctx_mf_clos.max_flow()), 50) - - clos100 = clos(100, 100) - ctx_fail = AnalysisContext.from_network( - clos100, - source="^leaf/leaf00[0-9]$", - sink="^leaf/leaf09[0-9]$", - mode=Mode.COMBINE, - ) - link_ids = sorted(clos100.links) - rng = random.Random(11) - exclusions = [set(rng.sample(link_ids, 12)) for _ in range(100)] - - def maxflow_failures(): - return digest([ctx_fail.max_flow(excluded_links=ex) for ex in exclusions]) - - wl["maxflow_failures_clos_100x100_x100"] = (maxflow_failures, 1) - - ctx_mf_grid = AnalysisContext.from_network( - grid100, source="^g/r00[0-9]c000$", sink="^g/r09[0-9]c099$", mode=Mode.COMBINE - ) - wl["maxflow_grid_100x100_weighted_combine"] = ( - lambda: digest(ctx_mf_grid.max_flow()), - 20, - ) - wl["maxflow_grid_100x100_weighted_pairwise"] = ( - lambda: digest( - AnalysisContext.from_network( - grid100, source="^g/r000c000$", sink="^g/r099c099$", mode=Mode.PAIRWISE - ).max_flow() - ), - 5, - ) - - backbone = Scenario.from_yaml(scenario_text("backbone_clos.yml", 1000)) - demands = [ - td.to_dict() for td in backbone.demand_set.get_set("baseline_traffic_matrix") - ] - wl["placement_backbone_clos"] = ( - lambda: digest( - demand_placement_analysis(backbone.network, set(), set(), demands) - ), - 50, - ) - - fm = FailureManager( - network=backbone.network, - failure_policy_set=backbone.failure_policy_set, - policy_name="weighted_modes", - ) - wl["placement_mc_backbone_clos_x300"] = ( - lambda: digest( - fm.run_demand_placement_monte_carlo( - demands_config=demands, iterations=300, parallelism=1, seed=42 - ) - ), - 1, - ) - wl["maxflow_mc_backbone_clos_x300"] = ( - lambda: digest( - fm.run_max_flow_monte_carlo( - source="^metro1/dc1/.*", - target="^metro2/dc1/.*", - mode="combine", - iterations=300, - parallelism=1, - seed=7, - ) - ), - 1, - ) - - fabric = clos3(pods=8, leaves=16, spines=8, supers=16) - ctx_fabric = AnalysisContext.from_network(fabric) - wl["ksp_clos3_pairwise_k8"] = ( - lambda: digest( - { - f"{k[0]}->{k[1]}": [str(p) for p in v] - for k, v in ctx_fabric.k_shortest_paths( - "^pod00/leaf/leaf00[0-3]$", - "^pod0[1-7]/leaf/leaf00[0-1]$", - mode=Mode.PAIRWISE, - max_k=8, - ).items() - } - ), - 1, - ) - wl["maxflow_clos3_pod_pairs_combine"] = ( - lambda: digest( - [ - AnalysisContext.from_network( - fabric, - source=f"^pod{a:02d}/leaf/.*", - sink=f"^pod{b:02d}/leaf/.*", - mode=Mode.COMBINE, - ).max_flow() - for a in range(8) - for b in range(8) - if a != b - ] - ), - 1, - ) - fabric_demands = [ - { - "source": f"^pod{a:02d}/leaf/.*", - "target": f"^pod{b:02d}/leaf/.*", - "mode": "pairwise", - "priority": 0, - "volume": 2000.0, - "flow_policy": policy, - } - for a in range(8) - for b in range(8) - if a != b - for policy in ("TE_WCMP_UNLIM",) - ] + [ - { - "source": f"^pod{a:02d}/leaf/.*", - "target": f"^pod{(a + 1) % 8:02d}/leaf/.*", - "mode": "pairwise", - "priority": 1, - "volume": 500.0, - "flow_policy": "SHORTEST_PATHS_ECMP", - } - for a in range(8) - ] - wl["placement_clos3_wcmp_and_ecmp"] = ( - lambda: digest(demand_placement_analysis(fabric, set(), set(), fabric_demands)), - 1, - ) - fabric_links = sorted(fabric.links) - rng2 = random.Random(5) - fabric_exclusions = [set(rng2.sample(fabric_links, 6)) for _ in range(20)] - wl["placement_clos3_failures_x20"] = ( - lambda: digest( - [ - demand_placement_analysis(fabric, set(), ex, fabric_demands) - for ex in fabric_exclusions - ] - ), - 1, - ) - - def run_scenario(name: str, iterations: int): - def fn(): - sc = Scenario.from_yaml(scenario_text(name, iterations)) - sc.run() - return results_digest(sc) - - return fn - - wl["scenario_square_mesh_iter100"] = (run_scenario("square_mesh.yaml", 100), 1) - wl["scenario_nsfnet_iter200"] = (run_scenario("nsfnet.yaml", 200), 1) - wl["scenario_backbone_clos_iter100"] = (run_scenario("backbone_clos.yml", 100), 1) - return wl - - -def main() -> int: - label = sys.argv[1] if len(sys.argv) > 1 else "run" - samples = int(sys.argv[2]) if len(sys.argv) > 2 else 5 - only = sys.argv[3].split(",") if len(sys.argv) > 3 else None - wl = build_workloads() - print("workload,label,median_s,samples,checksum,samples_s", flush=True) - for name, (fn, reps) in wl.items(): - if only and name not in only: - continue - checksum = fn() # warm-up and reference checksum - times = [] - for _ in range(samples): - t0 = time.perf_counter() - for _ in range(reps): - c = fn() - times.append(time.perf_counter() - t0) - if c != checksum: - raise SystemExit( - f"nondeterministic result in {name}: {c} != {checksum}" - ) - print( - f"{name},{label},{statistics.median(times):.6f},{samples},{checksum},{';'.join(f'{t:.6f}' for t in times)}", - flush=True, - ) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/dev/perf/spf_queue/bitgate/dump.cpp b/dev/perf/spf_queue/bitgate/dump.cpp deleted file mode 100644 index 1ef7ba4..0000000 --- a/dev/perf/spf_queue/bitgate/dump.cpp +++ /dev/null @@ -1,157 +0,0 @@ -// Old-versus-new output bit-gate. Built once against the previous sources and -// once against the working tree; the SHA-256 of stdout must match. Exercises -// forward/reverse SPF, max-flow, batch max-flow, sensitivity, FlowPolicy -// placement sequences and KSP through the public C++ API only. -#include "netgraph/core/algorithms.hpp" -#include "netgraph/core/backend.hpp" -#include "netgraph/core/constants.hpp" -#include "netgraph/core/flow_graph.hpp" -#include "netgraph/core/flow_policy.hpp" -#include "netgraph/core/k_shortest_paths.hpp" -#include "netgraph/core/max_flow.hpp" -#include "netgraph/core/options.hpp" -#include "netgraph/core/shortest_paths.hpp" -#include "netgraph/core/strict_multidigraph.hpp" - -#include -#include -#include -#include - -using namespace netgraph::core; - -struct Case { - StrictMultiDiGraph g; - std::unique_ptr nm, em; - std::vector residual; - NodeId src = 0, dst = 1; - std::span nmask() const { return {nm.get(), size_t(g.num_nodes())}; } - std::span emask() const { return {em.get(), size_t(g.num_edges())}; } -}; - -static Case make(unsigned seed, int n, int degree, Cost max_cost, int zero_pct, bool duplex, bool pseudo) { - std::mt19937 rng(seed); - std::vector s, d; std::vector cap; std::vector cost; - auto add = [&](int u, int v, Cost c, double k) { s.push_back(u); d.push_back(v); cost.push_back(c); cap.push_back(k); }; - for (int u = 0; u < n; ++u) for (int k = 0; k < degree; ++k) { - int v = k == 0 ? (u + 1) % n : int(rng() % n); - Cost c = (int(rng() % 100) < zero_pct) ? 0 : 1 + Cost(rng() % unsigned(max_cost)); - double w = 1.0 + double(rng() % 100) / 7.0; - add(u, v, c, w); if (duplex) add(v, u, c, w); - } - Case c; - NodeId src = 0, dst = n / 2; - int total = n; - if (pseudo) { - int k = std::max(1, n / 8); - for (int u = 0; u < k; ++u) add(n, u, 0, 1e9); - for (int u = n - k; u < n; ++u) add(u, n + 1, 0, 1e9); - src = n; dst = n + 1; total = n + 2; - } - c.g = StrictMultiDiGraph::from_arrays(total, s, d, cap, cost); - c.src = src; c.dst = dst; - int N = c.g.num_nodes(), E = c.g.num_edges(); - c.nm = std::make_unique(N); c.em = std::make_unique(E); - for (int i = 0; i < N; ++i) c.nm[i] = rng() % 29 != 0 || i == src || i == dst; - for (int i = 0; i < E; ++i) c.em[i] = rng() % 13 != 0; - c.residual.assign(c.g.capacity_view().begin(), c.g.capacity_view().end()); - for (int i = 0; i < E; ++i) if (rng() % 11 == 0) c.residual[i] = (rng() % 2) ? 0.0 : kMinCap / 2; - return c; -} - -static void dump_res(const std::pair, PredDAG>& r) { - for (auto v : r.first) std::printf("%lld,", (long long)v); - std::printf("|"); - for (auto v : r.second.parent_offsets) std::printf("%d,", v); - std::printf("|"); - for (auto v : r.second.parents) std::printf("%d,", v); - std::printf("|"); - for (auto v : r.second.via_edges) std::printf("%d,", v); - std::printf("\n"); -} - -static void dump_summary(double total, const FlowSummary& sm) { - std::printf("flow %.17g cut:", total); - for (auto e : sm.min_cut.edges) std::printf("%d,", e); - std::printf(" costs:"); - for (size_t i = 0; i < sm.costs.size(); ++i) std::printf("%lld=%.17g,", (long long)sm.costs[i], sm.flows[i]); - std::printf(" ef:"); - for (auto f : sm.edge_flows) std::printf("%.17g,", f); - std::printf("\n"); -} - -int main() { - auto be = make_cpu_backend(); - auto algs = std::make_shared(be); - std::vector cases; - cases.push_back(make(1, 60, 4, 31, 0, true, false)); - cases.push_back(make(2, 80, 3, 31, 20, true, true)); - cases.push_back(make(3, 120, 5, 1, 0, true, true)); - cases.push_back(make(4, 90, 4, 20, 10, false, false)); - cases.push_back(make(5, 200, 3, 1000, 5, true, true)); - cases.push_back(make(6, 40, 6, 4, 30, true, false)); - for (auto& c : cases) { - const int n = c.g.num_nodes(); - std::printf("# case n=%d e=%d\n", n, c.g.num_edges()); - // Forward and reverse SPF over the option matrix. - for (NodeId s : {c.src, NodeId(3), NodeId(n - 1)}) for (int mp = 0; mp < 2; ++mp) for (int me = 0; me < 2; ++me) - for (int tb = 0; tb < 2; ++tb) for (int rc = 0; rc < 2; ++rc) for (int ur = 0; ur < 2; ++ur) for (int um = 0; um < 2; ++um) { - EdgeSelection sel; sel.multi_edge = me; sel.require_capacity = rc; - sel.tie_break = tb ? EdgeTieBreak::PreferHigherResidual : EdgeTieBreak::Deterministic; - auto res = ur ? std::span(c.residual) : std::span{}; - auto nm = um ? c.nmask() : std::span{}; - auto em = um ? c.emask() : std::span{}; - for (std::optional dst : {std::optional{}, std::optional{c.dst}, std::optional{NodeId((s + 1) % n)}}) - dump_res(shortest_paths(c.g, s, dst, mp, sel, res, nm, em)); - const auto row = c.g.row_offsets_view(); const auto aei = c.g.adj_edge_index_view(); - std::vector fan(aei.begin() + row[c.src], aei.begin() + row[c.src + 1]); - dump_res(shortest_paths_to(c.g, s, mp, sel, res, nm, em, {})); - if (c.src >= n - 2) dump_res(shortest_paths_to(c.g, c.dst, mp, sel, res, nm, em, fan)); // pseudo source fan-out - } - // Max-flow matrix. - for (int pl = 1; pl <= 3; ++pl) for (int sp = 0; sp < 2; ++sp) for (int rc = 0; rc < 2; ++rc) for (int um = 0; um < 2; ++um) { - auto nm = um ? c.nmask() : std::span{}; - auto em = um ? c.emask() : std::span{}; - auto [total, sm] = calc_max_flow(c.g, c.src, c.dst, static_cast(pl), sp, rc, true, true, true, nm, em); - dump_summary(total, sm); - } - auto gh = algs->build_graph(c.g); - MaxFlowOptions mo; mo.with_edge_flows = true; - std::vector> pairs; - for (int i = 0; i < 12; ++i) pairs.emplace_back(NodeId((i * 7) % n), NodeId((i * 11 + 5) % n)); - for (auto& sm : algs->batch_max_flow(gh, pairs, mo)) dump_summary(sm.total_flow, sm); - for (auto& [e, f] : algs->sensitivity_analysis(gh, c.src, c.dst, mo)) std::printf("sens %d %.17g\n", e, f); - // FlowPolicy sequences. - for (int pl = 1; pl <= 3; ++pl) for (int mp = 0; mp < 2; ++mp) for (int rc = 0; rc < 2; ++rc) { - FlowGraph fg(c.g); - ExecutionContext ctx(algs, gh); - FlowPolicyConfig cfg; - cfg.flow_placement = static_cast(pl); - cfg.multipath = mp; cfg.require_capacity = rc; - cfg.min_flow_count = mp ? 1 : 4; cfg.max_flow_count = mp ? std::optional{} : std::optional{4}; - cfg.node_mask = c.nmask(); cfg.edge_mask = c.emask(); - FlowPolicy pol(ctx, cfg); - auto r1 = pol.place_demand(fg, c.src, c.dst, 0, 25.0); - auto r2 = pol.place_demand(fg, c.src, c.dst, 0, 40.0); - auto r3 = pol.rebalance_demand(fg, c.src, c.dst, 0, 5.0); - std::printf("policy %d %d %d: %.17g %.17g %.17g %.17g %.17g %.17g placed=%.17g flows=%zu\n", pl, mp, rc, - r1.first, r1.second, r2.first, r2.second, r3.first, r3.second, pol.placed_demand(), pol.flows().size()); - std::vector>> recs; - for (auto& kv : pol.flows()) recs.push_back({kv.first.flowId, {(long long)kv.second.cost, kv.second.placed_flow}}); - std::sort(recs.begin(), recs.end()); - for (auto& r : recs) std::printf(" flow %lld cost %lld placed %.17g\n", r.first, r.second.first, r.second.second); - for (auto f : fg.edge_flow_view()) std::printf("%.17g,", f); - std::printf("\n"); - pol.remove_demand(fg); - double left = 0; for (auto f : fg.edge_flow_view()) left += f; - std::printf("after remove %.17g\n", left); - } - // KSP. - for (int k : {1, 3, 6}) for (int uq = 0; uq < 2; ++uq) { - auto paths = k_shortest_paths(c.g, c.src, c.dst, k, std::optional{1.5}, uq, c.nmask(), c.emask()); - std::printf("ksp k=%d unique=%d n=%zu\n", k, uq, paths.size()); - for (auto& p : paths) dump_res(p); - } - } - return 0; -} diff --git a/dev/perf/spf_queue/bitgate/sha256.txt b/dev/perf/spf_queue/bitgate/sha256.txt deleted file mode 100644 index 7ae872f..0000000 --- a/dev/perf/spf_queue/bitgate/sha256.txt +++ /dev/null @@ -1,3 +0,0 @@ -a976f254eced152b14991ba2198351ec7983506f3f1e177c289852ec2bcaab90 dev/perf/spf_queue/bitgate/old.txt -a976f254eced152b14991ba2198351ec7983506f3f1e177c289852ec2bcaab90 dev/perf/spf_queue/bitgate/new.txt -a976f254eced152b14991ba2198351ec7983506f3f1e177c289852ec2bcaab90 dev/perf/spf_queue/bitgate/new-heap.txt diff --git a/dev/perf/spf_queue/bitgate/status.txt b/dev/perf/spf_queue/bitgate/status.txt deleted file mode 100644 index 9228bca..0000000 --- a/dev/perf/spf_queue/bitgate/status.txt +++ /dev/null @@ -1 +0,0 @@ -BITGATE IDENTICAL diff --git a/dev/perf/spf_queue/build.sh b/dev/perf/spf_queue/build.sh deleted file mode 100755 index 4c06ad5..0000000 --- a/dev/perf/spf_queue/build.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash -# Plain C++ build of the parity/timing harness (no Objective-C++/Metal; any -# clang/gcc with C++20). -# bash build.sh -> build/perf/cpu-queue (working tree sources) -# bash build.sh sanitize -> build/perf/cpu-queue-sanitize (ASan/UBSan) -# bash build.sh old [REV] -> build/perf/cpu-queue-old (sources of REV, default fbab119, -# extracted with git archive into build/perf/old-src) -set -euo pipefail -cd "$(dirname "$0")/../../.." -mode=${1:-release} -flags=(-std=c++20 -O3 -funroll-loops -fno-math-errno -fno-trapping-math -Wall -Wextra) -src=. -out=build/perf/cpu-queue -case "$mode" in - release) ;; - sanitize) - flags=(-std=c++20 -O1 -g -fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer) - out=build/perf/cpu-queue-sanitize ;; - old) - rev=${2:-fbab119} - src=build/perf/old-src - rm -rf "$src" && mkdir -p "$src" - git archive "$rev" src include | tar -x -C "$src" - out=build/perf/cpu-queue-old ;; - *) echo "usage: build.sh [release|sanitize|old [REV]]" >&2; exit 2 ;; -esac -mkdir -p build/perf -${CXX:-clang++} "${flags[@]}" -I"$src/include" \ - dev/perf/spf_queue/harness.cpp \ - "$src/src/strict_multidigraph.cpp" "$src/src/shortest_paths.cpp" "$src/src/profiling.cpp" -o "$out" -echo "built $out" diff --git a/dev/perf/spf_queue/harness.cpp b/dev/perf/spf_queue/harness.cpp deleted file mode 100644 index 7aa04c5..0000000 --- a/dev/perf/spf_queue/harness.cpp +++ /dev/null @@ -1,388 +0,0 @@ -// Portable CPU harness: bit-exact parity of queue/workspace variants against the -// production shortest_paths, plus sequential single-thread timing. -// -// harness correctness -> parity matrix, exit 1 on mismatch -// harness time [samples] [maxW] -> CSV timing rows on stdout -// harness sweep [samples] [maxW] -> cost-range sweep rows -// -// variant = ref | heap-fresh | heap-full | heap-sparse | bucket-fresh | -// bucket-full | bucket-sparse | bsort-sparse | bsort-fresh -#include "spf_variants.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -using namespace netgraph::core; -using spfx::INF; -using Result = std::pair, PredDAG>; -using Clock = std::chrono::steady_clock; - -static double ms(Clock::time_point start) { - return std::chrono::duration(Clock::now() - start).count(); -} - -struct Case { - std::string name; - StrictMultiDiGraph graph; - std::unique_ptr node_mask, edge_mask; - std::vector residual; - NodeId fixed_src = -1, fixed_dst = -1; - std::span nm() const { return {node_mask.get(), size_t(graph.num_nodes())}; } - std::span em() const { return {edge_mask.get(), size_t(graph.num_edges())}; } -}; - -// Verbatim port of the research generator (same RNG draws), plus new kinds: -// uniform : random topology, every cost == 31 -// wideK : random topology, costs 1..K (K via max_cost argument) -// pseudo : 2-tier fabric with a zero-cost pseudo source/sink attachment, as -// NetGraph's max-flow builds; fixed src/dst = the pseudo nodes -// zerocyc : random graph with a share of zero-cost edges (zero-cost cycles) -static Case make_case(std::string kind, int n, int degree = 8, bool masked = false, - bool large = false, unsigned seed = 1729, Cost max_cost = 31) { - std::vector src, dst; - std::vector costs; - std::vector caps; - std::mt19937 rng(seed); - auto add = [&](int u, int v, Cost c) { - src.push_back(u); dst.push_back(v); - costs.push_back(c + (large ? (Cost(1) << 34) : 0)); - caps.push_back(1.0 + double(rng() % 100) / 7.0); - }; - NodeId fsrc = -1, fdst = -1; - if (kind == "chain") { - for (int i = 0; i < n - 1; ++i) add(i, i + 1, 1 + rng() % 31); - } else if (kind == "grid") { - int side = int(std::sqrt(n)); - if (side * side != n) throw std::runtime_error("grid must be square"); - for (int u = 0; u < n; ++u) { - if (u % side + 1 < side) { add(u, u + 1, 1); add(u + 1, u, 1); } - if (u + side < n) { add(u, u + side, 1); add(u + side, u, 1); } - } - } else if (kind == "fabric" || kind == "clos") { - int spines = kind == "clos" ? n / 2 : 32; - for (int u = 0; u < n - spines; ++u) for (int v = n - spines; v < n; ++v) { - add(u, v, 1); add(v, u, 1); - } - } else if (kind == "uniform") { - for (int u = 0; u < n; ++u) { - add(u, (u + 1) % n, 31); - for (int k = 1; k < degree; ++k) add(u, rng() % n, 31); - } - } else if (kind == "wide") { - for (int u = 0; u < n; ++u) { - add(u, (u + 1) % n, 1 + Cost(rng() % max_cost)); - for (int k = 1; k < degree; ++k) add(u, rng() % n, 1 + Cost(rng() % max_cost)); - } - } else if (kind == "zerocyc") { - for (int u = 0; u < n; ++u) { - add(u, (u + 1) % n, rng() % 4 == 0 ? 0 : 1 + rng() % 31); - for (int k = 1; k < degree; ++k) add(u, rng() % n, rng() % 4 == 0 ? 0 : 1 + rng() % 31); - } - } else if (kind == "pseudo") { - // n real nodes: leaves [0, n/2), spines [n/2, n); then pseudo src n, pseudo dst n+1. - int leaves = n / 2; - for (int u = 0; u < leaves; ++u) for (int v = leaves; v < n; ++v) { add(u, v, 1); add(v, u, 1); } - int k = std::max(1, leaves / 4); - for (int u = 0; u < k; ++u) add(n, u, 0); - for (int u = leaves - k; u < leaves; ++u) add(u, n + 1, 0); - fsrc = n; fdst = n + 1; n += 2; - } else { - for (int u = 0; u < n; ++u) { - add(u, (u + 1) % n, 1 + rng() % 31); - for (int k = 1; k < degree; ++k) add(u, rng() % n, 1 + rng() % 31); - } - } - Case c{kind + (masked ? "_masked" : "") + (large ? "_int64" : ""), - StrictMultiDiGraph::from_arrays(n, src, dst, caps, costs)}; - c.fixed_src = fsrc; c.fixed_dst = fdst; - int e = c.graph.num_edges(); - c.node_mask = std::make_unique(n); - c.edge_mask = std::make_unique(e); - c.residual.assign(c.graph.capacity_view().begin(), c.graph.capacity_view().end()); - for (int i = 0; i < n; ++i) c.node_mask[i] = !masked || rng() % 23 != 0; - for (int i = 0; i < e; ++i) { - c.edge_mask[i] = !masked || rng() % 11 != 0; - if (masked && rng() % 13 == 0) c.residual[i] = 0; - } - if (fsrc >= 0) { c.node_mask[fsrc] = true; c.node_mask[fdst] = true; } - return c; -} - -struct Query { - NodeId src; std::optional dst; bool multipath; EdgeSelection sel; - bool use_residual; bool use_masks; bool reverse = false; -}; - -// SPFX_DAG=1 times shortest_paths_dag for destination queries; its result is -// folded into the Result shape with the target cost as the only distance. -static const bool kDagMode = std::getenv("SPFX_DAG") != nullptr; - -static Result run_ref(const Case& c, const Query& q) { - auto res = q.use_residual ? std::span(c.residual) : std::span{}; - auto nm = q.use_masks ? c.nm() : std::span{}; - auto em = q.use_masks ? c.em() : std::span{}; - if (q.reverse) return shortest_paths_to(c.graph, q.src, q.multipath, q.sel, res, nm, em, {}); - if (kDagMode && q.dst) { - auto [cost, dag] = shortest_paths_dag(c.graph, q.src, *q.dst, q.multipath, q.sel, res, nm, em); - return Result{std::vector{cost}, std::move(dag)}; - } - return shortest_paths(c.graph, q.src, q.dst, q.multipath, q.sel, res, nm, em); -} - -struct Runner { - std::string variant; - spfx::HeapQueue heap; spfx::BucketHeapQueue bucket; spfx::BucketPow2Queue bucket2; spfx::BucketSortQueue bsort; - spfx::Workspace ws; - spfx::CostProfile prof; - std::size_t max_width = 1u << 20; - bool fallback = false; - void prepare(const Case& c) { - prof = spfx::profile_costs(c.graph); - fallback = static_cast(prof.max_cost / prof.stride) + 1 > max_width; - ws = spfx::Workspace{}; - if (variant.find("fresh") != std::string::npos) ws.mode = spfx::WsMode::Fresh; - else if (variant.find("full") != std::string::npos) ws.mode = spfx::WsMode::ReuseFull; - else if (variant.find("hybrid") != std::string::npos) ws.mode = spfx::WsMode::ReuseHybrid; - else ws.mode = spfx::WsMode::ReuseSparse; - } - Result run(const Case& c, const Query& q) { - if (variant == "ref" || q.reverse) return run_ref(c, q); // reverse: production only - spfx::Workspace fresh; fresh.mode = spfx::WsMode::Fresh; - spfx::Workspace& w = (ws.mode == spfx::WsMode::Fresh) ? fresh : ws; - auto res = q.use_residual ? std::span(c.residual) : std::span{}; - auto nm = q.use_masks ? c.nm() : std::span{}; - auto em = q.use_masks ? c.em() : std::span{}; - const bool use_heap = fallback || variant.rfind("heap", 0) == 0; - if (use_heap) return spfx::spf_variant(c.graph, q.src, q.dst, q.multipath, q.sel, res, nm, em, heap, w, prof.max_cost, prof.stride); - if (variant.rfind("bsort", 0) == 0) return spfx::spf_variant(c.graph, q.src, q.dst, q.multipath, q.sel, res, nm, em, bsort, w, prof.max_cost, prof.stride); - if (variant.rfind("bucket2", 0) == 0) return spfx::spf_variant(c.graph, q.src, q.dst, q.multipath, q.sel, res, nm, em, bucket2, w, prof.max_cost, prof.stride); - return spfx::spf_variant(c.graph, q.src, q.dst, q.multipath, q.sel, res, nm, em, bucket, w, prof.max_cost, prof.stride); - } -}; - -static bool same(const Result& a, const Result& b) { - return a.first == b.first && a.second.parent_offsets == b.second.parent_offsets && - a.second.parents == b.second.parents && a.second.via_edges == b.second.via_edges; -} -// In DAG mode the expected result is the full search; compare its target cost and DAG. -static bool same_for_query(const Query& q, const Result& expected, const Result& actual) { - if (kDagMode && q.dst && !q.reverse) { - return actual.first.size() == 1 && actual.first[0] == expected.first[static_cast(*q.dst)] && - expected.second.parent_offsets == actual.second.parent_offsets && - expected.second.parents == actual.second.parents && expected.second.via_edges == actual.second.via_edges; - } - return same(expected, actual); -} - -static NodeId far_node(const std::vector& d) { - NodeId best = -1; Cost bd = -1; - for (std::size_t v = 0; v < d.size(); ++v) if (d[v] != INF && d[v] > bd) { bd = d[v]; best = NodeId(v); } - return best; -} - -static int correctness() { - const char* variants[] = {"heap-fresh", "heap-full", "heap-sparse", "heap-hybrid", "bucket-fresh", - "bucket-full", "bucket-sparse", "bucket-hybrid", "bucket2-fresh", "bucket2-sparse", - "bucket2-hybrid", "bsort-fresh", "bsort-sparse"}; - std::vector cases; - for (unsigned seed = 1; seed <= 12; ++seed) cases.push_back(make_case("random", 37 + seed * 3, 6, true, false, seed)); - for (unsigned seed = 1; seed <= 6; ++seed) cases.push_back(make_case("zerocyc", 40 + seed * 5, 5, seed % 2, false, seed)); - cases.push_back(make_case("random", 1024)); - cases.push_back(make_case("random", 512, 8, true, true, 7)); - cases.push_back(make_case("grid", 1024)); - cases.push_back(make_case("uniform", 500)); - cases.push_back(make_case("wide", 600, 8, true, false, 3, 100000)); - cases.push_back(make_case("pseudo", 64)); - cases.push_back(make_case("pseudo", 200, 8, true, false, 5)); - cases.push_back(make_case("chain", 300)); - cases.push_back(make_case("fabric", 100)); - // Dense parallel edges and self-loops: a tiny multigraph. - { - std::vector s, d; std::vector c; std::vector k; std::mt19937 r(99); - for (int i = 0; i < 400; ++i) { s.push_back(r() % 12); d.push_back(r() % 12); c.push_back(r() % 3); k.push_back(double(r() % 5)); } - Case mc{"multi", StrictMultiDiGraph::from_arrays(12, s, d, k, c)}; - mc.node_mask = std::make_unique(12); mc.edge_mask = std::make_unique(400); - mc.residual.assign(mc.graph.capacity_view().begin(), mc.graph.capacity_view().end()); - for (int i = 0; i < 12; ++i) mc.node_mask[i] = r() % 9 != 0; - for (int i = 0; i < 400; ++i) { mc.edge_mask[i] = r() % 7 != 0; if (r() % 5 == 0) mc.residual[i] = kMinCap * (r() % 3); } - cases.push_back(std::move(mc)); - } - std::size_t checked = 0, failed = 0; - for (auto& c : cases) { - const int n = c.graph.num_nodes(); - std::vector sources; - if (c.fixed_src >= 0) sources = {c.fixed_src, 0, 3}; - else for (int i = 0; i < 6; ++i) sources.push_back((i * 997 + 1) % n); - for (const char* vname : variants) { - Runner r; r.variant = vname; r.prepare(c); - for (NodeId s : sources) { - for (int mp = 0; mp < 2; ++mp) for (int me = 0; me < 2; ++me) for (int tb = 0; tb < 2; ++tb) - for (int rc = 0; rc < 2; ++rc) for (int ur = 0; ur < 2; ++ur) for (int um = 0; um < 2; ++um) { - Query q; q.src = s; q.multipath = mp; q.sel.multi_edge = me; - q.sel.tie_break = tb ? EdgeTieBreak::PreferHigherResidual : EdgeTieBreak::Deterministic; - q.sel.require_capacity = rc; q.use_residual = ur; q.use_masks = um; - std::vector> dsts = {std::nullopt}; - auto full = run_ref(c, q); - if (c.fixed_dst >= 0) dsts.push_back(c.fixed_dst); - auto row = c.graph.row_offsets_view(); auto col = c.graph.col_indices_view(); - if (row[s + 1] > row[s]) dsts.push_back(col[row[s]]); - NodeId far = far_node(full.first); if (far >= 0) dsts.push_back(far); - dsts.push_back(s); dsts.push_back((s + n / 2) % n); - for (auto& d : dsts) { - q.dst = d; - auto expected = run_ref(c, q); - for (int rep = 0; rep < 2; ++rep) { // second run exercises reuse/reset paths - auto actual = r.run(c, q); - ++checked; - if (!same(expected, actual)) { - ++failed; - if (failed <= 20) - std::cerr << "MISMATCH case=" << c.name << " n=" << n << " variant=" << vname - << " src=" << s << " dst=" << (d ? std::to_string(*d) : "none") - << " mp=" << mp << " me=" << me << " tb=" << tb << " rc=" << rc - << " ur=" << ur << " um=" << um << " rep=" << rep << '\n'; - } - } - } - } - } - } - } - std::cout << "checked=" << checked << " failed=" << failed << '\n'; - return failed ? 1 : 0; -} - -struct Workload { std::string label; Case c; int queries; std::string dst_mode; }; - -static void time_workloads(std::vector& wls, const std::string& variant, int samples, std::size_t maxw) { - std::cout << "case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms\n"; - for (auto& w : wls) { - auto& c = w.c; const int n = c.graph.num_nodes(); - std::vector qs; - for (int i = 0; i < w.queries; ++i) { - Query q; q.multipath = true; q.sel = EdgeSelection{}; q.use_residual = true; q.use_masks = true; - if (const char* pr = std::getenv("SPFX_PRIORITY")) { // time a non-default EdgePriority - const std::string p(pr); - if (p == "capacity") q.sel.priority = EdgePriority::Capacity; - else if (p == "headroom") q.sel.priority = EdgePriority::Headroom; - q.multipath = false; q.sel.tie_break = EdgeTieBreak::PreferHigherResidual; // where the value is used most - } - q.src = c.fixed_src >= 0 ? c.fixed_src : (i * 997) % n; - if (w.dst_mode == "reverse") q.reverse = true; - else if (w.dst_mode == "fixed") q.dst = c.fixed_dst; - else if (w.dst_mode == "near") { - auto row = c.graph.row_offsets_view(); auto col = c.graph.col_indices_view(); - q.dst = col[row[q.src]]; - } else if (w.dst_mode == "far") { - Query f = q; f.dst = std::nullopt; q.dst = far_node(run_ref(c, f).first); - } - qs.push_back(q); - } - std::vector expected; - for (auto& q : qs) { // reference: the full forward search for DAG-mode destination queries - if (kDagMode && q.dst && !q.reverse) { - expected.push_back(shortest_paths(c.graph, q.src, q.dst, q.multipath, q.sel, - q.use_residual ? std::span(c.residual) : std::span{}, - q.use_masks ? c.nm() : std::span{}, q.use_masks ? c.em() : std::span{})); - } else { - expected.push_back(run_ref(c, q)); - } - } - Runner r; r.variant = variant; r.max_width = maxw; r.prepare(c); - std::vector times; - for (int rep = -2; rep < samples; ++rep) { - std::vector actual(qs.size()); - auto start = Clock::now(); - for (std::size_t i = 0; i < qs.size(); ++i) actual[i] = r.run(c, qs[i]); - double el = ms(start); - for (std::size_t i = 0; i < qs.size(); ++i) if (!same_for_query(qs[i], expected[i], actual[i])) - throw std::runtime_error("timed result mismatch in " + w.label + " query " + std::to_string(i) + " dst=" + (qs[i].dst ? std::to_string(*qs[i].dst) : "none") + " dist sizes " + std::to_string(expected[i].first.size()) + "/" + std::to_string(actual[i].first.size())); - if (rep >= 0) times.push_back(el); - } - std::vector sorted = times; std::sort(sorted.begin(), sorted.end()); - std::cout << w.label << ',' << n << ',' << c.graph.num_edges() << ',' << w.queries << ',' << w.dst_mode << ',' - << variant << ',' << (r.fallback ? 1 : 0) << ',' << (r.prof.max_cost / r.prof.stride + 1) << ',' - << std::fixed << sorted[sorted.size() / 2] << ',' << samples << ','; - for (std::size_t j = 0; j < times.size(); ++j) std::cout << (j ? ";" : "") << times[j]; - std::cout << '\n' << std::flush; - } -} - -int main(int argc, char** argv) { - try { - std::string mode = argc > 1 ? argv[1] : "correctness"; - if (mode == "correctness") return correctness(); - std::string variant = argc > 2 ? argv[2] : "ref"; - int samples = argc > 3 ? std::stoi(argv[3]) : 7; - std::size_t maxw = argc > 4 ? std::stoull(argv[4]) : (1u << 20); - std::vector wls; - if (mode == "time") { - wls.push_back({"random", make_case("random", 1024), 1, "none"}); - wls.push_back({"random", make_case("random", 4096), 256, "none"}); - wls.push_back({"random", make_case("random", 16384), 64, "none"}); - wls.push_back({"random", make_case("random", 65536), 1, "none"}); - wls.push_back({"random", make_case("random", 4096, 32), 64, "none"}); - wls.push_back({"clos", make_case("clos", 800), 64, "none"}); - wls.push_back({"grid", make_case("grid", 10000), 1, "none"}); - wls.push_back({"chain", make_case("chain", 2048), 1, "none"}); - wls.push_back({"uniform", make_case("uniform", 16384), 64, "none"}); - wls.push_back({"random_masked", make_case("random", 16384, 8, true), 64, "none"}); - wls.push_back({"pseudo", make_case("pseudo", 800), 64, "fixed"}); - wls.push_back({"random", make_case("random", 1024), 64, "far"}); - wls.push_back({"random", make_case("random", 16384), 64, "far"}); - wls.push_back({"random", make_case("random", 65536), 16, "far"}); - wls.push_back({"random", make_case("random", 1024), 64, "near"}); - wls.push_back({"random", make_case("random", 16384), 64, "near"}); - wls.push_back({"random", make_case("random", 65536), 64, "near"}); - wls.push_back({"random", make_case("random", 262144), 16, "near"}); - wls.push_back({"random", make_case("random", 262144), 1, "none"}); - wls.push_back({"random", make_case("random", 4096), 64, "reverse"}); - wls.push_back({"random", make_case("random", 16384), 16, "reverse"}); - wls.push_back({"clos", make_case("clos", 800), 16, "reverse"}); - wls.push_back({"chain", make_case("chain", 2048), 1, "reverse"}); - } else if (mode == "short") { - wls.push_back({"random", make_case("random", 16384), 64, "none"}); - wls.push_back({"random", make_case("random", 65536), 1, "none"}); - wls.push_back({"clos", make_case("clos", 800), 64, "none"}); - wls.push_back({"chain", make_case("chain", 2048), 1, "none"}); - wls.push_back({"random", make_case("random", 16384), 64, "near"}); - wls.push_back({"random", make_case("random", 65536), 64, "near"}); - wls.push_back({"random", make_case("random", 262144), 16, "near"}); - wls.push_back({"random", make_case("random", 262144), 1, "none"}); - wls.push_back({"random", make_case("random", 4096), 64, "reverse"}); - wls.push_back({"clos", make_case("clos", 800), 16, "reverse"}); - } else if (mode == "faults") { - auto c = make_case("random", 262144); - Runner r; r.variant = variant; r.prepare(c); - std::vector qs; - for (int i = 0; i < 16; ++i) { - Query q; q.multipath = true; q.sel = EdgeSelection{}; q.use_residual = true; q.use_masks = true; - q.src = (i * 997) % c.graph.num_nodes(); - auto row = c.graph.row_offsets_view(); auto col = c.graph.col_indices_view(); - q.dst = col[row[q.src]]; qs.push_back(q); - } - std::cout << "variant,sample,ms,minor_faults\n"; - for (int rep = 0; rep < samples; ++rep) { - struct rusage a{}, b{}; getrusage(RUSAGE_SELF, &a); - auto start = Clock::now(); - for (auto& q : qs) { auto res = r.run(c, q); (void)res; } - double el = ms(start); getrusage(RUSAGE_SELF, &b); - std::cout << variant << ',' << rep << ',' << el << ',' << (b.ru_minflt - a.ru_minflt) << '\n'; - } - return 0; - } else if (mode == "sweep") { - for (Cost k : {Cost(1), Cost(31), Cost(1023), Cost(32767), Cost(1048575), Cost(33554431)}) - wls.push_back({"wide" + std::to_string(k), make_case("wide", 16384, 8, false, false, 11, k), 16, "none"}); - } else { std::cerr << "unknown mode\n"; return 2; } - time_workloads(wls, variant, samples, maxw); - return 0; - } catch (const std::exception& e) { std::cerr << "FAIL: " << e.what() << '\n'; return 1; } -} diff --git a/dev/perf/spf_queue/results/core/environment.txt b/dev/perf/spf_queue/results/core/environment.txt deleted file mode 100644 index 7e3715f..0000000 --- a/dev/perf/spf_queue/results/core/environment.txt +++ /dev/null @@ -1,5 +0,0 @@ -date: 2026-10-02T01:55:02Z -commit: fbab119e794c843838f4f3f96d75cb7f32f06845 -398e99a789a55595c2862a1574d5dbb7b79105debd23893e513145331c8ebf20 build/perf/cpu-queue-old -0caa759264760987d39f2e34774fb972e6d906aa30c0ba50b338b630bc2ecb82 build/perf/cpu-queue -32b56468bdbf2aa01cd24f4d49bc32572a4fb3df2cdfbee222919365ddb345ac src/shortest_paths.cpp diff --git a/dev/perf/spf_queue/results/core/load.txt b/dev/perf/spf_queue/results/core/load.txt deleted file mode 100644 index 355b2d8..0000000 --- a/dev/perf/spf_queue/results/core/load.txt +++ /dev/null @@ -1,5 +0,0 @@ - 2:55 up 52 days, 7:30, 3 users, load averages: 4.90 3.90 3.58 - 2:55 up 52 days, 7:31, 3 users, load averages: 5.87 4.25 3.72 - 2:55 up 52 days, 7:31, 3 users, load averages: 5.55 4.28 3.74 - 2:56 up 52 days, 7:32, 3 users, load averages: 4.78 4.23 3.74 - 2:56 up 52 days, 7:32, 3 users, load averages: 3.81 4.06 3.70 diff --git a/dev/perf/spf_queue/results/core/sweep-a1-old.csv b/dev/perf/spf_queue/results/core/sweep-a1-old.csv deleted file mode 100644 index f9e3244..0000000 --- a/dev/perf/spf_queue/results/core/sweep-a1-old.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -wide1,16384,131072,16,none,ref,0,2,44.352291,9,44.086875;44.377000;44.628125;44.352291;44.023541;43.918208;44.219417;44.613792;44.580041 -wide31,16384,131072,16,none,ref,0,32,64.523625,9,65.509584;63.234250;65.116667;65.243042;64.903458;64.523625;63.181459;63.612750;63.286166 -wide1023,16384,131072,16,none,ref,0,1024,59.174417,9,58.785959;58.733333;58.501958;59.025541;59.562083;59.174417;59.679125;61.427416;60.950667 -wide32767,16384,131072,16,none,ref,0,32768,51.340375,9,51.536250;50.769167;51.296250;50.826084;51.254458;51.340375;55.436375;52.199417;51.832375 -wide1048575,16384,131072,16,none,ref,0,1048570,49.692959,9,49.268709;49.916458;49.665333;50.078875;50.071583;49.323417;49.692959;49.539000;50.344708 -wide33554431,16384,131072,16,none,ref,1,33554196,50.979792,9,51.642458;50.843541;51.670125;52.071708;50.979792;49.830125;49.897750;53.102917;50.218000 diff --git a/dev/perf/spf_queue/results/core/sweep-a2-old.csv b/dev/perf/spf_queue/results/core/sweep-a2-old.csv deleted file mode 100644 index 481ab09..0000000 --- a/dev/perf/spf_queue/results/core/sweep-a2-old.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -wide1,16384,131072,16,none,ref,0,2,45.312333,9,44.464833;44.154500;47.801250;53.494416;50.825042;45.947750;45.312333;44.580125;43.997000 -wide31,16384,131072,16,none,ref,0,32,62.621250,9,62.490250;62.513000;63.224833;65.986583;63.337458;62.931083;62.621250;62.527625;62.346834 -wide1023,16384,131072,16,none,ref,0,1024,58.916208,9,59.217541;58.972542;58.553250;58.374542;58.483916;58.916208;58.997625;59.213500;58.537375 -wide32767,16384,131072,16,none,ref,0,32768,51.218083,9,51.855000;50.652958;51.594542;52.729791;54.058500;50.825625;51.218083;50.761458;50.697500 -wide1048575,16384,131072,16,none,ref,0,1048570,49.482167,9,49.696250;49.551542;50.780459;49.118250;49.691791;49.366750;49.482167;49.194792;49.096834 -wide33554431,16384,131072,16,none,ref,1,33554196,49.570416,9,49.298209;49.703792;49.571917;49.570416;49.748334;49.420875;48.950458;49.058333;49.650792 diff --git a/dev/perf/spf_queue/results/core/sweep-b-new-heap.csv b/dev/perf/spf_queue/results/core/sweep-b-new-heap.csv deleted file mode 100644 index ebdd1c8..0000000 --- a/dev/perf/spf_queue/results/core/sweep-b-new-heap.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -wide1,16384,131072,16,none,ref,0,2,44.878208,9,45.642250;44.777125;44.635208;44.826041;44.878208;44.698917;44.906916;45.127375;46.000833 -wide31,16384,131072,16,none,ref,0,32,65.879833,9,65.689708;65.387917;65.879833;65.649000;65.923375;65.449416;71.317416;70.386500;66.445333 -wide1023,16384,131072,16,none,ref,0,1024,64.511417,9,65.141875;64.957542;64.511417;63.439542;63.949875;65.523292;64.848458;62.969292;61.874125 -wide32767,16384,131072,16,none,ref,0,32768,53.333916,9,56.096500;53.301334;53.190750;52.911167;53.580208;53.497084;53.135542;53.689084;53.333916 -wide1048575,16384,131072,16,none,ref,0,1048570,51.609166,9,51.523833;51.587417;51.883958;51.651209;51.836750;51.609166;51.933042;51.236250;51.375834 -wide33554431,16384,131072,16,none,ref,1,33554196,52.825083,9,51.052583;53.398959;52.825083;53.651458;52.462500;53.022917;51.669167;52.227625;52.896750 diff --git a/dev/perf/spf_queue/results/core/sweep-b-new.csv b/dev/perf/spf_queue/results/core/sweep-b-new.csv deleted file mode 100644 index 9433c7f..0000000 --- a/dev/perf/spf_queue/results/core/sweep-b-new.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -wide1,16384,131072,16,none,ref,0,2,36.503458,9,37.167208;36.729708;37.140375;36.533916;36.503458;36.221459;36.294459;36.378834;36.433625 -wide31,16384,131072,16,none,ref,0,32,37.245334,9,37.449375;37.121292;37.245334;37.061000;37.297667;37.611959;37.674375;37.111042;37.001167 -wide1023,16384,131072,16,none,ref,0,1024,35.254125,9,35.443709;34.672542;34.525583;35.358042;35.254125;34.552375;35.635083;35.049750;37.176667 -wide32767,16384,131072,16,none,ref,0,32768,42.723500,9,46.660292;46.680417;43.522792;42.570000;42.480250;42.398000;43.176167;42.561459;42.723500 -wide1048575,16384,131072,16,none,ref,0,1048570,52.842042,9,52.842042;54.554709;52.937625;51.867250;53.022625;53.576167;51.793459;51.445958;51.624084 -wide33554431,16384,131072,16,none,ref,1,33554196,51.172125,9,51.042458;51.125750;51.288833;51.172125;51.396875;51.077375;50.950125;51.360083;51.348583 diff --git a/dev/perf/spf_queue/results/core/time-a1-old.csv b/dev/perf/spf_queue/results/core/time-a1-old.csv deleted file mode 100644 index 9fc4aae..0000000 --- a/dev/perf/spf_queue/results/core/time-a1-old.csv +++ /dev/null @@ -1,24 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,1024,8192,1,none,ref,0,32,0.106958,9,0.115667;0.110708;0.106792;0.106958;0.104708;0.109125;0.105583;0.110708;0.105542 -random,4096,32768,256,none,ref,0,32,211.610083,9,212.391417;213.572625;211.610083;212.972792;209.318375;211.922333;211.232708;210.825583;211.031791 -random,16384,131072,64,none,ref,0,32,254.445125,9,255.636625;250.567292;256.788750;255.059291;254.445125;252.375416;252.658750;251.488167;258.346709 -random,65536,524288,1,none,ref,0,32,21.207166,9,22.317625;20.960375;22.085000;21.607833;21.891917;20.436417;21.207166;20.212583;19.644375 -random,4096,131072,64,none,ref,0,32,111.982500,9,111.510291;111.280167;110.260750;114.967625;111.982500;116.551958;115.775541;112.375292;110.651750 -clos,800,320000,64,none,ref,0,2,143.850792,9,144.580583;159.426375;146.935833;144.435542;143.749167;143.498125;143.109583;143.096916;143.850792 -grid,10000,39600,1,none,ref,0,2,0.613667,9,0.626875;0.617250;0.614458;0.608209;0.605041;0.613667;0.602333;0.630500;0.612917 -chain,2048,2047,1,none,ref,0,32,0.021084,9,0.021125;0.023292;0.021250;0.021291;0.021000;0.020833;0.021084;0.021000;0.020833 -uniform,16384,131072,64,none,ref,0,2,181.359417,9,184.075459;184.356917;181.359417;179.613750;176.768791;176.969792;183.551792;185.391208;179.085709 -random_masked,16384,131072,64,none,ref,0,32,229.998916,9,231.366959;229.379667;229.071125;230.344541;229.998916;229.167958;231.250333;229.889500;233.766959 -pseudo,802,320200,64,fixed,ref,0,2,143.435458,9,145.647375;143.648291;143.467333;143.388459;143.066459;143.010917;142.905250;143.435458;144.383417 -random,1024,8192,64,far,ref,0,32,10.809542,9,10.983583;10.809542;10.820083;10.535125;10.627583;11.172125;10.714834;10.797625;10.889416 -random,16384,131072,64,far,ref,0,32,251.559084,9,251.398833;255.170833;249.550292;257.199750;256.924875;251.559084;262.081709;250.485417;250.757833 -random,65536,524288,16,far,ref,0,32,324.283292,9,317.256292;323.378583;317.522917;335.351792;344.360000;328.237084;332.692291;323.432333;324.283292 -random,1024,8192,64,near,ref,0,32,0.115916,9,0.131834;0.116125;0.114917;0.116166;0.114417;0.115916;0.115167;0.115916;0.114916 -random,16384,131072,64,near,ref,0,32,1.160042,9,1.184500;1.160042;1.155458;1.184875;1.169292;1.155208;1.161750;1.149708;1.133042 -random,65536,524288,64,near,ref,0,32,4.350791,9,4.351875;4.369875;4.271042;4.321834;4.495250;4.440125;4.322833;4.295916;4.350791 -random,262144,2097152,16,near,ref,0,32,4.508541,9,4.475417;4.420459;4.466000;4.534375;4.546958;4.508541;4.552375;4.574167;4.442375 -random,262144,2097152,1,none,ref,0,32,136.600542,9,131.104333;132.261833;151.478750;136.600542;153.047334;136.134208;151.398709;138.768875;133.424375 -random,4096,32768,64,reverse,ref,0,32,55.852125,9,56.317833;55.482417;55.200209;55.489792;55.858208;56.313583;55.924292;55.852125;55.504916 -random,16384,131072,16,reverse,ref,0,32,66.552625,9,66.607458;66.686250;66.552625;66.465708;66.405917;66.595375;66.715500;66.423000;66.335334 -clos,800,320000,16,reverse,ref,0,2,45.430417,9,45.366916;46.270000;45.521083;45.523167;45.045208;45.342166;46.058917;45.430417;45.360167 -chain,2048,2047,1,reverse,ref,0,32,0.002459,9,0.002542;0.002500;0.002458;0.002417;0.002458;0.002459;0.002459;0.002417;0.002500 diff --git a/dev/perf/spf_queue/results/core/time-a2-old.csv b/dev/perf/spf_queue/results/core/time-a2-old.csv deleted file mode 100644 index 94e8ab2..0000000 --- a/dev/perf/spf_queue/results/core/time-a2-old.csv +++ /dev/null @@ -1,24 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,1024,8192,1,none,ref,0,32,0.101834,9,0.109500;0.105542;0.101750;0.104625;0.101834;0.100333;0.101167;0.103750;0.100666 -random,4096,32768,256,none,ref,0,32,207.613708,9,207.485375;206.933041;208.212125;207.613708;207.810167;208.212750;210.455416;207.561500;207.319334 -random,16384,131072,64,none,ref,0,32,250.635500,9,248.790875;255.047708;252.925667;250.635500;259.166875;256.407833;249.778542;249.601458;249.926959 -random,65536,524288,1,none,ref,0,32,19.031875,9,19.095375;18.645916;18.997584;19.031875;19.140959;18.780000;18.720708;19.176583;19.497166 -random,4096,131072,64,none,ref,0,32,110.612875,9,109.587375;110.209167;111.275208;110.612875;111.021292;109.861250;109.868792;111.204875;112.800917 -clos,800,320000,64,none,ref,0,2,144.348666,9,144.072417;144.348666;142.863833;144.374250;143.269584;143.490667;145.960250;146.807167;149.376125 -grid,10000,39600,1,none,ref,0,2,0.617875,9,0.702541;0.759916;0.659916;0.617875;0.626667;0.612417;0.608000;0.610875;0.602750 -chain,2048,2047,1,none,ref,0,32,0.021125,9,0.021167;0.021208;0.020917;0.021042;0.021125;0.021042;0.021125;0.021125;0.021083 -uniform,16384,131072,64,none,ref,0,2,177.095166,9,182.894583;177.679000;176.943375;178.067417;177.095166;175.792833;175.764500;177.102125;176.212083 -random_masked,16384,131072,64,none,ref,0,32,229.178375,9,236.057750;234.569458;229.058791;230.634208;228.691667;229.766125;229.178375;228.735667;228.920333 -pseudo,802,320200,64,fixed,ref,0,2,143.852875,9,142.524209;145.574958;144.100459;142.070458;141.723667;142.472459;145.693959;143.852875;144.534584 -random,1024,8192,64,far,ref,0,32,10.644542,9,10.938208;10.673625;10.548000;10.667625;10.833166;10.644542;10.574875;10.554875;10.396333 -random,16384,131072,64,far,ref,0,32,250.480292,9,249.484750;256.099917;256.425250;250.480292;250.175000;252.559959;249.520334;250.489750;249.955709 -random,65536,524288,16,far,ref,0,32,330.221833,9,315.811792;313.175667;318.501292;354.013792;334.924291;358.063459;337.334500;330.221833;325.634834 -random,1024,8192,64,near,ref,0,32,0.115000,9,0.131166;0.120875;0.123250;0.114667;0.115125;0.114500;0.114542;0.115000;0.114458 -random,16384,131072,64,near,ref,0,32,1.158416,9,1.172833;1.157791;1.135833;1.134000;1.149042;1.170666;1.162125;1.159625;1.158416 -random,65536,524288,64,near,ref,0,32,4.254209,9,4.462792;4.332792;4.254209;4.196083;4.119875;4.314084;4.682750;4.132750;4.198625 -random,262144,2097152,16,near,ref,0,32,4.052500,9,4.064917;4.003458;4.176958;3.968500;4.052500;4.077541;4.024875;4.008208;4.084000 -random,262144,2097152,1,none,ref,0,32,133.776042,9,129.185667;137.226125;140.845583;129.927167;138.156834;133.776042;129.648542;129.228250;140.502583 -random,4096,32768,64,reverse,ref,0,32,55.341708,9,55.341708;54.893125;55.288291;55.720083;55.631458;55.147375;55.813000;55.523584;55.047041 -random,16384,131072,16,reverse,ref,0,32,66.335584,9,66.911958;66.478584;66.164500;66.552166;65.950875;66.265667;66.290083;66.454625;66.335584 -clos,800,320000,16,reverse,ref,0,2,45.715584,9,45.088375;45.245375;45.715584;45.252958;45.571917;47.052000;47.791334;48.474250;47.146917 -chain,2048,2047,1,reverse,ref,0,32,0.002500,9,0.002541;0.006000;0.002542;0.002500;0.002500;0.002459;0.002417;0.002417;0.002459 diff --git a/dev/perf/spf_queue/results/core/time-b-new-heap.csv b/dev/perf/spf_queue/results/core/time-b-new-heap.csv deleted file mode 100644 index 205909f..0000000 --- a/dev/perf/spf_queue/results/core/time-b-new-heap.csv +++ /dev/null @@ -1,24 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,1024,8192,1,none,ref,0,32,0.088833,9,0.096708;0.091625;0.088833;0.088750;0.090125;0.088541;0.088417;0.089375;0.087750 -random,4096,32768,256,none,ref,0,32,221.265750,9,220.144584;223.817375;220.986583;223.324666;222.115833;219.679500;221.265750;223.397125;220.469791 -random,16384,131072,64,none,ref,0,32,266.067667,9,274.505625;266.067667;267.851333;270.677250;269.163250;264.256125;265.097667;263.502791;263.624708 -random,65536,524288,1,none,ref,0,32,23.134583,9,20.670500;23.134583;24.174791;23.200709;23.227667;24.203375;22.576833;22.547500;22.666666 -random,4096,131072,64,none,ref,0,32,115.415541,9,115.824416;115.139792;115.361583;115.253792;118.206167;115.457125;114.843167;115.415541;115.862542 -clos,800,320000,64,none,ref,0,2,111.078375,9,111.197500;111.471166;110.903000;110.062458;111.763917;111.078375;111.423292;110.355000;109.572500 -grid,10000,39600,1,none,ref,0,2,0.634583,9,0.667334;0.642000;0.634583;0.619208;0.621583;0.626667;0.608708;0.755708;0.666667 -chain,2048,2047,1,none,ref,0,32,0.029416,9,0.029459;0.029416;0.029208;0.029459;0.029083;0.029542;0.029250;0.029125;0.029541 -uniform,16384,131072,64,none,ref,0,2,178.581250,9,195.664541;191.799417;182.415708;177.275333;178.194333;176.856083;178.398166;178.581250;185.570167 -random_masked,16384,131072,64,none,ref,0,32,236.748625,9,236.023250;236.838417;236.574250;236.748625;236.012875;235.968875;237.149583;243.117416;237.379625 -pseudo,802,320200,64,fixed,ref,0,2,110.311166,9,106.727958;108.254041;111.146250;113.320334;111.019083;109.168833;111.504666;110.311166;108.458625 -random,1024,8192,64,far,ref,0,32,11.213583,9,11.372458;11.213583;11.326291;11.234958;11.130166;11.206750;11.241334;11.163084;11.072542 -random,16384,131072,64,far,ref,0,32,264.043167,9,264.706709;261.972584;270.202333;264.043167;261.499958;260.463167;261.026333;272.374125;265.389125 -random,65536,524288,16,far,ref,0,32,336.034334,9,336.034334;332.651584;356.911208;348.736917;331.438584;330.369333;334.507666;361.791000;338.378541 -random,1024,8192,64,near,ref,0,32,0.037709,9,0.049833;0.037750;0.037709;0.038917;0.036584;0.036625;0.041125;0.037125;0.037375 -random,16384,131072,64,near,ref,0,32,0.391916,9,0.401042;0.391125;0.400458;0.387916;0.393584;0.391916;0.391000;0.391417;0.392417 -random,65536,524288,64,near,ref,0,32,1.516667,9,1.524625;1.527166;1.501125;1.529125;1.489458;1.496208;1.504041;1.516667;1.544250 -random,262144,2097152,16,near,ref,0,32,1.470708,9,1.496750;1.484625;1.459750;1.510000;1.470708;1.463125;1.449584;1.478750;1.448833 -random,262144,2097152,1,none,ref,0,32,130.310667,9,130.310667;129.807542;135.604750;131.635916;127.798916;133.017500;130.851417;128.863917;128.407125 -random,4096,32768,64,reverse,ref,0,32,58.814625,9,58.716666;58.974417;58.564417;58.924000;58.719750;58.765125;58.819625;59.099708;58.814625 -random,16384,131072,16,reverse,ref,0,32,69.334166,9,69.079041;70.882875;72.038375;70.165583;69.543625;69.334166;68.577084;68.910917;69.139291 -clos,800,320000,16,reverse,ref,0,2,36.895583,9,36.895583;36.931584;36.731667;36.628208;37.129375;37.363000;36.916333;36.660125;36.753125 -chain,2048,2047,1,reverse,ref,0,32,0.000959,9,0.000959;0.000958;0.000917;0.000958;0.001041;0.000917;0.001000;0.001000;0.001000 diff --git a/dev/perf/spf_queue/results/core/time-b-new.csv b/dev/perf/spf_queue/results/core/time-b-new.csv deleted file mode 100644 index 34b6d95..0000000 --- a/dev/perf/spf_queue/results/core/time-b-new.csv +++ /dev/null @@ -1,24 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,1024,8192,1,none,ref,0,32,0.066833,9,0.069250;0.075792;0.070875;0.068000;0.065625;0.066125;0.066042;0.066833;0.066625 -random,4096,32768,256,none,ref,0,32,132.648459,9,132.505375;132.265375;132.831417;132.648459;132.447625;132.762458;132.147750;133.442458;133.112792 -random,16384,131072,64,none,ref,0,32,150.945333,9,153.656875;164.973750;153.952125;152.398625;150.712417;150.172000;150.945333;148.924042;150.006708 -random,65536,524288,1,none,ref,0,32,11.514416,9,12.729084;11.643750;11.423167;11.514416;11.301959;11.454750;11.378291;11.714166;13.003042 -random,4096,131072,64,none,ref,0,32,77.140875,9,77.841792;76.885000;76.946334;77.022959;77.553333;77.419958;77.140875;77.250500;76.946625 -clos,800,320000,64,none,ref,0,2,110.201250,9,110.201250;110.459333;111.589125;110.316375;110.060292;109.016875;109.324375;111.536917;109.488958 -grid,10000,39600,1,none,ref,0,2,0.458667,9,0.455084;0.457958;0.457750;0.454583;0.525708;0.490208;0.472750;0.473292;0.458667 -chain,2048,2047,1,none,ref,0,32,0.027209,9,0.027667;0.032000;0.027583;0.027167;0.027167;0.027500;0.027209;0.027209;0.026875 -uniform,16384,131072,64,none,ref,0,2,146.318459,9,148.808250;145.343750;145.932500;145.638708;144.796250;146.318459;147.311791;149.775541;151.739708 -random_masked,16384,131072,64,none,ref,0,32,140.926917,9,141.910000;140.926917;140.947875;142.060625;140.763166;140.562292;140.122166;140.942208;140.426417 -pseudo,802,320200,64,fixed,ref,0,2,107.962250,9,107.962250;107.616041;110.084750;111.659083;109.297500;109.965042;107.801958;107.381167;107.513167 -random,1024,8192,64,far,ref,0,32,7.093000,9,7.049625;6.997417;7.093000;6.971875;7.082042;7.106000;7.180791;7.158083;7.143250 -random,16384,131072,64,far,ref,0,32,149.749750,9,149.000292;149.749750;149.729417;154.389250;149.780083;149.536709;149.227542;150.169167;155.094416 -random,65536,524288,16,far,ref,0,32,201.204958,9,198.333625;195.605250;206.026334;204.001458;199.659708;201.204958;196.077834;233.704458;211.461416 -random,1024,8192,64,near,ref,0,32,0.042584,9,0.053208;0.042500;0.042166;0.043625;0.042625;0.042584;0.042667;0.041833;0.042000 -random,16384,131072,64,near,ref,0,32,0.402375,9,0.403084;0.411250;0.396709;0.396209;0.402375;0.405083;0.404750;0.393667;0.394125 -random,65536,524288,64,near,ref,0,32,1.505625,9,1.490084;1.491875;1.527917;1.524792;1.505625;1.635250;1.522292;1.500542;1.498333 -random,262144,2097152,16,near,ref,0,32,1.491458,9,1.448042;1.475334;1.456458;1.498000;1.502959;1.516000;1.550250;1.491458;1.483708 -random,262144,2097152,1,none,ref,0,32,96.220542,9,96.220542;99.851167;96.823708;92.344792;90.409417;89.217250;89.115792;96.658666;97.151708 -random,4096,32768,64,reverse,ref,0,32,34.225416,9,34.518125;34.398250;34.308125;34.140792;34.143125;34.115125;34.155500;34.273000;34.225416 -random,16384,131072,16,reverse,ref,0,32,38.665209,9,38.807916;38.697167;38.897083;38.665209;38.407208;38.855583;38.632208;38.602916;38.591125 -clos,800,320000,16,reverse,ref,0,2,37.150083,9,39.364542;37.592042;37.042417;38.531000;37.150083;37.109750;38.969375;37.142042;36.280792 -chain,2048,2047,1,reverse,ref,0,32,0.000958,9,0.000958;0.000959;0.000917;0.000958;0.000959;0.000958;0.000916;0.000917;0.000916 diff --git a/dev/perf/spf_queue/results/dag/a1-old.csv b/dev/perf/spf_queue/results/dag/a1-old.csv deleted file mode 100644 index fefc4ce..0000000 --- a/dev/perf/spf_queue/results/dag/a1-old.csv +++ /dev/null @@ -1,11 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,16384,131072,64,none,ref,0,32,152.648875,7,152.167166;153.360875;152.907875;152.632458;152.822292;152.368542;152.648875 -random,65536,524288,1,none,ref,0,32,14.920958,7,16.236834;14.825542;16.897875;13.913708;15.185750;14.424959;14.920958 -clos,800,320000,64,none,ref,0,2,111.433875,7,112.056041;111.918375;110.848834;111.597542;110.760041;111.433875;110.599667 -chain,2048,2047,1,none,ref,0,32,0.029333,7,0.025750;0.029416;0.026708;0.027667;0.029417;0.029333;0.029667 -random,16384,131072,64,near,ref,0,32,0.407667,7,0.425208;0.403834;0.399167;0.407667;0.411083;0.402583;0.411292 -random,65536,524288,64,near,ref,0,32,1.579917,7,1.499792;1.495166;1.604000;2.009208;1.673083;1.579917;1.566625 -random,262144,2097152,16,near,ref,0,32,1.454875,7,1.460209;1.514375;1.394875;1.452666;1.452750;1.454875;1.578333 -random,262144,2097152,1,none,ref,0,32,97.267917,7,97.108584;97.267917;98.792542;96.674833;98.478833;97.012041;97.615250 -random,4096,32768,64,reverse,ref,0,32,34.863542,7,34.934208;35.170667;34.723084;34.276083;34.754542;34.863542;34.981958 -clos,800,320000,16,reverse,ref,0,2,40.670541,7,39.681333;41.645791;41.529250;40.433167;40.670541;40.816292;40.299417 diff --git a/dev/perf/spf_queue/results/dag/a2-old.csv b/dev/perf/spf_queue/results/dag/a2-old.csv deleted file mode 100644 index d1546b1..0000000 --- a/dev/perf/spf_queue/results/dag/a2-old.csv +++ /dev/null @@ -1,11 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,16384,131072,64,none,ref,0,32,153.165667,7,152.957375;151.711541;153.660375;153.659708;152.538166;153.165667;155.704375 -random,65536,524288,1,none,ref,0,32,13.659042,7,14.052708;13.553333;13.659042;14.954041;14.541875;12.997458;13.054584 -clos,800,320000,64,none,ref,0,2,113.659542,7,113.740625;114.077916;112.995792;113.659542;112.719250;114.274875;112.979500 -chain,2048,2047,1,none,ref,0,32,0.032583,7,0.032458;0.032958;0.032583;0.032292;0.032875;0.032833;0.032375 -random,16384,131072,64,near,ref,0,32,0.408459,7,0.396667;0.396875;0.394208;0.408459;0.411042;0.411958;0.420041 -random,65536,524288,64,near,ref,0,32,1.589042,7,1.506250;1.943542;1.643042;1.527500;1.519208;1.729375;1.589042 -random,262144,2097152,16,near,ref,0,32,1.516084,7,1.547583;1.555666;1.506417;1.528833;1.492750;1.516084;1.471041 -random,262144,2097152,1,none,ref,0,32,98.082583,7,98.082583;97.663250;100.059834;98.294333;98.673375;97.223625;97.694875 -random,4096,32768,64,reverse,ref,0,32,34.828750,7,34.917375;35.040416;34.719875;34.828458;35.091791;34.828750;34.700792 -clos,800,320000,16,reverse,ref,0,2,39.611083,7,39.576708;39.611083;39.915917;40.217500;39.978833;39.580583;39.046459 diff --git a/dev/perf/spf_queue/results/dag/b-new-dag.csv b/dev/perf/spf_queue/results/dag/b-new-dag.csv deleted file mode 100644 index bbd56d0..0000000 --- a/dev/perf/spf_queue/results/dag/b-new-dag.csv +++ /dev/null @@ -1,11 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,16384,131072,64,none,ref,0,32,147.204834,7,148.269791;146.921083;148.045750;146.513042;148.202917;147.204834;146.236167 -random,65536,524288,1,none,ref,0,32,13.981000,7,15.172208;15.104417;13.815083;13.842041;12.977916;15.596917;13.981000 -clos,800,320000,64,none,ref,0,2,102.374208,7,102.762583;102.448042;103.339666;102.294750;101.823958;102.374208;101.949791 -chain,2048,2047,1,none,ref,0,32,0.028000,7,0.027750;0.027709;0.028083;0.027917;0.028375;0.028000;0.028292 -random,16384,131072,64,near,ref,0,32,0.336208,7,0.362583;0.336208;0.351084;0.340000;0.309000;0.329541;0.334750 -random,65536,524288,64,near,ref,0,32,1.255792,7,1.378250;1.308292;1.330916;1.255792;1.228958;1.246417;1.253375 -random,262144,2097152,16,near,ref,0,32,1.204792,7,1.203208;1.234666;1.229458;1.204792;1.208625;1.188083;1.200250 -random,262144,2097152,1,none,ref,0,32,92.972125,7,93.473416;93.923791;94.374750;92.760375;92.890000;92.972125;92.331208 -random,4096,32768,64,reverse,ref,0,32,35.038958,7,35.038958;34.932333;35.057625;35.124458;34.640500;34.992333;35.300584 -clos,800,320000,16,reverse,ref,0,2,37.358000,7,38.057375;36.721375;38.048250;36.772167;37.358000;37.697042;36.755500 diff --git a/dev/perf/spf_queue/results/dag/b-new-full.csv b/dev/perf/spf_queue/results/dag/b-new-full.csv deleted file mode 100644 index 4ff193c..0000000 --- a/dev/perf/spf_queue/results/dag/b-new-full.csv +++ /dev/null @@ -1,11 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,16384,131072,64,none,ref,0,32,147.974625,7,147.189625;148.072833;146.896084;146.343458;148.334375;147.974625;150.991416 -random,65536,524288,1,none,ref,0,32,14.055917,7,15.668625;13.799667;14.560458;12.363667;14.155667;14.055917;12.275209 -clos,800,320000,64,none,ref,0,2,102.649416,7,102.649416;101.224791;102.788625;104.979084;103.555125;102.376875;101.695209 -chain,2048,2047,1,none,ref,0,32,0.026792,7,0.026708;0.026667;0.026250;0.026792;0.054167;0.034500;0.027459 -random,16384,131072,64,near,ref,0,32,0.425917,7,0.426292;0.410125;0.473583;0.429334;0.425917;0.400333;0.415375 -random,65536,524288,64,near,ref,0,32,1.647166,7,1.590334;1.812166;1.589500;1.647166;2.255959;1.654959;1.498917 -random,262144,2097152,16,near,ref,0,32,1.518500,7,1.526666;1.503250;1.493500;1.518500;1.483500;1.531750;1.591500 -random,262144,2097152,1,none,ref,0,32,91.525542,7,92.345542;92.210584;91.222125;91.525542;92.527084;91.375000;91.478625 -random,4096,32768,64,reverse,ref,0,32,35.060250,7,34.390375;34.607625;35.061334;35.060250;35.146916;35.280375;34.569750 -clos,800,320000,16,reverse,ref,0,2,37.389334,7,37.535584;36.957875;37.363000;37.207291;37.389334;37.840417;37.400125 diff --git a/dev/perf/spf_queue/results/dag/environment.txt b/dev/perf/spf_queue/results/dag/environment.txt deleted file mode 100644 index 52b0a04..0000000 --- a/dev/perf/spf_queue/results/dag/environment.txt +++ /dev/null @@ -1,6 +0,0 @@ -date: 2026-10-02T18:14:27Z -old: d5717cc (0.11.0) via build.sh old d5717cc; new: working tree with shortest_paths_dag -phases: a1-old, b-new-full (shortest_paths), b-new-dag (SPFX_DAG=1: shortest_paths_dag for destination rows), a2-old; short mode, 7 samples -b1760ef54d46011673e5f517d1b6242fcda0f9cd500898ec4748b2df4bb054ff src/shortest_paths.cpp -494b95a4a3b7fd9105a37c5f53acf4e88642167549b7eb56f4e01f64e013cd5e build/perf/cpu-queue -7de733ce4ec111c3414e2b262b6063a253b5cd790e4beceab893d802f0c5bf6a build/perf/cpu-queue-old diff --git a/dev/perf/spf_queue/results/dag/load.txt b/dev/perf/spf_queue/results/dag/load.txt deleted file mode 100644 index 75af2c4..0000000 --- a/dev/perf/spf_queue/results/dag/load.txt +++ /dev/null @@ -1,2 +0,0 @@ -19:13 up 52 days, 23:49, 3 users, load averages: 4.78 12.34 19.44 -19:13 up 52 days, 23:49, 3 users, load averages: 4.34 11.75 19.06 diff --git a/dev/perf/spf_queue/results/first/correctness-sanitized.txt b/dev/perf/spf_queue/results/first/correctness-sanitized.txt deleted file mode 100644 index 347c0ee..0000000 --- a/dev/perf/spf_queue/results/first/correctness-sanitized.txt +++ /dev/null @@ -1,2 +0,0 @@ -checked=1351168 failed=0 -exit=0 diff --git a/dev/perf/spf_queue/results/first/correctness-teeth.txt b/dev/perf/spf_queue/results/first/correctness-teeth.txt deleted file mode 100644 index d723574..0000000 --- a/dev/perf/spf_queue/results/first/correctness-teeth.txt +++ /dev/null @@ -1 +0,0 @@ -checked=1351168 failed=167636 diff --git a/dev/perf/spf_queue/results/first/correctness.txt b/dev/perf/spf_queue/results/first/correctness.txt deleted file mode 100644 index 347c0ee..0000000 --- a/dev/perf/spf_queue/results/first/correctness.txt +++ /dev/null @@ -1,2 +0,0 @@ -checked=1351168 failed=0 -exit=0 diff --git a/dev/perf/spf_queue/results/first/environment.txt b/dev/perf/spf_queue/results/first/environment.txt deleted file mode 100644 index b5c6fa9..0000000 --- a/dev/perf/spf_queue/results/first/environment.txt +++ /dev/null @@ -1,8 +0,0 @@ -date: 2026-09-27T13:35:56Z -commit: fbab119e794c843838f4f3f96d75cb7f32f06845 -Apple M4 Max -ae2f8f3b2f2839d6d088e0a938b86cba02c23c285c33cac99df6bf369286f264 dev/perf/spf_queue/spf_variants.hpp -a0ac4290fd6b47bdefcc4bd4cf50257e275406cb34f371e4c0ba30368b0fea45 dev/perf/spf_queue/harness.cpp -9ee6053d38a0b1e44df958e423e12ab93fb54c3d80c8d4bcb623730362b8f441 src/shortest_paths.cpp -12f65bdc8daa9415d118d062bdc897981bde448f5391bed2c711c8c968e9322b src/strict_multidigraph.cpp -Apple clang version 21.0.0 (clang-2100.1.1.101) diff --git a/dev/perf/spf_queue/results/first/load.txt b/dev/perf/spf_queue/results/first/load.txt deleted file mode 100644 index 3452f6c..0000000 --- a/dev/perf/spf_queue/results/first/load.txt +++ /dev/null @@ -1,15 +0,0 @@ -14:35 up 47 days, 19:11, 3 users, load averages: 3.33 3.01 3.05 -14:36 up 47 days, 19:12, 3 users, load averages: 2.94 2.95 3.03 -14:36 up 47 days, 19:12, 3 users, load averages: 4.08 3.21 3.12 -14:37 up 47 days, 19:12, 3 users, load averages: 3.95 3.25 3.14 -14:37 up 47 days, 19:13, 3 users, load averages: 3.41 3.18 3.11 -14:37 up 47 days, 19:13, 3 users, load averages: 3.16 3.14 3.10 -14:38 up 47 days, 19:13, 3 users, load averages: 3.64 3.25 3.14 -14:38 up 47 days, 19:14, 3 users, load averages: 3.28 3.18 3.12 -14:38 up 47 days, 19:14, 3 users, load averages: 3.21 3.17 3.11 -14:39 up 47 days, 19:14, 3 users, load averages: 3.47 3.24 3.14 -14:39 up 47 days, 19:15, 3 users, load averages: 3.59 3.29 3.16 -14:39 up 47 days, 19:15, 3 users, load averages: 3.55 3.28 3.16 -14:39 up 47 days, 19:15, 3 users, load averages: 3.42 3.26 3.15 -14:39 up 47 days, 19:15, 3 users, load averages: 3.63 3.31 3.17 -14:39 up 47 days, 19:15, 3 users, load averages: 3.63 3.31 3.17 diff --git a/dev/perf/spf_queue/results/first/sweep-bsort-sparse.csv b/dev/perf/spf_queue/results/first/sweep-bsort-sparse.csv deleted file mode 100644 index d6524b4..0000000 --- a/dev/perf/spf_queue/results/first/sweep-bsort-sparse.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -wide1,16384,131072,16,none,bsort-sparse,0,2,36.213417,9,37.503167;36.411458;36.550708;35.483709;36.001917;36.213417;37.912750;35.776291;36.088458 -wide31,16384,131072,16,none,bsort-sparse,0,32,37.633250,9,37.642292;37.615583;37.526041;37.746125;37.884709;37.733334;37.633250;37.480625;37.275917 -wide1023,16384,131072,16,none,bsort-sparse,0,1024,33.782917,9,33.744125;33.971042;33.692333;33.873166;33.782917;33.827375;33.746500;33.667792;33.785417 -wide32767,16384,131072,16,none,bsort-sparse,0,32768,34.269584,9,34.145166;34.158250;34.001209;34.269584;33.649958;34.406584;34.414625;34.346042;34.283834 -wide1048575,16384,131072,16,none,bsort-sparse,0,1048570,46.217500,9,45.310209;47.594125;46.217500;46.955084;46.440625;46.040500;45.000041;46.189209;47.516000 -wide33554431,16384,131072,16,none,bsort-sparse,1,33554196,56.886334,9,55.632334;57.164750;57.547542;57.042417;56.886334;57.274458;56.415333;56.450250;56.839292 diff --git a/dev/perf/spf_queue/results/first/sweep-bucket-sparse.csv b/dev/perf/spf_queue/results/first/sweep-bucket-sparse.csv deleted file mode 100644 index e172a77..0000000 --- a/dev/perf/spf_queue/results/first/sweep-bucket-sparse.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -wide1,16384,131072,16,none,bucket-sparse,0,2,35.220125,9,34.863958;35.220125;35.179708;35.015167;35.218083;35.266916;35.852333;35.964375;35.417875 -wide31,16384,131072,16,none,bucket-sparse,0,32,35.754000,9,35.186583;35.210584;36.171750;36.027334;36.380417;35.901208;35.754000;35.222417;35.417541 -wide1023,16384,131072,16,none,bucket-sparse,0,1024,34.063333,9,34.236000;34.070875;34.083625;33.672625;34.063333;33.680458;33.881833;33.872875;34.160292 -wide32767,16384,131072,16,none,bucket-sparse,0,32768,35.003458,9,35.149750;35.003458;34.967500;35.018583;35.021125;34.919542;34.933209;35.050500;34.908083 -wide1048575,16384,131072,16,none,bucket-sparse,0,1048570,46.844291,9,46.844291;44.614167;46.522541;45.393916;46.223250;47.520584;48.872792;48.944750;46.885625 -wide33554431,16384,131072,16,none,bucket-sparse,1,33554196,56.527708,9,56.282083;56.527708;56.688292;56.450375;58.205833;56.590208;56.040166;56.696125;56.417125 diff --git a/dev/perf/spf_queue/results/first/sweep-heap-sparse.csv b/dev/perf/spf_queue/results/first/sweep-heap-sparse.csv deleted file mode 100644 index 7164155..0000000 --- a/dev/perf/spf_queue/results/first/sweep-heap-sparse.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -wide1,16384,131072,16,none,heap-sparse,0,2,46.968625,9,47.229292;46.897250;46.189542;46.399666;48.464125;47.555375;46.968625;47.561292;46.556542 -wide31,16384,131072,16,none,heap-sparse,0,32,69.403708,9,69.283542;69.735167;69.185625;69.599000;69.889917;69.209417;69.402167;69.403708;70.222125 -wide1023,16384,131072,16,none,heap-sparse,0,1024,67.032584,9,67.360250;66.871958;67.032584;67.299542;67.001125;67.400292;68.543416;66.935334;66.819000 -wide32767,16384,131072,16,none,heap-sparse,0,32768,58.449708,9,57.046833;58.449708;59.940292;59.077958;59.057333;58.319000;58.592958;58.129917;57.809250 -wide1048575,16384,131072,16,none,heap-sparse,0,1048570,57.236042,9,57.512500;56.372916;57.242875;56.918167;57.274917;57.236042;56.913416;57.412625;57.007750 -wide33554431,16384,131072,16,none,heap-sparse,1,33554196,56.640542,9,56.641292;55.692875;55.862500;55.489417;56.796875;56.640542;56.854500;56.504375;56.956292 diff --git a/dev/perf/spf_queue/results/first/sweep-ref.csv b/dev/perf/spf_queue/results/first/sweep-ref.csv deleted file mode 100644 index 0b8390b..0000000 --- a/dev/perf/spf_queue/results/first/sweep-ref.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -wide1,16384,131072,16,none,ref,0,2,43.727417,9,43.343875;44.084000;43.727417;43.741292;43.888209;43.588916;43.806583;43.654875;43.198666 -wide31,16384,131072,16,none,ref,0,32,62.112375,9,62.803250;61.927958;62.112375;61.957708;61.832417;61.767541;63.810750;68.329792;62.300042 -wide1023,16384,131072,16,none,ref,0,1024,58.154708,9,57.620875;58.266375;58.270167;57.265875;57.078750;59.170875;57.157083;58.154708;63.124917 -wide32767,16384,131072,16,none,ref,0,32768,49.973584,9,50.194417;49.654458;49.866084;49.973584;50.138500;50.410167;49.828167;49.296625;50.260250 -wide1048575,16384,131072,16,none,ref,0,1048570,48.596208,9,47.921292;48.808916;48.395167;48.596208;48.227875;48.645500;48.894667;49.038500;48.373792 -wide33554431,16384,131072,16,none,ref,1,33554196,48.868375,9,48.062459;48.759333;49.000291;48.868375;48.529750;48.639958;49.409125;48.982166;48.976000 diff --git a/dev/perf/spf_queue/results/first/time-bsort-fresh-b.csv b/dev/perf/spf_queue/results/first/time-bsort-fresh-b.csv deleted file mode 100644 index 807378f..0000000 --- a/dev/perf/spf_queue/results/first/time-bsort-fresh-b.csv +++ /dev/null @@ -1,20 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,1024,8192,1,none,bsort-fresh,0,32,0.073000,9,0.079958;0.074375;0.074041;0.072541;0.072417;0.072791;0.073250;0.072875;0.073000 -random,4096,32768,256,none,bsort-fresh,0,32,127.398375,9,126.718708;125.971417;126.947417;129.972584;128.232417;128.374958;128.044166;127.398375;127.396167 -random,16384,131072,64,none,bsort-fresh,0,32,144.333333,9,144.555416;147.892875;145.481209;143.996083;144.333333;144.136167;144.596167;144.100209;143.942459 -random,65536,524288,1,none,bsort-fresh,0,32,10.920625,9,10.780291;10.920625;10.930583;10.925833;10.912958;10.827666;10.885833;11.177000;10.983875 -random,4096,131072,64,none,bsort-fresh,0,32,65.595250,9,65.367834;65.363375;64.938292;66.759000;67.187209;68.249708;67.946375;65.595250;64.582250 -clos,800,320000,64,none,bsort-fresh,0,2,126.180125,9,127.017042;126.199333;125.849000;125.754625;125.813333;126.180125;126.682042;126.612375;125.955541 -grid,10000,39600,1,none,bsort-fresh,0,2,0.392833,9,0.435875;0.402709;0.395417;0.394042;0.392833;0.389209;0.389291;0.389250;0.391500 -chain,2048,2047,1,none,bsort-fresh,0,32,0.044875,9,0.065416;0.045250;0.048167;0.045000;0.043750;0.044666;0.044708;0.044875;0.044791 -uniform,16384,131072,64,none,bsort-fresh,0,2,142.049166,9,142.963458;143.555708;140.099292;144.939667;147.514959;141.901334;142.049166;141.335459;141.541834 -random_masked,16384,131072,64,none,bsort-fresh,0,32,140.737542,9,139.656959;139.978542;141.379625;142.474875;140.737542;140.405875;141.147333;139.679375;141.383500 -pseudo,802,320200,64,fixed,bsort-fresh,0,2,130.713667,9,134.616125;131.302333;132.074667;131.003750;130.271167;130.713667;129.811250;128.510167;126.406125 -random,1024,8192,64,far,bsort-fresh,0,32,6.481416,9,6.446375;6.834125;6.495416;6.481416;6.725083;6.711584;6.344709;6.472000;6.272958 -random,16384,131072,64,far,bsort-fresh,0,32,143.584500,9,144.152958;143.569083;143.288000;144.914292;143.919625;143.357792;142.812792;144.227542;143.584500 -random,65536,524288,16,far,bsort-fresh,0,32,177.307125,9,172.243125;178.124000;174.089375;187.898458;194.189167;181.906334;177.307125;173.282583;175.041833 -random,1024,8192,64,near,bsort-fresh,0,32,0.115500,9,0.127708;0.113375;0.112916;0.111750;0.118541;0.116708;0.131792;0.115500;0.112166 -random,16384,131072,64,near,bsort-fresh,0,32,1.114708,9,1.096125;1.098250;1.114708;1.131916;1.203875;1.139833;1.164917;1.066291;1.106917 -random,65536,524288,64,near,bsort-fresh,0,32,4.342334,9,4.342334;4.513791;4.308459;4.539084;4.296958;4.536416;4.332875;4.542125;4.300667 -random,262144,2097152,16,near,bsort-fresh,0,32,4.199250,9,19.132917;19.258458;16.012125;3.874875;4.016334;4.104625;4.215708;4.122584;4.199250 -random,262144,2097152,1,none,bsort-fresh,0,32,82.487666,9,82.487666;82.558167;83.558916;82.006250;80.108042;81.022917;82.437333;87.462667;84.019958 diff --git a/dev/perf/spf_queue/results/first/time-bsort-sparse-b.csv b/dev/perf/spf_queue/results/first/time-bsort-sparse-b.csv deleted file mode 100644 index 42185a1..0000000 --- a/dev/perf/spf_queue/results/first/time-bsort-sparse-b.csv +++ /dev/null @@ -1,20 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,1024,8192,1,none,bsort-sparse,0,32,0.072208,9,0.077833;0.073958;0.074833;0.072125;0.072000;0.072208;0.072333;0.071375;0.071583 -random,4096,32768,256,none,bsort-sparse,0,32,130.563333,9,130.291542;129.928333;129.826125;130.341792;132.589334;130.890375;130.563333;132.631791;133.538958 -random,16384,131072,64,none,bsort-sparse,0,32,154.019875,9,150.272541;149.183208;158.544792;154.019875;152.807500;157.225917;154.544834;152.768542;156.195500 -random,65536,524288,1,none,bsort-sparse,0,32,12.254041,9,11.671833;11.614125;11.664000;12.254041;12.390959;13.516750;12.239416;13.174959;12.510875 -random,4096,131072,64,none,bsort-sparse,0,32,66.531042,9,66.602416;66.531042;67.645250;66.783667;66.124292;66.494833;66.341375;66.474667;66.600375 -clos,800,320000,64,none,bsort-sparse,0,2,128.233625,9,127.980125;127.916459;128.161375;128.233625;128.160375;131.579125;129.286625;131.803792;130.153792 -grid,10000,39600,1,none,bsort-sparse,0,2,0.414875,9,0.424042;0.419708;0.413625;0.414875;0.412208;0.410958;0.410708;0.578292;0.429708 -chain,2048,2047,1,none,bsort-sparse,0,32,0.045375,9,0.047542;0.047834;0.047791;0.047667;0.043625;0.043500;0.043500;0.043416;0.045375 -uniform,16384,131072,64,none,bsort-sparse,0,2,140.927958,9,140.451625;140.927958;140.518625;141.233375;140.714709;146.751417;144.065209;141.193125;140.622750 -random_masked,16384,131072,64,none,bsort-sparse,0,32,147.117084,9,150.706875;160.268500;150.488166;146.747542;146.520458;146.777958;147.836209;147.117084;146.608750 -pseudo,802,320200,64,fixed,bsort-sparse,0,2,126.737125,9,130.360708;126.737125;125.585334;126.883500;126.408666;126.394500;128.847042;127.032125;126.336500 -random,1024,8192,64,far,bsort-sparse,0,32,6.427875,9,6.558666;6.515333;6.526083;6.427875;6.496250;6.376792;6.409709;6.403500;6.394375 -random,16384,131072,64,far,bsort-sparse,0,32,150.673083,9,150.673083;150.674500;151.902042;149.348292;149.884167;150.329125;150.647417;154.169167;153.845334 -random,65536,524288,16,far,bsort-sparse,0,32,185.616042,9,186.573292;184.522542;185.096875;187.703416;185.322625;185.616042;185.213666;193.822083;188.567750 -random,1024,8192,64,near,bsort-sparse,0,32,0.043000,9,0.065917;0.043125;0.043125;0.043167;0.042916;0.042833;0.043000;0.043000;0.043000 -random,16384,131072,64,near,bsort-sparse,0,32,0.393959,9,0.405375;0.395667;0.393333;0.393542;0.393959;0.393208;0.393417;0.394000;0.418083 -random,65536,524288,64,near,bsort-sparse,0,32,1.495875,9,1.513000;1.495875;1.430333;1.527417;1.487333;1.504167;1.507000;1.488542;1.487417 -random,262144,2097152,16,near,bsort-sparse,0,32,1.450708,9,1.420625;1.438834;1.450708;1.506708;1.423125;1.437542;1.453250;1.454167;1.470750 -random,262144,2097152,1,none,bsort-sparse,0,32,86.061459,9,86.686250;86.173459;86.626250;86.437875;84.282875;86.061459;84.945792;84.178333;85.198459 diff --git a/dev/perf/spf_queue/results/first/time-bucket-fresh-b.csv b/dev/perf/spf_queue/results/first/time-bucket-fresh-b.csv deleted file mode 100644 index 5059745..0000000 --- a/dev/perf/spf_queue/results/first/time-bucket-fresh-b.csv +++ /dev/null @@ -1,20 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,1024,8192,1,none,bucket-fresh,0,32,0.078458,9,0.093458;0.084416;0.082417;0.076584;0.078708;0.077917;0.076000;0.078458;0.075917 -random,4096,32768,256,none,bucket-fresh,0,32,117.688709,9,117.763250;117.568541;119.826709;117.148083;116.848875;117.782000;117.688709;117.028166;118.412333 -random,16384,131072,64,none,bucket-fresh,0,32,133.422291,9,133.422291;135.131084;134.927209;134.389375;133.486125;132.716542;132.560333;133.355458;132.436375 -random,65536,524288,1,none,bucket-fresh,0,32,10.957041,9,10.897542;11.583166;10.951750;11.056250;10.957041;10.634750;10.294625;11.157000;11.140709 -random,4096,131072,64,none,bucket-fresh,0,32,64.141542,9,64.153834;64.116459;64.415125;64.283583;64.291917;64.082167;63.974250;64.141542;63.823958 -clos,800,320000,64,none,bucket-fresh,0,2,126.132042,9,126.527125;127.438167;126.097917;126.135291;124.511084;125.285209;127.010417;126.132042;125.727250 -grid,10000,39600,1,none,bucket-fresh,0,2,0.465958,9,0.481958;0.518250;0.500708;0.485542;0.445792;0.465958;0.458459;0.460042;0.458459 -chain,2048,2047,1,none,bucket-fresh,0,32,0.044875,9,0.040125;0.039916;0.039792;0.041417;0.044916;0.047375;0.045000;0.044875;0.044959 -uniform,16384,131072,64,none,bucket-fresh,0,2,142.378625,9,142.732667;142.469708;141.925417;141.230333;141.874083;141.373375;142.378625;144.852500;143.189958 -random_masked,16384,131072,64,none,bucket-fresh,0,32,130.637375,9,130.457042;130.042792;130.783042;130.637375;131.483084;129.935333;130.749792;130.790208;129.892000 -pseudo,802,320200,64,fixed,bucket-fresh,0,2,125.455083,9,126.943167;127.354084;127.369666;125.342417;124.162583;124.628250;126.541666;124.528000;125.455083 -random,1024,8192,64,far,bucket-fresh,0,32,6.255334,9,6.339792;6.285750;6.213042;6.201625;6.179083;6.276625;6.341917;6.255334;6.172750 -random,16384,131072,64,far,bucket-fresh,0,32,134.766458,9,133.359833;134.506208;135.116584;134.766458;133.574917;135.545875;134.077708;135.431416;134.812250 -random,65536,524288,16,far,bucket-fresh,0,32,162.663333,9,161.521083;163.212958;161.964459;162.663333;162.321958;162.549250;176.804958;175.382292;163.466083 -random,1024,8192,64,near,bucket-fresh,0,32,0.111042,9,0.117166;0.110916;0.111334;0.111125;0.103458;0.110916;0.111167;0.111042;0.106584 -random,16384,131072,64,near,bucket-fresh,0,32,1.190333,9,1.202959;1.218000;1.134083;1.148417;1.185709;1.190333;1.194292;1.222500;1.178833 -random,65536,524288,64,near,bucket-fresh,0,32,4.332041,9,4.265875;4.366833;4.247917;4.384917;4.282750;4.491208;4.332041;4.374708;4.267042 -random,262144,2097152,16,near,bucket-fresh,0,32,4.090291,9,3.828583;3.954084;3.899584;4.046292;4.130250;4.090291;4.241917;4.186083;4.144292 -random,262144,2097152,1,none,bucket-fresh,0,32,83.624875,9,91.434167;85.099291;87.027625;80.038375;82.772375;82.189167;89.432791;83.624875;82.109959 diff --git a/dev/perf/spf_queue/results/first/time-bucket-full-b.csv b/dev/perf/spf_queue/results/first/time-bucket-full-b.csv deleted file mode 100644 index 538ceb5..0000000 --- a/dev/perf/spf_queue/results/first/time-bucket-full-b.csv +++ /dev/null @@ -1,20 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,1024,8192,1,none,bucket-full,0,32,0.074792,9,0.077917;0.075750;0.075750;0.074292;0.075417;0.074625;0.074583;0.074792;0.074333 -random,4096,32768,256,none,bucket-full,0,32,119.398208,9,119.398208;120.878167;121.197792;119.620666;119.044750;119.726625;118.877792;119.055292;118.027042 -random,16384,131072,64,none,bucket-full,0,32,133.055000,9,132.952084;133.698125;133.055000;132.576417;132.905875;132.183916;134.968292;134.945500;133.846167 -random,65536,524288,1,none,bucket-full,0,32,10.186875,9,10.190542;10.191834;10.115083;10.186875;10.053959;10.254500;10.136125;10.263666;10.180708 -random,4096,131072,64,none,bucket-full,0,32,64.248666,9,64.910709;65.125209;64.397542;64.248666;64.636667;63.902875;64.126208;64.172666;64.079292 -clos,800,320000,64,none,bucket-full,0,2,126.467500,9,126.599375;125.362625;126.467500;125.210875;126.247625;126.411750;128.020208;127.637875;126.887833 -grid,10000,39600,1,none,bucket-full,0,2,0.472750,9,0.490500;0.472750;0.471250;0.472041;0.469583;0.471792;0.475917;0.481375;0.482125 -chain,2048,2047,1,none,bucket-full,0,32,0.045209,9,0.044459;0.049250;0.044791;0.045209;0.044875;0.045292;0.054958;0.052167;0.044208 -uniform,16384,131072,64,none,bucket-full,0,2,145.564458,9,144.952791;143.328833;147.899167;146.651959;145.564458;144.691208;149.940875;145.752250;145.242666 -random_masked,16384,131072,64,none,bucket-full,0,32,131.274375,9,135.150667;131.974417;131.884375;130.784167;131.197041;132.654042;131.274375;131.134000;131.139709 -pseudo,802,320200,64,fixed,bucket-full,0,2,125.527166,9,126.696875;125.384625;125.527166;124.887125;124.589833;124.728791;128.626125;126.069667;126.432875 -random,1024,8192,64,far,bucket-full,0,32,6.198584,9,6.198584;6.652333;6.384875;6.345208;6.184625;6.220209;6.114625;6.120791;6.145667 -random,16384,131072,64,far,bucket-full,0,32,133.248250,9,132.824000;134.288167;136.456167;134.005667;133.142208;133.248250;133.024458;133.895208;132.927083 -random,65536,524288,16,far,bucket-full,0,32,163.100042,9,167.189291;163.153667;162.840500;163.100042;163.073125;163.641167;165.388459;162.862792;163.054042 -random,1024,8192,64,near,bucket-full,0,32,0.111750,9,0.112333;0.111750;0.112292;0.102667;0.108375;0.112416;0.111000;0.102417;0.111916 -random,16384,131072,64,near,bucket-full,0,32,1.309166,9,1.309166;1.320833;1.325750;1.314833;1.301125;1.302167;1.301375;1.326458;1.305167 -random,65536,524288,64,near,bucket-full,0,32,4.804834,9,4.460792;4.986750;4.804834;5.224542;4.583125;4.763250;4.781834;4.829125;4.814208 -random,262144,2097152,16,near,bucket-full,0,32,5.560917,9,5.646708;5.523042;5.358334;5.583625;5.366417;5.509125;5.560917;5.655583;5.652541 -random,262144,2097152,1,none,bucket-full,0,32,80.930042,9,82.033958;78.861125;81.416959;81.632916;81.707416;80.163083;80.265000;79.492458;80.930042 diff --git a/dev/perf/spf_queue/results/first/time-bucket-sparse-b.csv b/dev/perf/spf_queue/results/first/time-bucket-sparse-b.csv deleted file mode 100644 index e4898a0..0000000 --- a/dev/perf/spf_queue/results/first/time-bucket-sparse-b.csv +++ /dev/null @@ -1,20 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,1024,8192,1,none,bucket-sparse,0,32,0.069417,9,0.073833;0.070791;0.069417;0.069167;0.068500;0.068208;0.068208;0.072083;0.075166 -random,4096,32768,256,none,bucket-sparse,0,32,122.055459,9,122.239000;121.626000;121.953875;121.374791;123.540416;122.909333;122.823917;121.561042;122.055459 -random,16384,131072,64,none,bucket-sparse,0,32,140.305917,9,140.002792;140.915750;141.959167;139.621583;140.114750;140.479542;140.596666;140.305917;139.659000 -random,65536,524288,1,none,bucket-sparse,0,32,10.681958,9,10.657250;10.608375;10.681958;10.690167;10.477083;10.461584;10.873584;12.338417;11.963750 -random,4096,131072,64,none,bucket-sparse,0,32,65.799209,9,65.799209;65.483625;65.478167;65.776875;66.112542;65.268375;66.647750;67.733209;67.061167 -clos,800,320000,64,none,bucket-sparse,0,2,127.859500,9,127.814458;126.084333;125.578083;127.859500;129.214167;128.186167;127.734041;128.022083;131.675041 -grid,10000,39600,1,none,bucket-sparse,0,2,0.497208,9,0.497417;0.496833;0.535958;0.512917;0.497208;0.490792;0.489250;0.496250;0.501875 -chain,2048,2047,1,none,bucket-sparse,0,32,0.044541,9,0.044166;0.044541;0.044792;0.044875;0.043875;0.040167;0.044000;0.044709;0.044833 -uniform,16384,131072,64,none,bucket-sparse,0,2,140.725750,9,140.679042;140.899333;140.650959;140.534083;143.067333;141.098916;140.725750;140.329209;141.557458 -random_masked,16384,131072,64,none,bucket-sparse,0,32,138.444792,9,137.654583;138.033375;139.399875;137.454083;139.411583;138.656625;140.437916;138.444792;137.928167 -pseudo,802,320200,64,fixed,bucket-sparse,0,2,127.199916,9,127.469500;126.634000;126.764916;127.199916;126.949334;126.892792;127.591084;127.256125;127.498833 -random,1024,8192,64,far,bucket-sparse,0,32,6.545583,9,6.714750;6.495500;6.545583;6.496458;6.496833;6.469416;6.560708;6.591291;6.639250 -random,16384,131072,64,far,bucket-sparse,0,32,140.847709,9,140.817500;144.266000;140.847709;140.950125;140.655417;141.417417;140.807834;140.897667;140.688000 -random,65536,524288,16,far,bucket-sparse,0,32,176.071583,9,174.404500;173.780334;176.126125;175.394916;174.731542;207.892875;177.738708;176.071583;177.683416 -random,1024,8192,64,near,bucket-sparse,0,32,0.043500,9,0.051000;0.044833;0.043958;0.044500;0.043500;0.043167;0.038750;0.043416;0.043208 -random,16384,131072,64,near,bucket-sparse,0,32,0.393292,9,0.393458;0.393208;0.394791;0.393292;0.393250;0.393375;0.395333;0.392917;0.392709 -random,65536,524288,64,near,bucket-sparse,0,32,1.488667,9,1.476667;1.489542;1.501916;1.490959;1.503042;1.488625;1.488667;1.420125;1.457042 -random,262144,2097152,16,near,bucket-sparse,0,32,1.448875,9,1.507625;1.487041;1.448875;1.448041;1.456500;1.461791;1.438708;1.408958;1.411375 -random,262144,2097152,1,none,bucket-sparse,0,32,87.025667,9,88.228625;88.995625;89.278125;86.785542;85.460000;87.117750;87.025000;86.052083;87.025667 diff --git a/dev/perf/spf_queue/results/first/time-heap-fresh-b.csv b/dev/perf/spf_queue/results/first/time-heap-fresh-b.csv deleted file mode 100644 index c7f0a9d..0000000 --- a/dev/perf/spf_queue/results/first/time-heap-fresh-b.csv +++ /dev/null @@ -1,20 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,1024,8192,1,none,heap-fresh,0,32,0.115375,9,0.135375;0.116291;0.120250;0.116500;0.113917;0.110208;0.114250;0.115084;0.115375 -random,4096,32768,256,none,heap-fresh,0,32,223.834916,9,224.898208;224.539834;224.930084;222.888125;222.325041;223.091583;221.774625;224.744125;223.834916 -random,16384,131072,64,none,heap-fresh,0,32,265.681750,9,266.057250;262.723041;265.681750;265.032292;264.191792;271.088500;266.109166;263.244750;269.137166 -random,65536,524288,1,none,heap-fresh,0,32,19.683459,9,19.345209;19.499500;20.155625;20.074083;19.409083;19.608167;19.887625;19.941041;19.683459 -random,4096,131072,64,none,heap-fresh,0,32,113.862791,9,114.006792;113.152833;113.679917;113.862791;115.032583;114.331750;113.343167;114.297083;113.528666 -clos,800,320000,64,none,heap-fresh,0,2,133.167417,9,134.150583;134.728791;135.087416;132.998625;132.928084;134.091042;133.023917;133.167417;132.971333 -grid,10000,39600,1,none,heap-fresh,0,2,0.689458,9,0.712083;0.697250;0.709292;0.689458;0.686583;0.683166;0.679958;0.682000;0.691167 -chain,2048,2047,1,none,heap-fresh,0,32,0.023542,9,0.023708;0.023750;0.023500;0.023792;0.023459;0.023458;0.023500;0.023666;0.023542 -uniform,16384,131072,64,none,heap-fresh,0,2,183.190666,9,181.771166;182.520459;184.364584;181.516292;183.190666;189.134792;183.239958;190.424542;183.068458 -random_masked,16384,131072,64,none,heap-fresh,0,32,242.620042,9,243.224625;251.311000;241.052000;241.867042;242.354042;250.248167;243.503416;241.469541;242.620042 -pseudo,802,320200,64,fixed,heap-fresh,0,2,133.608041,9,134.581458;133.274083;133.141459;132.606250;134.189167;133.608041;132.927208;136.439583;135.795666 -random,1024,8192,64,far,heap-fresh,0,32,11.316167,9,11.444291;11.045375;11.190875;11.316167;11.364459;11.323000;11.470167;11.278541;11.179041 -random,16384,131072,64,far,heap-fresh,0,32,266.656375,9,266.065000;263.982750;266.656375;270.425500;269.786708;266.468000;265.333458;268.683750;271.332375 -random,65536,524288,16,far,heap-fresh,0,32,326.680791,9,329.341125;326.680791;326.758208;321.000542;335.756333;326.244750;323.910042;340.101375;324.130500 -random,1024,8192,64,near,heap-fresh,0,32,0.107708,9,0.149875;0.118667;0.100459;0.107375;0.108125;0.107708;0.102208;0.107541;0.108417 -random,16384,131072,64,near,heap-fresh,0,32,1.120875,9,1.115834;1.116750;1.131292;1.120875;1.117917;1.148708;1.117417;1.132916;1.144584 -random,65536,524288,64,near,heap-fresh,0,32,4.239500,9,4.239500;4.149917;4.355250;4.177916;4.380166;4.192875;4.372500;4.234792;4.392750 -random,262144,2097152,16,near,heap-fresh,0,32,18.921708,9,18.899709;18.846708;19.207000;18.773125;18.921708;18.525292;19.286667;19.227375;19.109542 -random,262144,2097152,1,none,heap-fresh,0,32,128.563542,9,128.563542;129.742458;130.436708;130.550250;129.716083;127.658042;128.143750;127.020417;127.533833 diff --git a/dev/perf/spf_queue/results/first/time-heap-full-b.csv b/dev/perf/spf_queue/results/first/time-heap-full-b.csv deleted file mode 100644 index ff3653c..0000000 --- a/dev/perf/spf_queue/results/first/time-heap-full-b.csv +++ /dev/null @@ -1,20 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,1024,8192,1,none,heap-full,0,32,0.118166,9,0.146458;0.119167;0.118291;0.118166;0.115708;0.110000;0.114708;0.118625;0.114792 -random,4096,32768,256,none,heap-full,0,32,225.395542,9,228.126834;225.598041;226.784417;224.466333;225.105541;223.834291;229.336083;225.395542;224.983000 -random,16384,131072,64,none,heap-full,0,32,265.663667,9,265.335083;264.208125;265.271333;265.663667;270.274833;266.530500;266.512042;265.724917;265.575583 -random,65536,524288,1,none,heap-full,0,32,19.883583,9,20.028709;19.561417;20.049292;19.653542;20.800750;19.901500;19.473959;19.149125;19.883583 -random,4096,131072,64,none,heap-full,0,32,114.433792,9,114.462042;113.903833;114.012792;113.931000;114.783792;113.315125;116.039125;115.399625;114.433792 -clos,800,320000,64,none,heap-full,0,2,135.741666,9,135.116917;134.591250;135.741666;135.747500;137.683500;134.934209;135.111292;136.439125;136.778917 -grid,10000,39600,1,none,heap-full,0,2,0.666084,9,0.686292;0.695459;0.687708;0.668750;0.666084;0.652834;0.655833;0.664750;0.651542 -chain,2048,2047,1,none,heap-full,0,32,0.024042,9,0.024125;0.024000;0.024042;0.024166;0.024000;0.024166;0.024000;0.024042;0.023917 -uniform,16384,131072,64,none,heap-full,0,2,186.434333,9,186.382125;187.959625;187.210125;186.661417;186.400625;186.434333;185.308791;186.939208;186.230500 -random_masked,16384,131072,64,none,heap-full,0,32,242.225833,9,243.238083;241.939542;240.649917;243.730625;242.225833;241.985083;242.513833;242.397167;241.178917 -pseudo,802,320200,64,fixed,heap-full,0,2,135.039292,9,136.127209;133.944250;134.869292;135.412083;135.039292;136.334667;134.849584;135.085750;134.163375 -random,1024,8192,64,far,heap-full,0,32,11.354459,9,11.433541;11.354459;11.310458;11.410833;11.384834;11.358375;11.285875;11.261625;11.155958 -random,16384,131072,64,far,heap-full,0,32,266.233750,9,270.825250;270.773958;265.282750;266.282667;266.233750;265.749042;267.558791;264.842958;266.230209 -random,65536,524288,16,far,heap-full,0,32,326.155042,9,326.112541;326.155042;324.624875;328.796792;338.246416;326.962750;329.384750;325.279000;325.245000 -random,1024,8192,64,near,heap-full,0,32,0.111833,9,0.115042;0.111333;0.112917;0.111833;0.112875;0.108791;0.112125;0.111584;0.100958 -random,16384,131072,64,near,heap-full,0,32,1.321000,9,1.307833;1.309541;1.333500;1.328000;1.316583;1.303833;1.358750;1.322084;1.321000 -random,65536,524288,64,near,heap-full,0,32,4.666167,9,4.680125;4.746792;4.687000;4.710875;4.567125;4.445833;4.520084;4.598542;4.666167 -random,262144,2097152,16,near,heap-full,0,32,4.416333,9,4.437667;4.416333;4.343000;4.427208;4.405584;4.448750;4.410709;4.395916;4.423042 -random,262144,2097152,1,none,heap-full,0,32,133.189250,9,132.078166;154.603666;138.514625;134.731917;133.189250;132.093292;133.728792;132.005875;130.611875 diff --git a/dev/perf/spf_queue/results/first/time-heap-sparse-b.csv b/dev/perf/spf_queue/results/first/time-heap-sparse-b.csv deleted file mode 100644 index e0779bf..0000000 --- a/dev/perf/spf_queue/results/first/time-heap-sparse-b.csv +++ /dev/null @@ -1,20 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,1024,8192,1,none,heap-sparse,0,32,0.117333,9,0.130291;0.124250;0.121584;0.114292;0.117333;0.118042;0.116625;0.116834;0.112292 -random,4096,32768,256,none,heap-sparse,0,32,229.792625,9,229.388833;230.305875;231.628292;230.546042;229.461750;229.950458;228.498667;229.238584;229.792625 -random,16384,131072,64,none,heap-sparse,0,32,276.181541,9,279.428708;277.561000;276.181541;275.323584;278.222333;275.489833;274.415166;276.176833;276.760000 -random,65536,524288,1,none,heap-sparse,0,32,21.996833,9,21.128708;21.069625;20.960500;21.339375;22.240083;22.326084;24.122875;22.688334;21.996833 -random,4096,131072,64,none,heap-sparse,0,32,116.661125,9,117.081084;117.246125;117.409959;116.661125;115.278166;116.601917;115.869209;116.179458;116.846666 -clos,800,320000,64,none,heap-sparse,0,2,136.225542,9,135.596375;134.843917;137.157458;136.865917;138.362334;135.601875;136.225542;136.789542;134.530292 -grid,10000,39600,1,none,heap-sparse,0,2,0.707875,9,0.731333;0.707875;0.691917;0.692833;0.686791;0.682042;0.830041;0.820875;0.843250 -chain,2048,2047,1,none,heap-sparse,0,32,0.027833,9,0.027750;0.032125;0.027833;0.030542;0.028041;0.115791;0.024167;0.023709;0.024167 -uniform,16384,131072,64,none,heap-sparse,0,2,184.445750,9,183.342291;184.445750;184.596791;183.134708;183.946209;182.170375;184.866542;185.646792;186.874458 -random_masked,16384,131072,64,none,heap-sparse,0,32,251.644875,9,250.169625;249.810584;250.236708;252.644666;254.517125;258.871958;254.390125;251.196209;251.644875 -pseudo,802,320200,64,fixed,heap-sparse,0,2,136.496834,9,136.217500;136.784500;137.803792;136.429167;136.496834;135.609042;136.616375;135.702375;138.337834 -random,1024,8192,64,far,heap-sparse,0,32,11.728833,9,11.823709;11.680708;11.769959;11.700625;11.871875;11.755625;11.670667;11.596583;11.728833 -random,16384,131072,64,far,heap-sparse,0,32,276.217541,9,276.196792;276.217541;275.599250;276.902334;277.647209;276.274125;284.605208;275.271625;274.206834 -random,65536,524288,16,far,heap-sparse,0,32,331.986958,9,339.849750;338.197000;329.244917;331.144125;331.986958;329.266792;332.546125;342.616583;330.256208 -random,1024,8192,64,near,heap-sparse,0,32,0.040208,9,0.047250;0.040208;0.040750;0.037333;0.037959;0.040042;0.040209;0.040208;0.040208 -random,16384,131072,64,near,heap-sparse,0,32,0.385916,9,0.386458;0.395208;0.403959;0.385916;0.415292;0.360208;0.351250;0.381875;0.363916 -random,65536,524288,64,near,heap-sparse,0,32,1.486333,9,1.472916;1.488500;1.480625;1.486333;1.494833;1.479833;1.481375;1.495417;1.489333 -random,262144,2097152,16,near,heap-sparse,0,32,1.460417,9,1.436792;1.463291;1.460417;1.442041;1.469333;1.442625;1.458000;1.467083;1.467917 -random,262144,2097152,1,none,heap-sparse,0,32,137.015333,9,134.901125;140.954125;143.564417;137.015333;143.781125;143.934167;136.714000;134.648500;133.846833 diff --git a/dev/perf/spf_queue/results/first/time-ref-a1.csv b/dev/perf/spf_queue/results/first/time-ref-a1.csv deleted file mode 100644 index 0b656c2..0000000 --- a/dev/perf/spf_queue/results/first/time-ref-a1.csv +++ /dev/null @@ -1,20 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,1024,8192,1,none,ref,0,32,0.100625,9,0.100084;0.100625;0.113375;0.094750;0.101875;0.115375;0.109708;0.095333;0.095792 -random,4096,32768,256,none,ref,0,32,198.718250,9,197.933375;197.190208;195.268000;199.687250;205.407833;198.453000;198.718250;199.052333;199.647459 -random,16384,131072,64,none,ref,0,32,238.755083,9,238.755083;235.413583;238.942375;237.525208;235.592584;239.616750;240.090875;240.277500;237.924625 -random,65536,524288,1,none,ref,0,32,18.375666,9,18.342500;18.308292;18.375666;18.354417;17.978417;18.898875;18.709833;21.097667;19.911959 -random,4096,131072,64,none,ref,0,32,106.741584,9,106.072083;106.741584;105.991084;107.077666;107.519958;106.421125;107.008459;107.891375;105.394958 -clos,800,320000,64,none,ref,0,2,142.105167,9,142.505333;142.222834;141.403875;142.105167;140.911792;141.648084;141.805625;142.577541;142.248125 -grid,10000,39600,1,none,ref,0,2,0.614125,9,0.629125;0.630625;0.621958;0.620959;0.614125;0.607250;0.606917;0.602959;0.603291 -chain,2048,2047,1,none,ref,0,32,0.020833,9,0.022250;0.026583;0.045542;0.021500;0.020833;0.020709;0.020750;0.020667;0.020750 -uniform,16384,131072,64,none,ref,0,2,170.249750,9,168.616459;168.656292;170.249750;170.986333;172.660916;172.197834;167.438625;171.170375;168.618917 -random_masked,16384,131072,64,none,ref,0,32,219.464666,9,216.439000;220.720375;219.626583;219.428917;221.002208;220.503125;216.853333;219.464666;219.259125 -pseudo,802,320200,64,fixed,ref,0,2,140.618958,9,140.512042;140.764125;141.042750;138.901291;140.618958;140.345292;139.922334;142.146583;142.305542 -random,1024,8192,64,far,ref,0,32,10.129084,9,10.252625;10.129084;10.263500;10.034291;9.965875;10.187417;9.917250;10.094625;10.151000 -random,16384,131072,64,far,ref,0,32,240.012083,9,239.525250;239.438250;241.082250;240.157208;242.569167;239.640250;240.012083;241.302292;238.371542 -random,65536,524288,16,far,ref,0,32,289.354125,9,292.384500;302.566458;289.184583;290.246875;288.628292;289.354125;285.801458;288.569916;289.974167 -random,1024,8192,64,near,ref,0,32,0.104666,9,0.104666;0.101875;0.102208;0.115333;0.102042;0.113416;0.112625;0.111500;0.099667 -random,16384,131072,64,near,ref,0,32,1.044334,9,0.998958;1.029041;1.039625;1.044334;1.030583;1.056791;1.046792;1.069291;1.064209 -random,65536,524288,64,near,ref,0,32,4.034208,9,3.985625;4.076125;3.991750;4.027791;4.034208;4.034833;4.035667;4.096250;4.005333 -random,262144,2097152,16,near,ref,0,32,3.871542,9,3.840209;3.836792;3.842625;3.912084;3.873375;3.898500;3.871542;3.867917;3.907875 -random,262144,2097152,1,none,ref,0,32,119.703333,9,121.781375;119.108459;118.500666;120.011333;119.737667;122.042291;119.297917;118.898958;119.703333 diff --git a/dev/perf/spf_queue/results/first/time-ref-a2.csv b/dev/perf/spf_queue/results/first/time-ref-a2.csv deleted file mode 100644 index 948912b..0000000 --- a/dev/perf/spf_queue/results/first/time-ref-a2.csv +++ /dev/null @@ -1,20 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,1024,8192,1,none,ref,0,32,0.104208,9,0.111125;0.100583;0.110625;0.105583;0.103792;0.100542;0.103708;0.106417;0.104208 -random,4096,32768,256,none,ref,0,32,205.803292,9,207.161417;204.300125;205.676917;205.803292;207.224167;205.854417;206.188916;204.471167;205.455292 -random,16384,131072,64,none,ref,0,32,245.574917,9,246.231958;250.732666;245.339292;245.376208;245.066250;245.574917;247.844667;245.871292;245.273291 -random,65536,524288,1,none,ref,0,32,19.633375,9,19.437083;18.434834;18.742708;19.423083;19.633375;20.104666;20.097833;20.449042;19.730208 -random,4096,131072,64,none,ref,0,32,109.030458,9,109.414000;108.868500;108.683666;109.278042;109.245667;109.650583;109.021959;109.030458;108.857542 -clos,800,320000,64,none,ref,0,2,143.648375,9,143.608167;143.648375;142.897167;141.598667;145.929375;145.318334;145.996750;142.662042;143.719000 -grid,10000,39600,1,none,ref,0,2,0.621917,9,0.638042;0.621250;0.621917;0.629333;0.611333;0.618750;0.617416;0.636459;0.634125 -chain,2048,2047,1,none,ref,0,32,0.020583,9,0.020625;0.020375;0.020542;0.020584;0.020708;0.020583;0.020583;0.020542;0.020584 -uniform,16384,131072,64,none,ref,0,2,175.401208,9,175.401208;174.853709;174.902833;176.983417;175.576708;175.084000;174.032000;177.454708;176.266458 -random_masked,16384,131072,64,none,ref,0,32,225.265708,9,224.450417;225.327166;224.965458;225.131000;224.263917;225.615750;226.301708;227.413958;225.265708 -pseudo,802,320200,64,fixed,ref,0,2,142.151958,9,143.465209;142.830625;142.089583;141.701500;142.102959;142.151958;143.083291;141.934459;142.653250 -random,1024,8192,64,far,ref,0,32,10.432500,9,10.424959;10.307958;10.458000;10.432500;10.516459;10.506750;10.504625;10.343583;10.309125 -random,16384,131072,64,far,ref,0,32,246.256583,9,245.007042;246.015000;246.830125;245.896333;245.123167;246.953958;247.596042;248.142625;246.256583 -random,65536,524288,16,far,ref,0,32,299.940042,9,302.783792;297.925292;312.234125;303.503083;300.796917;297.393250;299.940042;297.575833;299.894000 -random,1024,8192,64,near,ref,0,32,0.120042,9,0.133000;0.118916;0.120667;0.117375;0.129250;0.120042;0.120416;0.119792;0.118791 -random,16384,131072,64,near,ref,0,32,1.170917,9,1.152417;1.192917;1.163625;1.170917;1.172084;1.167875;1.174625;1.188250;1.153333 -random,65536,524288,64,near,ref,0,32,4.207375,9,4.285375;4.206917;4.205583;4.207375;4.201167;4.213958;4.213542;4.165792;4.244750 -random,262144,2097152,16,near,ref,0,32,3.996708,9,3.968250;4.090666;3.967375;4.077625;3.996375;3.976584;3.996708;4.000083;4.050416 -random,262144,2097152,1,none,ref,0,32,125.213500,9,129.476167;123.631583;125.867208;125.910000;125.753333;125.213500;125.165250;124.951542;121.767417 diff --git a/dev/perf/spf_queue/results/netgraph-api/a1-old.csv b/dev/perf/spf_queue/results/netgraph-api/a1-old.csv deleted file mode 100644 index 885fbf3..0000000 --- a/dev/perf/spf_queue/results/netgraph-api/a1-old.csv +++ /dev/null @@ -1,17 +0,0 @@ -workload,label,median_s,samples,checksum,samples_s -spf_full_clos_200x200,a1-old,0.164199,5,946e7cf8b5a36980,0.164200;0.163339;0.163427;0.164199;0.164551 -spf_full_grid_100x100_weighted,a1-old,0.180069,5,4c2f7751ff9a5057,0.181406;0.179029;0.180069;0.179642;0.180765 -maxflow_clos_200x200_combine,a1-old,0.047420,5,2576c34f1fd9b85e,0.047402;0.047450;0.047170;0.047846;0.047420 -maxflow_failures_clos_100x100_x100,a1-old,0.040212,5,26dc7541b17fd051,0.040212;0.040723;0.040146;0.040529;0.039536 -maxflow_grid_100x100_weighted_combine,a1-old,0.172536,5,b0453b641647871b,0.174130;0.172536;0.173179;0.172331;0.170554 -maxflow_grid_100x100_weighted_pairwise,a1-old,0.214922,5,4fc2486a01221d75,0.220338;0.213739;0.182009;0.216421;0.214922 -placement_backbone_clos,a1-old,0.070275,5,a3bbef71a61656e1,0.070098;0.070461;0.070740;0.070275;0.070173 -placement_mc_backbone_clos_x300,a1-old,0.069225,5,13b9a5f311b911a9,0.070705;0.069225;0.068087;0.069235;0.069128 -maxflow_mc_backbone_clos_x300,a1-old,0.027504,5,ac971ec1b53a4b0a,0.028031;0.027778;0.027288;0.027374;0.027504 -ksp_clos3_pairwise_k8,a1-old,0.047320,5,9bf30f2f50792fe9,0.046562;0.046913;0.047353;0.047320;0.047576 -maxflow_clos3_pod_pairs_combine,a1-old,0.169847,5,2e23a5712f361ff7,0.169803;0.168511;0.206648;0.170894;0.169847 -placement_clos3_wcmp_and_ecmp,a1-old,0.385817,5,14d7ba5534c9ce95,0.387960;0.384565;0.385817;0.387922;0.345817 -placement_clos3_failures_x20,a1-old,7.906144,5,eafa50e144281360,7.858035;7.889058;7.998602;7.906144;7.938671 -scenario_square_mesh_iter100,a1-old,0.009979,5,5046cbe45e195f6e,0.009979;0.009923;0.010203;0.009979;0.010667 -scenario_nsfnet_iter200,a1-old,0.964245,5,d966e67ffac4393c,0.964245;0.964259;0.948062;1.007654;0.961705 -scenario_backbone_clos_iter100,a1-old,0.079351,5,e904ac0839c6cac1,0.080707;0.079139;0.078732;0.081250;0.079351 diff --git a/dev/perf/spf_queue/results/netgraph-api/a2-old.csv b/dev/perf/spf_queue/results/netgraph-api/a2-old.csv deleted file mode 100644 index 5b25f39..0000000 --- a/dev/perf/spf_queue/results/netgraph-api/a2-old.csv +++ /dev/null @@ -1,17 +0,0 @@ -workload,label,median_s,samples,checksum,samples_s -spf_full_clos_200x200,a2-old,0.164252,5,946e7cf8b5a36980,0.164252;0.164089;0.163610;0.164940;0.169394 -spf_full_grid_100x100_weighted,a2-old,0.180147,5,4c2f7751ff9a5057,0.181338;0.183952;0.180147;0.178888;0.180012 -maxflow_clos_200x200_combine,a2-old,0.047663,5,2576c34f1fd9b85e,0.048326;0.048074;0.047356;0.047663;0.047493 -maxflow_failures_clos_100x100_x100,a2-old,0.040527,5,26dc7541b17fd051,0.040527;0.040400;0.040282;0.040551;0.040915 -maxflow_grid_100x100_weighted_combine,a2-old,0.173642,5,b0453b641647871b,0.173692;0.171167;0.170759;0.174903;0.173642 -maxflow_grid_100x100_weighted_pairwise,a2-old,0.220239,5,4fc2486a01221d75,0.220239;0.216990;0.185458;0.222288;0.221898 -placement_backbone_clos,a2-old,0.070354,5,a3bbef71a61656e1,0.071010;0.070354;0.071020;0.070158;0.069685 -placement_mc_backbone_clos_x300,a2-old,0.069533,5,13b9a5f311b911a9,0.070353;0.070167;0.069416;0.069204;0.069533 -maxflow_mc_backbone_clos_x300,a2-old,0.027473,5,ac971ec1b53a4b0a,0.028459;0.027473;0.028129;0.027393;0.027454 -ksp_clos3_pairwise_k8,a2-old,0.046714,5,9bf30f2f50792fe9,0.046218;0.046637;0.046996;0.047152;0.046714 -maxflow_clos3_pod_pairs_combine,a2-old,0.170560,5,2e23a5712f361ff7,0.168721;0.170560;0.207953;0.170134;0.171528 -placement_clos3_wcmp_and_ecmp,a2-old,0.388207,5,14d7ba5534c9ce95,0.392023;0.388207;0.384011;0.395217;0.342003 -placement_clos3_failures_x20,a2-old,7.890882,5,eafa50e144281360,7.823005;7.897283;7.948790;7.803927;7.890882 -scenario_square_mesh_iter100,a2-old,0.010269,5,5046cbe45e195f6e,0.010292;0.010212;0.010466;0.010269;0.010108 -scenario_nsfnet_iter200,a2-old,0.959533,5,d966e67ffac4393c,0.963123;0.959533;0.955776;1.009108;0.953370 -scenario_backbone_clos_iter100,a2-old,0.080060,5,e904ac0839c6cac1,0.080287;0.078875;0.080223;0.079661;0.080060 diff --git a/dev/perf/spf_queue/results/netgraph-api/b-new-heap.csv b/dev/perf/spf_queue/results/netgraph-api/b-new-heap.csv deleted file mode 100644 index aace29a..0000000 --- a/dev/perf/spf_queue/results/netgraph-api/b-new-heap.csv +++ /dev/null @@ -1,17 +0,0 @@ -workload,label,median_s,samples,checksum,samples_s -spf_full_clos_200x200,b-new-heap,0.154301,5,946e7cf8b5a36980,0.154292;0.153986;0.156239;0.155807;0.154301 -spf_full_grid_100x100_weighted,b-new-heap,0.241783,5,4c2f7751ff9a5057,0.241890;0.241332;0.241625;0.241783;0.242557 -maxflow_clos_200x200_combine,b-new-heap,0.045705,5,2576c34f1fd9b85e,0.045888;0.045705;0.045257;0.045775;0.045253 -maxflow_failures_clos_100x100_x100,b-new-heap,0.037736,5,26dc7541b17fd051,0.037736;0.037922;0.037631;0.037622;0.037749 -maxflow_grid_100x100_weighted_combine,b-new-heap,0.221427,5,b0453b641647871b,0.221236;0.219613;0.221466;0.221565;0.221427 -maxflow_grid_100x100_weighted_pairwise,b-new-heap,0.220093,5,4fc2486a01221d75,0.224849;0.218149;0.185887;0.221495;0.220093 -placement_backbone_clos,b-new-heap,0.069219,5,a3bbef71a61656e1,0.071393;0.069138;0.067831;0.069622;0.069219 -placement_mc_backbone_clos_x300,b-new-heap,0.074105,5,13b9a5f311b911a9,0.074105;0.080540;0.074366;0.073329;0.072530 -maxflow_mc_backbone_clos_x300,b-new-heap,0.028863,5,ac971ec1b53a4b0a,0.028903;0.028762;0.029184;0.028828;0.028863 -ksp_clos3_pairwise_k8,b-new-heap,0.043672,5,9bf30f2f50792fe9,0.043959;0.043646;0.043925;0.043672;0.042721 -maxflow_clos3_pod_pairs_combine,b-new-heap,0.171207,5,2e23a5712f361ff7,0.171207;0.169931;0.205680;0.170800;0.171347 -placement_clos3_wcmp_and_ecmp,b-new-heap,0.382684,5,14d7ba5534c9ce95,0.382684;0.383823;0.379763;0.385744;0.345352 -placement_clos3_failures_x20,b-new-heap,7.783453,5,eafa50e144281360,7.757524;7.783453;7.861714;7.729196;7.797888 -scenario_square_mesh_iter100,b-new-heap,0.010213,5,5046cbe45e195f6e,0.009939;0.010215;0.010081;0.010415;0.010213 -scenario_nsfnet_iter200,b-new-heap,0.947496,5,d966e67ffac4393c,0.952279;0.947496;0.933890;0.984148;0.935582 -scenario_backbone_clos_iter100,b-new-heap,0.081325,5,e904ac0839c6cac1,0.081325;0.081223;0.082362;0.082170;0.080886 diff --git a/dev/perf/spf_queue/results/netgraph-api/b-new.csv b/dev/perf/spf_queue/results/netgraph-api/b-new.csv deleted file mode 100644 index 4c97086..0000000 --- a/dev/perf/spf_queue/results/netgraph-api/b-new.csv +++ /dev/null @@ -1,17 +0,0 @@ -workload,label,median_s,samples,checksum,samples_s -spf_full_clos_200x200,b-new,0.157989,5,946e7cf8b5a36980,0.158181;0.157301;0.157989;0.157473;0.158350 -spf_full_grid_100x100_weighted,b-new,0.180796,5,4c2f7751ff9a5057,0.180796;0.181414;0.180503;0.182915;0.180604 -maxflow_clos_200x200_combine,b-new,0.045661,5,2576c34f1fd9b85e,0.046378;0.045917;0.045661;0.045093;0.045019 -maxflow_failures_clos_100x100_x100,b-new,0.038526,5,26dc7541b17fd051,0.038590;0.038414;0.038400;0.038529;0.038526 -maxflow_grid_100x100_weighted_combine,b-new,0.171153,5,b0453b641647871b,0.172839;0.173989;0.170848;0.169668;0.171153 -maxflow_grid_100x100_weighted_pairwise,b-new,0.218760,5,4fc2486a01221d75,0.222728;0.217684;0.183078;0.218760;0.220241 -placement_backbone_clos,b-new,0.069585,5,a3bbef71a61656e1,0.069335;0.070424;0.069104;0.070529;0.069585 -placement_mc_backbone_clos_x300,b-new,0.068743,5,13b9a5f311b911a9,0.068743;0.069898;0.067806;0.067056;0.068923 -maxflow_mc_backbone_clos_x300,b-new,0.027577,5,ac971ec1b53a4b0a,0.027735;0.027662;0.027234;0.027577;0.027326 -ksp_clos3_pairwise_k8,b-new,0.044595,5,9bf30f2f50792fe9,0.044767;0.044511;0.045780;0.044129;0.044595 -maxflow_clos3_pod_pairs_combine,b-new,0.170686,5,2e23a5712f361ff7,0.169656;0.171535;0.205557;0.170686;0.170334 -placement_clos3_wcmp_and_ecmp,b-new,0.382645,5,14d7ba5534c9ce95,0.390539;0.383919;0.382645;0.381250;0.344172 -placement_clos3_failures_x20,b-new,7.832697,5,eafa50e144281360,7.797490;7.832697;7.863150;7.782276;7.885314 -scenario_square_mesh_iter100,b-new,0.010150,5,5046cbe45e195f6e,0.010457;0.010075;0.010150;0.010102;0.010323 -scenario_nsfnet_iter200,b-new,0.950681,5,d966e67ffac4393c,0.964545;0.950681;0.944029;0.992850;0.947637 -scenario_backbone_clos_iter100,b-new,0.079879,5,e904ac0839c6cac1,0.080338;0.079758;0.081120;0.079879;0.079752 diff --git a/dev/perf/spf_queue/results/netgraph-api/environment.txt b/dev/perf/spf_queue/results/netgraph-api/environment.txt deleted file mode 100644 index db978f6..0000000 --- a/dev/perf/spf_queue/results/netgraph-api/environment.txt +++ /dev/null @@ -1,8 +0,0 @@ -date: 2026-10-02T18:15:33Z -new: d5717cced8a4451258f114d8e78d67ab0dc14248 (dirty: 29 paths) -old: d5717cced8a4451258f114d8e78d67ab0dc14248 -netgraph: 33fadebf7fe05985cc70647fc422287afaa3221c -dd3b394c0ba16f7ed0e0e403188f4fcc38dbd9ce2cf594368d2b2a8a19a105a0 dev/perf/spf_queue/bench_netgraph.py -be97554943acc1301b21dfb4b12d1a8603de1f8017b843243902ef09c8e6b981 /Users/networmix/ws/NetGraph-Core/venv/lib/python3.14/site-packages/_netgraph_core.cpython-314-darwin.so -9d684e1280714cd8d4b275c418b34cf936e0b340243612bfab40913bbe60610c /Users/networmix/ws/NetGraph-Core-main/venv/lib/python3.14/site-packages/_netgraph_core.cpython-314-darwin.so -Apple M4 Max diff --git a/dev/perf/spf_queue/results/netgraph-api/load.txt b/dev/perf/spf_queue/results/netgraph-api/load.txt deleted file mode 100644 index c657a0f..0000000 --- a/dev/perf/spf_queue/results/netgraph-api/load.txt +++ /dev/null @@ -1,5 +0,0 @@ -19:15 up 52 days, 23:51, 3 users, load averages: 4.90 9.98 17.60 -19:16 up 52 days, 23:52, 3 users, load averages: 5.13 9.03 16.68 -19:17 up 52 days, 23:53, 3 users, load averages: 4.07 8.05 15.79 -19:18 up 52 days, 23:54, 3 users, load averages: 5.16 7.58 15.05 -19:19 up 52 days, 23:55, 3 users, load averages: 4.97 7.09 14.35 diff --git a/dev/perf/spf_queue/results/netgraph/a1-old.csv b/dev/perf/spf_queue/results/netgraph/a1-old.csv deleted file mode 100644 index ca2a577..0000000 --- a/dev/perf/spf_queue/results/netgraph/a1-old.csv +++ /dev/null @@ -1,17 +0,0 @@ -workload,label,median_s,samples,checksum,samples_s -spf_full_clos_200x200,a1-old,0.214242,5,946e7cf8b5a36980,0.215061;0.216031;0.213735;0.214242;0.212930 -spf_full_grid_100x100_weighted,a1-old,0.220778,5,4c2f7751ff9a5057,0.220560;0.220778;0.220257;0.221003;0.221978 -maxflow_clos_200x200_combine,a1-old,0.052564,5,2576c34f1fd9b85e,0.053311;0.053040;0.052564;0.052143;0.051846 -maxflow_failures_clos_100x100_x100,a1-old,0.044261,5,26dc7541b17fd051,0.044064;0.044782;0.044379;0.043525;0.044261 -maxflow_grid_100x100_weighted_combine,a1-old,0.202941,5,b0453b641647871b,0.202895;0.206061;0.205235;0.202941;0.202883 -maxflow_grid_100x100_weighted_pairwise,a1-old,0.203949,5,4fc2486a01221d75,0.205356;0.205253;0.172211;0.203949;0.200981 -placement_backbone_clos,a1-old,0.069218,5,a3bbef71a61656e1,0.069218;0.067452;0.067574;0.070175;0.071420 -placement_mc_backbone_clos_x300,a1-old,0.074473,5,13b9a5f311b911a9,0.077150;0.074793;0.073446;0.074473;0.073984 -maxflow_mc_backbone_clos_x300,a1-old,0.029282,5,ac971ec1b53a4b0a,0.029265;0.029166;0.029931;0.029314;0.029282 -ksp_clos3_pairwise_k8,a1-old,0.054646,5,9bf30f2f50792fe9,0.053493;0.053920;0.056333;0.054646;0.056298 -maxflow_clos3_pod_pairs_combine,a1-old,0.167483,5,2e23a5712f361ff7,0.167694;0.167483;0.197351;0.162379;0.163569 -placement_clos3_wcmp_and_ecmp,a1-old,0.366377,5,14d7ba5534c9ce95,0.370620;0.366377;0.372593;0.365110;0.333473 -placement_clos3_failures_x20,a1-old,7.646194,5,eafa50e144281360,7.614464;7.688954;7.711631;7.610305;7.646194 -scenario_square_mesh_iter100,a1-old,0.010309,5,5046cbe45e195f6e,0.009954;0.010309;0.010277;0.010874;0.010464 -scenario_nsfnet_iter200,a1-old,0.958151,5,d966e67ffac4393c,0.965309;0.958151;0.947347;0.993366;0.944487 -scenario_backbone_clos_iter100,a1-old,0.082340,5,e904ac0839c6cac1,0.082945;0.084257;0.082340;0.081514;0.082249 diff --git a/dev/perf/spf_queue/results/netgraph/a2-old.csv b/dev/perf/spf_queue/results/netgraph/a2-old.csv deleted file mode 100644 index 08b21fd..0000000 --- a/dev/perf/spf_queue/results/netgraph/a2-old.csv +++ /dev/null @@ -1,17 +0,0 @@ -workload,label,median_s,samples,checksum,samples_s -spf_full_clos_200x200,a2-old,0.212650,5,946e7cf8b5a36980,0.213604;0.213158;0.212128;0.212650;0.211883 -spf_full_grid_100x100_weighted,a2-old,0.220314,5,4c2f7751ff9a5057,0.219253;0.221249;0.220314;0.222180;0.219875 -maxflow_clos_200x200_combine,a2-old,0.051853,5,2576c34f1fd9b85e,0.052104;0.051853;0.052154;0.051545;0.051553 -maxflow_failures_clos_100x100_x100,a2-old,0.043021,5,26dc7541b17fd051,0.043132;0.043212;0.042954;0.042811;0.043021 -maxflow_grid_100x100_weighted_combine,a2-old,0.202080,5,b0453b641647871b,0.203003;0.202080;0.202227;0.202046;0.201641 -maxflow_grid_100x100_weighted_pairwise,a2-old,0.200578,5,4fc2486a01221d75,0.202322;0.200159;0.177585;0.204723;0.200578 -placement_backbone_clos,a2-old,0.067439,5,a3bbef71a61656e1,0.067937;0.067139;0.067662;0.067130;0.067439 -placement_mc_backbone_clos_x300,a2-old,0.075278,5,13b9a5f311b911a9,0.075415;0.074549;0.074713;0.075430;0.075278 -maxflow_mc_backbone_clos_x300,a2-old,0.029137,5,ac971ec1b53a4b0a,0.029212;0.029137;0.029388;0.029131;0.029046 -ksp_clos3_pairwise_k8,a2-old,0.053399,5,9bf30f2f50792fe9,0.053732;0.053322;0.054482;0.053399;0.053396 -maxflow_clos3_pod_pairs_combine,a2-old,0.166107,5,2e23a5712f361ff7,0.161893;0.166107;0.198977;0.167306;0.163009 -placement_clos3_wcmp_and_ecmp,a2-old,0.363318,5,14d7ba5534c9ce95,0.362654;0.363394;0.363318;0.363318;0.330417 -placement_clos3_failures_x20,a2-old,7.547826,5,eafa50e144281360,7.463701;7.512790;7.602702;7.609344;7.547826 -scenario_square_mesh_iter100,a2-old,0.010336,5,5046cbe45e195f6e,0.009918;0.010012;0.010376;0.010627;0.010336 -scenario_nsfnet_iter200,a2-old,0.939100,5,d966e67ffac4393c,0.945634;0.934857;0.927139;0.974911;0.939100 -scenario_backbone_clos_iter100,a2-old,0.081112,5,e904ac0839c6cac1,0.082197;0.081385;0.081112;0.080830;0.080991 diff --git a/dev/perf/spf_queue/results/netgraph/b-new-heap.csv b/dev/perf/spf_queue/results/netgraph/b-new-heap.csv deleted file mode 100644 index 7345cd3..0000000 --- a/dev/perf/spf_queue/results/netgraph/b-new-heap.csv +++ /dev/null @@ -1,17 +0,0 @@ -workload,label,median_s,samples,checksum,samples_s -spf_full_clos_200x200,b-new-heap,0.164632,5,946e7cf8b5a36980,0.171094;0.163302;0.164001;0.164632;0.165386 -spf_full_grid_100x100_weighted,b-new-heap,0.245422,5,4c2f7751ff9a5057,0.245311;0.244615;0.246294;0.245422;0.247913 -maxflow_clos_200x200_combine,b-new-heap,0.046796,5,2576c34f1fd9b85e,0.046604;0.046809;0.046796;0.046520;0.047011 -maxflow_failures_clos_100x100_x100,b-new-heap,0.039282,5,26dc7541b17fd051,0.039282;0.040234;0.038893;0.039675;0.038654 -maxflow_grid_100x100_weighted_combine,b-new-heap,0.219054,5,b0453b641647871b,0.217967;0.219054;0.218718;0.224953;0.221065 -maxflow_grid_100x100_weighted_pairwise,b-new-heap,0.212886,5,4fc2486a01221d75,0.212886;0.220461;0.186507;0.211249;0.216741 -placement_backbone_clos,b-new-heap,0.068715,5,a3bbef71a61656e1,0.068128;0.067605;0.072257;0.071829;0.068715 -placement_mc_backbone_clos_x300,b-new-heap,0.071399,5,13b9a5f311b911a9,0.072645;0.071349;0.071399;0.071738;0.070936 -maxflow_mc_backbone_clos_x300,b-new-heap,0.029019,5,ac971ec1b53a4b0a,0.029019;0.028594;0.028178;0.029278;0.031190 -ksp_clos3_pairwise_k8,b-new-heap,0.046233,5,9bf30f2f50792fe9,0.046716;0.046282;0.046233;0.045945;0.045440 -maxflow_clos3_pod_pairs_combine,b-new-heap,0.171827,5,2e23a5712f361ff7,0.171827;0.174949;0.201334;0.167000;0.164015 -placement_clos3_wcmp_and_ecmp,b-new-heap,0.368871,5,14d7ba5534c9ce95,0.368871;0.363981;0.374026;0.375693;0.334427 -placement_clos3_failures_x20,b-new-heap,7.570639,5,eafa50e144281360,7.638405;7.570639;7.578404;7.479565;7.433718 -scenario_square_mesh_iter100,b-new-heap,0.009861,5,5046cbe45e195f6e,0.009619;0.009698;0.009861;0.009916;0.009878 -scenario_nsfnet_iter200,b-new-heap,0.912169,5,d966e67ffac4393c,0.918451;0.912169;0.909963;0.962305;0.906028 -scenario_backbone_clos_iter100,b-new-heap,0.077625,5,e904ac0839c6cac1,0.077511;0.077981;0.077051;0.078228;0.077625 diff --git a/dev/perf/spf_queue/results/netgraph/b-new.csv b/dev/perf/spf_queue/results/netgraph/b-new.csv deleted file mode 100644 index 335acf4..0000000 --- a/dev/perf/spf_queue/results/netgraph/b-new.csv +++ /dev/null @@ -1,17 +0,0 @@ -workload,label,median_s,samples,checksum,samples_s -spf_full_clos_200x200,b-new,0.163387,5,946e7cf8b5a36980,0.163696;0.163391;0.163122;0.163197;0.163387 -spf_full_grid_100x100_weighted,b-new,0.181652,5,4c2f7751ff9a5057,0.181652;0.182234;0.180117;0.180609;0.181705 -maxflow_clos_200x200_combine,b-new,0.047118,5,2576c34f1fd9b85e,0.047558;0.046946;0.047023;0.047830;0.047118 -maxflow_failures_clos_100x100_x100,b-new,0.040410,5,26dc7541b17fd051,0.040004;0.040410;0.040879;0.040360;0.040449 -maxflow_grid_100x100_weighted_combine,b-new,0.174551,5,b0453b641647871b,0.177566;0.174597;0.174551;0.170893;0.173684 -maxflow_grid_100x100_weighted_pairwise,b-new,0.208183,5,4fc2486a01221d75,0.211970;0.206165;0.176239;0.208183;0.208189 -placement_backbone_clos,b-new,0.069022,5,a3bbef71a61656e1,0.069022;0.069117;0.067956;0.067765;0.072885 -placement_mc_backbone_clos_x300,b-new,0.067803,5,13b9a5f311b911a9,0.069155;0.067885;0.067111;0.066996;0.067803 -maxflow_mc_backbone_clos_x300,b-new,0.027553,5,ac971ec1b53a4b0a,0.028108;0.027674;0.027451;0.027553;0.027179 -ksp_clos3_pairwise_k8,b-new,0.046303,5,9bf30f2f50792fe9,0.046303;0.045928;0.047188;0.046233;0.046584 -maxflow_clos3_pod_pairs_combine,b-new,0.168243,5,2e23a5712f361ff7,0.167244;0.168132;0.199066;0.168243;0.172598 -placement_clos3_wcmp_and_ecmp,b-new,0.376317,5,14d7ba5534c9ce95,0.378218;0.372257;0.377731;0.376317;0.338946 -placement_clos3_failures_x20,b-new,7.639934,5,eafa50e144281360,7.639045;7.660284;7.732032;7.616304;7.639934 -scenario_square_mesh_iter100,b-new,0.009892,5,5046cbe45e195f6e,0.009892;0.009624;0.009909;0.009814;0.010528 -scenario_nsfnet_iter200,b-new,0.929637,5,d966e67ffac4393c,0.939539;0.929637;0.925644;0.978736;0.926841 -scenario_backbone_clos_iter100,b-new,0.078271,5,e904ac0839c6cac1,0.078327;0.078636;0.078104;0.077679;0.078271 diff --git a/dev/perf/spf_queue/results/netgraph/environment.txt b/dev/perf/spf_queue/results/netgraph/environment.txt deleted file mode 100644 index 014b006..0000000 --- a/dev/perf/spf_queue/results/netgraph/environment.txt +++ /dev/null @@ -1,8 +0,0 @@ -date: 2026-10-02T01:56:58Z -new: fbab119e794c843838f4f3f96d75cb7f32f06845 (dirty: 14 paths) -old: fbab119e794c843838f4f3f96d75cb7f32f06845 -netgraph: 775530768eec3951b7aec738f7e99d1abb6b7efc -dd3b394c0ba16f7ed0e0e403188f4fcc38dbd9ce2cf594368d2b2a8a19a105a0 dev/perf/spf_queue/bench_netgraph.py -9d684e1280714cd8d4b275c418b34cf936e0b340243612bfab40913bbe60610c /Users/networmix/ws/NetGraph-Core/venv/lib/python3.14/site-packages/_netgraph_core.cpython-314-darwin.so -91dc3b7e2bd3611d54d762e0fb1f875881138d66843866990f019cddfec5cc2c /Users/networmix/ws/NetGraph-Core-main/venv/lib/python3.14/site-packages/_netgraph_core.cpython-314-darwin.so -Apple M4 Max diff --git a/dev/perf/spf_queue/results/netgraph/hashseed-determinism.txt b/dev/perf/spf_queue/results/netgraph/hashseed-determinism.txt deleted file mode 100644 index 2f2a1e1..0000000 --- a/dev/perf/spf_queue/results/netgraph/hashseed-determinism.txt +++ /dev/null @@ -1,9 +0,0 @@ -new maxflow_mc_backbone_clos_x300 ac971ec1b53a4b0a -old1 maxflow_mc_backbone_clos_x300 ac971ec1b53a4b0a -old2 maxflow_mc_backbone_clos_x300 ac971ec1b53a4b0a -new placement_mc_backbone_clos_x300 13b9a5f311b911a9 -old1 placement_mc_backbone_clos_x300 13b9a5f311b911a9 -old2 placement_mc_backbone_clos_x300 13b9a5f311b911a9 -new scenario_backbone_clos_iter100 e904ac0839c6cac1 -old1 scenario_backbone_clos_iter100 e904ac0839c6cac1 -old2 scenario_backbone_clos_iter100 e904ac0839c6cac1 diff --git a/dev/perf/spf_queue/results/netgraph/load.txt b/dev/perf/spf_queue/results/netgraph/load.txt deleted file mode 100644 index 8adfc14..0000000 --- a/dev/perf/spf_queue/results/netgraph/load.txt +++ /dev/null @@ -1,5 +0,0 @@ - 2:56 up 52 days, 7:32, 3 users, load averages: 3.81 4.06 3.70 - 2:58 up 52 days, 7:33, 3 users, load averages: 5.32 4.48 3.88 - 2:59 up 52 days, 7:34, 3 users, load averages: 4.18 4.30 3.85 - 3:00 up 52 days, 7:35, 3 users, load averages: 4.64 4.42 3.93 - 3:01 up 52 days, 7:36, 3 users, load averages: 3.82 4.20 3.87 diff --git a/dev/perf/spf_queue/results/priority/a1-old.csv b/dev/perf/spf_queue/results/priority/a1-old.csv deleted file mode 100644 index 065c3ab..0000000 --- a/dev/perf/spf_queue/results/priority/a1-old.csv +++ /dev/null @@ -1,11 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,16384,131072,64,none,ref,0,32,149.947042,7,148.096291;146.522667;164.538958;170.384166;168.072167;147.836833;149.947042 -random,65536,524288,1,none,ref,0,32,14.036084,7,14.626750;16.255041;13.739958;14.972333;13.566417;13.306292;14.036084 -clos,800,320000,64,none,ref,0,2,108.680541,7,109.401542;108.082334;108.293334;110.645417;109.045250;108.680541;108.196625 -chain,2048,2047,1,none,ref,0,32,0.029709,7,0.030458;0.029709;0.029875;0.029833;0.029375;0.029209;0.026541 -random,16384,131072,64,near,ref,0,32,0.394292,7,0.392916;0.396292;0.394292;0.393292;0.393416;0.411791;0.408000 -random,65536,524288,64,near,ref,0,32,1.513084,7,1.513084;1.514875;1.520500;1.531958;1.490333;1.493584;1.503292 -random,262144,2097152,16,near,ref,0,32,1.475125,7,1.464167;1.463459;1.468833;1.475125;1.486791;1.477875;1.503500 -random,262144,2097152,1,none,ref,0,32,91.345667,7,91.712708;91.345667;91.229959;88.957541;93.626250;92.104708;90.893166 -random,4096,32768,64,reverse,ref,0,32,34.358542,7,33.641125;33.889917;34.118042;34.358542;35.146875;35.473083;34.650833 -clos,800,320000,16,reverse,ref,0,2,37.327792,7,37.838500;36.963750;36.841042;37.327792;37.318583;37.761125;38.013791 diff --git a/dev/perf/spf_queue/results/priority/a2-old.csv b/dev/perf/spf_queue/results/priority/a2-old.csv deleted file mode 100644 index 75b9513..0000000 --- a/dev/perf/spf_queue/results/priority/a2-old.csv +++ /dev/null @@ -1,11 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,16384,131072,64,none,ref,0,32,148.279417,7,148.184917;153.213083;148.147000;148.114083;149.098542;150.540208;148.279417 -random,65536,524288,1,none,ref,0,32,12.526334,7,13.445667;13.420208;13.369916;11.817458;12.019041;12.369042;12.526334 -clos,800,320000,64,none,ref,0,2,108.296291,7,108.092833;107.961917;109.981458;111.156125;108.413750;108.062584;108.296291 -chain,2048,2047,1,none,ref,0,32,0.026500,7,0.025875;0.025958;0.026334;0.026500;0.028834;0.029125;0.028750 -random,16384,131072,64,near,ref,0,32,0.405958,7,0.401833;0.409375;0.393375;0.421708;0.394041;0.405958;0.422333 -random,65536,524288,64,near,ref,0,32,1.511792,7,1.504083;1.570667;1.511792;1.529917;1.506250;1.516625;1.490667 -random,262144,2097152,16,near,ref,0,32,1.465083,7,1.449375;1.456542;1.473583;1.465083;1.481459;1.499750;1.449583 -random,262144,2097152,1,none,ref,0,32,91.408000,7,89.104458;92.220833;93.374208;92.226583;89.439250;91.408000;88.846250 -random,4096,32768,64,reverse,ref,0,32,34.143500,7,34.236500;34.307791;34.026709;33.957084;34.143500;34.224667;33.820292 -clos,800,320000,16,reverse,ref,0,2,37.667583,7,37.375000;38.367917;38.190458;37.667583;37.762083;36.788125;36.170958 diff --git a/dev/perf/spf_queue/results/priority/b-new-capacity.csv b/dev/perf/spf_queue/results/priority/b-new-capacity.csv deleted file mode 100644 index 9682476..0000000 --- a/dev/perf/spf_queue/results/priority/b-new-capacity.csv +++ /dev/null @@ -1,11 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,16384,131072,64,none,ref,0,32,151.414958,7,151.624500;150.795833;156.696459;149.771292;149.438541;152.103750;151.414958 -random,65536,524288,1,none,ref,0,32,13.349125,7,12.904959;14.485500;14.418417;13.398958;13.349125;13.241584;12.693167 -clos,800,320000,64,none,ref,0,2,55.644000,7,56.308125;56.209333;55.583083;55.285958;55.671083;55.644000;55.416084 -chain,2048,2047,1,none,ref,0,32,0.026625,7,0.026791;0.026125;0.026625;0.026375;0.026666;0.026167;0.026667 -random,16384,131072,64,near,ref,0,32,0.394917,7,0.414792;0.393667;0.399500;0.397209;0.394000;0.394292;0.394917 -random,65536,524288,64,near,ref,0,32,1.507209,7,1.512250;1.507209;1.497959;1.404750;1.510208;1.500500;1.523250 -random,262144,2097152,16,near,ref,0,32,1.476625,7,1.493833;1.472417;1.452833;1.515292;1.476625;1.449916;1.496917 -random,262144,2097152,1,none,ref,0,32,93.582083,7,93.134083;92.153500;99.965167;103.025542;94.254500;93.268208;93.582083 -random,4096,32768,64,reverse,ref,0,32,35.378500,7,36.071667;35.307708;35.378500;35.161625;35.456250;35.369500;35.790583 -clos,800,320000,16,reverse,ref,0,2,18.969208,7,19.031709;18.969208;18.642166;18.671083;18.642459;19.099584;19.787417 diff --git a/dev/perf/spf_queue/results/priority/b-new-headroom.csv b/dev/perf/spf_queue/results/priority/b-new-headroom.csv deleted file mode 100644 index b7e3068..0000000 --- a/dev/perf/spf_queue/results/priority/b-new-headroom.csv +++ /dev/null @@ -1,11 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,16384,131072,64,none,ref,0,32,146.796625,7,145.908792;146.796625;144.728250;144.656834;147.028583;150.304333;147.094459 -random,65536,524288,1,none,ref,0,32,12.153208,7,11.960625;12.153208;12.526166;11.373416;11.399250;12.413167;14.555958 -clos,800,320000,64,none,ref,0,2,53.643875,7,53.569250;53.643875;53.827125;53.712458;53.620333;53.635208;54.391542 -chain,2048,2047,1,none,ref,0,32,0.029000,7,0.028667;0.029000;0.028667;0.028833;0.031875;0.031667;0.029542 -random,16384,131072,64,near,ref,0,32,0.391958,7,0.384208;0.380708;0.409042;0.391958;0.389500;0.399166;0.399667 -random,65536,524288,64,near,ref,0,32,1.518458,7,1.518458;1.544708;1.665042;1.463750;1.537667;1.509084;1.510625 -random,262144,2097152,16,near,ref,0,32,1.469083,7,1.502083;1.467167;1.469083;1.480875;1.467584;1.508083;1.463042 -random,262144,2097152,1,none,ref,0,32,74.176667,7,74.176083;74.533000;74.875209;75.312667;74.176667;73.903292;73.634333 -random,4096,32768,64,reverse,ref,0,32,34.583708,7,34.863042;35.064666;34.825333;34.549792;34.583708;34.488125;34.552875 -clos,800,320000,16,reverse,ref,0,2,17.343000,7,17.329250;17.511542;17.439958;17.322833;17.687459;17.190833;17.343000 diff --git a/dev/perf/spf_queue/results/priority/b-new-residual-single.csv b/dev/perf/spf_queue/results/priority/b-new-residual-single.csv deleted file mode 100644 index 513f8c9..0000000 --- a/dev/perf/spf_queue/results/priority/b-new-residual-single.csv +++ /dev/null @@ -1,11 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,16384,131072,64,none,ref,0,32,147.487417,7,147.317250;147.268583;147.801167;147.176166;147.487417;148.155750;149.413417 -random,65536,524288,1,none,ref,0,32,11.326166,7,11.819458;12.234750;11.800084;10.985958;11.052417;11.079250;11.326166 -clos,800,320000,64,none,ref,0,2,54.487083,7,54.487083;54.215458;54.391500;55.709875;55.749041;54.909458;53.593959 -chain,2048,2047,1,none,ref,0,32,0.027667,7,0.027459;0.027458;0.027375;0.030750;0.032917;0.027667;0.042333 -random,16384,131072,64,near,ref,0,32,0.393666,7,0.398708;0.394875;0.418917;0.389875;0.393666;0.389583;0.388291 -random,65536,524288,64,near,ref,0,32,1.546584,7,1.612292;1.560833;1.556750;1.543833;1.539750;1.469000;1.546584 -random,262144,2097152,16,near,ref,0,32,1.451500,7,1.404000;1.479292;1.451500;1.453292;1.521333;1.432792;1.447250 -random,262144,2097152,1,none,ref,0,32,90.980209,7,91.145958;90.980209;89.013833;89.918084;94.619875;92.344875;90.443500 -random,4096,32768,64,reverse,ref,0,32,34.809083,7,35.012083;34.426667;34.760625;34.992375;34.809083;35.228750;34.739417 -clos,800,320000,16,reverse,ref,0,2,16.962417,7,16.856250;16.830958;16.741042;16.962417;16.981292;17.517209;17.196833 diff --git a/dev/perf/spf_queue/results/priority/b-new.csv b/dev/perf/spf_queue/results/priority/b-new.csv deleted file mode 100644 index e565dcc..0000000 --- a/dev/perf/spf_queue/results/priority/b-new.csv +++ /dev/null @@ -1,11 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,16384,131072,64,none,ref,0,32,144.942292,7,144.519542;145.649625;143.703583;144.353833;146.777459;145.086292;144.942292 -random,65536,524288,1,none,ref,0,32,11.476084,7,11.089792;11.476084;12.379958;12.141416;11.163583;11.282458;12.805625 -clos,800,320000,64,none,ref,0,2,103.054334,7,102.478542;103.054334;103.844792;102.837041;102.534541;104.274334;103.562792 -chain,2048,2047,1,none,ref,0,32,0.026333,7,0.026208;0.026208;0.026333;0.026917;0.030417;0.026208;0.026375 -random,16384,131072,64,near,ref,0,32,0.401375,7,0.409125;0.369000;0.401375;0.388167;0.393292;0.423291;0.404333 -random,65536,524288,64,near,ref,0,32,1.526208,7,1.508333;1.530334;1.526208;1.530458;1.518584;1.551291;1.520959 -random,262144,2097152,16,near,ref,0,32,1.478416,7,1.457625;1.478416;1.459708;1.477666;1.564958;1.481375;1.547083 -random,262144,2097152,1,none,ref,0,32,88.424125,7,86.749000;91.559250;92.226208;89.160375;88.424125;86.360209;87.833709 -random,4096,32768,64,reverse,ref,0,32,34.549417,7,35.338417;35.121542;34.662875;34.200791;34.330667;34.455125;34.549417 -clos,800,320000,16,reverse,ref,0,2,38.633208,7,37.534500;38.848417;39.507667;39.273625;38.633208;36.195500;37.918709 diff --git a/dev/perf/spf_queue/results/priority/environment.txt b/dev/perf/spf_queue/results/priority/environment.txt deleted file mode 100644 index 441cf03..0000000 --- a/dev/perf/spf_queue/results/priority/environment.txt +++ /dev/null @@ -1,6 +0,0 @@ -date: 2026-10-02T17:38:30Z -old: d5717cc (0.11.0) via build.sh old d5717cc; new: working tree with EdgeSelection::priority -phases: a1-old, b-new (default priority), b-new-residual-single (single-path, PreferHigherResidual, priority=Residual), b-new-capacity, b-new-headroom, a2-old; short mode, 7 samples -382f443bfde62bc56871d5335af9dc23ddc307a838b0624368217127c78fa499 src/shortest_paths.cpp -5499dbbdb66f7f63ad6c97f5718d0205c4f2f05992b60f4822a2ddcd5461f5d3 build/perf/cpu-queue -7de733ce4ec111c3414e2b262b6063a253b5cd790e4beceab893d802f0c5bf6a build/perf/cpu-queue-old diff --git a/dev/perf/spf_queue/results/priority/load.txt b/dev/perf/spf_queue/results/priority/load.txt deleted file mode 100644 index 6c26122..0000000 --- a/dev/perf/spf_queue/results/priority/load.txt +++ /dev/null @@ -1,5 +0,0 @@ -18:36 up 52 days, 23:12, 3 users, load averages: 13.67 8.95 6.69 -18:36 up 52 days, 23:12, 3 users, load averages: 13.05 8.90 6.69 -18:36 up 52 days, 23:12, 3 users, load averages: 12.09 8.77 6.65 -18:37 up 52 days, 23:12, 3 users, load averages: 10.86 8.61 6.62 -18:37 up 52 days, 23:12, 3 users, load averages: 10.07 8.49 6.59 diff --git a/dev/perf/spf_queue/results/short/environment.txt b/dev/perf/spf_queue/results/short/environment.txt deleted file mode 100644 index 65f8240..0000000 --- a/dev/perf/spf_queue/results/short/environment.txt +++ /dev/null @@ -1,3 +0,0 @@ -date: 2026-09-27T13:41:37Z -30748a1034498f6a2bd05645f72b60750f3ef366c81476fd3f87bbac93c1a381 dev/perf/spf_queue/spf_variants.hpp -cecf8edfd17c19624890ba8c6bc2fa647d7841c25baaebeaaa175712ceebf688 dev/perf/spf_queue/harness.cpp diff --git a/dev/perf/spf_queue/results/short/load.txt b/dev/perf/spf_queue/results/short/load.txt deleted file mode 100644 index 0abff49..0000000 --- a/dev/perf/spf_queue/results/short/load.txt +++ /dev/null @@ -1,8 +0,0 @@ -14:41 up 47 days, 19:17, 3 users, load averages: 3.46 3.26 3.16 -14:41 up 47 days, 19:17, 3 users, load averages: 3.34 3.23 3.15 -14:41 up 47 days, 19:17, 3 users, load averages: 3.23 3.21 3.14 -14:41 up 47 days, 19:17, 3 users, load averages: 3.06 3.18 3.13 -14:42 up 47 days, 19:17, 3 users, load averages: 2.89 3.14 3.12 -14:42 up 47 days, 19:17, 3 users, load averages: 2.82 3.12 3.11 -14:42 up 47 days, 19:17, 3 users, load averages: 2.93 3.13 3.11 -14:42 up 47 days, 19:18, 3 users, load averages: 3.18 3.18 3.13 diff --git a/dev/perf/spf_queue/results/short/sweep-bucket2-sparse.csv b/dev/perf/spf_queue/results/short/sweep-bucket2-sparse.csv deleted file mode 100644 index 04079ec..0000000 --- a/dev/perf/spf_queue/results/short/sweep-bucket2-sparse.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -wide1,16384,131072,16,none,bucket2-sparse,0,2,37.299375,9,37.058584;37.299375;37.285416;37.232875;37.307000;36.957291;39.781167;38.166292;37.387791 -wide31,16384,131072,16,none,bucket2-sparse,0,32,36.781667,9,36.781667;36.675875;36.531209;36.388875;37.259583;36.938000;36.847333;36.702833;36.799709 -wide1023,16384,131072,16,none,bucket2-sparse,0,1024,34.703667,9,34.732500;34.721584;34.703667;34.777416;34.599666;34.279333;34.665333;34.923625;34.495458 -wide32767,16384,131072,16,none,bucket2-sparse,0,32768,35.819209,9,36.423291;36.809583;35.480750;35.858125;35.819209;35.829750;35.007125;35.601084;35.079583 -wide1048575,16384,131072,16,none,bucket2-sparse,0,1048570,47.752208,9,47.993250;46.520667;47.752208;46.565125;48.841959;47.888041;46.563958;47.450625;48.453833 -wide33554431,16384,131072,16,none,bucket2-sparse,1,33554196,56.532583,9,56.001958;56.304709;57.229250;55.514583;56.225541;56.869458;56.854875;58.679041;56.532583 diff --git a/dev/perf/spf_queue/results/short/time-bucket-hybrid-b.csv b/dev/perf/spf_queue/results/short/time-bucket-hybrid-b.csv deleted file mode 100644 index 3840549..0000000 --- a/dev/perf/spf_queue/results/short/time-bucket-hybrid-b.csv +++ /dev/null @@ -1,9 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,16384,131072,64,none,bucket-hybrid,0,32,142.547666,9,141.814042;142.235333;142.547666;145.159000;142.753125;142.732000;141.346416;141.522875;143.547708 -random,65536,524288,1,none,bucket-hybrid,0,32,10.798875,9,10.798875;10.912625;10.936292;10.787250;10.552875;10.718083;10.778958;11.122250;10.962833 -clos,800,320000,64,none,bucket-hybrid,0,2,142.141958,9,142.959250;140.614458;141.870500;145.317042;141.619250;142.141958;142.838250;141.910292;143.447083 -chain,2048,2047,1,none,bucket-hybrid,0,32,0.040834,9,0.040834;0.041125;0.040458;0.040583;0.040750;0.040875;0.040958;0.041167;0.040792 -random,16384,131072,64,near,bucket-hybrid,0,32,0.389791,9,0.389542;0.412500;0.387250;0.390875;0.394083;0.387292;0.386500;0.389791;0.392209 -random,65536,524288,64,near,bucket-hybrid,0,32,1.484459,9,1.484459;1.473000;1.474459;1.487292;1.477583;1.475709;1.498208;1.484625;1.489750 -random,262144,2097152,16,near,bucket-hybrid,0,32,1.442292,9,1.459500;1.451458;1.432125;1.436708;1.440500;1.442292;1.450166;1.442209;1.446667 -random,262144,2097152,1,none,bucket-hybrid,0,32,84.464083,9,85.850916;89.099833;91.374917;84.464083;85.462917;82.968792;82.194042;83.954375;82.687375 diff --git a/dev/perf/spf_queue/results/short/time-bucket-sparse-b.csv b/dev/perf/spf_queue/results/short/time-bucket-sparse-b.csv deleted file mode 100644 index 9af8e2a..0000000 --- a/dev/perf/spf_queue/results/short/time-bucket-sparse-b.csv +++ /dev/null @@ -1,9 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,16384,131072,64,none,bucket-sparse,0,32,146.151375,9,145.365875;146.581375;147.046709;145.907750;145.879916;147.234791;146.151375;147.714292;145.136625 -random,65536,524288,1,none,bucket-sparse,0,32,10.988416,9,11.044125;10.804042;10.830375;10.933083;11.017500;10.988416;11.095125;10.766667;11.091958 -clos,800,320000,64,none,bucket-sparse,0,2,141.707584,9,141.602916;142.928875;141.394458;141.996958;144.307250;141.192500;141.707584;141.517500;141.947458 -chain,2048,2047,1,none,bucket-sparse,0,32,0.041792,9,0.037042;0.038417;0.041917;0.041792;0.041625;0.041625;0.041792;0.042292;0.046542 -random,16384,131072,64,near,bucket-sparse,0,32,0.386666,9,0.386666;0.385417;0.392792;0.381959;0.383250;0.387625;0.390833;0.383792;0.392417 -random,65536,524288,64,near,bucket-sparse,0,32,1.439458,9,1.416958;1.482792;1.507250;1.425667;1.424709;1.541000;1.429417;1.439458;1.523084 -random,262144,2097152,16,near,bucket-sparse,0,32,1.455000,9,1.455000;1.447125;1.366958;1.453084;1.511791;1.458042;1.451667;1.496875;1.467875 -random,262144,2097152,1,none,bucket-sparse,0,32,92.620833,9,93.362750;91.775833;96.660417;93.237000;98.978917;92.009125;92.620833;89.293292;88.834333 diff --git a/dev/perf/spf_queue/results/short/time-bucket2-fresh-b.csv b/dev/perf/spf_queue/results/short/time-bucket2-fresh-b.csv deleted file mode 100644 index e68bded..0000000 --- a/dev/perf/spf_queue/results/short/time-bucket2-fresh-b.csv +++ /dev/null @@ -1,9 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,16384,131072,64,none,bucket2-fresh,0,32,142.163000,9,140.967209;141.247417;142.163000;141.064542;144.924083;142.519750;142.667958;144.079083;141.456417 -random,65536,524288,1,none,bucket2-fresh,0,32,10.603833,9,10.704708;10.602166;10.760750;10.537584;10.620416;10.371667;10.603833;10.354041;10.773959 -clos,800,320000,64,none,bucket2-fresh,0,2,141.902750,9,141.549417;141.541042;144.366500;141.535417;142.107375;141.902167;143.362708;142.260042;141.902750 -chain,2048,2047,1,none,bucket2-fresh,0,32,0.035542,9,0.035083;0.035542;0.035458;0.035625;0.046708;0.035375;0.035708;0.035375;0.035834 -random,16384,131072,64,near,bucket2-fresh,0,32,1.336000,9,1.396750;1.399000;1.436458;1.324125;1.275750;1.291083;1.333292;1.336000;1.380375 -random,65536,524288,64,near,bucket2-fresh,0,32,5.261459,9,5.480084;4.884666;5.216500;5.141875;5.405084;5.249708;5.399750;5.261459;5.417583 -random,262144,2097152,16,near,bucket2-fresh,0,32,5.230458,9,5.177375;5.003542;5.108792;7.128292;5.230458;5.224084;5.279209;5.274916;5.299583 -random,262144,2097152,1,none,bucket2-fresh,0,32,86.550500,9,86.550500;86.679625;84.645666;85.720708;83.300834;85.359417;91.528000;91.843500;89.670625 diff --git a/dev/perf/spf_queue/results/short/time-bucket2-hybrid-b.csv b/dev/perf/spf_queue/results/short/time-bucket2-hybrid-b.csv deleted file mode 100644 index 9108b2c..0000000 --- a/dev/perf/spf_queue/results/short/time-bucket2-hybrid-b.csv +++ /dev/null @@ -1,9 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,16384,131072,64,none,bucket2-hybrid,0,32,146.908458,9,149.103333;146.908458;153.407334;151.466584;148.761833;142.588416;143.540292;144.859666;144.793083 -random,65536,524288,1,none,bucket2-hybrid,0,32,11.742542,9,11.742542;12.280833;12.336916;12.553708;11.515250;11.836375;11.498792;11.367084;11.614458 -clos,800,320000,64,none,bucket2-hybrid,0,2,144.050750,9,144.281250;143.780042;142.801334;144.050750;143.585833;145.442250;145.965375;144.048125;146.001791 -chain,2048,2047,1,none,bucket2-hybrid,0,32,0.038334,9,0.038542;0.038042;0.038458;0.038334;0.038291;0.038416;0.038292;0.038291;0.038500 -random,16384,131072,64,near,bucket2-hybrid,0,32,0.393166,9,0.394125;0.401459;0.403000;0.392291;0.392875;0.398375;0.392250;0.393166;0.392334 -random,65536,524288,64,near,bucket2-hybrid,0,32,1.498541,9,1.497833;1.510291;1.492042;1.498541;1.516292;1.486791;1.499625;1.487583;1.511375 -random,262144,2097152,16,near,bucket2-hybrid,0,32,1.411375,9,1.340875;1.415917;1.435625;1.349125;1.366625;1.403875;1.422208;1.411375;1.436292 -random,262144,2097152,1,none,bucket2-hybrid,0,32,84.643541,9,83.597292;84.989084;83.386458;85.441959;83.921208;83.676792;84.643541;86.611042;85.450250 diff --git a/dev/perf/spf_queue/results/short/time-bucket2-sparse-b.csv b/dev/perf/spf_queue/results/short/time-bucket2-sparse-b.csv deleted file mode 100644 index 705d100..0000000 --- a/dev/perf/spf_queue/results/short/time-bucket2-sparse-b.csv +++ /dev/null @@ -1,9 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,16384,131072,64,none,bucket2-sparse,0,32,147.686917,9,147.830750;147.531417;147.372209;147.061958;149.109292;149.496875;147.970583;146.964417;147.686917 -random,65536,524288,1,none,bucket2-sparse,0,32,11.416459,9,11.431292;11.416459;11.210667;11.834542;11.816958;11.380167;11.199083;11.488292;11.214833 -clos,800,320000,64,none,bucket2-sparse,0,2,143.678625,9,144.810291;143.678625;142.837750;142.298541;142.889500;143.704667;143.648000;145.034541;146.672667 -chain,2048,2047,1,none,bucket2-sparse,0,32,0.040916,9,0.037208;0.039334;0.040833;0.040916;0.041000;0.041041;0.041042;0.041083;0.039667 -random,16384,131072,64,near,bucket2-sparse,0,32,0.398333,9,0.403250;0.411209;0.398333;0.395750;0.391458;0.455709;0.478792;0.378000;0.378500 -random,65536,524288,64,near,bucket2-sparse,0,32,1.500166,9,1.500166;1.477167;1.504708;1.495167;1.497542;1.516500;1.489167;1.687291;1.531375 -random,262144,2097152,16,near,bucket2-sparse,0,32,1.450625,9,1.381625;1.413875;1.471417;1.457458;1.450625;1.440000;1.462625;1.466166;1.447083 -random,262144,2097152,1,none,bucket2-sparse,0,32,95.974167,9,100.352250;98.287042;95.974167;94.826000;92.828416;95.038750;93.860083;98.846875;96.269292 diff --git a/dev/perf/spf_queue/results/short/time-heap-hybrid-b.csv b/dev/perf/spf_queue/results/short/time-heap-hybrid-b.csv deleted file mode 100644 index b745303..0000000 --- a/dev/perf/spf_queue/results/short/time-heap-hybrid-b.csv +++ /dev/null @@ -1,9 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,16384,131072,64,none,heap-hybrid,0,32,273.173042,9,273.535959;273.173042;272.987500;274.413708;272.240708;272.831625;272.374041;273.861250;275.296667 -random,65536,524288,1,none,heap-hybrid,0,32,20.333792,9,20.587500;20.226958;20.591334;20.201833;20.277375;19.883125;20.333792;20.459667;20.590542 -clos,800,320000,64,none,heap-hybrid,0,2,135.961625,9,135.924375;135.899834;138.616000;135.521958;136.078250;136.760792;136.773459;135.961625;135.061542 -chain,2048,2047,1,none,heap-hybrid,0,32,0.024500,9,0.022000;0.023083;0.024500;0.024750;0.025042;0.024750;0.024375;0.024541;0.024500 -random,16384,131072,64,near,heap-hybrid,0,32,0.390458,9,0.387792;0.389541;0.390458;0.390459;0.386875;0.391084;0.392083;0.408167;0.388917 -random,65536,524288,64,near,heap-hybrid,0,32,1.481000,9,1.497167;1.481000;1.481041;1.492750;1.483334;1.396500;1.409125;1.445125;1.464084 -random,262144,2097152,16,near,heap-hybrid,0,32,1.456709,9,1.430542;1.456709;1.462583;1.456834;1.475000;1.445459;1.443833;1.464333;1.447459 -random,262144,2097152,1,none,heap-hybrid,0,32,131.476542,9,131.909875;131.235750;129.497458;131.023292;131.906417;132.037875;132.782167;130.478583;131.476542 diff --git a/dev/perf/spf_queue/results/short/time-ref-a1.csv b/dev/perf/spf_queue/results/short/time-ref-a1.csv deleted file mode 100644 index 4a3fb44..0000000 --- a/dev/perf/spf_queue/results/short/time-ref-a1.csv +++ /dev/null @@ -1,9 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,16384,131072,64,none,ref,0,32,240.574042,9,239.941250;243.446250;243.989666;240.574042;239.140083;241.751209;239.183292;241.564583;239.675084 -random,65536,524288,1,none,ref,0,32,18.512292,9,18.477250;18.512292;18.502583;18.579334;18.885459;18.153333;18.186375;18.595458;18.531083 -clos,800,320000,64,none,ref,0,2,137.762333,9,137.476083;137.589542;141.538542;139.405833;137.762333;136.658917;138.521917;138.898375;137.596916 -chain,2048,2047,1,none,ref,0,32,0.020583,9,0.020583;0.023083;0.019750;0.018500;0.018500;0.020166;0.020708;0.020667;0.020875 -random,16384,131072,64,near,ref,0,32,1.046583,9,1.099292;1.076792;1.031625;1.002583;1.015500;1.046583;1.051792;1.043291;1.053416 -random,65536,524288,64,near,ref,0,32,4.206375,9,4.206375;4.798000;5.190875;4.624958;4.175583;4.034417;4.407375;4.000666;4.178959 -random,262144,2097152,16,near,ref,0,32,3.913459,9,3.901625;3.917291;3.924083;3.913459;3.900458;3.938041;3.915958;3.895375;3.909083 -random,262144,2097152,1,none,ref,0,32,119.533542,9,120.662833;117.989875;119.298416;120.215542;117.608500;119.533542;119.545958;118.972500;129.493667 diff --git a/dev/perf/spf_queue/results/short/time-ref-a2.csv b/dev/perf/spf_queue/results/short/time-ref-a2.csv deleted file mode 100644 index 1c7b4be..0000000 --- a/dev/perf/spf_queue/results/short/time-ref-a2.csv +++ /dev/null @@ -1,9 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random,16384,131072,64,none,ref,0,32,245.430000,9,248.316125;247.171583;244.164041;245.430000;244.551542;243.334791;244.229459;247.001041;245.512083 -random,65536,524288,1,none,ref,0,32,18.767000,9,18.134292;18.544916;18.387041;18.409583;18.924417;18.847500;18.767000;18.922875;18.988125 -clos,800,320000,64,none,ref,0,2,141.644542,9,141.412958;141.113542;141.644542;140.970292;142.218375;143.736166;140.296792;143.137291;142.293208 -chain,2048,2047,1,none,ref,0,32,0.023334,9,0.020709;0.022041;0.021000;0.023375;0.023334;0.024250;0.024042;0.023875;0.023333 -random,16384,131072,64,near,ref,0,32,1.104167,9,1.087125;1.084917;1.086542;1.119541;1.148958;1.118583;1.104167;1.099708;1.144750 -random,65536,524288,64,near,ref,0,32,4.305042,9,4.331750;4.193667;4.377625;4.208583;4.354542;4.137708;4.409417;4.166125;4.305042 -random,262144,2097152,16,near,ref,0,32,19.192541,9,19.290125;19.192541;19.075667;18.905375;19.081709;19.091959;19.239083;19.242709;19.201542 -random,262144,2097152,1,none,ref,0,32,124.229000,9,122.850416;129.890541;123.901291;123.352166;131.279125;139.409250;136.247250;124.229000;122.618125 diff --git a/dev/perf/spf_queue/review-2026-10/BRIEF.md b/dev/perf/spf_queue/review-2026-10/BRIEF.md deleted file mode 100644 index 6c200b5..0000000 --- a/dev/perf/spf_queue/review-2026-10/BRIEF.md +++ /dev/null @@ -1,79 +0,0 @@ -# Critical review brief: SPF optimisation in NetGraph-Core - -You are an independent, sceptical performance reviewer. Repository root is the -current directory (NetGraph-Core, C++20 core + pybind11). The working tree has -an UNCOMMITTED change on top of commit `fbab119` that replaces the Dijkstra -frontier in `src/shortest_paths.cpp` with a monotone bucket queue (active bucket -kept as a heap on `(-bottleneck, node)` so pop order is identical to the old -binary heap), plus a thread-local scratch workspace with touched-node tracking -and a hybrid reset. Read, in this order: - -1. `dev/perf/spf_queue/PLAN.md` (the plan: decision, - experiment, implementation status, old/new/old measurements, trade-offs). -2. `dev/perf/spf_queue/review-2026-10/change.diff` - (the exact production + test diff) and the live files `src/shortest_paths.cpp`, - `src/strict_multidigraph.cpp`, `include/netgraph/core/shortest_paths.hpp`. -3. `dev/perf/spf_queue/README.md` and the harness - `spf_variants.hpp` / `harness.cpp` (plain C++; `bash dev/perf/spf_queue/build.sh` - builds `build/perf/cpu-queue`; `build/perf/cpu-queue-old` - is the same harness built from the previous sources; `time ref 7`, - `short ref 7`, `sweep ref 7 2097152`, `correctness` are the modes). -4. Callers: `src/max_flow.cpp`, `src/flow_state.cpp` (place_max_flow), - `src/flow_policy.cpp` (get_path_bundle, memo), `src/k_shortest_paths.cpp` - (its own `dijkstra_single` and spur searches), `src/cpu_backend.cpp`. -5. Context memos: `dev/research/metal_pathfinding/optimization/README.md` (GPU research) - and the measured dead ends listed in CPU_PLAN.md ("do not retry"). - -## Your task - -Think hard and answer: **were all easily achievable and reasonable -optimisations considered and verified?** Specifically: - -- Cheap wins inside the new code that were missed: the bucket queue (bucket - advance, bitmap, per-pop overhead, item layout, the chain-graph regression, - width limit), the heap route (the recorded 5–9% fallback regression and its - real cause), the workspace (reset policy, output materialisation, the O(N) - output floor), predecessor-list construction and the PredDAG conversion, - early-exit handling, the reverse search, the cost/gcd metadata. -- Cheap wins around the search that were not touched: how max-flow, - place_max_flow and FlowPolicy call SPF (repeated full searches, residual - recomputation, memoisation, early termination), KSP's private - `dijkstra_single`, graph construction (CSR layout, edge ordering by cost), - the Python binding path (copies, GIL), batch/thread usage. -- Whether the measurements support the claims: A/B/A design, noise, the - `W_max = 65536` choice, the sweep, the NetGraph-level workloads - (`cpu_queue/bench_netgraph.py`) and whether they represent realistic - NetGraph use; anything the author may have rationalised away. -- Correctness or robustness risks that an optimisation reviewer should flag: - thread_local lifetime and memory bound, exception paths, reentrancy, - int64 overflow in bucket arithmetic, graphs with zero edges, Python threads. - -## Rules - -- Be concrete and sceptical. Cite file paths and line numbers. Do not restate - the plan; find what it misses or gets wrong. -- Verify, don't speculate: build the harness and run experiments where a claim - can be checked in minutes (old binary vs new binary, forced heap via - `NGRAPH_CORE_SPF_QUEUE=heap`, fresh processes, several samples, record load - with `uptime`). Treat differences under ~5% as noise unless reproduced. -- Do NOT modify tracked files. Put every experiment, patch (as `.patch` files) - or script under `dev/perf/spf_queue/review-2026-10/`. - You may build into `build/`. Do not run `git commit`, `git stash`, `git - checkout`, or anything that changes the working tree outside that directory. - The Python venv is `venv/`; NetGraph lives in `/Users/networmix/ws/NetGraph` - (read-only for you). Do not run the full test suites; they already passed. -- Other sessions share this machine; keep runs short and sequential. - -## Report (write it to `dev/perf/spf_queue/review-2026-10/REPORT.md`, in English) - -1. A ranked list of findings. For each: title; what exactly is missed or wrong - (with evidence); expected gain and the workload class it applies to; cost - and risk; verdict as one of VERIFIED-GAIN (you measured it), PLAUSIBLE- - UNTESTED (why you could not verify), REFUTED (you measured no gain), - ALREADY-COVERED (the plan handles it; say where). -2. For every VERIFIED-GAIN: the exact patch (file + diff) and the numbers - (old / new / your variant, samples, load). -3. Measurement critique: what in the recorded A/B/A would you not trust, and - what would you rerun. -4. A short verdict: is the shipped approach the right streamlined choice, and - what (if anything) should be done before committing it. diff --git a/dev/perf/spf_queue/review-2026-10/REPORT.md b/dev/perf/spf_queue/review-2026-10/REPORT.md deleted file mode 100644 index 195d4b9..0000000 --- a/dev/perf/spf_queue/review-2026-10/REPORT.md +++ /dev/null @@ -1,280 +0,0 @@ -# Independent review of the SPF optimisation - -Reviewed 2 October 2026 against `fbab119e794c843838f4f3f96d75cb7f32f06845` and the supplied uncommitted diff. Line references below refer to the live, unmodified production files, not the experimental copies. - -**No: the main algorithm choice is sensible, but several small, worthwhile changes were neither considered nor verified.** I measured gains in neighbour processing, predecessor output construction, singleton-frontier handling, and fixed-routing callers. More importantly, I reproduced a new exception-safety defect and disproved the stated retained-memory bound. The weighted-graph speedup itself survives independent measurement. - -All experimental sources, patches, scripts and results are in this directory; executables/objects are under `build/perf/`. All 97 tracked files were checked byte-for-byte against the review-start snapshot; none changed ([verification](results/tracked-verification.txt)). No full repository suite, commit, checkout, stash, or downstream source modification was performed. - -## 1. Ranked findings - -### 1. A failed touched-list allocation corrupts the next SPF call - -**Verdict: VERIFIED-GAIN — correctness restoration, not a latency claim. Priority: fix before committing.** - -`src/shortest_paths.cpp:726–728` writes the source distance and bottleneck **before** the potentially allocating `touched.push_back`. Reverse SPF does the same at `:932–934`. If that allocation throws, the lease runs, but sparse cleanup at `:433–436`/`:475–479` has no record of the modified node. The next same-sized search can suppress a valid relaxation against the stale zero distance. Sparse output materialisation then reports the reachable node as unreachable. - -[Reproducer](robustness.cpp), [public old-build reproducer](fault-public.cpp), and [exact fix](exception-fix.patch), affecting those two blocks in `src/shortest_paths.cpp`: - -| Eight-node, one-edge graph; inject one four-byte allocation failure, then search from/to the other endpoint | Old | New | Fix | -|---|---:|---:|---:| -| Forward distance to the reachable node | 1 | `INT64_MAX` | 1 | -| Reverse distance from the reachable node | 1 | `INT64_MAX` | 1 | - -Raw outputs: [old](results/fault-old.txt), [new](results/fault.txt), [fix](results/fault-fixed.txt). The patch records the source/target first, then performs the nonthrowing writes. It does not change successful-call work or outputs. Cost/risk: two statement reorderings; very low. The existing fanout-throw test at `tests/cpp/shortest_paths_tests.cpp:641–668` does not exercise this failure point. The comment claiming restoration on *every* exit at `src/shortest_paths.cpp:497` is presently false. - -### 2. Do not recompute an identical SPF inside fixed-routing max-flow calls - -**Verdict: VERIFIED-GAIN.** - -`src/max_flow.cpp:123–151` and `src/flow_state.cpp:537–552` repeatedly call SPF. With `require_capacity=false`, they pass no residual; graph, masks, endpoints and selection remain identical. The next placement still needs current residuals, but the next SPF does not. This is a particularly simple caller optimisation, independent of the queue. It is unnecessary when `shortest_path=true`, which already stops after one successful tier. - -[Exact patch](fixed-routing.patch) caches the result **within the invocation**, in both files, only re-searching when capacity-aware routing is enabled. It does not cache changing residual-aware routes. On eight weighted 4,096-node/32,768-edge queries, seven samples per fresh process: - -| Workload | Old A1 / A2, ms | New A1 / A2, ms | Reuse variant, ms | Reduction from new | -|---|---:|---:|---:|---:| -| `place_max_flow`, fixed routing | 5.757 / 5.646 | 3.857 / 3.784 | 2.134 | 44–45% | -| Same, node/edge exclusions | 4.255 / 4.150 | 3.090 / 2.951 | 1.693 | 43–45% | -| `calc_max_flow`, exclusions | 6.032 / 6.033 | 4.942 / 4.846 | 3.531 | 27–29% | -| `calc_max_flow`, no exclusions | 7.105 / 6.903 | 5.139 / 4.972 | 4.486 | Unstable samples; do not use for a precise claim | - -The unmasked `calc_max_flow` variant samples span 3.851–5.648 ms; I am explicitly not treating its median as equally strong evidence. The other gains also appeared in the screening pass. The accepted rerun fixes all query endpoints in the masks before warmup; the earlier screening files are retained and labelled `screen-flow-*`. - -Evidence: [driver](flow-driver.cpp), [build/run script](build-flow.sh), [old A1](results/flow-old1.csv), [new A1](results/flow-new1.csv), [variant](results/flow-fixed.csv), [new A2](results/flow-new2.csv), [old A2](results/flow-old2.csv), [load](results/load-flow.txt): 1-minute load 4.46–4.76. Output hashes agree; capacity and conservation checks run outside timing. See section 2 for stronger flow validation. - -Expected applicability: fixed IP/IGP routing through these helpers, with repeated placement enabled. No promised improvement for residual-aware default max-flow or the recommended single-pass IP-ECMP combination. Cost/risk: small local lifetime extension; review the old result's extra lifetime during replacement on the residual-aware route. Keep this as a separate caller patch if the SPF change is to remain narrowly scoped. - -### 3. Skip settled neighbours before collecting their parallel edges - -**Verdict: VERIFIED-GAIN.** - -At `src/shortest_paths.cpp:583–640` and `:832–881`, the code collects eligible edges and computes their bottleneck even when the neighbour is settled. With nonnegative costs, a settled neighbour cannot receive a shorter path; equal-cost predecessor changes and single-path capacity improvements are already prohibited for settled neighbours at `:644–655`/`:884–895`. Skipping its contiguous neighbour run early preserves the existing outputs. - -[Exact patch](settled.patch): two condition changes in `src/shortest_paths.cpp`. This is not a different tie-break, cached eligibility policy, or new queue. - -| Dense unit fabric, 800 nodes / 320,000 edges, 16 full-source queries | Old A1 / A2 | New A1 / A2 | Skip-settled | -|---|---:|---:|---:| -| Pass A, ms | 36.784 / 36.827 | 34.775 / 35.326 | 30.552 | -| Pass B, ms | 36.005 / 36.967 | 35.225 / 36.691 | 29.811 | -| Reverse search, 16 targets, ms | 48.257 / 46.690 | 46.923 | 37.042 | - -Forward improvement is 12–19% against the new code; reverse improvement is 21%. Weighted random graphs do **not** establish a gain: pass B is 36.487 versus 36.437/36.404 ms. Do not advertise this as a universal improvement. The extra branch could be unfavourable in other graph families. - -Evidence: `results/{old-a1,new-a1,settled,new-a2,old-a2}.csv`, the corresponding `*-b*` files, and `results/reverse-*.csv`; [first load log](results/load.txt), [second load log](results/load-second.txt). Loads during these forward phases were 3.53–4.42; reverse phases 4.67. Full forward old-loop comparison: 1,351,168 comparisons, zero failures. Reverse hashes agree with the separately built old implementation; targeted sanitizers and the cross-caller bitgate also pass. - -Cost/risk: very small implementation, low semantic risk after nonnegative-weight proof and parity checks. This directly challenges the suggestion at `CPU_PLAN.md:150–155` that edge-scanning dominance makes unit fabrics merely neutral: some of that edge processing is avoidable. - -### 4. Forward PredDAG conversion need not walk every surviving list twice - -**Verdict: VERIFIED-GAIN.** - -`src/shortest_paths.cpp:679–700` walks each predecessor list to count entries, prefixes the counts, sizes/initialises two output arrays, and walks the same lists again to fill them. On dense equal-cost DAGs this is measurable work, not an irreducible result-size floor. - -[Exact patch](append.patch) keeps the sparse path unchanged. For the dense case it reserves the number of recorded entries as an upper bound, appends surviving entries once in node/list order, and writes each final offset immediately. It preserves parent ordering and all four output arrays. - -| Same dense forward workload as finding 3 | Old A1 / A2, ms | New A1 / A2, ms | One-pass conversion, ms | -|---|---:|---:|---:| -| Pass B | 36.005 / 36.967 | 35.225 / 36.691 | 29.755 | -| Pass C | 37.396 / 37.468 | 35.800 / 35.380 | 31.159 | - -This is a reproduced 12–19% reduction. Random-graph changes remain below the review's significance threshold. Combined with settled-neighbour skipping, the dense row is 26.327 ms versus 35.800/35.380 ms, about 26% lower; that combined timing is one phase, not a separately replicated universal claim. - -Evidence: [patch](append.patch), [combined patch](combined.patch), `results/{append,append-c,combined}.csv`, [pass C load](results/load-third.txt). Pass B load was 3.53; pass C was busier, 7.93–8.46. The individual improvement survives both passes. [Forward comparison](results/correctness-append.txt): 1,351,168 comparisons, zero failures. - -Expected applicability: dense multipath outputs with many live predecessor entries. Cost/risk: small conversion change, but reserving *all* recorded entries can retain excess space in the returned vectors when many predecessors were superseded. This must be weighed against the exact-sized current output; it is not a free memory improvement. Reverse conversion is a scatter by successor and requires ascending-tail order (`:981–1015`), so this exact patch cannot simply be copied there. - -### 5. The chain regression is partly avoidable without another search algorithm - -**Verdict: VERIFIED-GAIN.** - -For a frontier with one item, `BucketQueue` still divides/modulos its key, sets/clears occupancy, advances the cursor and touches a vector/heap (`src/shortest_paths.cpp:329–362`). The plan tried modulo masking and accepted the regression; that does not test avoiding the bucket machinery altogether for an isolated item. - -[Exact patch](inline.patch) stores a lone newly pushed item inline, promoting it into the existing buckets if a second item arrives. After ordinary bucket operation it only re-enters this mode when the frontier empties. Keys, queue selection and pop order are unchanged. This is not retrospective dispatch to a second Dijkstra implementation. - -| Chain: 2,048 nodes, 256 queries with varying sources | Old A1 / A2, ms | New A1 / A2, ms | Inline item, ms | -|---|---:|---:|---:| -| Pass B | 2.929 / 3.114 | 5.232 / 5.462 | 3.541 | -| Pass C | 3.039 / 3.115 | 5.607 / 5.569 | 3.618 | - -Reduction from the new bucket route: 32–35%. It still trails the old implementation by about 14–21% in these passes. These are 256 varying-length chain queries, **not** the plan's single full-chain query; do not quote the whole batch as single-query latency. Other focus rows show no reproduced material gain or regression. - -Evidence: `results/{inline,inline-c}.csv`, the B/C controls above, and [correctness](results/correctness-inline.txt): 1,351,168 comparisons, zero failures. Same load ranges as finding 4. A smaller experiment retaining just a sole item's bucket location ([singleton.patch](singleton.patch)) gave only about 9% in the first chain pass; bypassing the bucket storage is the useful version. - -Cost/risk: modest queue-state complexity; promotion after zero-cost pushes and cyclic cursor transitions deserve explicit tests. Applicability includes narrow-frontier portions of ordinary graphs, not just graphs that are globally chains. There is no workload evidence behind the plan's categorical statement that no production caller runs on a path graph (`CPU_PLAN.md:287–292`). - -### 6. Retained memory is not bounded by one largest-graph workspace - -**Verdict: PLAUSIBLE-UNTESTED for the reclamation optimisation; the counterexample itself is verified. Priority: correct the bound before committing.** - -Only the fixed node arrays go through `ensure_sized` (`src/shortest_paths.cpp:402–405,424–430,465–472`). Entry arrays, touched/selection buffers, heap storage, bucket headers and each individual bucket's item capacity retain their high-water marks. Both directional workspaces coexist per thread (`:494–495`). - -The stronger problem is the **sum of independent bucket high-water marks**. [Memory probe](robustness.cpp), [output](results/memory.txt): with 63 graphs, each having only 1,027 nodes and 1,026 edges and a maximum live frontier of 1,024 items, retained bucket-item capacity rises from **16,400 bytes to 1,032,208 bytes**. A subsequent two-node graph shrinks `dist.capacity()` to two but retains all 1,032,208 bucket bytes. - -Thus `:260–264` is misleading and `CPU_PLAN.md:368–369` accounts only for empty headers/bitmap. A conservative bound is proportional to `W_max × historical maximum queued items`, plus node/entry/heap buffers, **per direction per live thread**, rather than just the largest simultaneous frontier. The multiplier can be much more important than the 1.6 MB header number. Entries and heap capacity also survive a graph-size shrink. - -Expected benefit: memory reclamation after heterogeneous graphs or worker-pool traffic; no measured latency gain. Cost/risk: add a total retained-byte budget or reclaim bucket/item storage on substantial graph-size shrink; avoid destroying hot capacities every query. I did not benchmark such a policy because choosing a memory budget requires a workload trade-off; pretending that `W_max` already supplies it would be incorrect. Thread exit does free the TLS vectors; long-lived Python/executor threads are the relevant retention case. - -### 7. The fallback regression is real in some rows, but its “workspace cost” explanation is not established - -**Verdict: PLAUSIBLE-UNTESTED for the remaining causal attribution. Specific cheap remedies below are REFUTED as material gains.** - -`CPU_PLAN.md:156–162` converts an unresolved difference into “the cost of the reusable workspace.” The new route does extra touched-list work, copies dense distances (`src/shortest_paths.cpp:675`), and resets arrays after copying (`:438–443`); these are candidates, not a measured attribution. The three previously attempted mitigations were not repeated. - -I isolated three other possibilities, using the same production loop: - -| Pass A/B, 16 full queries | New forced heap | Experiment | Outcome | -|---|---:|---:|---| -| Random 16k, remove touched tracking and force dense handling | 65.364 / 66.960 ms | 64.784 ms | Under 5%; no established full-search gain | -| Wide ~2^20 costs, same ablation | 52.299 / 53.113 ms | 51.040 ms | Under 5%; insufficient to attribute the gap | -| Near-target queries, same ablation | 0.399 / 0.408 ms | 1.233 ms | Destroys the intended workspace benefit | -| Wide ~2^20, explicit heap item/comparator | 52.299 / 53.113 ms | 55.679 ms | No gain | -| Wide ~2^20, copy heap front before pop, like old priority_queue | 52.248 ms | 52.007 ms | Noise | - -Patches: [notouch](notouch.patch), [heapitem](heapitem.patch), [frontpop](frontpop.patch). The 24-byte heap item versus 16-byte bucket item is a genuine layout difference; merely replacing the tuple with an explicitly ordered struct did not help. Do not narrow the double bottleneck or int64 distance to pack a key. - -The quarter-touched threshold at `src/shortest_paths.cpp:399` is not established as the crossover: the reported workload matrix mostly samples very sparse or nearly full searches. Reset and output materialisation need not have the same optimal threshold; reverse sparse conversion additionally sorts touched nodes (`:986–987`). Intermediate touched fractions, locality and graph-size churn remain unmeasured. This is a plausible tuning opportunity, not a verified gain, and the no-touch ablation is not a threshold sweep. - -Fresh old/new controls vary: pass B wide ~2^20 is 48.972/50.562 ms old versus 53.175/52.534 ms new; pass A is 50.820/52.036 versus 53.541/53.120. The boundary run at the same broad cost range is 25.107/25.151 versus 27.498 ms for eight queries. These support a workload-dependent penalty, sometimes clearly over 5%, not a fixed universal 5–9% tax. Cost/risk of further attribution: targeted search/finish/reset profiling on a quieter machine. Expected recoverable percentage remains unknown. Neither “noise” nor “inevitable” is an adequate root cause. - -### 8. `W_max=65536` is conservative and useful, not a verified optimal or portable threshold - -**Verdict: PLAUSIBLE-UNTESTED for the remaining bitmap/cleanup refinements.** The bounded-dispatch design and deferred wider-queue alternatives are already covered by the plan. - -The recorded sweep jumps from `W=32768` to approximately `2^20`; it never measures the chosen boundary (`cpu_queue/harness.cpp:339–341`). Passing `2097152` as harness `maxW` does **not** change the production `ref` route's compile-time limit: `Runner::run` immediately calls `run_ref` (`:147`), while `src/shortest_paths.cpp:270,368–393` decides actual dispatch. The CSV's harness `fallback` field therefore is not a production fallback counter. - -I added the missing near-boundary rows, seven samples, 16,384 nodes/131,072 edges: - -| Actual W | Old A1 / A2, ms | New auto, ms | New forced heap, ms | -|---:|---:|---:|---:| -| 32,768; eight full queries | 25.959 / 26.272 | 17.628 | 27.216 | -| 65,535; eight full queries | 25.433 / 25.761 | 18.147 | 26.350 | -| 65,536; eight full queries | 25.641 / 26.609 | 18.499 | 27.175 | -| 65,537; eight full queries | 25.659 / 25.454 | 26.491 | 26.865 | -| 65,535; 64 near queries | 1.131 / 1.123 | 0.455 | 0.390 | -| 65,536; 64 near queries | 1.171 / 1.149 | 0.435 | 0.399 | - -[Raw boundary files](results/boundary-new1.csv), corresponding old/heap controls, and [load](results/load-second.txt), 4.42–4.67. The boundary supports full-search acceleration on this M4; near queries pay a 9–17% bucket overhead relative to the new heap while still beating old substantially. This is evidence against treating W alone as a universal profitability predictor, not a request to add a complex dispatcher immediately. - -The occupancy bitmap is already implemented. However, `settle_active` can scan up to `ceil(W/64)` words (`:349`); `end()` also scans **every word**, including zero ones (`:317`), and `begin()` clears every word (`:312`). “Clear only occupied buckets” does not mean O(number of occupied buckets) total cleanup. A second-level nonempty-word bitmap or touched-word list addresses this overhead; its benefit at small W is unproven. Both workspaces also call both queues' `end()`, so a heap query after a wide bucket query still scans that old bitmap. - -The plan already defers a hierarchical bitmap/radix alternative and x86 verification (`CPU_PLAN.md:367–373,433–438`); these are not omitted discoveries. Do not widen the limit solely from the old full-search sweep, especially before bounding retained item capacity and measuring cold construction, sparse frontiers, masks and graph changes. No second machine was available in this review. - -### 9. An O(N) result floor exists, but the proposed distance-only remedy is misstated; early exit also has a compatibility trap - -**Verdict: PLAUSIBLE-UNTESTED for a target-specific internal result path.** - -`src/shortest_paths.cpp:671–687` necessarily fills N distances and N+1 offsets under the current API. Sparse reset correctly removes scratch initialisation, not these outputs. But a **distance-only vector still has N elements**; it cannot remove the O(N) floor as claimed at `CPU_PLAN.md:454–457`. A scalar target-distance result, compact target subgraph/path, caller-owned output, or a lazy/sparse result contract is needed. The internal callers commonly inspect only `dist[dst]` (`src/max_flow.cpp:142`, `src/flow_policy.cpp:184`), and `place_max_flow` does not use distances at all (`src/flow_state.cpp:543–549`). They are concrete candidates for a narrower internal result interface, without changing Python defaults. Removing the distances alone still leaves the dense DAG offsets. - -Also, the current early-exit code is not simply “process exactly through the target's distance.” Stale-entry `continue` branches at `:566–569` bypass the stop check at `:661`. A stale equal-distance entry behind the target can cause a more expensive node to be expanded. [Six-node reproducer](early-exit.cpp): target cost 1, yet a node reachable only by expanding a distance-2 node gets distance 3. [Old](results/early-old.txt) and [new](results/early-new.txt) both do this. - -Moving the stop test to the loop head would save work, but changes the returned tentative off-target distances/DAG and fails strict old-result parity. Likewise, breaking immediately when the destination is popped or skipping all relaxations beyond its distance is not a drop-in optimisation. Use an explicit narrower internal contract if taking this route. Expected gain: large only when output/unused exploration dominates; not measured. Cost/risk: moderate API/contract work. Bidirectional search/A* and canonical incoming-CSR reconstruction are not equally cheap substitutes under the current ordered-output contract. - -### 10. FlowPolicy already avoids many searches; it still has avoidable copies and post-search rejection - -**Verdict: PLAUSIBLE-UNTESTED for the remaining copy and bounded-search opportunities.** Existing memoisation and shared seeding already cover much of this area. - -Memoisation is not missing: `src/flow_policy.cpp:148–205` matches exact state stamps or residual bytes, and `:434–444` computes the initial bundle once. The header documents a prior 94% repeat rate for the measured EqualBalanced cycle and 0% for Proportional (`include/netgraph/core/flow_policy.hpp:215–228`). This is historical profiling evidence, not a distribution I remeasured here. Residual-aware memoisation must retain exact capacities: unchanged eligibility does not preserve bottleneck ordering or zero-cost DAG choices. - -There is nevertheless a definite redundant copy: after obtaining an owned `dag`, `get_path_bundle` returns `std::make_pair(dag, dst_cost)` at `src/flow_policy.cpp:238`. Moving that local would avoid another full DAG copy on both hit and miss. A hit still needs its own owned result because the cache retains its DAG. Also, `has_min_flow` remains a memo-key discriminator at `:159–160`, although residual awareness now depends solely on `require_capacity_` at `:94–101`, and EB skips the threshold-derived edge mask. Revisit that stale key/comment with a call-count reproducer before changing it. - -Cost ceilings are applied after SPF at `:207–232`; when a bound is already known, an internal bounded-target search could reject earlier. Respect `best_path_cost_` updates and equal-cost/zero-edge behaviour. Expected gain: one DAG copy per bundle return, fewer equivalent-key misses, and less work on rejected high-cost routes; workload speedup unmeasured. Cost/risk: move is trivial; memo-key/ceiling changes require focused semantic tests. Broad new memoisation is not justified by the existing measured Proportional miss rate. I did not build an isolated memo-hit/bundle-return benchmark for this private helper; the source proves the copy, not a measurable end-to-end gain. - -### 11. KSP's main missed cheap cost is not necessarily its private Dijkstra queue - -**Verdict: PLAUSIBLE-UNTESTED for mask/result work.** Deferring private-queue replacement is already justified in the plan. - -`dijkstra_single` is used for the initial path (`src/k_shortest_paths.cpp:204`); its comparator only compares cost (`:61–63`), so substituting SPF's capacity/node key can change first-path selection. Most subsequent spur work calls the newly optimised public SPF (`:283`). Replacing the private queue without profiling may have little impact on k>1. - -Every spur builds byte masks of N/E elements (`:247–267`), then allocates bool arrays and copies them again (`:269–279`). Reuse bool scratch directly, reset only the prefix/edge exclusions that changed, and preserve the base masks. KSP already caps enumeration and rejects candidate families against the cost ceiling (`:289–305`), but the ceiling is checked *after* the complete SPF/DAG result. A bounded internal search could help rejected spurs. Expected benefit: fewer O(N+E) allocations/copies per spur, especially on large graphs and small reachable regions; no percentage established. Cost/risk: moderate scratch-lifetime and ordering work. The one k=8 Clos benchmark does not profile these components or cover long WAN paths and changing k. - -### 12. Graph layout and metadata are not free, but the obvious gcd micro-optimisation is not a demonstrated win - -**Verdict: REFUTED for a material gcd-guard gain in these measurements.** A separate adjacency-local attribute layout remains an untested possibility. - -[One-line gcd patch](gcd.patch) stops calling `std::gcd` once the running gcd reaches 1 (`src/strict_multidigraph.cpp:66`). I timed the **whole constructor**, not just that operation: 16,384 nodes, 131,072 edges, nine samples, old/new/guard/new/old. Random-input construction was 9.060/9.137 ms old, 9.164/9.289 new, 9.122 guarded. Already cost-ordered input was 1.344/1.374 old, 1.430/1.448 new, 1.364 guarded; the repeat was 1.448/1.456 new versus 1.411 guarded. The smaller repeat does not reproduce a >5% gain. Do not sell this as a significant speedup. Output graph hashes agree. Raw files: `results/build-*.csv`, `results/build-repeat-*.csv`; load logs are the B/C logs. - -The graph already has forward and reverse CSR. Global `(cost,src,dst)` edge ordering (`src/strict_multidigraph.cpp:80–108`) means a CSR row gathers cost/capacity through edge IDs, and the comment that *all* parallel edges are consecutive at `src/shortest_paths.cpp:580` is too strong across cost tiers. Cost-first rows are useful, but not the same as CSR-local cost/capacity arrays. Reordering edge IDs or neighbour visitation risks documented edge-ID tie-breaking and predecessor order. An auxiliary CSR-aligned attribute layout could preserve IDs/order but costs memory and still needs edge-ID gathers for residuals/masks. No layout experiment establishes that trade-off here. - -The max/gcd metadata itself is correct and cheap to access, travels with graph copies, handles zero costs, and avoids repeated eligibility scans. A masked-out wide edge can force the whole query onto the heap; computing a tighter per-query profile is not free and needs amortisation over a stable mask. It is not automatically a cheap win. - -### 13. Python copies and threading deserve separate measurements; “release the GIL” is already done - -**Verdict: PLAUSIBLE-UNTESTED for owned-output transfer and worker reuse.** GIL release and bounded existing batches are already implemented. - -`bindings/python/module.cpp:185–206,228–255` copies residuals/masks before releasing the GIL. The copied inputs give the native call a snapshot; blindly borrowing writable NumPy memory would remove that protection while another Python thread runs. The GIL is already released around SPF, reverse SPF, max-flow, KSP and placement. `CpuBackend` forwards spans directly (`src/cpu_backend.cpp:24–37,40–51`); removing its small duplicated validation is not a credible main optimisation. - -Outputs do have extra traffic: the binding allocates/copies or converts the distance vector and passes `res.second` as an lvalue into `py::make_tuple` (`bindings/python/module.cpp:208–217,257–266`). The installed pybind11 caster maps an lvalue under automatic policy to a copy (`venv/lib/python3.14/site-packages/pybind11/include/pybind11/detail/type_caster_base.h:1650–1659`). Move the owned DAG; consider a capsule owning the int64 vector for a zero-copy int64 result. Float64's sentinel conversion still needs a pass. No separately rebuilt pybind extension variant was timed in this review, so the existing-module NetGraph rerun cannot verify these transfer changes. Expected benefit: output-heavy and tiny-search Python calls; not measured, and public ownership/mutability/default-dtype tests are needed. - -`batch_max_flow` already dynamically schedules bounded `std::async` workers (`src/max_flow.cpp:354–379`), and sensitivity has its own bounded workers (`:427–454`). TLS scratch is reusable within those workers but is destroyed when each batch's workers exit. Persistent workers may amortise first-use allocation; nested Python pools plus native batches can oversubscribe the machine. The NetGraph benchmark explicitly uses serial Monte Carlo/scenarios, so it cannot establish throughput or persistent-pool memory behaviour. Sequential augmentations sharing residuals cannot be batched as independent searches. - -### 14. There is another potentially larger dense-placement hotspot outside SPF - -**Verdict: PLAUSIBLE-UNTESTED.** - -`src/flow_state.cpp:174–199` finds unique DAG parents by linear search and then rescans the entire parent list once per unique parent. For a node with P distinct predecessors, this is quadratic parent processing before the placement solve. Preserve first-appearance and edge accumulation order with a reusable indexed grouping/counting structure; do not sort indiscriminately because floating-point accumulation and group order are observable. - -Expected applicability: wide ECMP/Clos DAGs where queue changes hardly move end-to-end placement time. Expected asymptotic improvement is O(P²) to O(P), but I did not measure a latency gain or implement it: maintaining arbitrary caller-supplied PredDAG order and all three placement semantics needs a separate patch and feasibility/conservation/cut checks. Cost/risk: larger than the four small verified performance changes above. This is a concrete profiling target, not evidence that the existing solver can simply be replaced. - -### 15. Arithmetic/empty-graph/Python-thread risks are mostly covered; reentrancy is a separate assumption - -**Verdict: ALREADY-COVERED for ordinary arithmetic, edgeless graphs and independent threads.** Same-thread reentrancy remains a separate, unverified use case. - -The constructor enforces nonnegative costs and total cost below `2^62` (`src/strict_multidigraph.cpp:40–66`). Therefore `max_cost/gcd+1` cannot overflow int64; cursor advancement corresponds to an actual queued nonnegative distance, and its stride step is bounded by an edge-cost window. I found no ordinary signed-overflow counterexample in the bucket arithmetic. All-zero costs give W=1; no-edge graphs need no edge access despite empty residual/mask sizes comparing equal to E=0. - -[Targeted sanitizer probe](safety.cpp) covered exact W=65,535/65,536/65,537, zero costs, costs near the constructor's aggregate bound, N=0 and N>0/E=0, both directions, option combinations, reused workspaces and concurrent C++ threads. It passed under ASan/UBSan. This does not substitute for Python-thread throughput measurements. Independent immutable-graph calls receive independent TLS state; Python input copies and GIL release remain unchanged. - -There is no in-use guard or nested lease stack for `tls_forward`/`tls_reverse` (`src/shortest_paths.cpp:494–501`). A same-direction recursive call on one thread would reset the outer call's workspace. I found no supported callback/reentry path in these synchronous loops; ordinary Python threads are not such a path. Document the non-reentrant assumption or add an in-use fallback if callbacks/custom allocation hooks become supported. Do not present a hypothetical recursive call as a reproduced production failure. - -## 2. Experiments, exact patches and validation coverage - -The reproducible performance patches are linked in findings 2–5; the exception fix is linked in finding 1. Each `.patch` contains the full unified diff against the unmodified live source. Experimental `.cpp` copies let them be built without applying anything to the working tree. - -- [build-review.sh](build-review.sh) creates old sources/headers using read-only `git show fbab119:...` and builds the focused harness variants. Same Apple clang 21, C++20, `-O3 -funroll-loops -fno-math-errno -fno-trapping-math` as the supplied harness. The supplied `build.sh` was also run successfully. -- [driver.cpp](driver.cpp) reuses the supplied generators/query setup and adds focused, reverse and boundary rows. Random16/wide rows contain 16 queries; Clos contains 16; near contains 64; chain contains 256 varying sources. Every phase is a fresh process; two warmups precede seven measured samples. Raw CSVs include all samples. -- [run-review.py](run-review.py), [run-second.py](run-second.py), [run-third.py](run-third.py) record phase order/load and keep old and new controls on both sides of the focus experiments. [environment](results/environment.txt) records compiler/host/source/binary hashes; [final manifest](results/final-sha256.txt) covers later experimental builds and sources. Pass C is deliberately reported as noisier, not quietly substituted for pass B. -- Baseline, skip-settled, inline-item and one-pass-output versions each passed the supplied forward comparison matrix: **1,351,168 comparisons each**, against the preserved old-loop variants, not just distances. These are repeated combinations on a finite set of graphs, not millions of independent graphs. Files: `results/correctness-{baseline,settled,inline,append}.txt`. -- [validate-review.sh](validate-review.sh) builds settled+one-pass output together with the fixed-routing caller patch under **ASan/UBSan**, with sanitizer failures fatal. Its targeted fixture checks nonuniform-cost flow feasibility/conservation, all single-edge exclusions, exhaustive small-graph cut optimality and reported cut capacity, plus FlowPolicy cost factors 1 and 2. [Results](results/validate-flow-variant.txt), the build/run log (not retained). -- The same sanitized combined/caller build passes the existing cross-caller dump: **6,226 lines byte-identical** to the old binary, including forward/reverse/fanout, placement modes, FlowPolicy sequences, batch/sensitivity and KSP. [Hashes](results/bitgate-sha256.txt). Native batch/sensitivity thread budgets were set to 1 for this check to keep load bounded. The inline queue was separately checked by the full forward matrix; it was not included in that combined sanitized build. -- [safety.cpp](safety.cpp) adds the width/int64/edgeless/concurrency probes described above. [Output](results/safety.txt). -- [run-netgraph-review.py](run-netgraph-review.py) reruns four NetGraph workloads with five samples, `PYTHONHASHSEED=0`, bytecode writes disabled, explicitly selected old/new modules. [Module paths/hashes/load](results/netgraph-environment.txt) verify the same extension binaries used by the recorded experiment. No downstream rebuild was needed or performed; these runs measure the existing uncommitted change, **not** my C++ variants. - -NetGraph rerun, medians in ms, identical recorded workload digests in all phases: - -| Workload | Old A1 / A2 | New | New forced heap | -|---|---:|---:|---:| -| Full SPF, Clos 200×200 | 212.167 / 212.584 | 215.551 | 215.680 | -| Full SPF, weighted 100×100 grid | 219.249 / 219.160 | 169.151 | 235.446 | -| Bound-context max-flow, weighted grid combine | 201.715 / 204.482 | 160.344 | 216.545 | -| Backbone Clos placement | 68.333 / 68.736 | 67.171 | 67.445 | - -The weighted benefit is independently reproduced. Sub-5% Clos/placement changes are noise under this review's rule. The broader 16-row scenario suite was read but not rerun. - -## 3. What the measurements do and do not establish - -Already-measured dead ends were not retried: sorting active buckets once, power-of-two masking as a cure for chains, re-hoisting TLS array pointers, re-fixing the function-pointer comparator, and moving growable buffers into locals. None is presented here as a new recommendation. - -1. **The strongest original claims are sound.** A matched old/new/forced-heap/old sequence, result checks and fresh processes are much better than comparing the slower experimental heap copy with its sibling bucket implementation. The weighted Core gain and approximately 1.25–1.30× weighted NetGraph gains survived this review. The small-query workspace gain also survives. GPU ten-worker tables and relaxed canonical-DAG prototypes are not comparable replacements; the plan correctly separates them. - -2. **A/B/A is not machine isolation.** Original Core load ranged 6.5–12.1, and original NetGraph load 3.9–6.5. Medians from consecutive samples within one process do not remove thermal/frequency, scheduler or allocator drift. My own load logs show 3.5–4.7 during the first two passes and roughly 8 during the third. These are useful large-effect confirmations on a shared machine, not the plan's promised quiet-machine acceptance evidence. Do not derive confidence intervals from nine correlated samples as if they were nine isolated process runs. - -3. **The “no unit workload regressed beyond A1/A2 drift” statement is literally too strong.** The recorded backbone placement row is 68.5/67.9 old versus 71.4 new; three-tier placement is 370.7/366.7 versus 381.3 (`CPU_PLAN.md:126–139,150–155`). Both exceed the old bracketing drift while being around the declared noise band. The defensible conclusion is “not a demonstrated regression,” not “within A1/A2 drift.” The first pre-comparator-fix pass is useful context but is not an independent replication of the final binary. - -4. **The sweep samples the wrong dimensions for a universal cutoff.** One positive uniform-random cost distribution, one N/degree and full-source queries cannot establish a universal W limit. Add exact boundary widths, stride>1, all-zero costs, sparse/high-diameter graphs, outlier edges, changing masks, first-query allocation and memory high-water behaviour. This review supplies boundary and some safety rows, not that entire performance matrix. Linux/x86 remains open. A masked wide edge can cause a fallback, rather than “merely wasting empty buckets.” - -5. **All supplied Core timing rows are warmed.** Expected `run_ref` calls occur before timing (`harness.cpp:263`), followed by two warmups (`:266`). This warms the new TLS workspace before its first measured call. All graph construction is outside timing. The timing loop retains the entire batch's output vectors until checking (`:267–271`), unlike callers that immediately consume/discard each result. This matters for allocation/cache pressure and the large-output conversion variant. Add cold first-call and immediate-consumption measurements rather than replacing the warmed numbers. - -6. **The NetGraph workloads are useful software-path coverage, not a representative demand distribution.** They include real APIs, shipped scenarios, pseudo nodes, failures, WCMP/ECMP and KSP. However, the positive result is dominated by a synthetic planar 100×100 grid with costs sampled from 1–20 (`bench_netgraph.py:85–110`). It is not evidence that a typical metric-weighted WAN simulation speeds up 25%; the included NSFNET scenario was already near neutral. Bound contexts are created outside timing (`:174–175,187–188,200–205`), while pairwise grid builds a context inside (`:231–237`); their different gains do not isolate queue effectiveness. Timed Python functions also hash/normalise results and perform NumPy reductions. Serial scenarios/Monte Carlo do not represent parallel worker-pool reuse or oversubscription. - -7. **Digest agreement has limits.** SPF checksums contain only a distance sum and parent count (`bench_netgraph.py:178–196`), so they cannot detect reordered/wrong parents, swapped distances or wrong via-edges. `norm` drops fields named `id` recursively (`:38–51`). Only the last inner repetition's checksum is compared (`:376–381`). Keep the full bitgate/differential evidence separate from these weaker end-to-end digests. Hash-seed pinning is a legitimate correction, but does not turn this into a semantic max-flow certificate. The targeted certificate added here provides a small independent check, not complete application acceptance. - -8. **Reverse P2 was not finished according to its own gate.** `CPU_PLAN.md:426–431` requires a measured reverse result; the plan elsewhere admits none. This review's reverse measurements show 14.188/13.807 → 8.539 ms on random weighted 4k graphs, and 0.402/0.409 → 0.741 ms on chains. Add these to the evidence rather than labelling an unmeasured stage done. - -9. **Rerun priorities:** first the exception reproducer after the production fix; then isolated new/variant/new and old/new/old on dense fabrics, narrow frontiers and wide-cost fallback, with shorter interleaved phase blocks and source hashes. Include cold/warm and immediate-consumption results, realistic caller counts/touched fractions/cost ranges, output size and total per-thread retained bytes. Profile search versus output versus reset to settle the fallback attribution. Finally run the normal project/integration/sanitizer gates after applying any production patches, and CI plus a second CPU architecture. Local measurements and previously passed suites do not replace those checks on the final code. - -## 4. Commit recommendation - -**Keep the ordered bucket queue, exact pop-order contract and sparse workspace strategy. They are a good streamlined baseline. Do not commit this exact tree unchanged.** - -Fix the reproduced allocation-failure contamination first. Correct the memory claim and either bound aggregate retained bucket capacity or explicitly accept and document the actual retention policy. Consider the settled-neighbour skip and one-pass forward output together: their dense-fabric gain is large enough to merit the modest additional review. The inline singleton path and fixed-routing reuse are measured, contained follow-ups; they can be separate commits rather than expanding the queue patch without limit. - -Retain the conservative width limit while documenting that its second-platform and cold/memory acceptance remains open. Record the remaining heap penalty as unexplained rather than an unavoidable workspace tax. There is no justification to block this CPU direction on GPU work, a general batch API, a different KSP algorithm, or a wholesale graph-layout redesign. There is also no evidence for saying that all reasonable cheap wins were exhausted. diff --git a/dev/perf/spf_queue/review-2026-10/append.patch b/dev/perf/spf_queue/review-2026-10/append.patch deleted file mode 100644 index 6c1139b..0000000 --- a/dev/perf/spf_queue/review-2026-10/append.patch +++ /dev/null @@ -1,23 +0,0 @@ ---- a/src/shortest_paths.cpp -+++ b/src/shortest_paths.cpp -@@ -676,6 +676,20 @@ - } - PredDAG dag; - dag.parent_offsets.assign(static_cast(N+1), 0); -+ if (!sparse) { -+ // Upper bound includes obsolete entries, but avoids a second list walk. -+ dag.parents.reserve(ws.ent_parent.size()); -+ dag.via_edges.reserve(ws.ent_edge.size()); -+ for (std::int32_t v = 0; v < N; ++v) { -+ for (auto i = ws.pred_head[static_cast(v)]; i >= 0; -+ i = ws.ent_next[static_cast(i)]) { -+ dag.parents.push_back(ws.ent_parent[static_cast(i)]); -+ dag.via_edges.push_back(ws.ent_edge[static_cast(i)]); -+ } -+ dag.parent_offsets[static_cast(v) + 1] = static_cast(dag.parents.size()); -+ } -+ return {std::move(dist), std::move(dag)}; -+ } - auto count = [&](std::int32_t v) { - std::size_t c = 0; - for (std::int32_t i = ws.pred_head[static_cast(v)]; i >= 0; i = ws.ent_next[static_cast(i)]) ++c; diff --git a/dev/perf/spf_queue/review-2026-10/build-flow.sh b/dev/perf/spf_queue/review-2026-10/build-flow.sh deleted file mode 100644 index 5331fb6..0000000 --- a/dev/perf/spf_queue/review-2026-10/build-flow.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash -set -eu -r=dev/perf/spf_queue/review-2026-10 -flags=(-std=c++20 -O3 -funroll-loops -fno-math-errno -fno-trapping-math) -for v in old new fixed; do - case $v in - old) inc="$r/old-include"; spf="$r/old-shortest_paths.cpp"; graph="$r/old-strict_multidigraph.cpp"; flow=src/max_flow.cpp; state=src/flow_state.cpp;; - new) inc=include; spf=src/shortest_paths.cpp; graph=src/strict_multidigraph.cpp; flow=src/max_flow.cpp; state=src/flow_state.cpp;; - fixed) inc=include; spf=src/shortest_paths.cpp; graph=src/strict_multidigraph.cpp; flow="$r/max_flow-fixed.cpp"; state="$r/flow_state-fixed.cpp";; - esac - clang++ "${flags[@]}" -I"$inc" -Iinclude "$r/flow-driver.cpp" "$spf" "$graph" "$flow" "$state" src/profiling.cpp -o "build/perf/review-flow-$v" -done -for phase in old1 new1 fixed new2 old2; do - case $phase in old*) v=old;;new*)v=new;;*)v=fixed;;esac - uptime >> "$r/results/load-flow.txt" - build/perf/review-flow-$v > "$r/results/flow-$phase.csv" - echo "$phase done" -done diff --git a/dev/perf/spf_queue/review-2026-10/build-review.sh b/dev/perf/spf_queue/review-2026-10/build-review.sh deleted file mode 100644 index 4dc1fe2..0000000 --- a/dev/perf/spf_queue/review-2026-10/build-review.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash -set -eu -cd "$(dirname "$0")/../../../../.." -r=dev/perf/spf_queue/review-2026-10 -flags=(-std=c++20 -O3 -funroll-loops -fno-math-errno -fno-trapping-math) -mkdir -p "$r/old-include/netgraph/core" -for f in shortest_paths strict_multidigraph; do - git show "fbab119:include/netgraph/core/$f.hpp" > "$r/old-include/netgraph/core/$f.hpp" - git show "fbab119:src/$f.cpp" > "$r/old-$f.cpp" -done -clang++ "${flags[@]}" -I"$r/old-include" -Iinclude "$r/driver.cpp" "$r/old-shortest_paths.cpp" "$r/old-strict_multidigraph.cpp" src/profiling.cpp -o build/perf/review-old -for v in baseline settled singleton heapitem notouch inline append frontpop combined; do - clang++ "${flags[@]}" -Iinclude "$r/driver.cpp" "$r/$v.cpp" src/strict_multidigraph.cpp src/profiling.cpp -o "build/perf/review-$v" - echo "built $v" -done diff --git a/dev/perf/spf_queue/review-2026-10/check-and-build.sh b/dev/perf/spf_queue/review-2026-10/check-and-build.sh deleted file mode 100644 index 9abfc41..0000000 --- a/dev/perf/spf_queue/review-2026-10/check-and-build.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash -set -eu -r=dev/perf/spf_queue/review-2026-10 -for v in baseline settled inline; do - build/perf/review-$v correctness > "$r/results/correctness-$v.txt" - echo "correctness $v passed" -done -clang++ -std=c++20 -O3 -funroll-loops -fno-math-errno -fno-trapping-math -Iinclude "$r/driver.cpp" "$r/append.cpp" src/strict_multidigraph.cpp src/profiling.cpp -o build/perf/review-append -for v in old baseline gcd; do - case $v in - old) graph="$r/old-strict_multidigraph.cpp"; inc="$r/old-include";; - baseline) graph=src/strict_multidigraph.cpp; inc=include;; - gcd) graph="$r/gcd.cpp"; inc=include;; - esac - clang++ -std=c++20 -O3 -funroll-loops -fno-math-errno -fno-trapping-math -I"$inc" -Iinclude "$r/construction.cpp" "$graph" -o build/perf/review-build-$v -done diff --git a/dev/perf/spf_queue/review-2026-10/combined.patch b/dev/perf/spf_queue/review-2026-10/combined.patch deleted file mode 100644 index 5b645c3..0000000 --- a/dev/perf/spf_queue/review-2026-10/combined.patch +++ /dev/null @@ -1,41 +0,0 @@ ---- a/src/shortest_paths.cpp -+++ b/src/shortest_paths.cpp -@@ -580,7 +580,7 @@ - // Process edges grouped by destination node (parallel edges are consecutive in CSR). - while (i < end) { - NodeId v = col[i]; -- if (use_node_mask && !node_mask[static_cast(v)]) { -+ if (settled[static_cast(v)] || (use_node_mask && !node_mask[static_cast(v)])) { - std::size_t j_skip = i; while (j_skip < end && col[j_skip] == v) ++j_skip; i = j_skip; continue; - } - -@@ -676,6 +676,20 @@ - } - PredDAG dag; - dag.parent_offsets.assign(static_cast(N+1), 0); -+ if (!sparse) { -+ // Upper bound includes obsolete entries, but avoids a second list walk. -+ dag.parents.reserve(ws.ent_parent.size()); -+ dag.via_edges.reserve(ws.ent_edge.size()); -+ for (std::int32_t v = 0; v < N; ++v) { -+ for (auto i = ws.pred_head[static_cast(v)]; i >= 0; -+ i = ws.ent_next[static_cast(i)]) { -+ dag.parents.push_back(ws.ent_parent[static_cast(i)]); -+ dag.via_edges.push_back(ws.ent_edge[static_cast(i)]); -+ } -+ dag.parent_offsets[static_cast(v) + 1] = static_cast(dag.parents.size()); -+ } -+ return {std::move(dist), std::move(dag)}; -+ } - auto count = [&](std::int32_t v) { - std::size_t c = 0; - for (std::int32_t i = ws.pred_head[static_cast(v)]; i >= 0; i = ws.ent_next[static_cast(i)]) ++c; -@@ -829,7 +843,7 @@ - std::size_t i = start; - while (i < end) { - NodeId u = icol[i]; -- if (use_node_mask && !node_mask[static_cast(u)]) { -+ if (settled[static_cast(u)] || (use_node_mask && !node_mask[static_cast(u)])) { - std::size_t j_skip = i; while (j_skip < end && icol[j_skip] == u) ++j_skip; i = j_skip; continue; - } - Cost min_edge_cost = kInf; diff --git a/dev/perf/spf_queue/review-2026-10/construction.cpp b/dev/perf/spf_queue/review-2026-10/construction.cpp deleted file mode 100644 index afbeaac..0000000 --- a/dev/perf/spf_queue/review-2026-10/construction.cpp +++ /dev/null @@ -1,28 +0,0 @@ -#include "netgraph/core/strict_multidigraph.hpp" -#include -#include -#include -#include -#include -using namespace netgraph::core; -int main() { - for (bool ordered: {false,true}) { - const int n=16384,m=131072; - std::vectors(m),d(m);std::vectorw(m);std::vectora(m,1); - std::mt19937 rng(11); - for(int i=0;i samples; - std::uint64_t checksum=0; - for(int rep=-2;rep<9;++rep){ - auto t=std::chrono::steady_clock::now(); - auto g=StrictMultiDiGraph::from_arrays(n,s,d,a,w); - auto ms=std::chrono::duration(std::chrono::steady_clock::now()-t).count(); - std::uint64_t h=0;for(auto c:g.cost_view())h=h*31+c;for(auto c:g.adj_edge_index_view())h=h*31+c; - if(rep==-2)checksum=h;else if(h!=checksum)return 2; - if(rep>=0)samples.push_back(ms); - } - auto sorted=samples;std::sort(sorted.begin(),sorted.end()); - std::cout<<(ordered?"ordered":"random")<<','<(x);h*=1099511628211ULL;} }; - mix(r.first); mix(r.second.parent_offsets);mix(r.second.parents);mix(r.second.via_edges); - return h; -} -int main(int argc,char**argv) { - std::string mode=argc>1?argv[1]:"focus"; - int samples=argc>2?std::stoi(argv[2]):7; - if(mode=="correctness") return correctness(); - if(mode=="reverse") { - std::cout<<"case,median_ms,hash,samples_ms\n"; - for(auto kind:{"random","clos","chain","zerocyc"}) { - auto c=make_case(kind,std::string(kind)=="clos"?800:4096); - std::vector times; - std::uint64_t hash=0; - for(int rep=-2;rep out; - auto start=Clock::now(); - for(int q=0;q<16;++q) out.push_back(shortest_paths_to(c.graph,(q*997)%c.graph.num_nodes(),true,EdgeSelection{},c.residual,c.nm(),c.em())); - auto elapsed=ms(start); - std::uint64_t h=0;for(auto& r:out) h^=hash_result(r); - if(rep==-2) hash=h; else if(hash!=h) throw std::runtime_error("reverse nondeterminism"); - if(rep>=0) times.push_back(elapsed); - } - auto sorted=times;std::sort(sorted.begin(),sorted.end()); - std::cout< w; - if(mode=="boundary") { - for(Cost k:{32767,65534,65535,65536,131071,262143,1048575}) { - w.push_back({"wide"+std::to_string(k),make_case("wide",16384,8,false,false,11,k),8,"none"}); - w.push_back({"near"+std::to_string(k),make_case("wide",16384,8,false,false,11,k),64,"near"}); - } - } else { - w.push_back({"random16",make_case("random",16384),16,"none"}); - w.push_back({"clos800",make_case("clos",800),16,"none"}); - w.push_back({"chain2048",make_case("chain",2048),256,"none"}); - w.push_back({"near16",make_case("random",16384),64,"near"}); - w.push_back({"wide20",make_case("wide",16384,8,false,false,11,1048575),16,"none"}); - w.push_back({"wide25",make_case("wide",16384,8,false,false,11,33554431),16,"none"}); - } - time_workloads(w,"ref",samples,2097152); -} diff --git a/dev/perf/spf_queue/review-2026-10/early-exit.cpp b/dev/perf/spf_queue/review-2026-10/early-exit.cpp deleted file mode 100644 index 3e2619e..0000000 --- a/dev/perf/spf_queue/review-2026-10/early-exit.cpp +++ /dev/null @@ -1,8 +0,0 @@ -#include "netgraph/core/shortest_paths.hpp" -#include -using namespace netgraph::core; -int main(){ - auto g=StrictMultiDiGraph::from_arrays(6,std::vector{0,0,2,0,0,4},std::vector{1,2,1,3,4,5},std::vector{1,10,10,10,10,10},std::vector{1,0,0,1,2,1}); - auto r=shortest_paths(g,0,3,true,EdgeSelection{}); - std::cout<<"target_cost="<(N)); - WorkspaceLease lease{ws}; -+ ws.touched.push_back(src); - ws.dist[static_cast(src)] = static_cast(0); - ws.min_res[static_cast(src)] = std::numeric_limits::max(); -- ws.touched.push_back(src); - BucketPlan plan; - if (use_bucket_queue(g, queue, plan)) { - run_forward(ws.bucket, ws, g, src, dst, multipath, selection, residual, node_mask, edge_mask, plan); -@@ -929,9 +929,9 @@ - WorkspaceLease lease{ws}; - - if (target_allowed) { -+ ws.touched.push_back(target); - ws.dist[static_cast(target)] = static_cast(0); - ws.min_res[static_cast(target)] = std::numeric_limits::max(); -- ws.touched.push_back(target); - BucketPlan plan; - if (use_bucket_queue(g, queue, plan)) { - run_reverse(ws.bucket, ws, g, target, multipath, selection, residual, node_mask, edge_mask, plan); diff --git a/dev/perf/spf_queue/review-2026-10/fault-public.cpp b/dev/perf/spf_queue/review-2026-10/fault-public.cpp deleted file mode 100644 index 7a89b53..0000000 --- a/dev/perf/spf_queue/review-2026-10/fault-public.cpp +++ /dev/null @@ -1,30 +0,0 @@ -#include -#include -#include -static bool fail_four = false; -void* operator new(std::size_t n) { - if (fail_four && n == 4) { fail_four=false; throw std::bad_alloc(); } - if (void* p=std::malloc(n?n:1)) return p; - throw std::bad_alloc(); -} -void operator delete(void* p) noexcept { std::free(p); } -#include "netgraph/core/shortest_paths.hpp" -using namespace netgraph::core; -int main(int argc,char**argv) { - std::string mode=argc>1?argv[1]:"fault"; - EdgeSelection sel; - if(mode=="fault") { - auto g=StrictMultiDiGraph::from_arrays(8,std::vector{1},std::vector{0},std::vector{1},std::vector{1}); - fail_four=true; - try { (void)shortest_paths(g,0,std::nullopt,true,sel); } - catch(const std::bad_alloc&) { std::cout<<"caught forward bad_alloc\n"; } - auto r=shortest_paths(g,1,std::nullopt,true,sel); - std::cout<<"forward distance[0]="<{0},std::vector{1},std::vector{1},std::vector{1}); - fail_four=true; - try { (void)shortest_paths_to(rev,0,true,sel); } - catch(const std::bad_alloc&) { std::cout<<"caught reverse bad_alloc\n"; } - auto t=shortest_paths_to(rev,1,true,sel); - std::cout<<"reverse distance[0]="<, PredDAG> path_result; -+ bool have_path_result = false; - while (true) { - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = require_capacity; - sel.tie_break = EdgeTieBreak::Deterministic; -- auto [dist, dag] = shortest_paths( -+ if (require_capacity || !have_path_result) { -+ path_result = shortest_paths( - g, src, dst, - /*multipath=*/true, - sel, - require_capacity ? fs.residual_view() : std::span{}, - use_node_mask ? node_mask : std::span{}, - use_edge_mask ? edge_mask : std::span{}); -+ have_path_result = true; -+ } -+ const auto& [dist, dag] = path_result; - - // No path if t has no parents in DAG - if (static_cast(dst) >= dag.parent_offsets.size() - 1 || ---- a/src/flow_state.cpp -+++ b/src/flow_state.cpp -@@ -535,14 +535,20 @@ - } - - Flow total = static_cast(0.0); -+ std::pair, PredDAG> path_result; -+ bool have_path_result = false; - while (true) { - EdgeSelection sel; - sel.multi_edge = true; - sel.require_capacity = require_capacity; - sel.tie_break = EdgeTieBreak::Deterministic; -- auto [dist, dag] = shortest_paths(*g_, src, dst, /*multipath=*/true, sel, -+ if (require_capacity || !have_path_result) { -+ path_result = shortest_paths(*g_, src, dst, /*multipath=*/true, sel, - require_capacity ? residual_ : std::span{}, - node_mask, edge_mask); -+ have_path_result = true; -+ } -+ const auto& [dist, dag] = path_result; - if (static_cast(dst) >= dag.parent_offsets.size()-1 || dag.parent_offsets[static_cast(dst)] == dag.parent_offsets[static_cast(dst)+1]) { - break; - } diff --git a/dev/perf/spf_queue/review-2026-10/flow-driver.cpp b/dev/perf/spf_queue/review-2026-10/flow-driver.cpp deleted file mode 100644 index cf7fcb1..0000000 --- a/dev/perf/spf_queue/review-2026-10/flow-driver.cpp +++ /dev/null @@ -1,30 +0,0 @@ -#define main original_harness_main -#include "../harness.cpp" -#undef main -#include "netgraph/core/max_flow.hpp" -#include "netgraph/core/flow_state.hpp" -static void feasible(const StrictMultiDiGraph&g,NodeId s,NodeId t,double f,std::span ef) { - std::vector balance(g.num_nodes());auto src=g.edge_src_view();auto dst=g.edge_dst_view();auto cap=g.capacity_view(); - for(std::size_t e=0;ecap[e]+1e-7)throw std::runtime_error("capacity");balance[src[e]]+=ef[e];balance[dst[e]]-=ef[e];} - for(int v=0;v1e-6)throw std::runtime_error("conservation"); -} -int main() { - std::cout<<"case,median_ms,hash,samples_ms\n"; - for(bool masked:{false,true})for(bool state:{false,true}) { - auto c=make_case("random",4096,8,masked);std::vector samples;std::uint64_t hash=0; - for(int q=0;q<8;++q){c.node_mask[q*17]=true;c.node_mask[2048+q]=true;} - for(int rep=-2;rep<7;++rep){ - std::vector> out;out.reserve(8); - auto start=Clock::now(); - for(int q=0;q<8;++q){ - NodeId s=q*17,t=2048+q; - if(state){FlowState fs(c.graph);double f=fs.place_max_flow(s,t,FlowPlacement::Proportional,false,false,c.nm(),c.em());FlowSummary sm;sm.edge_flows.assign(fs.edge_flow_view().begin(),fs.edge_flow_view().end());out.emplace_back(f,std::move(sm));} - else out.push_back(calc_max_flow(c.graph,s,t,FlowPlacement::Proportional,false,false,true,false,false,c.nm(),c.em())); - } - auto elapsed=ms(start);std::uint64_t h=0; - for(int q=0;q<8;++q){auto&[f,sm]=out[q];feasible(c.graph,q*17,2048+q,f,sm.edge_flows);h=h*31+std::bit_cast(f);for(auto e:sm.edge_flows)h=h*31+std::bit_cast(e);for(auto e:sm.costs)h=h*31+e;for(auto e:sm.flows)h=h*31+std::bit_cast(e);} - if(rep==-2)hash=h;else if(hash!=h)throw std::runtime_error("changed output");if(rep>=0)samples.push_back(elapsed); - } - auto sorted=samples;std::sort(sorted.begin(),sorted.end());std::cout<<(state?"place_max_flow":"calc_max_flow")<<(masked?"_masked":"")<<','<(h.front()); } - QItem pop() { -+ QItem t = h.front(); - std::pop_heap(h.begin(), h.end(), Greater{}); -- QItem t = h.back(); h.pop_back(); return t; -+ h.pop_back(); return t; - } - void push(Cost c, Cap nr, NodeId v) { - h.emplace_back(c, nr, v); diff --git a/dev/perf/spf_queue/review-2026-10/gcd.cpp b/dev/perf/spf_queue/review-2026-10/gcd.cpp deleted file mode 100644 index 7c5ba12..0000000 --- a/dev/perf/spf_queue/review-2026-10/gcd.cpp +++ /dev/null @@ -1,165 +0,0 @@ -/* - StrictMultiDiGraph — immutable directed multigraph with deterministic layout. - - Construction from arrays validates inputs and builds CSR and reverse CSR - adjacency structures. Edges are reordered by (cost, src, dst) to cluster - equal-cost tiers for deterministic traversal. -*/ -#include "netgraph/core/strict_multidigraph.hpp" - -#include -#include -#include - -namespace netgraph::core { - -StrictMultiDiGraph StrictMultiDiGraph::from_arrays( - std::int32_t num_nodes, - std::span src, - std::span dst, - std::span capacity, - std::span cost, - std::span ext_edge_ids) { - - if (num_nodes < 0) { - throw std::invalid_argument("num_nodes must be >= 0"); - } - if (src.size() != dst.size() || src.size() != capacity.size() || src.size() != cost.size()) { - throw std::invalid_argument("src, dst, capacity, and cost must have the same length"); - } - if (!ext_edge_ids.empty() && ext_edge_ids.size() != src.size()) { - throw std::invalid_argument("ext_edge_ids must be empty or have the same length as src/dst/capacity/cost"); - } - StrictMultiDiGraph g; - g.num_nodes_ = num_nodes; - std::size_t m = src.size(); - if (m > static_cast(std::numeric_limits::max())) { - throw std::invalid_argument("number of edges exceeds INT32_MAX"); - } - - // Invariants: ids within [0, num_nodes), non-negative weights. - // SPF accumulates path costs as int64 without overflow checks and uses - // INT64_MAX as the unreachable sentinel, so the total of all edge costs - // (an upper bound on any simple path, and on one reverse-edge bounce) must - // stay below 2^62 or accumulated costs could wrap negative and silently - // corrupt results. - constexpr std::uint64_t kMaxTotalCost = (std::uint64_t{1} << 62); - std::uint64_t total_cost = 0; - Cost max_cost = 0; - Cost cost_gcd = 0; - for (std::size_t i = 0; i < m; ++i) { - if (src[i] < 0 || dst[i] < 0 || src[i] >= num_nodes || dst[i] >= num_nodes) { - throw std::out_of_range("edge index out of range of num_nodes"); - } - if (capacity[i] < 0.0) { - throw std::invalid_argument("capacity must be >= 0"); - } - if (cost[i] < 0) { throw std::invalid_argument("cost must be >= 0"); } - const auto c = static_cast(cost[i]); - if (c >= kMaxTotalCost - total_cost) { - throw std::invalid_argument( - "total edge cost must stay below 2^62: larger accumulated path costs " - "overflow int64 cost arithmetic and silently corrupt results"); - } - total_cost += c; - if (cost[i] > max_cost) max_cost = cost[i]; - if (cost_gcd != 1) cost_gcd = std::gcd(cost_gcd, cost[i]); - } - g.max_cost_ = max_cost; - g.cost_gcd_ = cost_gcd; - // Gather initial arrays - std::vector src_v(src.begin(), src.end()); - std::vector dst_v(dst.begin(), dst.end()); - std::vector cap_v(capacity.begin(), capacity.end()); - std::vector cost_v(cost.begin(), cost.end()); - std::vector ext_v; - if (!ext_edge_ids.empty()) { - ext_v.assign(ext_edge_ids.begin(), ext_edge_ids.end()); - } - - // Sort edges by (cost, src, dst) to cluster equal-cost tiers for deterministic traversal. - // Create an index array [0, 1, 2, ..., m-1] that we'll permute to define the reordering. - std::vector idx(m); - std::iota(idx.begin(), idx.end(), 0); // iota fills with sequential values - - // Cost-first ordering keeps equal-cost tiers clustered; within the same - // cost, maintain stable source/destination ordering for reproducibility. - // stable_sort preserves relative order of equal elements, ensuring determinism. - // Lambda comparator: [&] captures variables by reference, (a, b) are indices to compare. - std::stable_sort(idx.begin(), idx.end(), [&](std::size_t a, std::size_t b) { - if (cost_v[a] != cost_v[b]) return cost_v[a] < cost_v[b]; - if (src_v[a] != src_v[b]) return src_v[a] < src_v[b]; - return dst_v[a] < dst_v[b]; - }); - - // Reorder all edge arrays using the computed permutation. - // Generic lambda (auto parameters) works with any vector type. - auto apply_perm = [&](auto& out_vec, const auto& in_vec) { - out_vec.resize(m); - for (std::size_t i = 0; i < m; ++i) out_vec[i] = in_vec[idx[i]]; - }; - apply_perm(g.src_, src_v); - apply_perm(g.dst_, dst_v); - apply_perm(g.capacity_, cap_v); - apply_perm(g.cost_, cost_v); - if (!ext_v.empty()) { - apply_perm(g.ext_edge_ids_, ext_v); - } - g.edges_ = m; - - // Build CSR (Compressed Sparse Row) adjacency structure. - // For each node u, row_offsets_[u]:row_offsets_[u+1] gives the range in col_indices_ - // that stores u's outgoing neighbors, and adj_edge_index_ stores the corresponding EdgeIds. - - // Step 1: Count outgoing edges per node (histogram). - g.row_offsets_.assign(static_cast(num_nodes) + 1, 0); - for (std::size_t i = 0; i < m; ++i) { - g.row_offsets_[static_cast(g.src_[i]) + 1]++; - } - - // Step 2: Convert counts to cumulative offsets (prefix sum). - // row_offsets_[u] is the starting index for node u's adjacency list. - for (std::size_t i = 1; i < g.row_offsets_.size(); ++i) { - g.row_offsets_[i] += g.row_offsets_[i - 1]; - } - - // Step 3: Fill col_indices_ and adj_edge_index_ arrays. - g.col_indices_.resize(m); - g.adj_edge_index_.resize(m); - // cursor tracks the current write position for each node's adjacency list. - std::vector cursor = g.row_offsets_; // copy for in-place filling - for (std::size_t e = 0; e < m; ++e) { - auto u = g.src_[e]; - auto pos = static_cast(cursor[static_cast(u)]++); - g.col_indices_[pos] = g.dst_[e]; // neighbor node - g.adj_edge_index_[pos] = static_cast(e); // edge index - } - // Build reverse CSR (incoming adjacency) for predecessor tracking. - // Same CSR construction algorithm, but keyed by destination node instead of source. - // This enables efficient lookup of incoming edges for any node. - - // Step 1: Count incoming edges per node. - g.in_row_offsets_.assign(static_cast(num_nodes) + 1, 0); - for (std::size_t i = 0; i < m; ++i) { - g.in_row_offsets_[static_cast(g.dst_[i]) + 1]++; - } - - // Step 2: Convert counts to cumulative offsets. - for (std::size_t i = 1; i < g.in_row_offsets_.size(); ++i) { - g.in_row_offsets_[i] += g.in_row_offsets_[i - 1]; - } - - // Step 3: Fill reverse adjacency arrays. - g.in_col_indices_.resize(m); - g.in_adj_edge_index_.resize(m); - std::vector rcursor = g.in_row_offsets_; - for (std::size_t e = 0; e < m; ++e) { - auto v = g.dst_[e]; - auto pos = static_cast(rcursor[static_cast(v)]++); - g.in_col_indices_[pos] = g.src_[e]; // predecessor node - g.in_adj_edge_index_[pos] = static_cast(e); - } - return g; -} - -} // namespace netgraph::core diff --git a/dev/perf/spf_queue/review-2026-10/gcd.patch b/dev/perf/spf_queue/review-2026-10/gcd.patch deleted file mode 100644 index 44d24db..0000000 --- a/dev/perf/spf_queue/review-2026-10/gcd.patch +++ /dev/null @@ -1,11 +0,0 @@ ---- a/src/strict_multidigraph.cpp -+++ b/src/strict_multidigraph.cpp -@@ -63,7 +63,7 @@ - } - total_cost += c; - if (cost[i] > max_cost) max_cost = cost[i]; -- cost_gcd = std::gcd(cost_gcd, cost[i]); -+ if (cost_gcd != 1) cost_gcd = std::gcd(cost_gcd, cost[i]); - } - g.max_cost_ = max_cost; - g.cost_gcd_ = cost_gcd; diff --git a/dev/perf/spf_queue/review-2026-10/heapitem.patch b/dev/perf/spf_queue/review-2026-10/heapitem.patch deleted file mode 100644 index 65e21b4..0000000 --- a/dev/perf/spf_queue/review-2026-10/heapitem.patch +++ /dev/null @@ -1,26 +0,0 @@ ---- a/src/shortest_paths.cpp -+++ b/src/shortest_paths.cpp -@@ -269,19 +269,20 @@ - constexpr Cost kInf = std::numeric_limits::max(); - constexpr std::size_t kMaxBucketWidth = std::size_t{1} << 16; - --using QItem = std::tuple; -+struct QItem { Cost cost; Cap neg_res; NodeId node; }; -+ - - // Reference queue: binary heap on (cost, -bottleneck, node). Keys are unique per - // push (a node is re-pushed only with a strictly smaller cost or a strictly - // larger bottleneck), so the pop order is fully determined by the key. - struct HeapQueue { - // Functor, not a function pointer: std::push_heap / pop_heap inline it. -- struct Greater { bool operator()(const QItem& a, const QItem& b) const { return a > b; } }; -+ struct Greater { bool operator()(const QItem& a, const QItem& b) const { return a.cost != b.cost ? a.cost > b.cost : (a.neg_res != b.neg_res ? a.neg_res > b.neg_res : a.node > b.node); } }; - std::vector h; - void begin(std::size_t, Cost) { h.clear(); } - void end() { h.clear(); } - bool empty() const { return h.empty(); } -- Cost top_cost() const { return std::get<0>(h.front()); } -+ Cost top_cost() const { return h.front().cost; } - QItem pop() { - std::pop_heap(h.begin(), h.end(), Greater{}); - QItem t = h.back(); h.pop_back(); return t; diff --git a/dev/perf/spf_queue/review-2026-10/inline.patch b/dev/perf/spf_queue/review-2026-10/inline.patch deleted file mode 100644 index 11bf039..0000000 --- a/dev/perf/spf_queue/review-2026-10/inline.patch +++ /dev/null @@ -1,70 +0,0 @@ ---- a/src/shortest_paths.cpp -+++ b/src/shortest_paths.cpp -@@ -304,13 +304,15 @@ - std::vector occ; // one bit per non-empty bucket - std::size_t W = 0, cur_b = 0, pending = 0; - Cost cur = 0, stride = 1; -+ bool inline_item = false; -+ QItem single; - - void begin(std::size_t width, Cost stride_) { - stride = stride_; - W = width; - if (buckets.size() < W) buckets.resize(W); - occ.assign((W + 63) / 64, 0); -- cur = 0; cur_b = 0; pending = 0; -+ cur = 0; cur_b = 0; pending = 0; inline_item = false; - } - // Early exit can leave items behind; clear only the occupied buckets. - void end() { -@@ -323,20 +325,31 @@ - } - occ[w] = 0; - } -- pending = 0; -+ pending = 0; inline_item = false; - } - bool empty() const { return pending == 0; } - std::size_t bucket_of(Cost c) const { - const Cost q = stride == 1 ? c : c / stride; - return static_cast(q % static_cast(W)); - } -- void push(Cost c, Cap nr, NodeId v) { -+ void push_bucket(Cost c, Cap nr, NodeId v) { - const std::size_t b = bucket_of(c); - auto& vec = buckets[b]; - if (vec.empty()) occ[b >> 6] |= (std::uint64_t{1} << (b & 63)); - vec.push_back({nr, v}); - if (b == cur_b) std::push_heap(vec.begin(), vec.end(), Greater{}); - ++pending; -+ } -+ void push(Cost c, Cap nr, NodeId v) { -+ if (pending == 0) { -+ single = {c, nr, v}; inline_item = true; pending = 1; return; -+ } -+ if (inline_item) { -+ inline_item = false; pending = 0; -+ cur_b = bucket_of(cur); -+ push_bucket(std::get<0>(single), std::get<1>(single), std::get<2>(single)); -+ } -+ push_bucket(c, nr, v); - } - // Advance to the next non-empty bucket when the active one is exhausted. - // Precondition: pending > 0. -@@ -352,8 +365,15 @@ - cur += static_cast(steps) * stride; cur_b = f; - std::make_heap(buckets[f].begin(), buckets[f].end(), Greater{}); - } -- Cost top_cost() { settle_active(); return cur; } -+ Cost top_cost() { if (inline_item) return std::get<0>(single); settle_active(); return cur; } - QItem pop() { -+ if (inline_item) { -+ inline_item = false; pending = 0; -+ // Keep cursor tied to the last bucket activation. A later promotion -+ // must first restore the current absolute cost and cyclic index. -+ cur = std::get<0>(single); -+ return single; -+ } - settle_active(); - auto& vec = buckets[cur_b]; - std::pop_heap(vec.begin(), vec.end(), Greater{}); diff --git a/dev/perf/spf_queue/review-2026-10/notouch.patch b/dev/perf/spf_queue/review-2026-10/notouch.patch deleted file mode 100644 index d89d905..0000000 --- a/dev/perf/spf_queue/review-2026-10/notouch.patch +++ /dev/null @@ -1,29 +0,0 @@ ---- a/src/shortest_paths.cpp -+++ b/src/shortest_paths.cpp -@@ -396,7 +396,7 @@ - // Shared reset policy: sparse when few nodes were touched, wholesale otherwise. - // The same predicate decides how outputs are materialised, so the two agree. - template --bool sparse_reset(const WS& ws) { return ws.touched.size() * 4 < ws.dist.size(); } -+bool sparse_reset(const WS&) { return false; } - - template - void ensure_sized(Vec& v, std::size_t n, typename Vec::value_type init) { -@@ -644,7 +644,7 @@ - if (new_cost < dist[v_idx] || - (!multipath && new_cost == dist[v_idx] && !settled[v_idx] && - path_residual > min_residual_to_node[v_idx] + kEpsilon)) { -- if (dist[v_idx] == kInf) ws.touched.push_back(v); -+ - dist[v_idx] = new_cost; - min_residual_to_node[v_idx] = path_residual; - pred_clear(v_idx); -@@ -884,7 +884,7 @@ - if (new_cost < dist[u_idx] || - (!multipath && new_cost == dist[u_idx] && !settled[u_idx] && - path_residual > min_residual_to_dst[u_idx] + kEpsilon)) { -- if (dist[u_idx] == kInf) ws.touched.push_back(u); -+ - dist[u_idx] = new_cost; - min_residual_to_dst[u_idx] = path_residual; - succ_clear(u_idx); diff --git a/dev/perf/spf_queue/review-2026-10/results/append-c.csv b/dev/perf/spf_queue/review-2026-10/results/append-c.csv deleted file mode 100644 index 963e199..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/append-c.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,37.635792,7,38.207416;37.606375;37.612834;38.542167;37.918750;36.896417;37.635792 -clos800,800,320000,16,none,ref,0,2,31.158875,7,32.008042;31.218041;30.541250;31.576083;30.373084;30.469209;31.158875 -chain2048,2048,2047,256,none,ref,0,32,5.696250,7,5.622625;5.661208;5.606292;5.892458;6.009208;5.816542;5.696250 -near16,16384,131072,64,near,ref,0,32,0.406750,7,0.406542;0.404417;0.408167;0.406750;0.404791;0.409375;0.415750 -wide20,16384,131072,16,none,ref,0,1048570,54.188333,7,54.352083;54.185958;55.721250;54.188333;55.605583;52.820000;53.110667 -wide25,16384,131072,16,none,ref,1,33554196,51.991458,7,51.991458;51.997417;52.447250;52.295000;51.866917;51.883875;51.872750 diff --git a/dev/perf/spf_queue/review-2026-10/results/append.csv b/dev/perf/spf_queue/review-2026-10/results/append.csv deleted file mode 100644 index 6c8c744..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/append.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,35.207541,7,35.238625;35.097416;35.207541;35.161000;35.137917;35.443708;35.211333 -clos800,800,320000,16,none,ref,0,2,29.755084,7,29.659500;29.755084;30.265916;29.593917;29.666625;31.104000;31.114667 -chain2048,2048,2047,256,none,ref,0,32,5.591333,7,5.560791;5.577542;5.731958;5.845959;5.459833;5.591333;5.787625 -near16,16384,131072,64,near,ref,0,32,0.392125,7,0.392125;0.382834;0.387250;0.393375;0.392000;0.400333;0.393125 -wide20,16384,131072,16,none,ref,0,1048570,50.562958,7,50.479000;50.832333;50.819333;50.634875;50.442125;50.358042;50.562958 -wide25,16384,131072,16,none,ref,1,33554196,52.024125,7,53.655625;53.328708;52.807833;51.244209;52.024125;51.598250;50.987958 diff --git a/dev/perf/spf_queue/review-2026-10/results/bitgate-sha256.txt b/dev/perf/spf_queue/review-2026-10/results/bitgate-sha256.txt deleted file mode 100644 index 5c7d9c6..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/bitgate-sha256.txt +++ /dev/null @@ -1,2 +0,0 @@ -a976f254eced152b14991ba2198351ec7983506f3f1e177c289852ec2bcaab90 dev/perf/spf_queue/review-2026-10/results/bitgate-old.txt -a976f254eced152b14991ba2198351ec7983506f3f1e177c289852ec2bcaab90 dev/perf/spf_queue/review-2026-10/results/bitgate-variant.txt diff --git a/dev/perf/spf_queue/review-2026-10/results/boundary-heap.csv b/dev/perf/spf_queue/review-2026-10/results/boundary-heap.csv deleted file mode 100644 index 9a39655..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/boundary-heap.csv +++ /dev/null @@ -1,15 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -wide32767,16384,131072,8,none,ref,0,32768,27.216125,7,28.146458;28.266458;27.100084;27.216125;27.213083;26.975083;27.599292 -near32767,16384,131072,64,near,ref,0,32768,0.395458,7,0.395458;0.391791;0.388500;0.389708;0.395542;0.395542;0.430625 -wide65534,16384,131072,8,none,ref,0,65535,26.349792,7,26.349792;26.298375;26.907792;26.210750;27.317625;26.253333;26.499417 -near65534,16384,131072,64,near,ref,0,65535,0.389917,7,0.389917;0.390083;0.388792;0.389791;0.390125;0.389750;0.392041 -wide65535,16384,131072,8,none,ref,0,65536,27.174750,7,27.377917;28.266584;26.928917;27.180500;27.174750;26.481250;26.367625 -near65535,16384,131072,64,near,ref,0,65536,0.398709,7,0.380667;0.395583;0.475958;0.724667;0.407708;0.398709;0.394125 -wide65536,16384,131072,8,none,ref,0,65537,26.865125,7,26.781208;26.649750;26.865125;26.975500;27.283250;26.901583;26.306791 -near65536,16384,131072,64,near,ref,0,65537,0.406292,7,0.406375;0.394000;0.394916;0.406292;0.400000;0.407583;0.407459 -wide131071,16384,131072,8,none,ref,0,131072,27.059250,7,26.298084;27.059250;27.075958;27.368333;26.491750;27.280125;26.502750 -near131071,16384,131072,64,near,ref,0,131072,0.389958,7,0.389833;0.389958;0.389042;0.388833;0.440458;0.397708;0.407625 -wide262143,16384,131072,8,none,ref,0,262144,26.460459,7,27.210333;26.460459;25.910166;26.587958;26.447375;27.095792;26.146417 -near262143,16384,131072,64,near,ref,0,262144,0.394750,7,0.394750;0.388334;0.404417;0.399833;0.397458;0.393167;0.393458 -wide1048575,16384,131072,8,none,ref,0,1048570,27.349042,7,27.048000;26.335291;26.375625;27.913125;29.361833;28.475000;27.349042 -near1048575,16384,131072,64,near,ref,0,1048570,0.441584,7,0.397750;0.396167;0.442500;0.448958;0.426250;0.453333;0.441584 diff --git a/dev/perf/spf_queue/review-2026-10/results/boundary-new1.csv b/dev/perf/spf_queue/review-2026-10/results/boundary-new1.csv deleted file mode 100644 index 5e16cdf..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/boundary-new1.csv +++ /dev/null @@ -1,15 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -wide32767,16384,131072,8,none,ref,0,32768,17.627750,7,17.467708;17.788917;17.774958;17.912958;17.627750;17.498041;17.571250 -near32767,16384,131072,64,near,ref,0,32768,0.420792,7,0.420792;0.418875;0.419792;0.424292;0.422000;0.419125;0.450750 -wide65534,16384,131072,8,none,ref,0,65535,18.146791,7,17.373209;18.146791;17.820625;18.424875;18.379041;19.409584;18.128625 -near65534,16384,131072,64,near,ref,0,65535,0.455083,7,0.485750;0.434000;0.433791;0.455083;0.460917;0.459959;0.449458 -wide65535,16384,131072,8,none,ref,0,65536,18.499458,7,18.164084;21.170541;19.691125;18.580833;18.453917;18.499458;17.288917 -near65535,16384,131072,64,near,ref,0,65536,0.434500,7,0.439750;0.494458;0.464708;0.432375;0.434500;0.431833;0.431417 -wide65536,16384,131072,8,none,ref,0,65537,26.490750,7,26.255750;27.382208;26.490750;26.883292;26.491833;26.317709;26.318292 -near65536,16384,131072,64,near,ref,0,65537,0.413542,7,0.413542;0.412375;0.412042;0.422792;0.530541;0.414708;0.412959 -wide131071,16384,131072,8,none,ref,0,131072,26.303167,7,26.388291;26.303167;26.215125;26.181541;26.688667;26.346208;26.260875 -near131071,16384,131072,64,near,ref,0,131072,0.411958,7,0.412792;0.411958;0.412542;0.411333;0.411958;0.413125;0.411833 -wide262143,16384,131072,8,none,ref,0,262144,27.104375,7,27.291750;28.251584;27.074000;27.684125;26.756209;26.675708;27.104375 -near262143,16384,131072,64,near,ref,0,262144,0.418458,7,0.422875;0.416291;0.412375;0.411208;0.418458;0.418916;0.535708 -wide1048575,16384,131072,8,none,ref,0,1048570,27.498000,7,28.209334;26.654250;27.265667;27.498000;28.699583;27.445416;27.548208 -near1048575,16384,131072,64,near,ref,0,1048570,0.419458,7,0.412292;0.409958;0.438000;0.465000;0.417958;0.419458;0.420875 diff --git a/dev/perf/spf_queue/review-2026-10/results/boundary-old1.csv b/dev/perf/spf_queue/review-2026-10/results/boundary-old1.csv deleted file mode 100644 index cf301c2..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/boundary-old1.csv +++ /dev/null @@ -1,15 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -wide32767,16384,131072,8,none,ref,0,32768,25.959000,7,25.920750;25.994708;26.018958;25.959000;25.868584;25.998833;25.945334 -near32767,16384,131072,64,near,ref,0,32768,1.123792,7,1.123792;1.143875;1.312083;1.174542;1.120500;1.118458;1.117542 -wide65534,16384,131072,8,none,ref,0,65535,25.432833,7,25.434417;25.484792;25.432833;25.497625;25.411417;25.386000;25.402792 -near65534,16384,131072,64,near,ref,0,65535,1.131375,7,1.134792;1.131375;1.126750;1.150958;1.130917;1.142292;1.127750 -wide65535,16384,131072,8,none,ref,0,65536,25.641458,7,26.183459;25.641458;25.704083;25.475583;25.608166;26.744875;25.582750 -near65535,16384,131072,64,near,ref,0,65536,1.171208,7,1.171375;1.171208;1.144834;1.162584;1.129458;1.229166;1.239417 -wide65536,16384,131072,8,none,ref,0,65537,25.659250,7,25.589125;25.567500;25.659250;25.878333;25.921041;25.928000;25.645167 -near65536,16384,131072,64,near,ref,0,65537,1.172500,7,1.195500;1.233375;1.134375;1.126542;1.172500;1.192833;1.132958 -wide131071,16384,131072,8,none,ref,0,131072,25.524916,7,25.363959;26.327625;25.538041;25.349375;25.411375;25.524916;25.594667 -near131071,16384,131072,64,near,ref,0,131072,1.128166,7,1.148709;1.154292;1.137500;1.125667;1.128166;1.125709;1.125333 -wide262143,16384,131072,8,none,ref,0,262144,25.191500,7,25.191500;25.104833;25.250792;25.137333;25.226084;25.305292;25.094250 -near262143,16384,131072,64,near,ref,0,262144,1.129625,7,1.129625;1.127042;1.123833;1.136125;1.134375;1.128792;1.140958 -wide1048575,16384,131072,8,none,ref,0,1048570,25.106542,7,25.123750;24.851209;25.116375;25.106542;25.039958;24.997625;25.230250 -near1048575,16384,131072,64,near,ref,0,1048570,1.142375,7,1.179875;1.140000;1.142375;1.240625;1.144541;1.120459;1.122583 diff --git a/dev/perf/spf_queue/review-2026-10/results/boundary-old2.csv b/dev/perf/spf_queue/review-2026-10/results/boundary-old2.csv deleted file mode 100644 index f00beda..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/boundary-old2.csv +++ /dev/null @@ -1,15 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -wide32767,16384,131072,8,none,ref,0,32768,26.271750,7,26.301167;26.686875;26.371917;26.255292;26.271750;26.190042;26.068333 -near32767,16384,131072,64,near,ref,0,32768,1.136167,7,1.182583;1.136167;1.135125;1.136917;1.135708;1.133125;1.143458 -wide65534,16384,131072,8,none,ref,0,65535,25.761125,7,25.856750;26.884209;26.271416;25.761125;25.596167;25.732250;25.505250 -near65534,16384,131072,64,near,ref,0,65535,1.122666,7,1.153667;1.121250;1.114917;1.114459;1.201875;1.122666;1.176334 -wide65535,16384,131072,8,none,ref,0,65536,26.609084,7,26.609084;26.286708;26.560916;27.546417;26.013458;26.998000;27.639542 -near65535,16384,131072,64,near,ref,0,65536,1.149334,7,1.201292;1.135834;1.149334;1.146417;1.154333;1.138625;1.189833 -wide65536,16384,131072,8,none,ref,0,65537,25.453542,7,25.453542;25.423167;25.036875;25.191000;25.708125;26.894000;26.066042 -near65536,16384,131072,64,near,ref,0,65537,1.156958,7,1.140209;1.261541;1.156958;1.205750;1.184833;1.146458;1.149083 -wide131071,16384,131072,8,none,ref,0,131072,25.179291,7,25.184625;25.557708;25.030125;25.059708;25.179291;25.229375;25.011292 -near131071,16384,131072,64,near,ref,0,131072,1.118291,7,1.117792;1.129750;1.117000;1.118291;1.116959;1.148750;1.128125 -wide262143,16384,131072,8,none,ref,0,262144,25.164583,7,25.164583;24.947750;24.892708;25.117916;25.187125;26.459750;25.999500 -near262143,16384,131072,64,near,ref,0,262144,1.178209,7,1.201500;1.223667;1.178209;1.121833;1.120084;1.119375;1.190333 -wide1048575,16384,131072,8,none,ref,0,1048570,25.150958,7,25.270875;25.395917;25.150958;25.117542;25.158292;24.994500;25.007375 -near1048575,16384,131072,64,near,ref,0,1048570,1.136750,7,1.179000;1.136750;1.121125;1.119167;1.150625;1.138500;1.136375 diff --git a/dev/perf/spf_queue/review-2026-10/results/boundary-settled.csv b/dev/perf/spf_queue/review-2026-10/results/boundary-settled.csv deleted file mode 100644 index b638446..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/boundary-settled.csv +++ /dev/null @@ -1,15 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -wide32767,16384,131072,8,none,ref,0,32768,17.436917,7,19.366000;17.364333;17.382625;16.994875;17.436917;19.600291;17.841792 -near32767,16384,131072,64,near,ref,0,32768,0.414458,7,0.420208;0.413459;0.413458;0.414458;0.832042;0.430917;0.412333 -wide65534,16384,131072,8,none,ref,0,65535,18.717125,7,18.717125;17.463917;17.628958;16.499875;19.448833;19.348125;18.993583 -near65534,16384,131072,64,near,ref,0,65535,0.471833,7,0.493834;0.480708;0.470208;0.470625;0.483500;0.471833;0.470708 -wide65535,16384,131072,8,none,ref,0,65536,19.366166,7,19.366166;18.572250;17.788084;20.007792;20.720959;19.390792;18.908375 -near65535,16384,131072,64,near,ref,0,65536,0.465041,7,0.479500;0.518542;0.465041;0.484917;0.464042;0.451834;0.450375 -wide65536,16384,131072,8,none,ref,0,65537,27.207833,7,27.207833;26.963750;27.583833;27.970459;26.963125;26.581584;27.401875 -near65536,16384,131072,64,near,ref,0,65537,0.406500,7,0.407208;0.407375;0.406209;0.406166;0.406500;0.407334;0.406125 -wide131071,16384,131072,8,none,ref,0,131072,26.218208,7,26.337125;26.079042;26.577292;25.786125;26.615584;26.218208;25.834875 -near131071,16384,131072,64,near,ref,0,131072,0.426083,7,0.506875;0.455750;0.429416;0.426083;0.425041;0.424834;0.424500 -wide262143,16384,131072,8,none,ref,0,262144,26.430375,7,26.452084;26.167375;26.274000;26.541625;26.430375;26.457833;25.998250 -near262143,16384,131072,64,near,ref,0,262144,0.431250,7,0.424125;0.431250;0.422500;0.435792;0.450667;0.437833;0.425791 -wide1048575,16384,131072,8,none,ref,0,1048570,26.015250,7,26.626792;26.934209;25.769292;26.277167;25.513209;25.970167;26.015250 -near1048575,16384,131072,64,near,ref,0,1048570,0.428000,7,0.466750;0.462792;0.421166;0.426500;0.419584;0.436417;0.428000 diff --git a/dev/perf/spf_queue/review-2026-10/results/build-gcd.csv b/dev/perf/spf_queue/review-2026-10/results/build-gcd.csv deleted file mode 100644 index c795d2b..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/build-gcd.csv +++ /dev/null @@ -1,2 +0,0 @@ -random,9.12225,12169309226976897007,9.0255;9.29696;9.24317;8.97583;9.07829;9.15029;9.33504;9.12225;9.08346; -ordered,1.36387,16379939557917917184,1.34192;1.37963;1.37363;1.36387;1.37554;1.36342;1.36637;1.19475;1.25596; diff --git a/dev/perf/spf_queue/review-2026-10/results/build-new1.csv b/dev/perf/spf_queue/review-2026-10/results/build-new1.csv deleted file mode 100644 index b5b70f2..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/build-new1.csv +++ /dev/null @@ -1,2 +0,0 @@ -random,9.16392,12169309226976897007,9.23296;9.28317;9.09125;9.15975;9.1805;9.07479;9.11642;9.16392;9.2345; -ordered,1.42971,16379939557917917184,1.42;1.42271;1.45396;1.44892;1.53179;1.43958;1.42463;1.426;1.42971; diff --git a/dev/perf/spf_queue/review-2026-10/results/build-new2.csv b/dev/perf/spf_queue/review-2026-10/results/build-new2.csv deleted file mode 100644 index ace6a92..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/build-new2.csv +++ /dev/null @@ -1,2 +0,0 @@ -random,9.28858,12169309226976897007,9.44521;9.57671;9.38425;9.28858;9.21017;9.27567;9.307;9.17375;9.10067; -ordered,1.44796,16379939557917917184,1.57033;1.44246;1.42517;1.44796;1.47513;1.44875;1.70025;1.44121;1.43358; diff --git a/dev/perf/spf_queue/review-2026-10/results/build-old1.csv b/dev/perf/spf_queue/review-2026-10/results/build-old1.csv deleted file mode 100644 index a4b41de..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/build-old1.csv +++ /dev/null @@ -1,2 +0,0 @@ -random,9.06046,12169309226976897007,9.02929;9.15596;9.06567;9.11125;8.99808;9.17017;9.01704;9.06046;9.05; -ordered,1.34379,16379939557917917184,1.39417;1.32854;1.34379;1.32638;1.35642;1.32892;1.32942;1.44129;1.37017; diff --git a/dev/perf/spf_queue/review-2026-10/results/build-old2.csv b/dev/perf/spf_queue/review-2026-10/results/build-old2.csv deleted file mode 100644 index c1d84c0..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/build-old2.csv +++ /dev/null @@ -1,2 +0,0 @@ -random,9.13721,12169309226976897007,9.37308;9.16767;9.17583;9.13721;8.98692;9.06342;9.17796;9.13525;9.11583; -ordered,1.37433,16379939557917917184,1.37433;1.36271;1.41938;1.37692;1.36771;1.36867;1.39604;1.38129;1.36892; diff --git a/dev/perf/spf_queue/review-2026-10/results/build-repeat-gcd.csv b/dev/perf/spf_queue/review-2026-10/results/build-repeat-gcd.csv deleted file mode 100644 index 1dabbeb..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/build-repeat-gcd.csv +++ /dev/null @@ -1,2 +0,0 @@ -random,9.32917,12169309226976897007,9.33104;9.60546;8.98971;9.32917;10.0803;9.30875;9.5125;9.26842;9.25737; -ordered,1.41108,16379939557917917184,1.43392;1.41154;1.40246;1.41108;1.4155;1.43271;1.39921;1.30079;1.30358; diff --git a/dev/perf/spf_queue/review-2026-10/results/build-repeat-new1.csv b/dev/perf/spf_queue/review-2026-10/results/build-repeat-new1.csv deleted file mode 100644 index ed3fe22..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/build-repeat-new1.csv +++ /dev/null @@ -1,2 +0,0 @@ -random,9.38404,12169309226976897007,9.69817;9.4765;9.30413;9.89342;9.32408;9.525;9.38404;9.29583;9.32975; -ordered,1.44821,16379939557917917184,1.45875;1.44821;1.47833;1.41417;1.46692;1.45879;1.42346;1.42167;1.43983; diff --git a/dev/perf/spf_queue/review-2026-10/results/build-repeat-new2.csv b/dev/perf/spf_queue/review-2026-10/results/build-repeat-new2.csv deleted file mode 100644 index ab39ff6..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/build-repeat-new2.csv +++ /dev/null @@ -1,2 +0,0 @@ -random,9.31475,12169309226976897007,9.33629;9.25583;9.40537;9.58625;9.47825;9.30892;9.31475;9.11708;9.164; -ordered,1.45571,16379939557917917184,1.48362;1.41492;1.42183;1.41204;1.48937;1.45629;1.49546;1.45571;1.42967; diff --git a/dev/perf/spf_queue/review-2026-10/results/combined.csv b/dev/perf/spf_queue/review-2026-10/results/combined.csv deleted file mode 100644 index ff1c4cf..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/combined.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,36.724875,7,36.876459;36.764792;36.888208;36.207875;36.669584;36.724875;36.502542 -clos800,800,320000,16,none,ref,0,2,26.326583,7,26.326583;26.456250;26.174583;26.294958;25.905208;26.594291;26.357125 -chain2048,2048,2047,256,none,ref,0,32,5.749834,7,6.107542;5.749834;5.632792;5.484209;5.450416;6.257625;5.891458 -near16,16384,131072,64,near,ref,0,32,0.392708,7,0.396208;0.393666;0.392583;0.392000;0.393166;0.392708;0.392458 -wide20,16384,131072,16,none,ref,0,1048570,52.384667,7,51.748375;52.904292;52.384667;52.881667;52.289333;52.270334;52.416291 -wide25,16384,131072,16,none,ref,1,33554196,52.605833,7,52.605833;51.553042;52.051292;53.022542;52.452708;57.664459;52.894334 diff --git a/dev/perf/spf_queue/review-2026-10/results/correctness-append.txt b/dev/perf/spf_queue/review-2026-10/results/correctness-append.txt deleted file mode 100644 index 24d6ed9..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/correctness-append.txt +++ /dev/null @@ -1 +0,0 @@ -checked=1351168 failed=0 diff --git a/dev/perf/spf_queue/review-2026-10/results/correctness-baseline.txt b/dev/perf/spf_queue/review-2026-10/results/correctness-baseline.txt deleted file mode 100644 index 24d6ed9..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/correctness-baseline.txt +++ /dev/null @@ -1 +0,0 @@ -checked=1351168 failed=0 diff --git a/dev/perf/spf_queue/review-2026-10/results/correctness-inline.txt b/dev/perf/spf_queue/review-2026-10/results/correctness-inline.txt deleted file mode 100644 index 24d6ed9..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/correctness-inline.txt +++ /dev/null @@ -1 +0,0 @@ -checked=1351168 failed=0 diff --git a/dev/perf/spf_queue/review-2026-10/results/correctness-settled.txt b/dev/perf/spf_queue/review-2026-10/results/correctness-settled.txt deleted file mode 100644 index 24d6ed9..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/correctness-settled.txt +++ /dev/null @@ -1 +0,0 @@ -checked=1351168 failed=0 diff --git a/dev/perf/spf_queue/review-2026-10/results/early-new.txt b/dev/perf/spf_queue/review-2026-10/results/early-new.txt deleted file mode 100644 index e4ba7ee..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/early-new.txt +++ /dev/null @@ -1 +0,0 @@ -target_cost=1 beyond_target_cost=3 (node 5 requires expanding distance-2 node 4) diff --git a/dev/perf/spf_queue/review-2026-10/results/early-old.txt b/dev/perf/spf_queue/review-2026-10/results/early-old.txt deleted file mode 100644 index e4ba7ee..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/early-old.txt +++ /dev/null @@ -1 +0,0 @@ -target_cost=1 beyond_target_cost=3 (node 5 requires expanding distance-2 node 4) diff --git a/dev/perf/spf_queue/review-2026-10/results/environment.txt b/dev/perf/spf_queue/review-2026-10/results/environment.txt deleted file mode 100644 index b12a313..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/environment.txt +++ /dev/null @@ -1,17 +0,0 @@ -Fri Oct 2 01:24:25 UTC 2026 -Darwin mbp_andrey.lan 25.6.0 Darwin Kernel Version 25.6.0: Sat Jul 11 15:27:27 PDT 2026; root:xnu-12377.161.13~4/RELEASE_ARM64_T6041 arm64 -Apple M4 Max -fbab119e794c843838f4f3f96d75cb7f32f06845 -Apple clang version 21.0.0 (clang-2100.1.1.101) -Target: arm64-apple-darwin25.6.0 -Thread model: posix -InstalledDir: /Library/Developer/CommandLineTools/usr/bin -08a76164496831181942b976ef2b2034a506df7e530853db315cd6cf956408ac build/perf/review-settled -d7aa874e4b7dfde3ed53eabf644cb8e6a73a0581638f771276ab2c851b5580c3 build/perf/review-old -c27c21e0446c0b07881cd2ab671b3ae46978082571c88a0860e71e7d920fb26d build/perf/review-singleton -493c12c969ecb87fb753796cbae177fb6214db988197fcea5eb72404e38338cc build/perf/review-heapitem -7452d40d922869ca2ea3d1f0e0f46088f57b5a01782f9c5200dd8b5ada71855f build/perf/review-notouch -e48e8976f1a0dea1c4bd6edfdd1a4490b7a5e00b40892871a1580ae5680c5106 build/perf/review-baseline -c0b0cb4d010a706f6c545cfb716fd21e10c3ed0aee4ecec7b346eeb9f9eb7ffc build/perf/cpu-queue -4e717d373d97fd2789b2292529646d4f81353c8ba205e384c818f121d4cbe167 build/perf/cpu-queue-old -aa1437b6e04447662c521979eb68b23a354447f634e777442c2dc72404f5bce7 src/shortest_paths.cpp diff --git a/dev/perf/spf_queue/review-2026-10/results/fault-fixed.txt b/dev/perf/spf_queue/review-2026-10/results/fault-fixed.txt deleted file mode 100644 index 4157487..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/fault-fixed.txt +++ /dev/null @@ -1,4 +0,0 @@ -caught forward bad_alloc -forward distance[0]=1 expected=1 -caught reverse bad_alloc -reverse distance[0]=1 expected=1 diff --git a/dev/perf/spf_queue/review-2026-10/results/fault-old.txt b/dev/perf/spf_queue/review-2026-10/results/fault-old.txt deleted file mode 100644 index 4157487..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/fault-old.txt +++ /dev/null @@ -1,4 +0,0 @@ -caught forward bad_alloc -forward distance[0]=1 expected=1 -caught reverse bad_alloc -reverse distance[0]=1 expected=1 diff --git a/dev/perf/spf_queue/review-2026-10/results/fault.txt b/dev/perf/spf_queue/review-2026-10/results/fault.txt deleted file mode 100644 index 7043974..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/fault.txt +++ /dev/null @@ -1,4 +0,0 @@ -caught forward bad_alloc -forward distance[0]=9223372036854775807 expected=1 -caught reverse bad_alloc -reverse distance[0]=9223372036854775807 expected=1 diff --git a/dev/perf/spf_queue/review-2026-10/results/final-sha256.txt b/dev/perf/spf_queue/review-2026-10/results/final-sha256.txt deleted file mode 100644 index e4b68e8..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/final-sha256.txt +++ /dev/null @@ -1,68 +0,0 @@ -2744894b016d205e8c2d27124702b8584a16c01768d0b478e43ddda37133118b build/perf/review-append -e48e8976f1a0dea1c4bd6edfdd1a4490b7a5e00b40892871a1580ae5680c5106 build/perf/review-baseline -f1930794735764cb2d83f93c3cb5fb3cea0bfaaa631cf6b35939054e95a80b87 build/perf/review-build-baseline -aca82c7a96944ea868506171cb3ecceb62d31555b927ab5aae5233fa1bcafea2 build/perf/review-build-gcd -0dbb455ca3bc0d9fc1cc73ee2ddf9ef88f06634b4d138a0dcf242ba778d9f9c4 build/perf/review-build-old -7cb163b801210c385d0b058833a977b09c0ce2061ea2b6755ddd51064fc5869a build/perf/review-combined -a4685d608080bbac1904e2884fc306a9de971129c8d4098691c061d77e50fb76 build/perf/review-early-new -b7f45fc227bc8df3679ba26d210ebb682f219d582296c8af8becc29d671a492d build/perf/review-early-old -35eb0e8a822b977e66c8fb3620c6c5cc15674e5eccee1220d2a46f2a5cd057bc build/perf/review-fault-old -a53a8436f40ec23a1bf1b6e8bd78d6e3536943a534a7d72e445520130f778d77 build/perf/review-flow-fixed -b95d95d88924ec47a404225c80fcc2757759cfdc6c1466612516abb76dbffa93 build/perf/review-flow-new -d556b4d468a45f5334bb27a460368aede0da75ab3d2188b37c851a33a05f8a8f build/perf/review-flow-old -789be1de40247f35ef8303a017a2e7f93c42ac78cfbc17738f4ac16a37837bb8 build/perf/review-frontpop -493c12c969ecb87fb753796cbae177fb6214db988197fcea5eb72404e38338cc build/perf/review-heapitem -e4611d037e1fdc92d5dd837f6cc5226b6c268b6671a2275429fd6f80c35e00e9 build/perf/review-inline -7452d40d922869ca2ea3d1f0e0f46088f57b5a01782f9c5200dd8b5ada71855f build/perf/review-notouch -d7aa874e4b7dfde3ed53eabf644cb8e6a73a0581638f771276ab2c851b5580c3 build/perf/review-old -caa9b636764606a8756cd1c24a4690d70d83d72270fd9c1907b67e94f029b696 build/perf/review-robustness -41f68d6f39296328c681c924ba430805a5a2703deed661f2237687b6bbbd7cd0 build/perf/review-robustness-fixed -41eccdff5c751acb75678bd0981afc84ee3f57666a7f5b733f2816c9a7274a7a build/perf/review-safety -08a76164496831181942b976ef2b2034a506df7e530853db315cd6cf956408ac build/perf/review-settled -c27c21e0446c0b07881cd2ab671b3ae46978082571c88a0860e71e7d920fb26d build/perf/review-singleton -f3aa9f51f195b98adb2d88e5df7cc55f34393b33c455b98e1fb94f2a1787419f dev/perf/spf_queue/review-2026-10/BRIEF.md -2adfec05029ff96cf16f4d286ec29cb81859914712d1a6e4f87a12f6e70f9274 dev/perf/spf_queue/review-2026-10/append.cpp -4a92134cff8baddcbf0eca4193b478d66414a90ac409b943372fb4a46d755e51 dev/perf/spf_queue/review-2026-10/append.patch -aa1437b6e04447662c521979eb68b23a354447f634e777442c2dc72404f5bce7 dev/perf/spf_queue/review-2026-10/baseline.cpp -480fd230f8603af15aa2f9c9ff454806552bd94c7fb88c1395c6f428a666fd54 dev/perf/spf_queue/review-2026-10/build-flow.sh -29b4e6cbdaa87570899c474aed6dad8a20beab19195c8f99a2bcaa28335811b0 dev/perf/spf_queue/review-2026-10/build-review.sh -2a991bae311206a2e4bf48d1fae14c5eebdb15b38cae0e7ed1c5278e09fc24c5 dev/perf/spf_queue/review-2026-10/change.diff -baaa96908abd5cff5dd46e1e3a19d715c7728adedede93e6af5c9cc648f67e0c dev/perf/spf_queue/review-2026-10/check-and-build.sh -1abad31a13aa7bddb492fd9331c42157f98969348773e6898fcc39bce77dd126 dev/perf/spf_queue/review-2026-10/combined.cpp -8a3515790228359aac4a25517843bad6eecf00c6bb5815ed663a3fdbe96bff64 dev/perf/spf_queue/review-2026-10/combined.patch -ef9941c043378591c5a772c7b179fe107fd3c96f64e28939f3741d5fe73f4df4 dev/perf/spf_queue/review-2026-10/construction.cpp -9138f40c0f91b6c8bc769d75aa99d6099639f7798a5373478c09278ccc6c21bf dev/perf/spf_queue/review-2026-10/driver.cpp -edca624df600d7757020c7b73869d1c79469a2c28e75f4e0f59264d401f6e298 dev/perf/spf_queue/review-2026-10/early-exit.cpp -9a57d0e6cb308ff744f6ccfc5adbf7ec96d79304453a34d45db91c1b2dbb7827 dev/perf/spf_queue/review-2026-10/exception-fix.cpp -43f1a4a9f49eaaf4e5a048c554f628110c45c1d80208d1de4b112029ca7886e9 dev/perf/spf_queue/review-2026-10/exception-fix.patch -2ef1bcd3a030c446bb0741ea6a717ec53d1e8ea5ae8da1d6301ef0b97fb7bcd8 dev/perf/spf_queue/review-2026-10/fault-public.cpp -1b29d98b499beddb38a42c9d0629418566e2e170737e6b43d3a3875a49b744e3 dev/perf/spf_queue/review-2026-10/fixed-routing.patch -28fb9c4871b23f802fcf31263743d9d42bedf14e1572e7ccc84310056758b194 dev/perf/spf_queue/review-2026-10/flow-driver.cpp -6b3587eddb0d2400b175a6e202eb76c90eefe18faab7d9e282d0d4f3fa1f52b3 dev/perf/spf_queue/review-2026-10/flow_state-fixed.cpp -ae6027fe3b7ed96e1d2a521049cc477c9a4525c43e2a7f35d7359a3d984a48b0 dev/perf/spf_queue/review-2026-10/frontpop.cpp -01ddb2f04a7a9fe09527ae775922e583a5dc601b93e555390bfbfc4b8d518e5d dev/perf/spf_queue/review-2026-10/frontpop.patch -afbc777de4ffb05584d07e23068af20ac757df393a046d912933547a66a54d11 dev/perf/spf_queue/review-2026-10/gcd.cpp -82c08c66f360d85f1c3fd22d5d025fee9e79b8d8e0c6cddc1694173121c8cc0b dev/perf/spf_queue/review-2026-10/gcd.patch -d24fc5af1fe919e9253d4c04c72f3e47cdcc494053e408d370dbadf1d1154bdc dev/perf/spf_queue/review-2026-10/heapitem.cpp -5773f710a5da67c058d1b6315f239f9f6f7593fafe2c42d5fc21ade73c56b7af dev/perf/spf_queue/review-2026-10/heapitem.patch -aab2c327bcc7312c07b8f8bc2bc02a2f21ec22e8978eb08a3dc9f4464e6518bf dev/perf/spf_queue/review-2026-10/inline.cpp -6e412d05382b923d1a8e8cc82a2ecc9c5e0a4aa6419ba2be4c2df2317ac747c5 dev/perf/spf_queue/review-2026-10/inline.patch -48aea198eac88fbc9e0cfa67192038b82dcdffd13c6e42fcf011455cc83231b4 dev/perf/spf_queue/review-2026-10/max_flow-fixed.cpp -7411f13155b1afc714eb53d14929c4a251f6ea1666eacaf35d166fc674430337 dev/perf/spf_queue/review-2026-10/notouch.cpp -4d8e57e6fb155c47713e98c02ff6f0aeeaf96bfdd9edb1b2f11c2c8d5f7dfdc4 dev/perf/spf_queue/review-2026-10/notouch.patch -9ee6053d38a0b1e44df958e423e12ab93fb54c3d80c8d4bcb623730362b8f441 dev/perf/spf_queue/review-2026-10/old-shortest_paths.cpp -12f65bdc8daa9415d118d062bdc897981bde448f5391bed2c711c8c968e9322b dev/perf/spf_queue/review-2026-10/old-strict_multidigraph.cpp -d24c175f74cf132b695beceb419cadcada130fbd6b90d2d1583ded5676ce4532 dev/perf/spf_queue/review-2026-10/robustness.cpp -bc9620bc27580072f6836f916a6388c4a7da6b17086e4f38a497b59f1a82fbf8 dev/perf/spf_queue/review-2026-10/run-netgraph-review.py -bd8575eb91dbf5e72bac1c490e38fb5a5de39175562d1972843ab940d5c04ebd dev/perf/spf_queue/review-2026-10/run-review.py -e7641bf19905876035df281a69b09819c2ac8a6d044f23cfdfa8fc73e7352673 dev/perf/spf_queue/review-2026-10/run-second.py -2c32450294701cd948f0a093c03df129e0046dcdf2f8a5a27c445f50b78ebac2 dev/perf/spf_queue/review-2026-10/run-third.py -4a0b720c75d3911cfba99caeed4f0ef3a83dd4bd44ef29ef7ef8c04a2f0fec85 dev/perf/spf_queue/review-2026-10/safety.cpp -ec2d5cfe913551b6ae225e2f2576d79a4230dbb55fe95eace34344a591f9801a dev/perf/spf_queue/review-2026-10/session.log -7522cb44d30dbd3a74171ad6ec36b1b463ca7a5b74794e4fd0cd99fa004f6eb0 dev/perf/spf_queue/review-2026-10/settled.cpp -25b8bdcf8620c78c5a16668ddb6c0454cbf5e3acdb656c2bb3c2f3a4135564c5 dev/perf/spf_queue/review-2026-10/settled.patch -56136aa3598672bf39673e55774ace0f4bc32de88406cf78a477a11cc368f93f dev/perf/spf_queue/review-2026-10/singleton.cpp -89f6374be7a2318289daeeb1c5a4d034295fb3856e7dedc15218383fe42bb4c5 dev/perf/spf_queue/review-2026-10/singleton.patch -8c0fb19a0e62d710cfc18d8e000b05e322ae3954aba6f3639358ed513736417f dev/perf/spf_queue/review-2026-10/tracked-before.json -8b106f036839a2de638a340968084bfbd04dba027129062f70c1690f57ac818c dev/perf/spf_queue/review-2026-10/validate-flow.cpp -0028e525aa9503b25a741a04523bd6bb113c4bb96359b251c4e23740baa8fd6f dev/perf/spf_queue/review-2026-10/validate-review.sh diff --git a/dev/perf/spf_queue/review-2026-10/results/flow-fixed.csv b/dev/perf/spf_queue/review-2026-10/results/flow-fixed.csv deleted file mode 100644 index 6eb842e..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/flow-fixed.csv +++ /dev/null @@ -1,5 +0,0 @@ -case,median_ms,hash,samples_ms -calc_max_flow,4.48646,17694640663380043544,5.64775;4.78913;4.61479;4.48646;4.03329;4.01167;3.85121; -place_max_flow,2.13392,7688852859487360956,2.13375;2.13392;2.16279;2.223;2.05733;2.11654;2.21879; -calc_max_flow_masked,3.53092,9889262484581709037,3.53092;3.60033;3.58104;3.63612;3.50758;3.42379;3.39221; -place_max_flow_masked,1.69258,17611571108701524338,1.66437;1.69258;1.68496;1.68037;1.81683;1.83758;1.73129; diff --git a/dev/perf/spf_queue/review-2026-10/results/flow-new1.csv b/dev/perf/spf_queue/review-2026-10/results/flow-new1.csv deleted file mode 100644 index 46ed257..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/flow-new1.csv +++ /dev/null @@ -1,5 +0,0 @@ -case,median_ms,hash,samples_ms -calc_max_flow,5.13942,17694640663380043544,5.13942;5.10746;5.1655;5.18054;5.18562;5.04467;5.0835; -place_max_flow,3.85738,7688852859487360956,3.99929;3.89029;4.10267;3.83567;3.80596;3.85738;3.74071; -calc_max_flow_masked,4.94221,9889262484581709037,4.97875;4.96592;4.87587;4.852;4.91233;4.94221;4.98758; -place_max_flow_masked,3.09025,17611571108701524338,3.1895;3.08596;3.09504;3.09025;2.93175;2.93383;3.11563; diff --git a/dev/perf/spf_queue/review-2026-10/results/flow-new2.csv b/dev/perf/spf_queue/review-2026-10/results/flow-new2.csv deleted file mode 100644 index da00167..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/flow-new2.csv +++ /dev/null @@ -1,5 +0,0 @@ -case,median_ms,hash,samples_ms -calc_max_flow,4.97171,17694640663380043544,12.4792;5.39579;4.84162;4.97171;4.92546;4.85942;5.09742; -place_max_flow,3.78354,7688852859487360956,3.86612;4.03892;3.81296;3.77933;3.77858;3.77338;3.78354; -calc_max_flow_masked,4.84563,9889262484581709037,4.76683;4.93637;4.70108;4.87838;4.74988;4.87417;4.84563; -place_max_flow_masked,2.95096,17611571108701524338,3.04258;2.95096;2.95379;2.94037;2.93587;3.03713;2.88842; diff --git a/dev/perf/spf_queue/review-2026-10/results/flow-old1.csv b/dev/perf/spf_queue/review-2026-10/results/flow-old1.csv deleted file mode 100644 index ab82099..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/flow-old1.csv +++ /dev/null @@ -1,5 +0,0 @@ -case,median_ms,hash,samples_ms -calc_max_flow,7.10546,17694640663380043544,7.31242;7.09342;7.04033;7.10546;6.95367;7.22346;7.20029; -place_max_flow,5.75662,7688852859487360956,5.85796;5.75662;6.00521;5.74729;6.26367;5.69708;5.68617; -calc_max_flow_masked,6.03229,9889262484581709037,6.01621;6.01492;6.09821;6.08133;6.05596;6.03229;5.88475; -place_max_flow_masked,4.25525,17611571108701524338,4.16933;4.25525;4.1865;4.20538;4.41496;4.33842;4.36954; diff --git a/dev/perf/spf_queue/review-2026-10/results/flow-old2.csv b/dev/perf/spf_queue/review-2026-10/results/flow-old2.csv deleted file mode 100644 index 011041b..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/flow-old2.csv +++ /dev/null @@ -1,5 +0,0 @@ -case,median_ms,hash,samples_ms -calc_max_flow,6.90321,17694640663380043544,7.05717;7.08817;6.90321;6.98071;6.71904;6.76312;6.74846; -place_max_flow,5.64579,7688852859487360956,5.64337;5.49796;5.64579;5.71962;5.80021;5.50375;5.68417; -calc_max_flow_masked,6.03338,9889262484581709037,6.05592;5.85275;6.01258;5.94246;6.03558;6.03338;6.18562; -place_max_flow_masked,4.1495,17611571108701524338,4.27125;4.15796;4.13183;4.11067;4.1495;4.16883;4.12979; diff --git a/dev/perf/spf_queue/review-2026-10/results/frontpop.csv b/dev/perf/spf_queue/review-2026-10/results/frontpop.csv deleted file mode 100644 index 3510c69..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/frontpop.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,65.101375,7,64.946167;64.845875;65.101375;66.617167;65.057041;65.682500;66.550625 -clos800,800,320000,16,none,ref,0,2,36.453708,7,36.859000;36.501083;36.235875;36.292584;36.453708;36.801833;36.208833 -chain2048,2048,2047,256,none,ref,0,32,3.965958,7,3.979875;3.971083;3.965958;3.944334;3.945583;4.008875;3.904917 -near16,16384,131072,64,near,ref,0,32,0.399084,7,0.401458;0.397583;0.398417;0.399084;0.413459;0.399000;0.400584 -wide20,16384,131072,16,none,ref,0,1048570,52.007125,7,51.567083;51.588583;53.758542;51.699834;52.041208;52.007125;53.121209 -wide25,16384,131072,16,none,ref,1,33554196,52.792000,7,52.439583;52.826333;52.792000;51.533250;53.775917;52.867667;51.741292 diff --git a/dev/perf/spf_queue/review-2026-10/results/heap-a1.csv b/dev/perf/spf_queue/review-2026-10/results/heap-a1.csv deleted file mode 100644 index c17721f..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/heap-a1.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,65.364334,7,64.756958;65.060417;65.364334;65.463167;65.001625;68.270959;66.302125 -clos800,800,320000,16,none,ref,0,2,36.700709,7,36.959416;37.673333;37.636916;36.700709;35.915000;35.896875;35.791000 -chain2048,2048,2047,256,none,ref,0,32,3.833208,7,3.968084;3.818500;3.833208;3.799833;3.817417;3.895208;3.882750 -near16,16384,131072,64,near,ref,0,32,0.398833,7,0.398500;0.405291;0.396542;0.413500;0.398833;0.399917;0.394875 -wide20,16384,131072,16,none,ref,0,1048570,52.299333,7,51.901458;52.139167;51.921916;53.186583;52.547000;52.299333;52.796500 -wide25,16384,131072,16,none,ref,1,33554196,53.495500,7,53.457000;54.495542;53.495500;52.239750;53.158500;60.914167;54.363500 diff --git a/dev/perf/spf_queue/review-2026-10/results/heap-a2.csv b/dev/perf/spf_queue/review-2026-10/results/heap-a2.csv deleted file mode 100644 index fbe8c21..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/heap-a2.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,66.959792,7,66.386958;67.185125;66.959792;66.987375;67.456792;66.839292;66.192000 -clos800,800,320000,16,none,ref,0,2,38.442333,7,38.359708;38.193083;38.292875;38.442333;38.770833;38.635042;38.761959 -chain2048,2048,2047,256,none,ref,0,32,3.979125,7,4.027625;3.979125;4.017958;3.958625;3.933666;3.968250;4.100041 -near16,16384,131072,64,near,ref,0,32,0.408334,7,0.408334;0.419542;0.399500;0.417125;0.396375;0.395500;0.467042 -wide20,16384,131072,16,none,ref,0,1048570,53.113375,7,55.139833;53.365250;55.346916;52.507042;52.512042;52.528292;53.113375 -wide25,16384,131072,16,none,ref,1,33554196,52.646833,7,52.678042;52.766708;52.343250;52.677500;52.646833;52.270042;52.505291 diff --git a/dev/perf/spf_queue/review-2026-10/results/heap-b.csv b/dev/perf/spf_queue/review-2026-10/results/heap-b.csv deleted file mode 100644 index ab7c51b..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/heap-b.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,64.951416,7,65.302459;65.453125;65.329416;64.659166;64.446250;64.951416;64.798125 -clos800,800,320000,16,none,ref,0,2,36.226458,7,37.005584;36.537167;36.034083;36.226458;36.123375;36.493833;35.697291 -chain2048,2048,2047,256,none,ref,0,32,3.839833,7,3.794083;3.867417;3.791833;3.912125;3.839833;3.788834;3.859667 -near16,16384,131072,64,near,ref,0,32,0.395334,7,0.397834;0.406833;0.417958;0.395334;0.394542;0.395083;0.394875 -wide20,16384,131072,16,none,ref,0,1048570,52.248167,7,51.969083;52.299750;52.537667;53.162542;52.145375;52.248167;52.134541 -wide25,16384,131072,16,none,ref,1,33554196,52.650625,7,56.771042;52.650625;52.413416;53.770875;51.828708;52.274333;52.884625 diff --git a/dev/perf/spf_queue/review-2026-10/results/heapitem.csv b/dev/perf/spf_queue/review-2026-10/results/heapitem.csv deleted file mode 100644 index a8d22f9..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/heapitem.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,63.692209,7,63.582792;63.341250;63.692209;64.101500;62.794375;63.752166;64.545166 -clos800,800,320000,16,none,ref,0,2,36.740125,7,36.518208;36.507958;37.177708;36.930292;36.740125;36.797583;36.663875 -chain2048,2048,2047,256,none,ref,0,32,3.830709,7,3.828250;3.830709;3.863875;3.904916;3.865083;3.727083;3.738709 -near16,16384,131072,64,near,ref,0,32,0.401250,7,0.401167;0.421708;0.406167;0.398958;0.404458;0.401250;0.394708 -wide20,16384,131072,16,none,ref,0,1048570,55.679208,7,55.679208;54.908417;55.376417;55.983709;55.692125;55.792750;55.223375 -wide25,16384,131072,16,none,ref,1,33554196,55.341667,7,55.341667;54.808333;54.961084;56.034541;58.019625;56.560750;53.575291 diff --git a/dev/perf/spf_queue/review-2026-10/results/inline-c.csv b/dev/perf/spf_queue/review-2026-10/results/inline-c.csv deleted file mode 100644 index eea0177..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/inline-c.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,37.301584,7,38.150125;36.608291;36.901625;37.301584;37.162167;38.334875;38.687125 -clos800,800,320000,16,none,ref,0,2,36.877125,7,37.527166;36.744125;36.877125;38.040291;37.015458;36.548667;36.728291 -chain2048,2048,2047,256,none,ref,0,32,3.618334,7,3.663250;3.590083;3.618334;3.732833;3.579958;3.576042;3.697333 -near16,16384,131072,64,near,ref,0,32,0.398833,7,0.404917;0.398667;0.398209;0.400166;0.398417;0.398833;0.409833 -wide20,16384,131072,16,none,ref,0,1048570,54.109250,7,53.218667;53.234042;52.633916;54.959083;54.398584;54.646250;54.109250 -wide25,16384,131072,16,none,ref,1,33554196,52.257916,7,51.796250;52.257916;52.638666;51.878834;51.613834;52.369708;52.386042 diff --git a/dev/perf/spf_queue/review-2026-10/results/inline.csv b/dev/perf/spf_queue/review-2026-10/results/inline.csv deleted file mode 100644 index aa86258..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/inline.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,36.222334,7,36.340209;36.811125;36.222334;36.304458;35.971375;35.917041;36.017125 -clos800,800,320000,16,none,ref,0,2,35.565125,7,35.587916;35.565125;35.683458;35.919167;35.414541;35.554083;35.558292 -chain2048,2048,2047,256,none,ref,0,32,3.540667,7,3.521250;3.546916;3.657333;3.527917;3.520250;3.584084;3.540667 -near16,16384,131072,64,near,ref,0,32,0.398500,7,0.400458;0.393000;0.392417;0.398500;0.401208;0.395834;0.439000 -wide20,16384,131072,16,none,ref,0,1048570,52.062375,7,50.544250;50.945583;52.159042;53.085375;52.836917;52.062375;51.674417 -wide25,16384,131072,16,none,ref,1,33554196,50.749417,7,50.995958;50.749417;50.962750;50.671917;50.935917;50.605375;50.528250 diff --git a/dev/perf/spf_queue/review-2026-10/results/load-flow-screen.txt b/dev/perf/spf_queue/review-2026-10/results/load-flow-screen.txt deleted file mode 100644 index 9e06b03..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/load-flow-screen.txt +++ /dev/null @@ -1,5 +0,0 @@ - 2:34 up 52 days, 7:10, 3 users, load averages: 3.54 4.58 3.94 - 2:34 up 52 days, 7:10, 3 users, load averages: 3.54 4.58 3.94 - 2:34 up 52 days, 7:10, 3 users, load averages: 3.54 4.58 3.94 - 2:34 up 52 days, 7:10, 3 users, load averages: 3.54 4.58 3.94 - 2:34 up 52 days, 7:10, 3 users, load averages: 3.54 4.58 3.94 diff --git a/dev/perf/spf_queue/review-2026-10/results/load-flow.txt b/dev/perf/spf_queue/review-2026-10/results/load-flow.txt deleted file mode 100644 index f1cb9c1..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/load-flow.txt +++ /dev/null @@ -1,5 +0,0 @@ - 2:35 up 52 days, 7:11, 3 users, load averages: 4.76 4.70 4.02 - 2:35 up 52 days, 7:11, 3 users, load averages: 4.76 4.70 4.02 - 2:35 up 52 days, 7:11, 3 users, load averages: 4.76 4.70 4.02 - 2:35 up 52 days, 7:11, 3 users, load averages: 4.46 4.64 4.01 - 2:35 up 52 days, 7:11, 3 users, load averages: 4.46 4.64 4.01 diff --git a/dev/perf/spf_queue/review-2026-10/results/load-second.txt b/dev/perf/spf_queue/review-2026-10/results/load-second.txt deleted file mode 100644 index 248ed13..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/load-second.txt +++ /dev/null @@ -1,24 +0,0 @@ -old-b1 2:28 up 52 days, 7:04, 3 users, load averages: 3.66 3.48 3.15 -new-b1 2:28 up 52 days, 7:04, 3 users, load averages: 3.66 3.48 3.15 -settled-b 2:28 up 52 days, 7:04, 3 users, load averages: 3.66 3.48 3.15 -inline 2:28 up 52 days, 7:04, 3 users, load averages: 3.53 3.45 3.14 -append 2:28 up 52 days, 7:04, 3 users, load averages: 3.53 3.45 3.14 -frontpop 2:28 up 52 days, 7:04, 3 users, load averages: 3.57 3.46 3.15 -heap-b 2:29 up 52 days, 7:04, 3 users, load averages: 3.57 3.46 3.15 -new-b2 2:29 up 52 days, 7:04, 3 users, load averages: 3.60 3.47 3.15 -old-b2 2:29 up 52 days, 7:04, 3 users, load averages: 3.60 3.47 3.15 -build-old1 2:29 up 52 days, 7:04, 3 users, load averages: 3.60 3.47 3.15 -build-new1 2:29 up 52 days, 7:04, 3 users, load averages: 3.60 3.47 3.15 -build-gcd 2:29 up 52 days, 7:04, 3 users, load averages: 4.67 3.69 3.23 -build-new2 2:29 up 52 days, 7:04, 3 users, load averages: 4.67 3.69 3.23 -build-old2 2:29 up 52 days, 7:04, 3 users, load averages: 4.67 3.69 3.23 -reverse-old1 2:29 up 52 days, 7:04, 3 users, load averages: 4.67 3.69 3.23 -reverse-new1 2:29 up 52 days, 7:04, 3 users, load averages: 4.67 3.69 3.23 -reverse-heap 2:29 up 52 days, 7:04, 3 users, load averages: 4.67 3.69 3.23 -reverse-settled 2:29 up 52 days, 7:04, 3 users, load averages: 4.67 3.69 3.23 -reverse-old2 2:29 up 52 days, 7:04, 3 users, load averages: 4.67 3.69 3.23 -boundary-old1 2:29 up 52 days, 7:04, 3 users, load averages: 4.67 3.69 3.23 -boundary-new1 2:29 up 52 days, 7:04, 3 users, load averages: 4.46 3.67 3.22 -boundary-heap 2:29 up 52 days, 7:04, 3 users, load averages: 4.46 3.67 3.22 -boundary-settled 2:29 up 52 days, 7:05, 3 users, load averages: 4.42 3.67 3.23 -boundary-old2 2:29 up 52 days, 7:05, 3 users, load averages: 4.42 3.67 3.23 diff --git a/dev/perf/spf_queue/review-2026-10/results/load-third.txt b/dev/perf/spf_queue/review-2026-10/results/load-third.txt deleted file mode 100644 index ed7b1df..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/load-third.txt +++ /dev/null @@ -1,10 +0,0 @@ -old-c1 2:31 up 52 days, 7:07, 3 users, load averages: 8.46 5.42 3.97 -new-c1 2:31 up 52 days, 7:07, 3 users, load averages: 8.19 5.41 3.98 -append-c 2:31 up 52 days, 7:07, 3 users, load averages: 8.19 5.41 3.98 -combined 2:31 up 52 days, 7:07, 3 users, load averages: 8.19 5.41 3.98 -inline-c 2:31 up 52 days, 7:07, 3 users, load averages: 7.93 5.41 3.98 -new-c2 2:31 up 52 days, 7:07, 3 users, load averages: 7.93 5.41 3.98 -old-c2 2:31 up 52 days, 7:07, 3 users, load averages: 8.26 5.51 4.03 -build-repeat-new1 2:31 up 52 days, 7:07, 3 users, load averages: 8.26 5.51 4.03 -build-repeat-gcd 2:31 up 52 days, 7:07, 3 users, load averages: 8.26 5.51 4.03 -build-repeat-new2 2:31 up 52 days, 7:07, 3 users, load averages: 8.26 5.51 4.03 diff --git a/dev/perf/spf_queue/review-2026-10/results/load.txt b/dev/perf/spf_queue/review-2026-10/results/load.txt deleted file mode 100644 index 6437b22..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/load.txt +++ /dev/null @@ -1,12 +0,0 @@ -old-a1 2:24 up 52 days, 7 hrs, 3 users, load averages: 3.69 3.44 3.03 -new-a1 2:24 up 52 days, 7 hrs, 3 users, load averages: 3.69 3.44 3.03 -heap-a1 2:24 up 52 days, 7 hrs, 3 users, load averages: 4.04 3.52 3.06 -settled 2:24 up 52 days, 7 hrs, 3 users, load averages: 4.04 3.52 3.06 -settled-heap 2:24 up 52 days, 7 hrs, 3 users, load averages: 4.11 3.54 3.07 -singleton 2:24 up 52 days, 7 hrs, 3 users, load averages: 4.11 3.54 3.07 -heapitem 2:24 up 52 days, 7 hrs, 3 users, load averages: 4.42 3.61 3.10 -notouch 2:24 up 52 days, 7 hrs, 3 users, load averages: 4.42 3.61 3.10 -new-a2 2:24 up 52 days, 7 hrs, 3 users, load averages: 4.39 3.62 3.10 -heap-a2 2:24 up 52 days, 7 hrs, 3 users, load averages: 4.39 3.62 3.10 -old-a2 2:24 up 52 days, 7 hrs, 3 users, load averages: 4.39 3.62 3.10 -end 2:24 up 52 days, 7 hrs, 3 users, load averages: 4.28 3.61 3.10 diff --git a/dev/perf/spf_queue/review-2026-10/results/memory.txt b/dev/perf/spf_queue/review-2026-10/results/memory.txt deleted file mode 100644 index 5f003a1..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/memory.txt +++ /dev/null @@ -1,3 +0,0 @@ -after 1 graphs: bucket item capacity bytes=16400 N=1027 E=1026 peak_queue=1024 -after 63 graphs: bucket item capacity bytes=1032208 N=1027 E=1026 peak_queue=1024 -after N=2 graph: bucket item capacity bytes=1032208 dist_capacity=2 diff --git a/dev/perf/spf_queue/review-2026-10/results/netgraph-environment.txt b/dev/perf/spf_queue/review-2026-10/results/netgraph-environment.txt deleted file mode 100644 index 16d68f0..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/netgraph-environment.txt +++ /dev/null @@ -1,16 +0,0 @@ -old1 2:32 up 52 days, 7:08, 3 users, load averages: 5.87 5.41 4.09 -91dc3b7e2bd3611d54d762e0fb1f875881138d66843866990f019cddfec5cc2c /Users/networmix/ws/NetGraph-Core-main/venv/lib/python3.14/site-packages/_netgraph_core.cpython-314-darwin.so -/Users/networmix/ws/NetGraph-Core-main/venv/lib/python3.14/site-packages/_netgraph_core.cpython-314-darwin.so -/Users/networmix/ws/NetGraph-Core-main/python/netgraph_core/__init__.py -new 2:32 up 52 days, 7:08, 3 users, load averages: 5.64 5.37 4.08 -996ae1c6aef243b9950c345651c901b155b375da2fc429d00f079de0df67e116 /Users/networmix/ws/NetGraph-Core/venv/lib/python3.14/site-packages/_netgraph_core.cpython-314-darwin.so -/Users/networmix/ws/NetGraph-Core/venv/lib/python3.14/site-packages/_netgraph_core.cpython-314-darwin.so -/Users/networmix/ws/NetGraph-Core/python/netgraph_core/__init__.py -heap 2:32 up 52 days, 7:08, 3 users, load averages: 5.64 5.37 4.08 -996ae1c6aef243b9950c345651c901b155b375da2fc429d00f079de0df67e116 /Users/networmix/ws/NetGraph-Core/venv/lib/python3.14/site-packages/_netgraph_core.cpython-314-darwin.so -/Users/networmix/ws/NetGraph-Core/venv/lib/python3.14/site-packages/_netgraph_core.cpython-314-darwin.so -/Users/networmix/ws/NetGraph-Core/python/netgraph_core/__init__.py -old2 2:32 up 52 days, 7:08, 3 users, load averages: 5.74 5.40 4.10 -91dc3b7e2bd3611d54d762e0fb1f875881138d66843866990f019cddfec5cc2c /Users/networmix/ws/NetGraph-Core-main/venv/lib/python3.14/site-packages/_netgraph_core.cpython-314-darwin.so -/Users/networmix/ws/NetGraph-Core-main/venv/lib/python3.14/site-packages/_netgraph_core.cpython-314-darwin.so -/Users/networmix/ws/NetGraph-Core-main/python/netgraph_core/__init__.py diff --git a/dev/perf/spf_queue/review-2026-10/results/netgraph-heap.csv b/dev/perf/spf_queue/review-2026-10/results/netgraph-heap.csv deleted file mode 100644 index 67501d0..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/netgraph-heap.csv +++ /dev/null @@ -1,5 +0,0 @@ -workload,label,median_s,samples,checksum,samples_s -spf_full_clos_200x200,heap,0.215680,5,946e7cf8b5a36980,0.215093;0.215680;0.217045;0.214475;0.215709 -spf_full_grid_100x100_weighted,heap,0.235446,5,4c2f7751ff9a5057,0.235446;0.235428;0.234089;0.236250;0.238091 -maxflow_grid_100x100_weighted_combine,heap,0.216545,5,b0453b641647871b,0.216545;0.214937;0.219780;0.215704;0.216563 -placement_backbone_clos,heap,0.067445,5,a3bbef71a61656e1,0.068307;0.068552;0.067445;0.067098;0.066769 diff --git a/dev/perf/spf_queue/review-2026-10/results/netgraph-new.csv b/dev/perf/spf_queue/review-2026-10/results/netgraph-new.csv deleted file mode 100644 index a4840b0..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/netgraph-new.csv +++ /dev/null @@ -1,5 +0,0 @@ -workload,label,median_s,samples,checksum,samples_s -spf_full_clos_200x200,new,0.215551,5,946e7cf8b5a36980,0.215551;0.215907;0.218243;0.214622;0.214636 -spf_full_grid_100x100_weighted,new,0.169151,5,4c2f7751ff9a5057,0.171143;0.169339;0.168719;0.169148;0.169151 -maxflow_grid_100x100_weighted_combine,new,0.160344,5,b0453b641647871b,0.160344;0.159538;0.159366;0.161421;0.160476 -placement_backbone_clos,new,0.067171,5,a3bbef71a61656e1,0.067222;0.067171;0.067064;0.066943;0.069957 diff --git a/dev/perf/spf_queue/review-2026-10/results/netgraph-old1.csv b/dev/perf/spf_queue/review-2026-10/results/netgraph-old1.csv deleted file mode 100644 index 060b57b..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/netgraph-old1.csv +++ /dev/null @@ -1,5 +0,0 @@ -workload,label,median_s,samples,checksum,samples_s -spf_full_clos_200x200,old1,0.212167,5,946e7cf8b5a36980,0.212167;0.211826;0.211942;0.215837;0.213694 -spf_full_grid_100x100_weighted,old1,0.219249,5,4c2f7751ff9a5057,0.220500;0.219115;0.219249;0.219192;0.219299 -maxflow_grid_100x100_weighted_combine,old1,0.201715,5,b0453b641647871b,0.201715;0.200953;0.200980;0.201761;0.202154 -placement_backbone_clos,old1,0.068333,5,a3bbef71a61656e1,0.071259;0.069470;0.068333;0.067665;0.067146 diff --git a/dev/perf/spf_queue/review-2026-10/results/netgraph-old2.csv b/dev/perf/spf_queue/review-2026-10/results/netgraph-old2.csv deleted file mode 100644 index 8fb46d2..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/netgraph-old2.csv +++ /dev/null @@ -1,5 +0,0 @@ -workload,label,median_s,samples,checksum,samples_s -spf_full_clos_200x200,old2,0.212584,5,946e7cf8b5a36980,0.215052;0.213002;0.212584;0.211673;0.211985 -spf_full_grid_100x100_weighted,old2,0.219160,5,4c2f7751ff9a5057,0.219166;0.219314;0.219160;0.218651;0.218967 -maxflow_grid_100x100_weighted_combine,old2,0.204482,5,b0453b641647871b,0.204155;0.205131;0.202814;0.206448;0.204482 -placement_backbone_clos,old2,0.068736,5,a3bbef71a61656e1,0.073860;0.068736;0.069312;0.067899;0.067647 diff --git a/dev/perf/spf_queue/review-2026-10/results/new-a1.csv b/dev/perf/spf_queue/review-2026-10/results/new-a1.csv deleted file mode 100644 index 437e0c8..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/new-a1.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,36.102459,7,35.199334;35.690875;38.373709;38.308667;36.978666;35.956458;36.102459 -clos800,800,320000,16,none,ref,0,2,34.775459,7,34.500583;34.933417;35.096750;34.769667;34.775459;34.758333;34.956375 -chain2048,2048,2047,256,none,ref,0,32,5.474458,7,5.474458;5.427250;5.537375;5.544917;5.404750;5.244750;5.608208 -near16,16384,131072,64,near,ref,0,32,0.399542,7,0.405667;0.398833;0.399542;0.397875;0.397334;0.401334;0.527208 -wide20,16384,131072,16,none,ref,0,1048570,53.540750,7,54.768666;53.546709;53.889500;53.540750;51.955959;51.682333;51.324000 -wide25,16384,131072,16,none,ref,1,33554196,51.896333,7,51.418416;51.288208;51.196500;52.728375;51.896333;52.195541;51.900459 diff --git a/dev/perf/spf_queue/review-2026-10/results/new-a2.csv b/dev/perf/spf_queue/review-2026-10/results/new-a2.csv deleted file mode 100644 index 1c162b2..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/new-a2.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,35.831792,7,36.189667;35.773375;35.695792;35.831792;35.725083;36.297667;36.324250 -clos800,800,320000,16,none,ref,0,2,35.326334,7,35.498916;35.326334;35.124375;35.321917;35.321917;36.149375;35.790500 -chain2048,2048,2047,256,none,ref,0,32,5.512958,7,5.512958;5.298833;5.615125;5.432375;5.915125;5.433833;5.638750 -near16,16384,131072,64,near,ref,0,32,0.400833,7,0.441875;0.400833;0.399042;0.400542;0.401750;0.402708;0.397625 -wide20,16384,131072,16,none,ref,0,1048570,53.120167,7,53.321333;53.068083;53.353167;52.922958;52.360084;53.120167;53.810084 -wide25,16384,131072,16,none,ref,1,33554196,53.291750,7,52.971000;53.298666;53.693958;52.207583;53.393625;53.291750;53.217625 diff --git a/dev/perf/spf_queue/review-2026-10/results/new-b1.csv b/dev/perf/spf_queue/review-2026-10/results/new-b1.csv deleted file mode 100644 index 8a65069..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/new-b1.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,36.437375,7,36.458750;36.437375;38.069833;36.224666;36.010291;36.396042;37.253792 -clos800,800,320000,16,none,ref,0,2,35.225250,7,34.455709;35.475875;35.529291;35.883833;35.225250;35.163875;34.286250 -chain2048,2048,2047,256,none,ref,0,32,5.232375,7,5.232375;5.161833;5.193875;5.241000;5.442209;5.288500;5.128166 -near16,16384,131072,64,near,ref,0,32,0.393208,7,0.393208;0.422042;0.394291;0.392958;0.393208;0.392875;0.528167 -wide20,16384,131072,16,none,ref,0,1048570,53.174917,7,58.161917;55.200583;57.610292;53.174917;52.132458;52.841833;52.854667 -wide25,16384,131072,16,none,ref,1,33554196,51.289500,7,51.586333;51.127750;51.289500;51.351333;51.227667;51.099000;51.490417 diff --git a/dev/perf/spf_queue/review-2026-10/results/new-b2.csv b/dev/perf/spf_queue/review-2026-10/results/new-b2.csv deleted file mode 100644 index 39a7f2c..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/new-b2.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,36.404042,7,36.404042;36.566625;36.893834;36.811875;35.520042;35.665875;35.423125 -clos800,800,320000,16,none,ref,0,2,36.691083,7,36.693000;36.691083;36.777375;36.492916;36.721750;36.260792;36.411417 -chain2048,2048,2047,256,none,ref,0,32,5.462458,7,5.488625;5.462458;5.325083;5.346833;5.282000;5.603708;5.615292 -near16,16384,131072,64,near,ref,0,32,0.398292,7,0.402209;0.398041;0.398292;0.403500;0.398208;0.398250;0.398875 -wide20,16384,131072,16,none,ref,0,1048570,52.534209,7,53.152833;52.864875;52.173209;52.534209;51.993000;51.975541;52.824167 -wide25,16384,131072,16,none,ref,1,33554196,51.805792,7,51.580833;51.931458;51.890167;51.724667;51.865584;51.728083;51.805792 diff --git a/dev/perf/spf_queue/review-2026-10/results/new-c1.csv b/dev/perf/spf_queue/review-2026-10/results/new-c1.csv deleted file mode 100644 index b1b8412..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/new-c1.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,37.139500,7,36.504416;36.802209;37.139500;38.133916;37.465166;37.054833;38.317666 -clos800,800,320000,16,none,ref,0,2,35.800416,7,35.303583;36.024750;36.169166;36.144750;35.518792;35.800416;35.443250 -chain2048,2048,2047,256,none,ref,0,32,5.607416,7,5.607416;5.575208;5.649000;5.624584;5.693125;5.270625;5.536083 -near16,16384,131072,64,near,ref,0,32,0.399667,7,0.399667;0.400375;0.400917;0.394542;0.392208;0.392333;0.568209 -wide20,16384,131072,16,none,ref,0,1048570,54.419250,7,54.419250;54.934584;53.961750;58.652125;55.884458;53.924291;54.415167 -wide25,16384,131072,16,none,ref,1,33554196,53.599334,7,53.390917;53.599334;53.398084;53.811875;54.324917;53.464542;53.836208 diff --git a/dev/perf/spf_queue/review-2026-10/results/new-c2.csv b/dev/perf/spf_queue/review-2026-10/results/new-c2.csv deleted file mode 100644 index ae9d0a6..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/new-c2.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,35.804375,7,37.196709;36.498875;35.856709;35.804375;35.703334;35.523000;35.752333 -clos800,800,320000,16,none,ref,0,2,35.380250,7,34.909542;34.823166;35.486375;35.805667;35.469875;35.380250;35.227375 -chain2048,2048,2047,256,none,ref,0,32,5.569458,7,5.755125;5.363458;5.786500;5.960208;5.536959;5.553417;5.569458 -near16,16384,131072,64,near,ref,0,32,0.410583,7,0.410583;0.406541;0.480917;0.408208;0.413625;0.412792;0.404583 -wide20,16384,131072,16,none,ref,0,1048570,54.147083,7,55.012958;54.037667;54.147083;54.609250;58.278625;53.827542;52.777916 -wide25,16384,131072,16,none,ref,1,33554196,55.281334,7,55.281334;55.300417;54.891208;55.485083;54.879417;56.778000;54.301083 diff --git a/dev/perf/spf_queue/review-2026-10/results/notouch.csv b/dev/perf/spf_queue/review-2026-10/results/notouch.csv deleted file mode 100644 index 02b2e38..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/notouch.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,64.783708,7,65.583000;64.783708;65.158708;64.282958;63.893125;63.678958;64.801625 -clos800,800,320000,16,none,ref,0,2,35.476459,7,35.412791;35.386666;35.476459;35.508416;36.327584;39.811958;35.461834 -chain2048,2048,2047,256,none,ref,0,32,3.864416,7,3.864416;3.936958;3.750625;3.911541;3.743166;3.722166;3.938917 -near16,16384,131072,64,near,ref,0,32,1.233209,7,1.226250;1.227584;1.253875;1.213750;1.234292;1.233209;1.234292 -wide20,16384,131072,16,none,ref,0,1048570,51.039792,7,51.251833;50.497792;50.554000;50.847667;51.061708;53.260042;51.039792 -wide25,16384,131072,16,none,ref,1,33554196,52.060541,7,52.351833;52.380375;52.677792;51.515375;50.656833;51.244459;52.060541 diff --git a/dev/perf/spf_queue/review-2026-10/results/old-a1.csv b/dev/perf/spf_queue/review-2026-10/results/old-a1.csv deleted file mode 100644 index ccec261..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/old-a1.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,62.661917,7,63.361167;62.796042;62.371417;61.183166;61.657917;62.661917;63.670791 -clos800,800,320000,16,none,ref,0,2,36.783917,7,37.430625;37.233792;36.468375;36.783917;36.168666;35.968083;37.098833 -chain2048,2048,2047,256,none,ref,0,32,3.057542,7,2.948125;3.260791;3.042750;3.032208;3.165833;3.057542;3.211375 -near16,16384,131072,64,near,ref,0,32,1.092541,7,1.155958;1.279500;1.142084;1.017416;1.051916;1.092042;1.092541 -wide20,16384,131072,16,none,ref,0,1048570,50.820084,7,50.733791;50.221000;50.086750;50.820084;52.921666;52.840042;54.729458 -wide25,16384,131072,16,none,ref,1,33554196,52.896333,7,51.156375;53.299750;53.507959;52.896333;52.904042;51.731917;52.568667 diff --git a/dev/perf/spf_queue/review-2026-10/results/old-a2.csv b/dev/perf/spf_queue/review-2026-10/results/old-a2.csv deleted file mode 100644 index 5f08a8f..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/old-a2.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,64.328708,7,65.860417;67.227500;69.844208;63.343917;64.328708;63.977542;63.754750 -clos800,800,320000,16,none,ref,0,2,36.826667,7,37.149125;37.741042;36.605166;36.811375;36.489750;37.581458;36.826667 -chain2048,2048,2047,256,none,ref,0,32,2.990084,7,3.024791;2.990084;3.052875;2.964083;2.980958;3.019750;2.961667 -near16,16384,131072,64,near,ref,0,32,1.199667,7,1.191166;1.206875;1.208292;1.198708;1.190500;1.199667;1.214209 -wide20,16384,131072,16,none,ref,0,1048570,52.035958,7,52.165042;53.316625;52.035958;51.401875;51.295833;52.107750;51.960041 -wide25,16384,131072,16,none,ref,1,33554196,50.975833,7,51.363500;51.186625;50.880625;50.549250;50.549292;50.975833;52.110708 diff --git a/dev/perf/spf_queue/review-2026-10/results/old-b1.csv b/dev/perf/spf_queue/review-2026-10/results/old-b1.csv deleted file mode 100644 index 7872b37..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/old-b1.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,61.637542,7,61.942917;61.538791;61.641750;61.961083;61.442291;61.637542;61.568000 -clos800,800,320000,16,none,ref,0,2,36.004833,7,36.153250;36.197042;36.012833;35.441125;35.771541;35.491000;36.004833 -chain2048,2048,2047,256,none,ref,0,32,2.928958,7,2.928958;2.962292;3.033417;2.922334;2.904750;2.933958;2.916417 -near16,16384,131072,64,near,ref,0,32,1.124750,7,1.148625;1.238209;1.100708;1.092209;1.103667;1.124750;1.146000 -wide20,16384,131072,16,none,ref,0,1048570,48.971583,7,48.840500;48.835708;49.106709;48.960542;49.092709;48.971583;49.089000 -wide25,16384,131072,16,none,ref,1,33554196,50.581042,7,49.251042;48.808875;49.316292;50.581042;52.614792;53.201250;54.016584 diff --git a/dev/perf/spf_queue/review-2026-10/results/old-b2.csv b/dev/perf/spf_queue/review-2026-10/results/old-b2.csv deleted file mode 100644 index fa019c1..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/old-b2.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,63.736459,7,63.736459;63.137583;63.046959;62.846708;63.976583;65.935500;64.739708 -clos800,800,320000,16,none,ref,0,2,36.966917,7,37.437042;37.236958;36.966917;36.455042;36.567083;37.288084;36.713500 -chain2048,2048,2047,256,none,ref,0,32,3.113500,7,3.200416;3.036708;3.115750;3.069792;3.124292;2.971250;3.113500 -near16,16384,131072,64,near,ref,0,32,1.156625,7,1.156625;1.151708;1.166083;1.200334;1.282791;1.110500;1.146583 -wide20,16384,131072,16,none,ref,0,1048570,50.562250,7,50.748417;50.573167;50.539084;50.234584;50.562250;49.976792;50.625125 -wide25,16384,131072,16,none,ref,1,33554196,50.306625,7,50.242875;50.322416;51.103708;50.099792;50.298542;50.384375;50.306625 diff --git a/dev/perf/spf_queue/review-2026-10/results/old-c1.csv b/dev/perf/spf_queue/review-2026-10/results/old-c1.csv deleted file mode 100644 index 1d1ba55..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/old-c1.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,65.450625,7,65.882125;64.276208;65.260500;67.423833;65.450625;64.917750;66.206416 -clos800,800,320000,16,none,ref,0,2,37.396042,7,37.188167;37.396042;37.494208;37.605083;37.457750;37.083417;37.271541 -chain2048,2048,2047,256,none,ref,0,32,3.038625,7,3.047042;3.021875;3.101083;3.038625;3.018084;3.103375;3.018792 -near16,16384,131072,64,near,ref,0,32,1.172167,7,1.313208;1.183792;1.158750;1.165458;1.171416;1.172167;1.174500 -wide20,16384,131072,16,none,ref,0,1048570,52.389542,7,52.971667;51.957750;52.280500;52.823083;51.671041;52.389542;54.248084 -wide25,16384,131072,16,none,ref,1,33554196,52.215416,7,52.625458;52.215416;52.307000;51.824541;51.515833;51.631542;52.662375 diff --git a/dev/perf/spf_queue/review-2026-10/results/old-c2.csv b/dev/perf/spf_queue/review-2026-10/results/old-c2.csv deleted file mode 100644 index e5b4788..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/old-c2.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,67.528541,7,66.008416;68.058333;70.523875;65.632458;67.528541;66.204166;69.484250 -clos800,800,320000,16,none,ref,0,2,37.468209,7,37.360667;37.451250;37.468209;37.111125;43.702125;40.555084;37.667917 -chain2048,2048,2047,256,none,ref,0,32,3.115042,7,3.325166;3.384667;3.109333;3.115042;3.085416;3.101334;3.127666 -near16,16384,131072,64,near,ref,0,32,1.226250,7,1.229375;1.227542;1.220333;1.224041;1.234542;1.226250;1.216458 -wide20,16384,131072,16,none,ref,0,1048570,54.983958,7,54.983958;53.121458;52.081750;52.332667;61.260375;55.755916;62.780084 -wide25,16384,131072,16,none,ref,1,33554196,51.311292,7,50.930792;51.031875;51.381208;50.627125;51.311292;52.826625;53.041833 diff --git a/dev/perf/spf_queue/review-2026-10/results/reverse-heap.csv b/dev/perf/spf_queue/review-2026-10/results/reverse-heap.csv deleted file mode 100644 index d6b3e18..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/reverse-heap.csv +++ /dev/null @@ -1,5 +0,0 @@ -case,median_ms,hash,samples_ms -random,14.29,4323550201995714722,14.2146;14.2688;14.1871;14.3387;14.3591;14.29;14.2905; -clos,46.6671,16998307955055842784,46.2878;48.1355;46.3228;47.327;46.6671;46.6519;48.5254; -chain,0.519167,6923677047050424156,0.52075;0.515292;0.532917;0.5175;0.516583;0.519167;0.525042; -zerocyc,17.8648,3520531437519771524,18.1898;18.3212;17.6796;17.7299;17.8648;17.9677;17.7341; diff --git a/dev/perf/spf_queue/review-2026-10/results/reverse-new1.csv b/dev/perf/spf_queue/review-2026-10/results/reverse-new1.csv deleted file mode 100644 index 8e362a8..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/reverse-new1.csv +++ /dev/null @@ -1,5 +0,0 @@ -case,median_ms,hash,samples_ms -random,8.53917,4323550201995714722,8.53917;8.44875;8.4335;8.56229;8.54592;8.52125;8.59829; -clos,46.9226,16998307955055842784,46.9246;46.809;47.0009;46.268;46.7838;46.9226;47.6712; -chain,0.7405,6923677047050424156,0.813791;0.7405;0.70425;0.729833;0.716333;0.742916;0.742958; -zerocyc,11.101,3520531437519771524,11.2392;11.2819;10.9927;11.0575;10.9636;11.2812;11.101; diff --git a/dev/perf/spf_queue/review-2026-10/results/reverse-old1.csv b/dev/perf/spf_queue/review-2026-10/results/reverse-old1.csv deleted file mode 100644 index c151b3d..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/reverse-old1.csv +++ /dev/null @@ -1,5 +0,0 @@ -case,median_ms,hash,samples_ms -random,14.1883,4323550201995714722,13.9235;13.8795;15.3225;14.1821;14.587;14.1883;14.5707; -clos,48.2569,16998307955055842784,47.954;48.6985;48.2569;47.5812;47.9113;48.4309;49.74; -chain,0.401625,6923677047050424156,0.403;0.403834;0.400166;0.401167;0.400791;0.401625;0.405333; -zerocyc,17.3234,3520531437519771524,18.1433;17.3234;17.294;17.1341;16.9652;17.4247;17.6095; diff --git a/dev/perf/spf_queue/review-2026-10/results/reverse-old2.csv b/dev/perf/spf_queue/review-2026-10/results/reverse-old2.csv deleted file mode 100644 index b917885..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/reverse-old2.csv +++ /dev/null @@ -1,5 +0,0 @@ -case,median_ms,hash,samples_ms -random,13.8073,4323550201995714722,13.9139;13.7902;13.8063;13.707;13.8688;13.8073;13.871; -clos,46.6904,16998307955055842784,47.0873;46.4569;46.6064;47.3114;46.4868;46.6904;47.2773; -chain,0.409,6923677047050424156,0.404834;0.408334;0.41;0.41075;0.409;0.405917;0.410666; -zerocyc,17.2142,3520531437519771524,17.2065;17.2142;17.3031;17.17;17.3148;17.2411;17.2045; diff --git a/dev/perf/spf_queue/review-2026-10/results/reverse-settled.csv b/dev/perf/spf_queue/review-2026-10/results/reverse-settled.csv deleted file mode 100644 index 808695a..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/reverse-settled.csv +++ /dev/null @@ -1,5 +0,0 @@ -case,median_ms,hash,samples_ms -random,8.46929,4323550201995714722,8.32779;8.44088;8.74062;8.55787;8.46929;8.53213;8.43412; -clos,37.0421,16998307955055842784,37.0421;36.9092;37.2337;36.9606;36.7457;37.5333;37.2674; -chain,0.712,6923677047050424156,0.703833;0.703125;0.711333;0.715333;0.712;0.719459;0.73825; -zerocyc,10.7184,3520531437519771524,10.7537;10.7373;10.701;10.7016;10.8767;10.7184;10.7129; diff --git a/dev/perf/spf_queue/review-2026-10/results/safety.txt b/dev/perf/spf_queue/review-2026-10/results/safety.txt deleted file mode 100644 index 2cd383b..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/safety.txt +++ /dev/null @@ -1 +0,0 @@ -PASS: boundary widths, near-int64-bound, zero costs, edgeless, forward/reverse, reused and concurrent threads diff --git a/dev/perf/spf_queue/review-2026-10/results/screen-flow-fixed.csv b/dev/perf/spf_queue/review-2026-10/results/screen-flow-fixed.csv deleted file mode 100644 index 4a67f8f..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/screen-flow-fixed.csv +++ /dev/null @@ -1,5 +0,0 @@ -case,median_ms,hash,samples_ms -calc_max_flow,3.49771,17694640663380043544,3.49771;3.44867;3.59746;3.5455;3.50575;3.47817;3.46129; -place_max_flow,2.17654,7688852859487360956,2.19533;2.13963;2.12258;2.17654;2.27933;2.22033;2.16417; -calc_max_flow_masked,3.52533,9889262484581709037,3.65667;3.66458;3.51767;3.585;3.52533;3.47987;3.45167; -place_max_flow_masked,1.74058,17611571108701524338,1.83658;1.77683;1.732;1.76604;1.69054;1.74058;1.72042; diff --git a/dev/perf/spf_queue/review-2026-10/results/screen-flow-new1.csv b/dev/perf/spf_queue/review-2026-10/results/screen-flow-new1.csv deleted file mode 100644 index 81f770f..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/screen-flow-new1.csv +++ /dev/null @@ -1,5 +0,0 @@ -case,median_ms,hash,samples_ms -calc_max_flow,6.30813,17694640663380043544,9.53404;7.89408;6.85742;6.30813;5.85308;5.42833;5.04875; -place_max_flow,3.75046,7688852859487360956,3.74292;3.99771;3.72096;3.729;3.75046;3.83758;3.78788; -calc_max_flow_masked,4.66933,9889262484581709037,4.59392;4.66933;4.66683;4.63588;4.70075;4.6815;4.69033; -place_max_flow_masked,2.88571,17611571108701524338,2.89621;2.88571;2.85225;2.96892;2.94071;2.87242;2.83696; diff --git a/dev/perf/spf_queue/review-2026-10/results/screen-flow-new2.csv b/dev/perf/spf_queue/review-2026-10/results/screen-flow-new2.csv deleted file mode 100644 index c4e6c54..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/screen-flow-new2.csv +++ /dev/null @@ -1,5 +0,0 @@ -case,median_ms,hash,samples_ms -calc_max_flow,5.17758,17694640663380043544,5.17758;5.18304;5.10125;5.14938;5.25413;5.22612;5.08204; -place_max_flow,3.74721,7688852859487360956,3.75838;3.76867;3.74721;3.74708;3.81875;3.70783;3.73663; -calc_max_flow_masked,4.81,9889262484581709037,4.72837;5.11983;4.78008;4.89187;4.76479;4.89292;4.81; -place_max_flow_masked,3.03613,17611571108701524338,2.90725;3.02983;3.03613;3.24267;3.35433;3.13017;2.93463; diff --git a/dev/perf/spf_queue/review-2026-10/results/screen-flow-old1.csv b/dev/perf/spf_queue/review-2026-10/results/screen-flow-old1.csv deleted file mode 100644 index 8fc2c18..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/screen-flow-old1.csv +++ /dev/null @@ -1,5 +0,0 @@ -case,median_ms,hash,samples_ms -calc_max_flow,7.46421,17694640663380043544,10.7697;9.501;8.27529;7.46421;7.01146;6.62575;6.60421; -place_max_flow,5.55046,7688852859487360956,5.59325;5.56725;5.55046;5.51917;5.50913;5.47129;5.66104; -calc_max_flow_masked,5.81396,9889262484581709037,5.84025;5.81396;5.74475;5.87383;5.77688;5.95671;5.79425; -place_max_flow_masked,4.05721,17611571108701524338,4.07908;3.97954;4.06804;4.05721;4.03087;4.07292;4.049; diff --git a/dev/perf/spf_queue/review-2026-10/results/screen-flow-old2.csv b/dev/perf/spf_queue/review-2026-10/results/screen-flow-old2.csv deleted file mode 100644 index 858bc08..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/screen-flow-old2.csv +++ /dev/null @@ -1,5 +0,0 @@ -case,median_ms,hash,samples_ms -calc_max_flow,6.88671,17694640663380043544,6.84333;7.00104;6.84063;6.88671;6.92425;6.96867;6.83817; -place_max_flow,5.62904,7688852859487360956,5.63633;5.73642;5.73917;5.62904;5.55379;5.56463;5.51196; -calc_max_flow_masked,5.98637,9889262484581709037,6.01696;6.03279;5.98104;6.00467;5.90329;5.98637;5.91071; -place_max_flow_masked,4.12329,17611571108701524338,4.01337;4.18213;4.31404;4.14071;4.12329;4.08954;4.03029; diff --git a/dev/perf/spf_queue/review-2026-10/results/settled-b.csv b/dev/perf/spf_queue/review-2026-10/results/settled-b.csv deleted file mode 100644 index ef1d574..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/settled-b.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,36.487333,7,36.090542;35.959583;36.007542;36.487333;37.377167;37.102084;37.204959 -clos800,800,320000,16,none,ref,0,2,29.811083,7,30.447334;29.933166;29.725458;29.967459;29.711791;29.811083;29.528292 -chain2048,2048,2047,256,none,ref,0,32,5.354125,7,5.354125;5.338375;5.379333;5.297166;5.476250;5.512584;5.302708 -near16,16384,131072,64,near,ref,0,32,0.395167,7,0.395041;0.398792;0.394875;0.395167;0.424417;0.403875;0.394458 -wide20,16384,131072,16,none,ref,0,1048570,50.286542,7,50.904542;50.359750;50.286542;50.461416;50.228625;49.448917;49.680083 -wide25,16384,131072,16,none,ref,1,33554196,50.049500,7,49.963709;50.266833;49.922667;50.049500;50.208000;50.201000;50.021375 diff --git a/dev/perf/spf_queue/review-2026-10/results/settled-heap.csv b/dev/perf/spf_queue/review-2026-10/results/settled-heap.csv deleted file mode 100644 index 1b13e44..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/settled-heap.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,66.158167,7,65.501291;66.360792;66.991167;66.015583;65.004458;66.158167;66.720208 -clos800,800,320000,16,none,ref,0,2,31.214834,7,30.765625;30.921833;31.332292;31.329458;31.445500;30.828375;31.214834 -chain2048,2048,2047,256,none,ref,0,32,3.968333,7,3.945500;3.968333;3.876041;3.986583;4.052458;3.981292;3.927458 -near16,16384,131072,64,near,ref,0,32,0.414542,7,0.414542;0.408208;0.422417;0.401750;0.511000;0.439917;0.376750 -wide20,16384,131072,16,none,ref,0,1048570,52.167875,7,52.122625;51.577041;51.598167;52.167875;53.366667;54.189042;53.137125 -wide25,16384,131072,16,none,ref,1,33554196,52.316375,7,52.712750;52.543958;52.056167;51.818583;52.316375;52.301791;53.811375 diff --git a/dev/perf/spf_queue/review-2026-10/results/settled.csv b/dev/perf/spf_queue/review-2026-10/results/settled.csv deleted file mode 100644 index 6c37d15..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/settled.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,36.302292,7,36.267083;36.252209;36.461292;36.329834;36.278333;36.428250;36.302292 -clos800,800,320000,16,none,ref,0,2,30.552375,7,30.552375;30.614834;30.552375;30.799083;30.420875;30.354875;30.576750 -chain2048,2048,2047,256,none,ref,0,32,5.646583,7,5.629958;5.646583;5.675459;5.604167;5.825583;5.854708;5.641583 -near16,16384,131072,64,near,ref,0,32,0.399458,7,0.399375;0.400292;0.401459;0.397750;0.398125;0.399625;0.399458 -wide20,16384,131072,16,none,ref,0,1048570,50.866667,7,50.945000;51.409333;50.611417;50.734542;51.366167;50.730708;50.866667 -wide25,16384,131072,16,none,ref,1,33554196,52.316458,7,51.991083;52.700875;52.316458;52.266458;52.743542;52.306916;52.581667 diff --git a/dev/perf/spf_queue/review-2026-10/results/singleton.csv b/dev/perf/spf_queue/review-2026-10/results/singleton.csv deleted file mode 100644 index f904009..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/singleton.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,dst,variant,fallback,W,median_ms,samples,wall_samples_ms -random16,16384,131072,16,none,ref,0,32,37.232584,7,43.237917;46.446917;37.663959;36.975792;37.232584;36.434750;37.170750 -clos800,800,320000,16,none,ref,0,2,35.920375,7,35.833750;36.086250;35.904916;36.143000;36.049708;35.366042;35.920375 -chain2048,2048,2047,256,none,ref,0,32,5.007084,7,5.030291;4.919834;5.007084;4.996291;5.026125;4.996833;5.083959 -near16,16384,131072,64,near,ref,0,32,0.402250,7,0.402958;0.397958;0.470084;0.408875;0.402250;0.398000;0.397250 -wide20,16384,131072,16,none,ref,0,1048570,54.110209,7,53.133625;53.488875;55.287875;54.976625;54.198959;53.358917;54.110209 -wide25,16384,131072,16,none,ref,1,33554196,52.396375,7,52.396375;51.643959;52.706292;52.540000;51.862250;51.330000;53.099542 diff --git a/dev/perf/spf_queue/review-2026-10/results/tracked-verification.txt b/dev/perf/spf_queue/review-2026-10/results/tracked-verification.txt deleted file mode 100644 index 424b153..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/tracked-verification.txt +++ /dev/null @@ -1 +0,0 @@ -97 tracked files compared; changed since review start: [] diff --git a/dev/perf/spf_queue/review-2026-10/results/validate-flow-variant.txt b/dev/perf/spf_queue/review-2026-10/results/validate-flow-variant.txt deleted file mode 100644 index 114a384..0000000 --- a/dev/perf/spf_queue/review-2026-10/results/validate-flow-variant.txt +++ /dev/null @@ -1,8 +0,0 @@ -excluded=-1 flow=cut=6 -excluded=0 flow=cut=4 -excluded=1 flow=cut=2 -excluded=2 flow=cut=6 -excluded=3 flow=cut=4 -excluded=4 flow=cut=3 -factor=1 placed=2 -factor=2 placed=6 diff --git a/dev/perf/spf_queue/review-2026-10/robustness.cpp b/dev/perf/spf_queue/review-2026-10/robustness.cpp deleted file mode 100644 index f1cda4a..0000000 --- a/dev/perf/spf_queue/review-2026-10/robustness.cpp +++ /dev/null @@ -1,48 +0,0 @@ -#include -#include -#include -static bool fail_four = false; -void* operator new(std::size_t n) { - if (fail_four && n == 4) { fail_four=false; throw std::bad_alloc(); } - if (void* p=std::malloc(n?n:1)) return p; - throw std::bad_alloc(); -} -void operator delete(void* p) noexcept { std::free(p); } -#ifndef REVIEW_SOURCE -#define REVIEW_SOURCE "baseline.cpp" -#endif -#include REVIEW_SOURCE -using namespace netgraph::core; -int main(int argc,char**argv) { - std::string mode=argc>1?argv[1]:"fault"; - EdgeSelection sel; - if(mode=="fault") { - auto g=StrictMultiDiGraph::from_arrays(8,std::vector{1},std::vector{0},std::vector{1},std::vector{1}); - fail_four=true; - try { (void)shortest_paths(g,0,std::nullopt,true,sel); } - catch(const std::bad_alloc&) { std::cout<<"caught forward bad_alloc\n"; } - auto r=shortest_paths(g,1,std::nullopt,true,sel); - std::cout<<"forward distance[0]="<{0},std::vector{1},std::vector{1},std::vector{1}); - fail_four=true; - try { (void)shortest_paths_to(rev,0,true,sel); } - catch(const std::bad_alloc&) { std::cout<<"caught reverse bad_alloc\n"; } - auto t=shortest_paths_to(rev,1,true,sel); - std::cout<<"reverse distance[0]="< s,d;std::vector a;std::vector w; - for(int v=1;v<=1024;++v){s.push_back(0);d.push_back(v);a.push_back(1);w.push_back(c);} - s.push_back(1025);d.push_back(1026);a.push_back(1);w.push_back(63); - s.push_back(1025);d.push_back(1026);a.push_back(1);w.push_back(1); - auto g=StrictMultiDiGraph::from_arrays(1027,s,d,a,w); - auto r=shortest_paths(g,0,std::nullopt,true,sel); - if(r.first[1]!=c) return 2; - if(c==1 || c==63)std::cout<<"after "<{0},std::vector{1},std::vector{1},std::vector{1}); - (void)shortest_paths(g,0,std::nullopt,true,sel); - std::cout<<"after N=2 graph: bucket item capacity bytes="< -#include -#include -using namespace netgraph::core; -static bool eq(const auto&a,const auto&b) {return a.first==b.first&&a.second.parent_offsets==b.second.parent_offsets&&a.second.parents==b.second.parents&&a.second.via_edges==b.second.via_edges;} -static void checks() { - for(Cost k: {Cost(0),Cost(1),Cost(65534),Cost(65535),Cost(65536),(Cost(1)<<60)-1}) { - auto g=StrictMultiDiGraph::from_arrays(8,std::vector{0,0,1,2,3,4,6},std::vector{1,2,3,3,4,5,7},std::vector{1,2,4,8,16,32,64},std::vector{k,k,0,0,k,0,1}); - auto nm=std::make_unique(8);auto em=std::make_unique(7); - std::fill_n(nm.get(),8,true);std::fill_n(em.get(),7,true); - std::vector res(g.capacity_view().begin(),g.capacity_view().end()); - for(int mp=0;mp<2;++mp)for(int me=0;me<2;++me)for(int tb=0;tb<2;++tb)for(int rc=0;rc<2;++rc)for(int mask=0;mask<2;++mask) { - EdgeSelection sel;sel.multi_edge=me;sel.require_capacity=rc;sel.tie_break=tb?EdgeTieBreak::PreferHigherResidual:EdgeTieBreak::Deterministic; - em[0]=!mask;nm[2]=!mask; - for(int dest=-1;dest<8;++dest) { - std::optional dst=dest<0?std::nullopt:std::optional(dest); - auto a=shortest_paths(g,0,dst,mp,sel,res,{nm.get(),8},{em.get(),7},SpfQueue::Heap); - auto b=shortest_paths(g,0,dst,mp,sel,res,{nm.get(),8},{em.get(),7},SpfQueue::Bucket); - spfx::HeapQueue h;spfx::Workspace ws;auto c=spfx::spf_variant(g,0,dst,mp,sel,res,{nm.get(),8},{em.get(),7},h,ws,k,1); - if(!eq(a,b)||!eq(a,c))throw std::runtime_error("forward mismatch"); - if(dest>=0){auto x=shortest_paths_to(g,dest,mp,sel,res,{nm.get(),8},{em.get(),7},{},SpfQueue::Heap);auto y=shortest_paths_to(g,dest,mp,sel,res,{nm.get(),8},{em.get(),7},{},SpfQueue::Bucket);if(!eq(x,y))throw std::runtime_error("reverse mismatch");} - } - } - } - for(int n:{0,1,8}){ - auto g=StrictMultiDiGraph::from_arrays(n,{},{},{},{}); - auto a=shortest_paths(g,0,std::nullopt,true,EdgeSelection{}); - auto b=shortest_paths_to(g,0,true,EdgeSelection{}); - if(a.first.size()!=std::size_t(n)||b.first.size()!=std::size_t(n)||a.second.parent_offsets.size()!=std::size_t(n+1))throw std::runtime_error("empty mismatch"); - } -} -int main(){checks();std::thread t(checks);checks();t.join();std::cout<<"PASS: boundary widths, near-int64-bound, zero costs, edgeless, forward/reverse, reused and concurrent threads\n";} diff --git a/dev/perf/spf_queue/review-2026-10/settled.patch b/dev/perf/spf_queue/review-2026-10/settled.patch deleted file mode 100644 index 31cfe2b..0000000 --- a/dev/perf/spf_queue/review-2026-10/settled.patch +++ /dev/null @@ -1,20 +0,0 @@ ---- a/src/shortest_paths.cpp -+++ b/src/shortest_paths.cpp -@@ -580,7 +580,7 @@ - // Process edges grouped by destination node (parallel edges are consecutive in CSR). - while (i < end) { - NodeId v = col[i]; -- if (use_node_mask && !node_mask[static_cast(v)]) { -+ if (settled[static_cast(v)] || (use_node_mask && !node_mask[static_cast(v)])) { - std::size_t j_skip = i; while (j_skip < end && col[j_skip] == v) ++j_skip; i = j_skip; continue; - } - -@@ -829,7 +829,7 @@ - std::size_t i = start; - while (i < end) { - NodeId u = icol[i]; -- if (use_node_mask && !node_mask[static_cast(u)]) { -+ if (settled[static_cast(u)] || (use_node_mask && !node_mask[static_cast(u)])) { - std::size_t j_skip = i; while (j_skip < end && icol[j_skip] == u) ++j_skip; i = j_skip; continue; - } - Cost min_edge_cost = kInf; diff --git a/dev/perf/spf_queue/review-2026-10/singleton.patch b/dev/perf/spf_queue/review-2026-10/singleton.patch deleted file mode 100644 index c01ae87..0000000 --- a/dev/perf/spf_queue/review-2026-10/singleton.patch +++ /dev/null @@ -1,29 +0,0 @@ ---- a/src/shortest_paths.cpp -+++ b/src/shortest_paths.cpp -@@ -304,6 +304,9 @@ - std::vector occ; // one bit per non-empty bucket - std::size_t W = 0, cur_b = 0, pending = 0; - Cost cur = 0, stride = 1; -+ Cost sole_cost = 0; -+ std::size_t sole_bucket = 0; -+ bool sole = false; - - void begin(std::size_t width, Cost stride_) { - stride = stride_; -@@ -333,6 +336,8 @@ - void push(Cost c, Cap nr, NodeId v) { - const std::size_t b = bucket_of(c); - auto& vec = buckets[b]; -+ sole = pending == 0; -+ if (sole) { sole_cost = c; sole_bucket = b; } - if (vec.empty()) occ[b >> 6] |= (std::uint64_t{1} << (b & 63)); - vec.push_back({nr, v}); - if (b == cur_b) std::push_heap(vec.begin(), vec.end(), Greater{}); -@@ -341,6 +346,7 @@ - // Advance to the next non-empty bucket when the active one is exhausted. - // Precondition: pending > 0. - void settle_active() { -+ if (sole) { cur = sole_cost; cur_b = sole_bucket; return; } - if (!buckets[cur_b].empty()) return; - std::size_t i = cur_b + 1; if (i == W) i = 0; - std::size_t w = i >> 6; diff --git a/dev/perf/spf_queue/review-2026-10/validate-flow.cpp b/dev/perf/spf_queue/review-2026-10/validate-flow.cpp deleted file mode 100644 index b3412a6..0000000 --- a/dev/perf/spf_queue/review-2026-10/validate-flow.cpp +++ /dev/null @@ -1,28 +0,0 @@ -#include "netgraph/core/algorithms.hpp" -#include "netgraph/core/backend.hpp" -#include "netgraph/core/flow_policy.hpp" -#include "netgraph/core/max_flow.hpp" -#include -#include -using namespace netgraph::core; -int main(){ - auto g=StrictMultiDiGraph::from_arrays(4,std::vector{0,0,1,1,2},std::vector{1,2,2,3,3},std::vector{3,4,1,2,4},std::vector{1,4,2,2,1}); - bool em[5];auto s=g.edge_src_view();auto d=g.edge_dst_view();auto cap=g.capacity_view(); - for(int excluded=-1;excluded<5;++excluded){ - for(int e=0;e<5;++e)em[e]=e!=excluded; - auto [total,sm]=calc_max_flow(g,0,3,FlowPlacement::Proportional,false,true,true,true,true,{},em); - double balance[4]={};for(int e=0;e<5;++e){double f=sm.edge_flows[e];if(f<-1e-7||f>cap[e]+1e-7||(!em[e]&&std::abs(f)>1e-7))throw std::runtime_error("capacity/mask");balance[s[e]]+=f;balance[d[e]]-=f;} - for(int v=0;v<4;++v)if(std::abs(balance[v]-(v==0?total:v==3?-total:0))>1e-7)throw std::runtime_error("conservation"); - double cut=1e30;for(int subset=1;subset<8;subset+=2){double k=0;for(int e=0;e<5;++e)if(em[e]&&(subset&(1<1e-7)throw std::runtime_error("optimality"); - double reported=0;for(auto e:sm.min_cut.edges)reported+=cap[e];if(std::abs(reported-total)>1e-7)throw std::runtime_error("reported cut"); - std::cout<<"excluded="<(make_cpu_backend());auto gh=algs->build_graph(g);ExecutionContext ctx(algs,gh); - for(double factor:{1.,2.}){ - FlowPolicyConfig cfg;cfg.max_path_cost_factor=factor;cfg.require_capacity=true;cfg.multipath=true; - FlowPolicy p(ctx,cfg);FlowGraph fg(g);auto res=p.place_demand(fg,0,3,1,100); - double expected=factor==1.?2.:6.;if(std::abs(res.first-expected)>1e-7)throw std::runtime_error("factor cap"); - std::cout<<"factor="< "$r/results/$spec-variant.txt" -done -NGRAPH_CORE_BATCH_THREADS=1 NGRAPH_CORE_SENSITIVITY_THREADS=1 build/perf/bitgate-old > "$r/results/bitgate-old.txt" -cmp "$r/results/bitgate-old.txt" "$r/results/bitgate-variant.txt" -shasum -a 256 "$r/results/bitgate-old.txt" "$r/results/bitgate-variant.txt" > "$r/results/bitgate-sha256.txt" -echo 'targeted flow certificates, cost factors, and bitgate passed under ASan/UBSan' diff --git a/dev/perf/spf_queue/run_aba.sh b/dev/perf/spf_queue/run_aba.sh deleted file mode 100755 index 5a4aeff..0000000 --- a/dev/perf/spf_queue/run_aba.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# Fresh process per variant; production reference first and last (A1 ... A2). -set -euo pipefail -cd "$(dirname "$0")/../../.." -out=${1:-dev/perf/spf_queue/results/first} -samples=${2:-9} -mkdir -p "$out" -bin=build/perf/cpu-queue -{ - echo "date: $(date -u +%Y-%m-%dT%H:%M:%SZ)"; echo "commit: $(git rev-parse HEAD)"; sysctl -n machdep.cpu.brand_string - shasum -a 256 dev/perf/spf_queue/*.hpp dev/perf/spf_queue/*.cpp src/shortest_paths.cpp src/strict_multidigraph.cpp - "${CXX:-clang++}" --version | head -1 -} > "$out/environment.txt" -for phase in ref:a1 heap-fresh:b heap-full:b heap-sparse:b bucket-fresh:b bucket-full:b bucket-sparse:b bsort-fresh:b bsort-sparse:b ref:a2; do - v=${phase%%:*}; tag=${phase##*:} - uptime >> "$out/load.txt" - "$bin" time "$v" "$samples" > "$out/time-$v-$tag.csv" -done -for v in ref heap-sparse bucket-sparse bsort-sparse; do - uptime >> "$out/load.txt" - "$bin" sweep "$v" "$samples" 2097152 > "$out/sweep-$v.csv" -done -uptime >> "$out/load.txt" -echo done diff --git a/dev/perf/spf_queue/run_core_aba.sh b/dev/perf/spf_queue/run_core_aba.sh deleted file mode 100755 index ff1c557..0000000 --- a/dev/perf/spf_queue/run_core_aba.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -# Core-level old/new/old A/B/A on the harness workloads: the `ref` variant is the -# production shortest_paths of whichever sources the binary was built from. -# cpu-queue-old: previous sources (git archive of the base commit) -# cpu-queue: this worktree; B-heap forces the heap route in the new binary -set -euo pipefail -cd "$(dirname "$0")/../../.." -out=${1:-dev/perf/spf_queue/results/core} -samples=${2:-9} -mkdir -p "$out" -{ - echo "date: $(date -u +%Y-%m-%dT%H:%M:%SZ)"; echo "commit: $(git rev-parse HEAD)" - shasum -a 256 build/perf/cpu-queue-old build/perf/cpu-queue src/shortest_paths.cpp -} > "$out/environment.txt" -for phase in a1-old b-new b-new-heap a2-old; do - uptime >> "$out/load.txt" - case $phase in - *old) bin=build/perf/cpu-queue-old; envs=() ;; - b-new) bin=build/perf/cpu-queue; envs=() ;; - b-new-heap) bin=build/perf/cpu-queue; envs=(NGRAPH_CORE_SPF_QUEUE=heap) ;; - esac - env ${envs[@]+"${envs[@]}"} "$bin" time ref "$samples" > "$out/time-$phase.csv" - env ${envs[@]+"${envs[@]}"} "$bin" sweep ref "$samples" 2097152 > "$out/sweep-$phase.csv" -done -uptime >> "$out/load.txt" -echo done diff --git a/dev/perf/spf_queue/run_netgraph_aba.sh b/dev/perf/spf_queue/run_netgraph_aba.sh deleted file mode 100755 index 707a4ca..0000000 --- a/dev/perf/spf_queue/run_netgraph_aba.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -# Old/new/old A/B/A on realistic NetGraph workloads, one fresh process per phase. -# A1: main worktree build (fbab119) B: this worktree B-heap: this worktree with -# NGRAPH_CORE_SPF_QUEUE=heap (in-binary control) A2: main worktree build again. -set -euo pipefail -cd "$(dirname "$0")/../../.." -out=${1:-dev/perf/spf_queue/results/netgraph} -samples=${2:-5} -old=${OLD_CORE:-/Users/networmix/ws/NetGraph-Core-main} -new=$PWD -py=/Users/networmix/ws/NetGraph/venv/bin/python -bench=dev/perf/spf_queue/bench_netgraph.py -mkdir -p "$out" -{ - echo "date: $(date -u +%Y-%m-%dT%H:%M:%SZ)" - echo "new: $(git rev-parse HEAD) (dirty: $(git status --short | wc -l | tr -d ' ') paths)" - echo "old: $(git -C "$old" rev-parse HEAD)" - echo "netgraph: $(git -C /Users/networmix/ws/NetGraph rev-parse HEAD)" - shasum -a 256 "$bench" "$new"/venv/lib/python3.14/site-packages/_netgraph_core*.so "$old"/venv/lib/python3.14/site-packages/_netgraph_core*.so - sysctl -n machdep.cpu.brand_string -} > "$out/environment.txt" -run() { # label core-root extra-env - local label=$1 root=$2; shift 2 - uptime >> "$out/load.txt" - # PYTHONHASHSEED pins NetGraph's set-iteration order so Monte Carlo result - # digests are comparable across processes (verified: with a random seed the - # digests differ between two runs of the same build). - env "$@" PYTHONHASHSEED=0 PYTHONPATH="$root/venv/lib/python3.14/site-packages:$root/python" "$py" "$bench" "$label" "$samples" > "$out/$label.csv" -} -run a1-old "$old" -run b-new "$new" -run b-new-heap "$new" NGRAPH_CORE_SPF_QUEUE=heap -run a2-old "$old" -uptime >> "$out/load.txt" -echo done diff --git a/dev/perf/spf_queue/spf_variants.hpp b/dev/perf/spf_queue/spf_variants.hpp deleted file mode 100644 index 653cd58..0000000 --- a/dev/perf/spf_queue/spf_variants.hpp +++ /dev/null @@ -1,492 +0,0 @@ -// Experimental copies of the production forward SPF loop (src/shortest_paths.cpp, -// shortest_paths_core) with the priority queue and the scratch workspace made -// pluggable. The relaxation, selection, tie-break and predecessor rules are kept -// verbatim so that any observable difference is attributable to the queue or -// the workspace policy. Research code only; not part of the library. -#pragma once - -#include "netgraph/core/constants.hpp" -#include "netgraph/core/shortest_paths.hpp" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace spfx { - -using namespace netgraph::core; -constexpr Cost INF = std::numeric_limits::max(); -using QItem = std::tuple; - -// Reference queue: a binary heap over (cost, -bottleneck, node), the exact key -// std::priority_queue uses in production. Keys are unique per push (a node is -// re-pushed only with a strictly smaller cost or a strictly larger bottleneck), so -// any correct binary heap pops in the same order as std::priority_queue. -struct HeapQueue { - static constexpr const char* name = "heap"; - std::vector h; - static bool gt(const QItem& a, const QItem& b) { return a > b; } - void begin(Cost, Cost) { h.clear(); } - void end() { h.clear(); } - bool empty() const { return h.empty(); } - Cost top_cost() { return std::get<0>(h.front()); } - QItem pop() { - std::pop_heap(h.begin(), h.end(), gt); - QItem t = h.back(); h.pop_back(); return t; - } - void push(Cost c, Cap nr, NodeId v) { - h.emplace_back(c, nr, v); - std::push_heap(h.begin(), h.end(), gt); - } -}; - -// Monotone bucket queue with ordered buckets. Dijkstra pops keys in -// non-decreasing cost order and every push is >= the cost being popped, so with -// W = max_cost/stride + 1 cyclic buckets each bucket holds exactly one cost value -// at any time. The active bucket is a binary heap on (-bottleneck, node); pushes -// into it (zero-cost edges) keep the heap property. The pop sequence is therefore -// identical to the global heap's (cost, -bottleneck, node) order, including -// stale-entry handling, zero-cost edges, single-path re-pushes and early exit. -template -struct BucketHeapQueueT { - static constexpr const char* name = kPow2 ? "bucket2" : "bucket"; - struct Item { Cap neg_res; NodeId node; }; - static bool gt(const Item& a, const Item& b) { -#ifdef SPFX_BAD_ORDER - return a.node > b.node; // teeth check: ignore bottleneck ordering -#else - return a.neg_res != b.neg_res ? a.neg_res > b.neg_res : a.node > b.node; -#endif - } - std::vector> buckets; - std::vector occ; - std::size_t W = 0, cur_b = 0, pending = 0; - Cost cur = 0, stride = 1; - - void begin(Cost max_cost, Cost stride_) { - stride = stride_ > 0 ? stride_ : 1; - W = static_cast(max_cost / stride) + 1; - if constexpr (kPow2) W = std::bit_ceil(W); // modulo becomes a mask - if (buckets.size() < W) buckets.resize(W); - occ.assign((W + 63) / 64, 0); - cur = 0; cur_b = 0; pending = 0; - } - void end() { - // Early exit can leave items behind; clear only occupied buckets. - for (std::size_t w = 0; w < occ.size(); ++w) { - std::uint64_t word = occ[w]; - while (word) { - const std::size_t b = (w << 6) + static_cast(std::countr_zero(word)); - buckets[b].clear(); - word &= word - 1; - } - occ[w] = 0; - } - pending = 0; - } - bool empty() const { return pending == 0; } - std::size_t bucket_of(Cost c) const { - const Cost q = stride == 1 ? c : c / stride; - if constexpr (kPow2) return static_cast(q) & (W - 1); - else return static_cast(q % static_cast(W)); - } - void push(Cost c, Cap nr, NodeId v) { - const std::size_t b = bucket_of(c); - auto& vec = buckets[b]; - if (vec.empty()) occ[b >> 6] |= (std::uint64_t{1} << (b & 63)); - vec.push_back({nr, v}); - if (b == cur_b) std::push_heap(vec.begin(), vec.end(), gt); - ++pending; - } - void settle_active() { - if (!buckets[cur_b].empty()) return; - std::size_t i = cur_b + 1; if (i == W) i = 0; - std::size_t w = i >> 6; - std::uint64_t word = occ[w] & (~std::uint64_t{0} << (i & 63)); - const std::size_t nwords = occ.size(); - while (!word) { w = (w + 1 == nwords) ? 0 : w + 1; word = occ[w]; } - const std::size_t f = (w << 6) + static_cast(std::countr_zero(word)); - const std::size_t steps = f > cur_b ? f - cur_b : f + W - cur_b; - cur += static_cast(steps) * stride; cur_b = f; - std::make_heap(buckets[f].begin(), buckets[f].end(), gt); - } - Cost top_cost() { settle_active(); return cur; } - QItem pop() { - settle_active(); - auto& vec = buckets[cur_b]; - std::pop_heap(vec.begin(), vec.end(), gt); - const Item it = vec.back(); vec.pop_back(); --pending; - if (vec.empty()) occ[cur_b >> 6] &= ~(std::uint64_t{1} << (cur_b & 63)); - return {cur, it.neg_res, it.node}; - } -}; -using BucketHeapQueue = BucketHeapQueueT; -using BucketPow2Queue = BucketHeapQueueT; - -// Variant of the bucket queue that sorts a bucket once when it becomes active and -// consumes it linearly; same-cost pushes that arrive while the bucket is active -// (zero-cost edges) go to a small side heap, and pop takes the smaller head. -struct BucketSortQueue { - static constexpr const char* name = "bsort"; - using Item = BucketHeapQueue::Item; - static bool lt(const Item& a, const Item& b) { - return a.neg_res != b.neg_res ? a.neg_res < b.neg_res : a.node < b.node; - } - static bool gt(const Item& a, const Item& b) { return lt(b, a); } - std::vector> buckets; - std::vector occ; - std::vector side; // heap of same-cost pushes into the active bucket - std::size_t W = 0, cur_b = 0, pending = 0, head = 0; - Cost cur = 0, stride = 1; - - void begin(Cost max_cost, Cost stride_) { - stride = stride_ > 0 ? stride_ : 1; - W = static_cast(max_cost / stride) + 1; - if (buckets.size() < W) buckets.resize(W); - occ.assign((W + 63) / 64, 0); - side.clear(); cur = 0; cur_b = 0; pending = 0; head = 0; - } - void end() { - for (std::size_t w = 0; w < occ.size(); ++w) { - std::uint64_t word = occ[w]; - while (word) { - const std::size_t b = (w << 6) + static_cast(std::countr_zero(word)); - buckets[b].clear(); - word &= word - 1; - } - occ[w] = 0; - } - buckets[cur_b].clear(); side.clear(); pending = 0; head = 0; - } - bool empty() const { return pending == 0; } - std::size_t bucket_of(Cost c) const { - return static_cast((c / stride) % static_cast(W)); - } - bool active_empty() const { return head >= buckets[cur_b].size() && side.empty(); } - void push(Cost c, Cap nr, NodeId v) { - const std::size_t b = bucket_of(c); - if (b == cur_b) { - side.push_back({nr, v}); - std::push_heap(side.begin(), side.end(), gt); - } else { - auto& vec = buckets[b]; - if (vec.empty()) occ[b >> 6] |= (std::uint64_t{1} << (b & 63)); - vec.push_back({nr, v}); - } - ++pending; - } - void settle_active() { - if (!active_empty()) return; - buckets[cur_b].clear(); head = 0; - std::size_t i = cur_b + 1; if (i == W) i = 0; - std::size_t w = i >> 6; - std::uint64_t word = occ[w] & (~std::uint64_t{0} << (i & 63)); - const std::size_t nwords = occ.size(); - while (!word) { w = (w + 1 == nwords) ? 0 : w + 1; word = occ[w]; } - const std::size_t f = (w << 6) + static_cast(std::countr_zero(word)); - const std::size_t steps = f > cur_b ? f - cur_b : f + W - cur_b; - cur += static_cast(steps) * stride; cur_b = f; - occ[f >> 6] &= ~(std::uint64_t{1} << (f & 63)); // active bucket is tracked by head/side - auto& vec = buckets[f]; - std::sort(vec.begin(), vec.end(), lt); - } - Cost top_cost() { settle_active(); return cur; } - QItem pop() { - settle_active(); - auto& vec = buckets[cur_b]; - Item it; - if (head < vec.size() && (side.empty() || lt(vec[head], side.front()))) { - it = vec[head++]; - } else { - std::pop_heap(side.begin(), side.end(), gt); - it = side.back(); side.pop_back(); - } - --pending; - return {cur, it.neg_res, it.node}; - } -}; - -enum class WsMode { Fresh, ReuseFull, ReuseSparse, ReuseHybrid }; -// ReuseHybrid tracks touched nodes like ReuseSparse but chooses the reset, output -// and DAG-conversion strategy at the end of the query from the touched count. - -// Scratch arrays of the production loop. Fresh: allocate per query as production -// does (including reserve(E) on the entry arrays). ReuseFull: keep the vectors and -// reset all N entries. ReuseSparse: keep the vectors and reset only touched nodes. -struct Workspace { - std::vector dist; - std::vector minres; - std::vector pred_head, pred_tail; - std::vector settled; - std::vector ent_parent; - std::vector ent_edge; - std::vector ent_next; - std::vector touched; - WsMode mode = WsMode::Fresh; - - bool tracking() const { return mode == WsMode::ReuseSparse || mode == WsMode::ReuseHybrid; } - // Decisions taken after the search from the touched fraction (hybrid only). - bool sparse_reset() const { - return mode == WsMode::ReuseSparse || (mode == WsMode::ReuseHybrid && touched.size() * 4 < dist.size()); - } - void begin(std::int32_t n, std::int32_t e) { - const auto N = static_cast(n); - if (tracking()) { - if (dist.size() != N) { - dist.assign(N, INF); minres.assign(N, 0); pred_head.assign(N, -1); - pred_tail.assign(N, -1); settled.assign(N, 0); - } - touched.clear(); - } else { - dist.assign(N, INF); minres.assign(N, 0); pred_head.assign(N, -1); - pred_tail.assign(N, -1); settled.assign(N, 0); - } - ent_parent.clear(); ent_edge.clear(); ent_next.clear(); - if (mode == WsMode::Fresh) { - const auto E = static_cast(e); - ent_parent.reserve(E); ent_edge.reserve(E); ent_next.reserve(E); - } - } - inline void touch(NodeId v) { if (tracking()) touched.push_back(v); } - void end() { - if (!tracking()) return; - if (sparse_reset()) { - for (auto v : touched) { - const auto i = static_cast(v); - dist[i] = INF; minres[i] = 0; pred_head[i] = -1; pred_tail[i] = -1; settled[i] = 0; - } - } else { - std::fill(dist.begin(), dist.end(), INF); std::fill(minres.begin(), minres.end(), 0); - std::fill(pred_head.begin(), pred_head.end(), -1); std::fill(pred_tail.begin(), pred_tail.end(), -1); - std::fill(settled.begin(), settled.end(), 0); - } - touched.clear(); - } -}; - -// Copy of shortest_paths_core with the queue and workspace injected. -template -std::pair, PredDAG> -spf_variant(const StrictMultiDiGraph& g, NodeId src, - std::optional dst, - bool multipath_arg, - const EdgeSelection& selection, - std::span residual, - std::span node_mask, - std::span edge_mask, - Queue& pq, Workspace& ws, Cost max_cost, Cost stride) { - const auto N = g.num_nodes(); - const auto row = g.row_offsets_view(); - const auto col = g.col_indices_view(); - const auto aei = g.adj_edge_index_view(); - const auto cost = g.cost_view(); - const auto cap = g.capacity_view(); - - ws.begin(N, g.num_edges()); - auto& dist = ws.dist; - auto& min_residual_to_node = ws.minres; - auto& pred_head = ws.pred_head; - auto& pred_tail = ws.pred_tail; - auto& ent_parent = ws.ent_parent; - auto& ent_edge = ws.ent_edge; - auto& ent_next = ws.ent_next; - auto& settled = ws.settled; - - auto finish_dist = [&]() { - std::vector out; - if (ws.mode == WsMode::Fresh) { out = std::move(dist); dist.clear(); } - else if (ws.mode == WsMode::ReuseFull || !ws.sparse_reset()) { out = dist; } - else { - out.assign(static_cast(N), INF); - for (auto v : ws.touched) out[static_cast(v)] = dist[static_cast(v)]; - } - return out; - }; - - const bool use_node_mask = (node_mask.size() == static_cast(g.num_nodes())); - const bool use_edge_mask = (edge_mask.size() == static_cast(g.num_edges())); - const bool src_allowed = (src >= 0 && src < N && (!use_node_mask || node_mask[static_cast(src)])); - if (src_allowed) { - dist[static_cast(src)] = static_cast(0); - min_residual_to_node[static_cast(src)] = std::numeric_limits::max(); - ws.touch(src); - } - - auto pred_clear = [&](std::size_t v){ pred_head[v] = -1; pred_tail[v] = -1; }; - auto pred_append = [&](std::size_t v, NodeId p, EdgeId e){ - const auto idx = static_cast(ent_parent.size()); - ent_parent.push_back(p); ent_edge.push_back(e); ent_next.push_back(-1); - if (pred_head[v] < 0) { pred_head[v] = idx; } - else { ent_next[static_cast(pred_tail[v])] = idx; } - pred_tail[v] = idx; - }; - if (!src_allowed) { - PredDAG dag; - dag.parent_offsets.assign(static_cast(N + 1), 0); - auto out = finish_dist(); - ws.end(); - return {std::move(out), std::move(dag)}; - } - - pq.begin(max_cost, stride); - pq.push(static_cast(0), -std::numeric_limits::max(), src); - Cost best_dst_cost = std::numeric_limits::max(); - bool have_best_dst = false; - const bool early_exit = dst.has_value(); - const NodeId dst_node = dst.value_or(-1); - - const bool has_residual = (residual.size() == static_cast(g.num_edges())); - const bool require_cap = selection.require_capacity || has_residual; - const bool multipath = multipath_arg; - - std::vector sel_buf; sel_buf.reserve(16); - while (!pq.empty()) { - auto [d_u, neg_res_u, u] = pq.pop(); - if (u < 0 || u >= N) continue; - if (d_u > dist[static_cast(u)]) continue; - if (!multipath && d_u == dist[static_cast(u)] && - -neg_res_u < min_residual_to_node[static_cast(u)] - kEpsilon) continue; - settled[static_cast(u)] = 1; - - if (early_exit && u == dst_node && !have_best_dst) { best_dst_cost = d_u; have_best_dst = true; } - if (early_exit && u == dst_node) { - if (pq.empty() || pq.top_cost() > best_dst_cost) break; else continue; - } - - auto start = static_cast(row[static_cast(u)]); - auto end = static_cast(row[static_cast(u)+1]); - std::size_t i = start; - while (i < end) { - NodeId v = col[i]; - if (use_node_mask && !node_mask[static_cast(v)]) { - std::size_t j_skip = i; while (j_skip < end && col[j_skip] == v) ++j_skip; i = j_skip; continue; - } - Cost min_edge_cost = std::numeric_limits::max(); - std::vector& selected_edges = sel_buf; selected_edges.clear(); - double best_rem_for_min_cost = -1.0; - std::size_t j = i; - int best_edge_id = -1; - for (; j < end && col[j] == v; ++j) { - auto e = static_cast(aei[j]); - if (use_edge_mask && !edge_mask[e]) continue; - const Cap rem = has_residual ? residual[e] : cap[e]; - if (require_cap && rem < kMinCap) continue; - const Cost ecost = static_cast(cost[e]); - if (ecost < min_edge_cost) { - min_edge_cost = ecost; - selected_edges.clear(); - if (selection.multi_edge) { - selected_edges.push_back(static_cast(aei[j])); - } else { - best_edge_id = static_cast(e); - best_rem_for_min_cost = static_cast(rem); - } - } else if (ecost == min_edge_cost) { - if (selection.multi_edge) { - selected_edges.push_back(static_cast(aei[j])); - } else { - if (selection.tie_break == EdgeTieBreak::PreferHigherResidual) { - if (static_cast(rem) > best_rem_for_min_cost + kEpsilon) { - best_edge_id = static_cast(e); - best_rem_for_min_cost = static_cast(rem); - } else if (std::abs(static_cast(rem) - best_rem_for_min_cost) <= kEpsilon) { - if (best_edge_id < 0 || static_cast(e) < best_edge_id) { - best_edge_id = static_cast(e); - } - } - } else { - if (best_edge_id < 0 || static_cast(e) < best_edge_id) { - best_edge_id = static_cast(e); - } - } - } - } - } - if (!selection.multi_edge && best_edge_id >= 0) { - selected_edges.clear(); - selected_edges.push_back(static_cast(best_edge_id)); - } - if (!selected_edges.empty()) { - Cost new_cost = static_cast(d_u + min_edge_cost); - auto v_idx = static_cast(v); - Cap max_edge_residual = static_cast(0); - for (auto edge_id : selected_edges) { - const Cap rem = has_residual ? residual[static_cast(edge_id)] - : cap[static_cast(edge_id)]; - if (rem > max_edge_residual) max_edge_residual = rem; - } - Cap path_residual = std::min(min_residual_to_node[static_cast(u)], max_edge_residual); - if (new_cost < dist[v_idx] || - (!multipath && new_cost == dist[v_idx] && !settled[v_idx] && - path_residual > min_residual_to_node[v_idx] + kEpsilon)) { - if (dist[v_idx] == INF) ws.touch(v); - dist[v_idx] = new_cost; - min_residual_to_node[v_idx] = path_residual; - pred_clear(v_idx); - for (auto sel_e : selected_edges) pred_append(v_idx, u, sel_e); - pq.push(new_cost, -path_residual, v); - } - else if (multipath && new_cost == dist[v_idx] && !settled[v_idx]) { - for (auto sel_e : selected_edges) pred_append(v_idx, u, sel_e); - } - } - i = j; - } - if (have_best_dst) { if (pq.empty() || pq.top_cost() > best_dst_cost) break; } - } - pq.end(); - - PredDAG dag; - dag.parent_offsets.assign(static_cast(N+1), 0); - if (ws.tracking() && ws.sparse_reset()) { - for (auto v : ws.touched) { - std::size_t c = 0; - for (std::int32_t i = pred_head[static_cast(v)]; i >= 0; i = ent_next[static_cast(i)]) ++c; - dag.parent_offsets[static_cast(v+1)] = static_cast(c); - } - } else { - for (std::int32_t v=0; v(v)]; i >= 0; i = ent_next[static_cast(i)]) ++c; - dag.parent_offsets[static_cast(v+1)] = static_cast(c); - } - } - for (std::size_t k=1; k(dag.parent_offsets.back())); - dag.via_edges.resize(static_cast(dag.parent_offsets.back())); - auto fill = [&](std::int32_t v) { - auto base = static_cast(dag.parent_offsets[static_cast(v)]); - std::size_t k = 0; - for (std::int32_t i = pred_head[static_cast(v)]; i >= 0; i = ent_next[static_cast(i)]) { - dag.parents[base+k] = ent_parent[static_cast(i)]; - dag.via_edges[base+k] = ent_edge[static_cast(i)]; - ++k; - } - }; - if (ws.tracking() && ws.sparse_reset()) { for (auto v : ws.touched) fill(v); } - else { for (std::int32_t v=0; v p.max_cost) p.max_cost = c; - Cost a = gcd, b = c; while (b) { Cost t = a % b; a = b; b = t; } gcd = a; - } - p.stride = gcd > 0 ? gcd : 1; - return p; -} - -} // namespace spfx diff --git a/dev/perf/spf_queue/summarize.py b/dev/perf/spf_queue/summarize.py deleted file mode 100644 index 89b4efa..0000000 --- a/dev/perf/spf_queue/summarize.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python3 -"""Tabulate cpu_queue A/B/A results as Markdown (sequential single-thread medians).""" - -import csv -import glob -import os -import sys - -root = ( - sys.argv[1] - if len(sys.argv) > 1 - else os.path.join(os.path.dirname(__file__), "results", "first") -) - - -def load(pattern): - rows = {} - for path in sorted(glob.glob(os.path.join(root, pattern))): - tag = ( - os.path.basename(path)[len("time-") : -4] - if "time-" in path - else os.path.basename(path)[len("sweep-") : -4] - ) - with open(path) as f: - for r in csv.DictReader(f): - key = (r["case"], r["n"], r["e"], r["queries"], r["dst"]) - rows.setdefault(key, {})[tag] = r - return rows - - -def fmt(r): - if r is None: - return "—" - s = f"{float(r['median_ms']):.3f}" - return s + (" (heap fallback)" if r.get("fallback") == "1" else "") - - -time_rows = load("time-*.csv") -order = [ - "ref-a1", - "heap-fresh-b", - "heap-full-b", - "heap-sparse-b", - "bucket-fresh-b", - "bucket-full-b", - "bucket-sparse-b", - "bsort-fresh-b", - "bsort-sparse-b", - "ref-a2", -] -seen = {t for v in time_rows.values() for t in v} -present = [t for t in order if t in seen] + sorted(t for t in seen if t not in order) -present = [t for t in present if t != "ref-a2"] + ( - ["ref-a2"] if "ref-a2" in seen else [] -) -print( - "| Workload: case / nodes / edges / queries / dst | W | " - + " | ".join(present) - + " |" -) -print("|---|---:|" + "---:|" * len(present)) -for key, v in time_rows.items(): - any_row = next(iter(v.values())) - label = f"{key[0]} / {key[1]} / {key[2]} / {key[3]} / {key[4]}" - print( - f"| {label} | {any_row['W']} | " - + " | ".join(fmt(v.get(t)) for t in present) - + " |" - ) - -sweep_rows = load("sweep-*.csv") -if sweep_rows: - tags = sorted({t for v in sweep_rows.values() for t in v}) - print() - print("| Cost sweep: case / nodes / queries | W | " + " | ".join(tags) + " |") - print("|---|---:|" + "---:|" * len(tags)) - for key, v in sweep_rows.items(): - any_row = next(iter(v.values())) - print( - f"| {key[0]} / {key[1]} / {key[3]} | {any_row['W']} | " - + " | ".join(fmt(v.get(t)) for t in tags) - + " |" - ) diff --git a/dev/perf/spf_queue/summarize_core.py b/dev/perf/spf_queue/summarize_core.py deleted file mode 100644 index b409d8d..0000000 --- a/dev/perf/spf_queue/summarize_core.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python3 -"""Tabulate the Core-level old/new/old A/B/A (results-core) as Markdown.""" - -import csv -import os -import sys - -root = ( - sys.argv[1] - if len(sys.argv) > 1 - else os.path.join(os.path.dirname(__file__), "results", "core") -) -phases = ["a1-old", "b-new", "b-new-heap", "a2-old"] -for kind in ("time", "sweep"): - rows = {} - for p in phases: - with open(f"{root}/{kind}-{p}.csv") as f: - for r in csv.DictReader(f): - key = (r["case"], r["n"], r["e"], r["queries"], r["dst"], r["W"]) - rows.setdefault(key, {})[p] = float(r["median_ms"]) - print( - f"| {kind}: case / nodes / edges / queries / destination | W | " - + " | ".join(phases) - + " | old/new | old/new-heap |" - ) - print("|---|---:|" + "---:|" * (len(phases) + 2)) - for k, v in rows.items(): - lo, hi = min(v["a1-old"], v["a2-old"]), max(v["a1-old"], v["a2-old"]) - print( - f"| {' / '.join(k[:5])} | {k[5]} | " - + " | ".join(f"{v[p]:.3f}" for p in phases) - + f" | {lo / v['b-new']:.2f}–{hi / v['b-new']:.2f} | {lo / v['b-new-heap']:.2f}–{hi / v['b-new-heap']:.2f} |" - ) - print() diff --git a/dev/perf/spf_queue/summarize_netgraph.py b/dev/perf/spf_queue/summarize_netgraph.py deleted file mode 100644 index dab8f4a..0000000 --- a/dev/perf/spf_queue/summarize_netgraph.py +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env python3 -"""Tabulate the NetGraph-workload A/B/A (old/new/old, fresh processes) as Markdown.""" - -import csv -import glob -import os -import sys - -root = ( - sys.argv[1] - if len(sys.argv) > 1 - else os.path.join(os.path.dirname(__file__), "results", "netgraph") -) -phases = ["a1-old", "b-new", "b-new-heap", "a2-old"] -rows: dict[str, dict[str, dict]] = {} -for path in sorted(glob.glob(os.path.join(root, "*.csv"))): - tag = os.path.basename(path)[:-4] - with open(path) as f: - for r in csv.DictReader(f): - rows.setdefault(r["workload"], {})[tag] = r -present = [p for p in phases if any(p in v for v in rows.values())] -print( - "| Workload | " - + " | ".join(f"{p} ms" for p in present) - + " | old/new | checksums |" -) -print("|---|" + "---:|" * len(present) + "---:|---|") -mismatch = 0 -for name, v in rows.items(): - med = {p: float(v[p]["median_s"]) * 1000 for p in present if p in v} - ratio = "" - if "a1-old" in med and "a2-old" in med and "b-new" in med: - ratio = f"{min(med['a1-old'], med['a2-old']) / med['b-new']:.2f}–{max(med['a1-old'], med['a2-old']) / med['b-new']:.2f}" - sums = {v[p]["checksum"] for p in present if p in v} - ok = "same" if len(sums) == 1 else "DIFFER" - if ok != "same": - mismatch += 1 - print( - f"| {name} | " - + " | ".join(f"{med[p]:.2f}" if p in med else "—" for p in present) - + f" | {ratio} | {ok} |" - ) -print() -print(f"checksum mismatches: {mismatch}") diff --git a/dev/research/README.md b/dev/research/README.md deleted file mode 100644 index 9bc2e5d..0000000 --- a/dev/research/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Research record - -Investigations whose outcome informed the code but whose prototypes are not -part of the library. They are kept for the evidence and the dead ends they -document, not as maintained tools. - -- [`metal_pathfinding/`](metal_pathfinding/): Apple Metal GPU shortest-path - prototypes (two phases, with raw measurements and validation audits). Their - conclusion, that a better CPU queue should come first, led to the work in - [`../perf/spf_queue/`](../perf/spf_queue/). Objective-C++ and Metal; builds - only on macOS with Xcode. diff --git a/dev/research/metal_pathfinding/README.md b/dev/research/metal_pathfinding/README.md deleted file mode 100644 index 65d7de3..0000000 --- a/dev/research/metal_pathfinding/README.md +++ /dev/null @@ -1,293 +0,0 @@ -# Metal pathfinding investigation - -**Follow-up:** the [optimization research](optimization/README.md) tests active -frontiers, buckets, batch layout and improved result construction. It finds -substantially better batch results, so the initial timings below must not be -treated as a GPU performance ceiling. Both rounds remain exploratory because -the desktop was not isolated for measurement. - -The [consolidated CPU optimisation plan](../../perf/spf_queue/PLAN.md) now governs -implementation order and acceptance. The backend integration notes below remain -relevant to a later Metal follow-up. - -Research against NetGraph-Core `fbab119` on 26–27 September 2026, using an -Apple M4 Max (14 CPU cores, 32 GPU cores, 36 GB unified memory). -This directory contains standalone experiments. It does not register a backend, -change the Python API, or modify production algorithms. - -**Answer:** adding a Metal implementation is technically feasible, and the host -integration is fairly small. A useful, compatible replacement for `Backend.cpu()` -is a substantially larger task. The candidate interface is **distances on an -immutable graph, with optional batches**, particularly for large low-hop -full-source searches, and explicit CPU fallback. It is premature -to make Metal the default SPF implementation or promise faster flow simulation. - -**What the current backend gets right.** `StrictMultiDiGraph` already has immutable -forward and reverse CSR, stable internal edge IDs, nonnegative int64 costs, and -a constructor bound on the sum of costs that prevents path-cost overflow. -These are useful GPU inputs. `Backend` and `Algorithms` provide a dispatch seam; -`FlowPolicy` calls that seam. The CPU implementation is native C++ Dijkstra, -with flat predecessor storage and Python GIL release. Its existing memoization -and reverse SPF also avoid computations that a GPU would otherwise repeat. -The appropriate comparison is this implementation, including parallel CPU -queries, not a Python Dijkstra loop. - -**Where the abstraction needs work.** These are verified properties of the -current source, not newly claimed correctness defects: - -| Area | Current behavior | Consequence for Metal | -|---|---|---| -| Graph ownership | `GraphHandle` contains only `shared_ptr`. CPU construction is non-owning; Python separately keeps the graph alive. | No place for a typed device representation or backend identity. Add owned device state and preserve topology lifetime. | -| Query interface | `spf` / `spf_to` are synchronous, single-query calls returning host vectors and a `PredDAG`. There is no batched SPF or distances-only API. | Cannot amortize dispatch across independent queries through the current interface. Every call produces a host DAG even if the caller only needs distances. | -| Cost semantics | Internal distances are int64; Python offers int64 explicitly and defaults to float64 conversion. | A float32 GPU implementation would change path selection. Keep exact integer arithmetic. | -| Predecessors | Positive-cost ECMP keeps all equal alternatives; zero-cost edges use settle order to remain acyclic. Parallel-edge selection and capacity ties are configurable. | A distance kernel plus tight-edge extraction is insufficient to reproduce the full contract. | -| Result order | Predecessor arrays follow CPU discovery order. Path enumeration consumes that order, including when capped by `max_paths`. | The prototype's deterministic edge-ID order has equivalent positive-cost edge sets, but is not byte-identical or necessarily enumeration-identical. | -| Residuals and masks | Borrowed per-call spans; capacities and residuals are double; a residual span forces capacity gating. Python currently copies residual/mask input. | Compute exact eligibility on the CPU or reproduce these semantics explicitly. Static topology upload does not remove changing-mask work. | -| Max-flow | `calc_max_flow` calls the free `shortest_paths` function directly between residual updates. | Overriding `Backend::spf` does not accelerate `Backend::max_flow`, batch max-flow, or sensitivity when those delegate to the existing CPU functions. | -| KSP | Yen-style enumeration has its own Dijkstra helper and directly calls free SPF for spurs. | Overriding backend SPF does not make KSP run on the GPU. It is also sequentially dependent across accepted paths. | -| FlowPolicy | Calls `ctx_.algorithms->spf`, generally with `dst` and changing flow state; caches some SPF results. | It can use a new backend, but single-call latency, early exit, cache hits, and residual invalidation matter. Batching across dependent placements would change semantics. | -| Downstream selection | NetGraph's context builder explicitly constructs `Backend.cpu()`. | Shipping a factory alone would not enable acceleration downstream. | - -Source pointers: [backend.hpp](../../../include/netgraph/core/backend.hpp), -[algorithms.hpp](../../../include/netgraph/core/algorithms.hpp), -[shortest_paths.hpp](../../../include/netgraph/core/shortest_paths.hpp), -[shortest_paths.cpp](../../../src/shortest_paths.cpp), -[flow_policy.cpp](../../../src/flow_policy.cpp), [max_flow.cpp](../../../src/max_flow.cpp), -[k_shortest_paths.cpp](../../../src/k_shortest_paths.cpp), -[bindings](../../../bindings/python/module.cpp). -Downstream inspection was read-only: `ngraph/analysis/context.py`, -`ngraph/analysis/placement.py`, and `dev/perf/profiles.py` in the sibling NetGraph -checkout. No downstream behavior or integration speedup was tested. - -**What the external research establishes.** Apple supplies a direct C++ Metal -interface, so there is no need to introduce Swift or a tensor framework just to -call compute kernels. An isolated Objective-C++ translation unit also works; -that is what this experiment builds using Command Line Tools and the system -Foundation/Metal frameworks. [Apple Metal-cpp](https://developer.apple.com/metal/cpp/) - -The current Metal language specification supports 64-bit `long`/`ulong`, but -does not support `double`. Its 64-bit atomics are limited: void-returning -`atomic_min_explicit` / `atomic_max_explicit` exist; a general 64-bit atomic -fetch-min/load API is not available. This is a reason to choose a suitable -algorithm, not a reason to abandon exact costs. Apple lists 64-bit atomics from -Apple9 hardware, whereas 64-bit integer math has broader availability. Capability -checks must cover the target hardware family. -[MSL specification, sections 2.1 and 6.16.4.6](https://developer.apple.com/metal/Metal-Shading-Language-Specification.pdf), -[Apple feature tables](https://developer.apple.com/metal/Metal-Feature-Set-Tables.pdf). -The [local compiler probes](results/capabilities.txt) confirm all of these -distinctions on this M4 Max, including successful void-returning 64-bit minimum. -Compilation alone is not a performance test of that atomic operation. - -MPSGraph is a graph of tensor operations, not an off-the-shelf implementation of -NetGraph's routing semantics. MLX offers custom Metal kernels, which is useful -for prototyping, but still leaves the graph algorithm and correctness contract -to us. I found no drop-in implementation of this API in these libraries. -[Apple MPSGraph](https://developer.apple.com/documentation/metalperformanceshadersgraph?changes=__5_7), -[MLX custom Metal kernels](https://ml-explore.github.io/mlx/build/html/dev/custom_metal_kernels.html). - -Gunrock provides useful GPU graph-processing designs based on frontiers and -load balancing, but its advertised backends are CUDA and HIP, not Metal. -cuGraph likewise depends on NVIDIA's CUDA stack. Neither is a library switch -for an Apple GPU. Their existence does suggest a more ambitious implementation -direction: frontier compaction and work-efficient SSSP rather than repeatedly -scanning every edge. That direction was researched, not implemented or timed. -[Gunrock](https://github.com/gunrock/gunrock), -[cuGraph installation](https://docs.nvidia.com/cugraph/26.10/installation/getting_cugraph/). - -**The experiments.** All three kernels compute exact int64 distances with -double-buffered Bellman–Ford-style pull relaxation over incoming CSR. Reverse -routing uses outgoing CSR. They do not race on distance writes and do not need -64-bit atomics. Per-edge eligibility is prepared on the host from finite double -residuals, node masks, and edge masks. The cost of this preparation and buffer -allocation is recorded as `upload_ms`; each warm query reuses that snapshot. -All prototypes currently model capacity-gated, multipath, multi-edge, full-source -queries. General selection options and changing eligibility are not benchmarked. -Every query in a batch shares the same mask/residual snapshot; this is not a -benchmark of independent failure scenarios with different masks per query. - -| Method | Execution | Intended experiment | -|---|---|---| -| `metal_pull` | One thread per query/vertex, separate dependent dispatches; up to eight rounds per command buffer before checking convergence. | Spread one large query across the GPU; measure repeated launch/synchronization costs. | -| `metal_group` | One threadgroup per query, convergence inside the kernel, global-memory ping-pong distance arrays. | Amortize dispatch across many independent sources without a grid-wide barrier. | -| `metal_shared` | One threadgroup per query, with distance arrays in threadgroup memory. | Reduce memory/barrier overhead for small graphs; enabled only when runtime scratch-memory limits permit. | - -Each returns host distance vectors. The measured full result then scans edges -on the CPU and constructs a positive-cost ECMP DAG. **That full timing includes -DAG construction**, but its order differs from the current CPU implementation. -`distance_wall_ms` ends before DAG construction; `gpu_ms` contains command-buffer -GPU timestamps, not end-to-end API latency. Neither should be presented as the -speed of a compatible `Algorithms.spf` replacement. - -The pull algorithms perform `O(Q * H * (V + E))` work, where `H` is -the number of relaxation rounds (up to `V`), versus heap Dijkstra's usual -`O((V + E) log V)` per source. This makes hop depth and degree as important as -vertex count. These kernels are feasibility baselines, not an optimized -frontier/delta-stepping implementation or a bound on what Metal can achieve. -Two int64 distance buffers require `16 * V * Q` bytes: 64 MiB at 65,536 vertices -and 64 sources, before host outputs and DAGs. A dense int64 matrix at the same -vertex count would itself require 32 GiB. An all-pairs approach therefore needs -an explicit memory/work analysis and batching on this 36 GB machine. - -The CPU controls use the actual current `shortest_paths` / `shortest_paths_to` -sources, built with `-O3 -flto -funroll-loops`. Controls include serial full SPF, -an independent distances-only Dijkstra, and both full SPF and distances-only -Dijkstra through ten reusable worker threads. Pool startup is outside timing; -task submission and result allocation -are inside. Profiling is disabled. The experiment does not exercise Python -binding overhead or an integrated flow-placement workload. - -The full suite covers 27 graph/batch configurations: 128–65,536 vertices, -up to 524,288 directed edges, batches of 1–256 queries, nonuniform random costs, -parallel edges/self-loops, leaf–spine fabrics, 32x32 / 100x100 / 200x200 grids, -directed chains, and masked reverse queries with costs above 32 bits. The Clos -and grid shapes were selected after inspecting NetGraph's own benchmark catalogue; -these remain generated fixtures, not a replay of a user's production topology. - -**Correctness evidence.** Every timed sample, including warmups, compares every -distance and the canonical `(child, parent, edge-id)` predecessor multiset to the -current C++ result. The independent CPU Dijkstra is also checked against that -result. Timing excludes verification. Twelve seeded small masked multigraphs -exercise all three GPU methods in both normal and sanitizer/Metal validation -runs. Hand-derived witnesses additionally check: - -- Diamond ECMP, equal-cost parallel edges, positive self-loop exclusion, and an isolated node. -- Reverse distances and forward-oriented predecessor edges. -- Masked source, masked transit node, and combined residual/edge masks. -- A residual just below `kMinCap` versus exactly at the threshold. -- Int64 costs above 2^53, including a one-unit difference between candidate paths. - -The witnesses also deliberately reproduce unsupported simplifications: - -- Float32 equates 16,777,216 and 16,777,217 and rounds a residual immediately below - `kMinCap` up to the threshold. It also erases a `1` versus `1 + 1e-8` capacity tie. -- On a zero-cost `0 -> 1 -> 2 -> 1` graph, distances agree but naive tight-edge - reconstruction contains a cycle; the CPU DAG correctly retains only two edges. -- Full-suite CSVs record how many query DAGs retain exactly the CPU's array order. - Canonical equality is not silently reported as byte-for-byte parity. - -An initial long run exposed a convergence-flag race in the research kernels: -after the end-of-round barrier, a fast lane could reset the flag for the next -round before every lane had read it. An additional barrier after the reads -fixes that ordering. The rejected run is retained under -[results/rejected-race](results/rejected-race/), and is excluded from conclusions. -After correction, each of two 64-query stress cases passed 100 timed repetitions -plus two warmups for each of three kernels (39,168 complete query comparisons). -An additional 20 timed repetitions plus two warmups per case/kernel passed with -ASan/UBSan and Metal API/shader validation (8,448 query comparisons). -These checks found and then exercised the fix; they are not a proof of every -possible shader interleaving. See [stress.csv](results/stress.csv), -[stress-sanitized.csv](results/stress-sanitized.csv), -[validation log](results/stress-validation.txt), and -[witnesses](results/witnesses-sanitized.txt). - -**Performance acceptance remains separate from correctness.** The script uses -fresh processes in CPU / GPU / CPU order on this Mac, nine timed samples per row -after two warmups, and records each raw sample, pipeline startup, upload cost, -CPU load, and GPU utilization. Builds and test suites do not overlap those runs. -The desktop has active background work. These observations must be labelled -exploratory; the repository's quiet-machine requirement is not yet satisfied. -No production speedup or universal CPU/GPU crossover is established by them. -See [A/B/A data](results/aba/) and the generated -[timing table](results/aba/summary.md), including CPU-control drift. - -The final run's second CPU-load snapshots were 90.7%, 90.75%, and 73.37% idle -before A1, B, and A2. Some tiny-query CPU controls drifted substantially -(up to 67% between phase medians), so an automatic dispatch threshold cannot -be justified from this run. Selected raw observations below are milliseconds -for distances **plus DAG construction**; CPU batches use ten workers: - -| Generated graph | Queries | CPU A1 | Metal B | CPU A2 | -|---|---:|---:|---:|---:| -| Random, 1,024 vertices / 8,192 edges | 1 | 0.145 | 0.707 | 0.115 | -| Random, 65,536 vertices / 524,288 edges | 1 | 20.762 | 4.922 | 22.345 | -| Random, 16,384 vertices / 131,072 edges | 64 | 28.290 | 54.004 | 28.429 | -| Clos, 800 vertices / 320,000 edges | 64 | 17.462 | 72.452 | 17.634 | -| Grid, 40,000 vertices / 159,200 edges | 1 | 2.913 | 8.408 | 2.937 | - -These observations identify follow-up candidates rather than establish a -validated speedup. Large low-hop full-source searches deserve a quiet repeat; -small graphs and deep grids/chains do not provide a general reason to offload -with these algorithms. Dropping the DAG changes the experiment materially: -at 16,384 random vertices and 64 queries, CPU distances-only controls were -14.898 / 15.059 ms, with 9.176 ms for the GPU distance stage. Conversely, -the 800-vertex Clos batch was 2.672 / 2.661 ms on the CPU versus 5.681 ms for -the GPU distance stage. Batching alone is not a sufficient selection rule. -Pipeline startup in this repeat was 24.135 ms; the shader cache had already -been exercised, so this is not a cold-install startup measurement. - -**Implementation sequence, revised after the CPU experiments.** Follow the -[CPU plan](../../perf/spf_queue/PLAN.md) before selecting a GPU backend. Its -[queue-substitution experiment](../../perf/spf_queue/) showed that an ordered -bucket queue inside the existing search reproduces the native result contract -bit-exactly and runs 1.3–1.9x faster on weighted graphs; BFS and -bounded-integer buckets are its special cases, with a width limit and heap -fallback. It is implemented in the working tree and measured old/new/old at the -Core level and through NetGraph. A batch or distances-only API is conditional -on demonstrated callers. -Reuse reverse SPF and caches before increasing the query count, and measure -representative NetGraph workloads end to end. - -Then, if a quiet benchmark justifies it, add an opt-in `Backend.metal()` or an -explicit batch accelerator. Store owned CSR buffers and a backend identity in -the graph handle; keep the graph immutable and the masks versioned or copied per -request. A macOS-only Objective-C++ file and optional CMake Metal/Foundation -linkage are sufficient for the host layer. Keep per-request scratch state safe -under concurrent GIL-released callers. Runtime device/pipeline failures need a -documented policy, and non-Apple builds must retain the CPU-only path. -A first adapter can hold `make_cpu_backend()` and delegate its unimplemented -virtual methods there; it does not require rewriting every algorithm at once. -That is a straightforward way to add a selectable factory, but it must expose -which operations actually execute on the GPU rather than imply that delegating -max-flow or KSP became accelerated. - -For the first compatible SPF implementation, retain int64 arithmetic and exact -CPU eligibility; keep zero-cost DAGs, single-path capacity tie-breaking, -fanout-edge semantics, and any unimplemented selection modes on CPU. Preserve -existing predecessor order before declaring parity for the existing result API, -as required by the consolidated CPU plan. A new distances-only batch API avoids most DAG-specific compatibility -work. It must still distinguish supported options from silently ignored ones. - -The [follow-up](optimization/README.md) has since tested frontier/bucket methods, -GPU predecessor extraction and guarded 32-bit paths. Production adoption still -requires a compatible result contract and a measured win over the improved CPU. -Directly moving the existing heap-based Dijkstra loop to a GPU is unlikely to -expose useful parallel work. Dense all-pairs tensor formulations change the -memory/work complexity and are not the straightforward default for sparse -network graphs. These are engineering judgments, not measured claims about -unimplemented algorithms. - -Keep max-flow, KSP, and placement on the CPU initially. Accelerating their SPF -internals requires additional dispatch plumbing and end-to-end validation of -flow amounts, feasibility, conservation, cuts, costs, masks, and cost factors; -the present distance experiment provides no such validation. Bindings and -`python/netgraph_core/_docs.py` must change together if public APIs are added. -Metal runtime tests need a real Apple GPU CI runner. Local success does not -replace CI. - -**Reproduction.** Run from the repository root on a Mac with a Metal device: - -```bash -bash dev/research/metal_pathfinding/build.sh -build/metal_pathfinding/capabilities -build/metal_pathfinding/experiment gpu dev/research/metal_pathfinding/sssp.metal witnesses 1 -build/metal_pathfinding/experiment gpu dev/research/metal_pathfinding/sssp.metal correctness 2 -build/metal_pathfinding/experiment gpu dev/research/metal_pathfinding/sssp.metal stress 100 - -# Close out other compute work before making performance claims. -# Use a fresh output directory to preserve the recorded run. -bash dev/research/metal_pathfinding/run_aba.sh /tmp/netgraph-metal-aba -venv/bin/python dev/research/metal_pathfinding/summarize.py /tmp/netgraph-metal-aba - -bash dev/research/metal_pathfinding/build.sh sanitize -MTL_DEBUG_LAYER=1 MTL_SHADER_VALIDATION=1 ASAN_OPTIONS=detect_leaks=0 \ - build/metal_pathfinding/experiment-sanitize gpu \ - dev/research/metal_pathfinding/sssp.metal stress 20 -``` - -Repository validation also ran `bash .superset/workspace.sh check` (extension -rebuilt; lint/types clean; 170 C++ tests and 465 Python tests passed) and -`make sanitize-test` (170 C++ tests passed). No production C++ source, binding, -or downstream code was modified. No CI run, commit, or push was made. -The [workspace check log](results/workspace-check.txt), -[core sanitizer log](results/sanitize-test.txt), and -[prototype CPU sanitizer results](results/cpu-sanitized.csv) are retained. diff --git a/dev/research/metal_pathfinding/build.sh b/dev/research/metal_pathfinding/build.sh deleted file mode 100644 index 15f5b24..0000000 --- a/dev/research/metal_pathfinding/build.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -cd "$(dirname "$0")/../.." -mkdir -p build/metal_pathfinding -flags=(-std=c++20 -O3 -flto -funroll-loops -fno-math-errno -fno-trapping-math) -if [[ ${1:-} == sanitize ]]; then - flags=(-std=c++20 -O1 -g -fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer) -fi -xcrun clang++ "${flags[@]}" -fobjc-arc -Iinclude \ - dev/research/metal_pathfinding/experiment.mm src/strict_multidigraph.cpp \ - src/shortest_paths.cpp src/profiling.cpp -framework Foundation -framework Metal \ - -o "build/metal_pathfinding/experiment${1:+-$1}" -xcrun clang++ -std=c++20 -O3 -fobjc-arc dev/research/metal_pathfinding/capabilities.mm \ - -framework Foundation -framework Metal -o build/metal_pathfinding/capabilities diff --git a/dev/research/metal_pathfinding/capabilities.mm b/dev/research/metal_pathfinding/capabilities.mm deleted file mode 100644 index bbef385..0000000 --- a/dev/research/metal_pathfinding/capabilities.mm +++ /dev/null @@ -1,28 +0,0 @@ -#import -#import -#include - -int main() { - @autoreleasepool { - id device = MTLCreateSystemDefaultDevice(); - if (!device) return 1; - std::cout << "device=" << device.name.UTF8String - << " unified=" << device.hasUnifiedMemory - << " max_buffer=" << device.maxBufferLength << '\n'; - NSArray* bodies = @[ - @"kernel void test(device long* x [[buffer(0)]], uint i [[thread_position_in_grid]]) { x[i] += 1; }", - @"kernel void test(device double* x [[buffer(0)]], uint i [[thread_position_in_grid]]) { x[i] += 1.0; }", - @"kernel void test(device atomic_ulong* x [[buffer(0)]]) { atomic_fetch_min_explicit(x, 1ul, memory_order_relaxed); }", - @"kernel void test(device atomic_uint* x [[buffer(0)]]) { atomic_fetch_min_explicit(x, 1u, memory_order_relaxed); }", - @"kernel void test(device atomic_ulong* x [[buffer(0)]]) { atomic_min_explicit(x, 1ul, memory_order_relaxed); }", - @"kernel void test(device atomic_ulong* x [[buffer(0)]], device ulong* y [[buffer(1)]]) { y[0] = atomic_load_explicit(x, memory_order_relaxed); }" - ]; - for (NSUInteger i = 0; i < bodies.count; ++i) { - NSError* error = nil; - NSString* source = [@"#include \nusing namespace metal;\n" stringByAppendingString:bodies[i]]; - id library = [device newLibraryWithSource:source options:nil error:&error]; - std::cout << "probe=" << i << " compiled=" << (library != nil) << '\n'; - if (!library) std::cout << error.localizedDescription.UTF8String << '\n'; - } - } -} diff --git a/dev/research/metal_pathfinding/experiment.mm b/dev/research/metal_pathfinding/experiment.mm deleted file mode 100644 index 671cbcb..0000000 --- a/dev/research/metal_pathfinding/experiment.mm +++ /dev/null @@ -1,526 +0,0 @@ -// Standalone research harness, not a registered NetGraph backend. -#import -#import - -#include "netgraph/core/shortest_paths.hpp" -#include "netgraph/core/constants.hpp" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -using namespace netgraph::core; -using Result = std::pair, PredDAG>; -using Clock = std::chrono::steady_clock; -constexpr Cost INF = std::numeric_limits::max(); -double ms(Clock::time_point start) { - return std::chrono::duration(Clock::now() - start).count(); -} - -struct Case { - std::string name; - StrictMultiDiGraph graph; - std::unique_ptr node_mask, edge_mask; - std::vector residual; - bool reverse = false; - std::span nm() const { return {node_mask.get(), size_t(graph.num_nodes())}; } - std::span em() const { return {edge_mask.get(), size_t(graph.num_edges())}; } -}; - -Case make_case(std::string kind, int n, int degree = 8, bool masked = false, - bool large = false, bool reverse = false, unsigned seed = 1729) { - std::vector src, dst; - std::vector costs; - std::vector caps; - std::mt19937 rng(seed); - auto add = [&](int u, int v, Cost c) { - src.push_back(u); dst.push_back(v); - costs.push_back(c + (large ? (Cost(1) << 34) : 0)); - caps.push_back(1.0 + double(rng() % 100) / 7.0); - }; - if (kind == "chain") { - for (int i = 0; i < n - 1; ++i) add(i, i + 1, 1 + rng() % 31); - } else if (kind == "grid") { - int side = int(std::sqrt(n)); - if (side * side != n) throw std::runtime_error("grid must be square"); - for (int u = 0; u < n; ++u) { - if (u % side + 1 < side) { add(u, u + 1, 1); add(u + 1, u, 1); } - if (u + side < n) { add(u, u + side, 1); add(u + side, u, 1); } - } - } else if (kind == "fabric" || kind == "clos") { - int spines = kind == "clos" ? n / 2 : 32; - for (int u = 0; u < n - spines; ++u) for (int v = n - spines; v < n; ++v) { - add(u, v, 1); add(v, u, 1); - } - } else { - for (int u = 0; u < n; ++u) { - add(u, (u + 1) % n, 1 + rng() % 31); - for (int k = 1; k < degree; ++k) add(u, rng() % n, 1 + rng() % 31); - } - } - Case c{kind + (masked ? "_masked" : "") + (large ? "_int64" : "") + - (reverse ? "_reverse" : ""), - StrictMultiDiGraph::from_arrays(n, src, dst, caps, costs)}; - c.reverse = reverse; - int e = c.graph.num_edges(); - c.node_mask = std::make_unique(n); - c.edge_mask = std::make_unique(e); - c.residual.assign(c.graph.capacity_view().begin(), c.graph.capacity_view().end()); - for (int i = 0; i < n; ++i) c.node_mask[i] = !masked || rng() % 23 != 0; - for (int i = 0; i < e; ++i) { - c.edge_mask[i] = !masked || rng() % 11 != 0; - if (masked && rng() % 13 == 0) c.residual[i] = 0; - } - return c; -} - -Result cpu(const Case& c, NodeId source) { - if (c.reverse) - return shortest_paths_to(c.graph, source, true, EdgeSelection{}, c.residual, c.nm(), c.em()); - return shortest_paths(c.graph, source, {}, true, EdgeSelection{}, c.residual, c.nm(), c.em()); -} - -// Stronger CPU comparator: no DAG allocation and no capacity tie-breaking. -std::vector cpu_distance(const Case& c, NodeId source) { - auto& g = c.graph; - auto row = c.reverse ? g.in_row_offsets_view() : g.row_offsets_view(); - auto col = c.reverse ? g.in_col_indices_view() : g.col_indices_view(); - auto ids = c.reverse ? g.in_adj_edge_index_view() : g.adj_edge_index_view(); - auto costs = g.cost_view(); - std::vector dist(g.num_nodes(), INF); - if (!c.node_mask[source]) return dist; - using Item = std::pair; - std::priority_queue, std::greater> queue; - dist[source] = 0; queue.emplace(0, source); - while (!queue.empty()) { - auto [d, u] = queue.top(); queue.pop(); - if (d != dist[u]) continue; - for (int j = row[u]; j < row[u + 1]; ++j) { - int v = col[j], e = ids[j]; - if (!c.node_mask[v] || !c.edge_mask[e] || c.residual[e] < kMinCap) continue; - Cost next = d + costs[e]; - if (next < dist[v]) { dist[v] = next; queue.emplace(next, v); } - } - } - return dist; -} - -// Reusable workers: CPU batch baseline does not pay thread creation per sample. -class Pool { - std::mutex mutex; - std::condition_variable start, done; - std::vector workers; - std::function task; - std::atomic next{0}; - int count = 0, pending = 0, generation = 0; - bool stop = false; -public: - explicit Pool(int n) { - for (int i = 0; i < n; ++i) workers.emplace_back([this] { - int seen = 0; - for (;;) { - std::unique_lock lock(mutex); - start.wait(lock, [&] { return stop || generation != seen; }); - if (stop) return; - seen = generation; - lock.unlock(); - for (int i; (i = next.fetch_add(1)) < count;) task(i); - lock.lock(); - if (--pending == 0) done.notify_one(); - } - }); - } - void run(int n, std::function f) { - std::unique_lock lock(mutex); - task = std::move(f); count = n; next = 0; pending = int(workers.size()); ++generation; - start.notify_all(); - done.wait(lock, [&] { return pending == 0; }); - } - ~Pool() { - { std::lock_guard lock(mutex); stop = true; } - start.notify_all(); - for (auto& t : workers) t.join(); - } -}; - -struct Metal { - id device; - id queue; - id pull, group, shared; - double setup_ms; - Metal(const char* path) { - auto t = Clock::now(); - device = MTLCreateSystemDefaultDevice(); - if (!device) throw std::runtime_error("No Metal device"); - queue = [device newCommandQueue]; - NSError* error = nil; - NSString* code = [NSString stringWithContentsOfFile:[NSString stringWithUTF8String:path] - encoding:NSUTF8StringEncoding error:&error]; - if (!code) throw std::runtime_error(error.localizedDescription.UTF8String); - id library = [device newLibraryWithSource:code options:nil error:&error]; - if (!library) throw std::runtime_error(error.localizedDescription.UTF8String); - pull = [device newComputePipelineStateWithFunction:[library newFunctionWithName:@"relax_pull"] error:&error]; - group = [device newComputePipelineStateWithFunction:[library newFunctionWithName:@"solve_group"] error:&error]; - shared = [device newComputePipelineStateWithFunction:[library newFunctionWithName:@"solve_shared"] error:&error]; - if (!pull || !group || !shared) throw std::runtime_error(error.localizedDescription.UTF8String); - setup_ms = ms(t); - std::cerr << "device=" << device.name.UTF8String << " setup_ms=" << setup_ms - << " execution_width=" << pull.threadExecutionWidth << '\n'; - } - id buffer(const void* ptr, size_t size) { - auto b = [device newBufferWithLength:std::max(size_t(4), size) options:MTLResourceStorageModeShared]; - if (!b) throw std::runtime_error("Metal allocation failed"); - if (ptr && size) std::memcpy(b.contents, ptr, size); - return b; - } -}; - -struct GpuGraph { - const Case& c; - Metal& m; - id row, col, cost, allowed, a, b, changed, sources; - std::vector enabled; - std::vector valid_sources; - size_t n, q; - double upload_ms = 0, gpu_ms = 0, distance_ms = 0; - int rounds = 0; - GpuGraph(const Case& testcase, Metal& metal, const std::vector& query_sources) - : c(testcase), m(metal), n(c.graph.num_nodes()), q(query_sources.size()) { - auto t = Clock::now(); - auto& g = c.graph; - // Reverse SSSP pulls from outgoing CSR. Eligibility uses original edge IDs. - auto rows = c.reverse ? g.row_offsets_view() : g.in_row_offsets_view(); - auto cols = c.reverse ? g.col_indices_view() : g.in_col_indices_view(); - auto ids = c.reverse ? g.adj_edge_index_view() : g.in_adj_edge_index_view(); - std::vector costs(ids.size()); - enabled.resize(ids.size()); - for (size_t j = 0; j < ids.size(); ++j) { - int e = ids[j]; - enabled[j] = c.node_mask[g.edge_src_view()[e]] && c.node_mask[g.edge_dst_view()[e]] && - c.edge_mask[e] && c.residual[e] >= kMinCap; - costs[j] = g.cost_view()[e]; - } - valid_sources = query_sources; - for (auto& source : valid_sources) if (!c.node_mask[source]) source = -1; - row = m.buffer(rows.data(), rows.size_bytes()); - col = m.buffer(cols.data(), cols.size_bytes()); - cost = m.buffer(costs.data(), costs.size() * sizeof(Cost)); - allowed = m.buffer(enabled.data(), enabled.size()); - a = m.buffer(nullptr, n * q * sizeof(Cost)); - b = m.buffer(nullptr, n * q * sizeof(Cost)); - changed = m.buffer(nullptr, std::max(size_t(8), q) * sizeof(uint32_t)); - sources = m.buffer(valid_sources.data(), q * sizeof(int)); - upload_ms = ms(t); - } - - void bind(id encoder, id before, id after) { - [encoder setBuffer:row offset:0 atIndex:0]; - [encoder setBuffer:col offset:0 atIndex:1]; - [encoder setBuffer:cost offset:0 atIndex:2]; - [encoder setBuffer:allowed offset:0 atIndex:3]; - [encoder setBuffer:before offset:0 atIndex:4]; - [encoder setBuffer:after offset:0 atIndex:5]; - [encoder setBuffer:changed offset:0 atIndex:6]; - } - void wait(id command) { - [command commit]; [command waitUntilCompleted]; - if (command.status == MTLCommandBufferStatusError) - throw std::runtime_error(command.error.localizedDescription.UTF8String); - gpu_ms += (command.GPUEndTime - command.GPUStartTime) * 1000; - } - std::vector run(bool persistent, bool materialize = true, bool shared = false) { - auto begin = Clock::now(); - gpu_ms = 0; rounds = 0; - struct Params { uint32_t n, queries, round, unused; } p{uint32_t(n), uint32_t(q), 0, 0}; - std::vector outputs(q); - if (persistent) { - id command = [m.queue commandBuffer]; - auto encoder = [command computeCommandEncoder]; - [encoder setComputePipelineState:shared ? m.shared : m.group]; - bind(encoder, a, b); - [encoder setBytes:&p length:sizeof(p) atIndex:7]; - [encoder setBuffer:sources offset:0 atIndex:8]; - if (shared) { - if (n * 16 + m.shared.staticThreadgroupMemoryLength > m.device.maxThreadgroupMemoryLength) - throw std::runtime_error("threadgroup scratch too large"); - [encoder setThreadgroupMemoryLength:n * 16 atIndex:0]; - } - [encoder dispatchThreadgroups:MTLSizeMake(q, 1, 1) threadsPerThreadgroup:MTLSizeMake(256, 1, 1)]; - [encoder endEncoding]; wait(command); - auto counts = static_cast(changed.contents); - for (size_t i = 0; i < q; ++i) { - rounds = std::max(rounds, int(counts[i])); - outputs[i] = static_cast((!shared && counts[i] % 2 ? b : a).contents) + i * n; - } - } else { - auto initial = static_cast(a.contents); - std::fill(initial, initial + n * q, INF); - for (size_t i = 0; i < q; ++i) if (valid_sources[i] >= 0) initial[i * n + valid_sources[i]] = 0; - id before = a, after = b; - while (rounds < int(n)) { - int steps = std::min(8, int(n) - rounds); - std::memset(changed.contents, 0, 8 * sizeof(uint32_t)); - id command = [m.queue commandBuffer]; - for (int j = 0; j < steps; ++j) { - // Separate encoders with tracked resources establish the round dependency. - auto encoder = [command computeCommandEncoder]; - [encoder setComputePipelineState:m.pull]; bind(encoder, before, after); - p.round = j; - [encoder setBytes:&p length:sizeof(p) atIndex:7]; - [encoder dispatchThreads:MTLSizeMake(n * q, 1, 1) threadsPerThreadgroup:MTLSizeMake(256, 1, 1)]; - [encoder endEncoding]; std::swap(before, after); - } - wait(command); rounds += steps; - if (static_cast(changed.contents)[steps - 1] == 0) break; - } - for (size_t i = 0; i < q; ++i) outputs[i] = static_cast(before.contents) + i * n; - } - std::vector results(q); - for (size_t i = 0; i < q; ++i) results[i].first.assign(outputs[i], outputs[i] + n); - distance_ms = ms(begin); - if (!materialize) return results; - for (size_t query = 0; query < q; ++query) { - auto& d = results[query].first; - auto& dag = results[query].second; - auto& g = c.graph; - // Positive-cost ECMP reconstruction. Zero costs intentionally require fallback. - auto esrc = g.edge_src_view(); auto edst = g.edge_dst_view(); auto costs = g.cost_view(); - auto tight = [&](size_t e) { - int u = esrc[e], v = edst[e]; - return c.node_mask[u] && c.node_mask[v] && c.edge_mask[e] && c.residual[e] >= kMinCap && - (c.reverse ? (d[v] != INF && d[u] == d[v] + costs[e]) : - (d[u] != INF && d[v] == d[u] + costs[e])); - }; - dag.parent_offsets.assign(n + 1, 0); - for (size_t e = 0; e < costs.size(); ++e) if (tight(e)) ++dag.parent_offsets[edst[e] + 1]; - std::partial_sum(dag.parent_offsets.begin(), dag.parent_offsets.end(), dag.parent_offsets.begin()); - dag.parents.resize(dag.parent_offsets.back()); dag.via_edges.resize(dag.parents.size()); - auto cursor = dag.parent_offsets; - for (size_t e = 0; e < costs.size(); ++e) if (tight(e)) { - int pos = cursor[edst[e]]++; dag.parents[pos] = esrc[e]; dag.via_edges[pos] = int(e); - } - } - return results; - } -}; - -std::vector> canonical(const PredDAG& dag) { - std::vector> entries; - for (size_t v = 0; v + 1 < dag.parent_offsets.size(); ++v) - for (int j = dag.parent_offsets[v]; j < dag.parent_offsets[v + 1]; ++j) - entries.emplace_back(v, dag.parents[j], dag.via_edges[j]); - std::sort(entries.begin(), entries.end()); return entries; -} - -void verify(const std::vector& expected, const std::vector& actual) { - if (expected.size() != actual.size()) throw std::runtime_error("query size mismatch"); - for (size_t q = 0; q < expected.size(); ++q) { - if (expected[q].first != actual[q].first) { - auto mismatch = std::mismatch(expected[q].first.begin(), expected[q].first.end(), actual[q].first.begin()); - throw std::runtime_error("distance mismatch at query " + std::to_string(q) + " node " + - std::to_string(mismatch.first - expected[q].first.begin()) + " expected " + - std::to_string(*mismatch.first) + " actual " + std::to_string(*mismatch.second)); - } - if (canonical(expected[q].second) != canonical(actual[q].second)) - throw std::runtime_error("DAG edge set mismatch at query " + std::to_string(q)); - } -} - -double median(std::vector x) { std::sort(x.begin(), x.end()); return x[x.size() / 2]; } - -void witnesses(Metal& metal) { - auto fixture = [](int n, std::vector src, std::vector dst, - std::vector costs, std::vector caps) { - Case c{"witness", StrictMultiDiGraph::from_arrays(n, src, dst, caps, costs)}; - c.node_mask = std::make_unique(n); - c.edge_mask = std::make_unique(costs.size()); - std::fill_n(c.node_mask.get(), n, true); - std::fill_n(c.edge_mask.get(), costs.size(), true); - c.residual.assign(c.graph.capacity_view().begin(), c.graph.capacity_view().end()); - return c; - }; - auto check = [&](Case& c, NodeId source, const std::vector& distances, size_t edges) { - auto expected = cpu(c, source); - if (expected.first != distances || expected.second.via_edges.size() != edges) - throw std::runtime_error("hand-derived witness mismatch"); - GpuGraph gpu(c, metal, {source}); - for (int method = 0; method < 3; ++method) - verify({expected}, gpu.run(method != 0, true, method == 2)); - }; - auto c = fixture(5, {0, 0, 0, 1, 2, 1}, {1, 1, 2, 3, 3, 1}, - {1, 1, 1, 1, 1, 1}, {1, 1, 1, 1, 1, 1}); - check(c, 0, {0, 1, 1, 2, INF}, 5); - std::cout << "PASS diamond: all ECMP alternatives, parallel edges, positive self-loop excluded, isolated node\n"; - c.reverse = true; - check(c, 3, {2, 1, 1, 0, INF}, 5); - c.reverse = false; - std::cout << "PASS reverse diamond: hand-derived distances and forward DAG\n"; - c.node_mask[0] = false; - check(c, 0, {INF, INF, INF, INF, INF}, 0); - c.node_mask[0] = true; - c.node_mask[1] = false; - check(c, 0, {0, INF, 1, 2, INF}, 2); - c.node_mask[1] = true; - std::cout << "PASS masked source and transit node\n"; - // All costs equal, graph order is (src,dst), making edge 0 the first parallel edge. - c.residual[0] = std::nextafter(kMinCap, 0.0); - c.residual[1] = kMinCap; - check(c, 0, {0, 1, 1, 2, INF}, 4); - std::cout << "PASS double capacity threshold: below rejected, exact threshold admitted\n"; - c.edge_mask[1] = false; - check(c, 0, {0, INF, 1, 2, INF}, 2); - std::cout << "PASS edge mask and residual gate combined\n"; - - Cost big = Cost(1) << 54; - auto wide = fixture(5, {0, 0, 1, 2}, {1, 2, 3, 3}, - {big, big, big + 1, big + 2}, {1, 1, 1, 1}); - check(wide, 0, {0, big, big, 2 * big + 1, INF}, 3); - std::cout << "PASS exact int64 costs above 2^53 and one-unit path difference\n"; - - auto zero = fixture(3, {0, 1, 2}, {1, 2, 1}, {0, 0, 0}, {1, 1, 1}); - auto expected = cpu(zero, 0); - GpuGraph zg(zero, metal, {0}); - auto actual = zg.run(true); - if (actual[0].first != expected.first || expected.second.via_edges.size() != 2 || - actual[0].second.via_edges.size() != 3) - throw std::runtime_error("zero-cost gap witness changed"); - std::cout << "PASS expected gap: zero-cost distances agree, tight-edge GPU DAG has 1->2->1 cycle; CPU DAG has 2 edges\n"; - if (float(16777216) != float(16777217) || - float(std::nextafter(kMinCap, 0.0)) != float(kMinCap)) - throw std::runtime_error("precision gap witness changed"); - std::cout << "PASS precision gaps: float32 collapses 16777216/16777217 and admits nextafter(kMinCap,0)\n"; - - auto tie = fixture(2, {0, 0}, {1, 1}, {1, 1}, {1, 1 + 1e-8}); - EdgeSelection sel; - sel.multi_edge = false; sel.tie_break = EdgeTieBreak::PreferHigherResidual; - auto t = shortest_paths(tie.graph, 0, {}, false, sel); - if (t.second.via_edges != std::vector{1} || float(1 + 1e-8) != float(1)) - throw std::runtime_error("capacity tie witness changed"); - std::cout << "PASS capacity tie gap: CPU chooses edge 1 at 1+1e-8; float32 collapses both capacities\n"; -} - -void benchmark(Case& c, int queries, const std::string& mode, Metal* metal, Pool& pool, int samples) { - std::vector sources(queries); - for (int i = 0; i < queries; ++i) sources[i] = (i * 997) % c.graph.num_nodes(); - std::vector expected(queries); - for (int i = 0; i < queries; ++i) expected[i] = cpu(c, sources[i]); - std::unique_ptr gpu; - if (metal) gpu = std::make_unique(c, *metal, sources); - std::vector methods = mode == "cpu" ? - std::vector{"cpu_full", "cpu_distance", "cpu_pool10", "cpu_pool10_distance"} : - std::vector{"metal_pull", "metal_group"}; - if (metal && size_t(c.graph.num_nodes()) * 16 + metal->shared.staticThreadgroupMemoryLength <= metal->device.maxThreadgroupMemoryLength) - methods.push_back("metal_shared"); - for (const auto& method : methods) { - std::vector times, device_times, distance_times; - int exact_order = 0; - for (int rep = -2; rep < samples; ++rep) { - @autoreleasepool { - auto start = Clock::now(); - std::vector actual; - if (method == "cpu_full") { - actual.resize(queries); - for (int i = 0; i < queries; ++i) actual[i] = cpu(c, sources[i]); - } else if (method == "cpu_pool10") { - actual.resize(queries); - pool.run(queries, [&](int i) { actual[i] = cpu(c, sources[i]); }); - } else if (method == "cpu_distance" || method == "cpu_pool10_distance") { - actual.resize(queries); - auto task = [&](int i) { actual[i].first = cpu_distance(c, sources[i]); }; - if (method == "cpu_distance") { - for (int i = 0; i < queries; ++i) task(i); - } else pool.run(queries, task); - } else actual = gpu->run(method != "metal_pull", true, method == "metal_shared"); - double elapsed = ms(start); - // Full validation is outside timing, on every sample, not just warmup. - if (method == "cpu_distance" || method == "cpu_pool10_distance") { - for (int i = 0; i < queries; ++i) if (actual[i].first != expected[i].first) - throw std::runtime_error("CPU distance baseline mismatch"); - } else { - verify(expected, actual); - exact_order = 0; - for (int i = 0; i < queries; ++i) - if (expected[i].second.parents == actual[i].second.parents && - expected[i].second.via_edges == actual[i].second.via_edges) ++exact_order; - } - if (rep >= 0) { - times.push_back(elapsed); - device_times.push_back(gpu ? gpu->gpu_ms : 0); - distance_times.push_back(gpu ? gpu->distance_ms : elapsed); - } - } - } - std::cout << c.name << ',' << c.graph.num_nodes() << ',' << c.graph.num_edges() << ',' << queries - << ',' << method << ',' << std::fixed << std::setprecision(6) << median(times) - << ',' << median(device_times) << ',' << median(distance_times) << ',' - << (gpu ? gpu->upload_ms : 0) << ',' << (gpu ? gpu->rounds : 0) << ',' << samples - << ",pass," << exact_order << ','; - for (size_t i = 0; i < times.size(); ++i) std::cout << (i ? ";" : "") << times[i]; - std::cout << '\n' << std::flush; - } -} - -int main(int argc, char** argv) { - @autoreleasepool { - try { - if (argc < 4) throw std::runtime_error("usage: experiment cpu|gpu kernel-path smoke|full|correctness [samples]"); - std::string mode = argv[1], suite = argv[3]; - if (mode != "cpu" && mode != "gpu") throw std::runtime_error("invalid mode"); - int samples = argc > 4 ? std::stoi(argv[4]) : 7; - if (samples < 1) throw std::runtime_error("samples must be positive"); - Pool pool(10); - std::unique_ptr metal; - if (mode == "gpu") metal = std::make_unique(argv[2]); - if (suite == "witnesses") { - if (!metal) throw std::runtime_error("witnesses require gpu mode"); - witnesses(*metal); return 0; - } - std::cout << "case,n,e,queries,method,wall_ms,gpu_ms,distance_wall_ms,upload_ms,rounds,samples,correct,exact_order_queries,wall_samples_ms\n"; - auto run = [&](Case c, std::initializer_list qs) { - for (int q : qs) benchmark(c, q, mode, metal.get(), pool, samples); - }; - if (suite == "stress") { - run(make_case("grid", 1024), {64}); - run(make_case("random", 1024, 8, true, true, true), {64}); - } else if (suite == "correctness") { - for (unsigned seed = 1; seed <= 12; ++seed) { - run(make_case("random", 37 + seed * 3, 6, true, seed % 2, seed % 3 == 0, seed), {8}); - } - } else { - run(make_case("random", 128), {1, 64}); - run(make_case("random", 1024), {1, 64}); - run(make_case("fabric", 288), {1, 64}); - run(make_case("grid", 1024), {1, 64}); - run(make_case("chain", 256), {1}); - run(make_case("random", 1024, 8, true, true, true), {8}); - if (suite == "full") { - run(make_case("random", 4096), {1, 64, 256}); - run(make_case("random", 16384), {1, 64}); - run(make_case("random", 65536), {1, 64}); - run(make_case("chain", 2048), {1}); - run(make_case("random", 4096, 32), {1, 64}); - run(make_case("clos", 200), {1, 64}); - run(make_case("clos", 800), {1, 64}); - run(make_case("grid", 10000), {1, 8}); - run(make_case("grid", 40000), {1}); - } else if (suite != "smoke") throw std::runtime_error("invalid suite"); - } - } catch (const std::exception& e) { - std::cerr << "FAIL: " << e.what() << '\n'; return 1; - } - } -} diff --git a/dev/research/metal_pathfinding/optimization/README.md b/dev/research/metal_pathfinding/optimization/README.md deleted file mode 100644 index 010b28c..0000000 --- a/dev/research/metal_pathfinding/optimization/README.md +++ /dev/null @@ -1,279 +0,0 @@ -# GPU optimization follow-up - -27 September 2026, NetGraph-Core `fbab119`, Apple M4 Max, 32 GPU cores, -14 CPU cores, 36 GiB unified memory. Standalone research; no backend registration -or production algorithm changes. - -**Yes, the first investigation missed important optimizations.** Its poor batch -results were partly an implementation limitation, especially serial CPU -predecessor construction. They are not evidence of a GPU performance ceiling. -The experiments also show why changing to a frontier or bucket algorithm alone -does not guarantee improvement. Work organization, result construction and the -CPU comparison algorithm matter together. - -**The CPU algorithm comparison also changes the decision.** A follow-up CPU -Dial-style bucket implementation brings the 16k weighted batch from the native -CPU's 27.7 ms to 7.7 ms. GPU results across two passes are 3.9–7.5 ms. On the -65k single-query case the stronger CPU takes 4.4 ms and GPU takes 1.2–1.4 ms. -Improve CPU specialization first; pursue GPU acceleration selectively. - -The consolidated [CPU optimisation plan](../../../perf/spf_queue/PLAN.md) is the implementation -source of truth. After the [ordered bucket queue experiment](../../../perf/spf_queue/) it -specifies one queue substitution inside the existing search (BFS and Dial are -its special cases), verified bit-exact against the production result contract, -with a hybrid-reset workspace, a width-based dispatch rule and fallback, and -downstream acceptance requirements. That change is now implemented in -`src/shortest_paths.cpp`; the plan records its old/new/old measurements at the -Core level and through NetGraph. - -## What changed in the evidence - -Fresh processes ran sequentially: CPU A1, original GPU A1, optimized GPU B, -original GPU A2, CPU A2. Seven measured samples plus two warmups per row; -every result was checked outside the timed interval. CPU batches use ten -reusable workers. Times include host distances and positive-cost predecessor -DAG construction, but exclude graph preparation and pipeline creation. - -| Workload | Native CPU A1 / A2, ms | Original GPU A1 / A2, ms | Best tested optimized GPU, ms | Observation | -|---|---:|---:|---:|---| -| Weighted random, 1,024 nodes / 8,192 edges, one query | 0.086 / 0.097 | 0.915 / 0.972 | 0.599 | CPU still wins small individual queries | -| Weighted random, 4,096 / 32,768, 256 queries | 21.391 / 21.281 | 47.812 / 49.310 | 7.003 | Interleaved 64-bit pull makes batching promising | -| Weighted random, 16,384 / 131,072, 64 queries | 27.693 / 27.721 | 57.748 / 53.106 | 7.473 | Earlier batch pessimism is no longer supported | -| Weighted random, 65,536 / 524,288, one query | 19.316 / 19.240 | 5.184 / 5.854 | 1.373 | Guarded 32-bit pull plus GPU DAG extraction helps | -| Unit-cost fabric, 800 / 320,000, 64 queries | 17.042 / 16.525 | 69.896 / 70.228 | 8.185 | CPU BFS plus optimized DAG is already 8.296 / 7.885 ms | -| Unit-cost grid, 10,000 / 39,600, one query | 0.570 / 0.582 | 3.836 / 5.874 | 3.378 | CPU BFS plus DAG is 0.089 / 0.088 ms | -| Weighted chain, 2,048 / 2,047, one query | 0.017 / 0.017 | 4.834 / 4.908 | 4.847 | Almost no useful parallel work per frontier | - -These are **exploratory measurements, not accepted performance claims**. The -desktop was not isolated: pre-phase CPU idle readings ranged from 73.28% to -87.88%, with nonzero background GPU activity. The original GPU grid result -drifted substantially between A1 and A2. A quiet-machine repeat is still required -by this repository's performance standard. Nothing was stopped or altered in -the user's other sessions to obtain these measurements. - -The best method is selected after measurement, not by a working runtime -dispatcher. Original GPU columns select between the original pull and persistent -group methods; the original shared-memory method was not rerun. The complete -[18-case table and all 216 optimized rows](results/aba/summary.md), -[raw samples](results/aba/optimized-b.csv), and -[environment/source hashes](results/aba/environment.txt) preserve the scope. - -## Stronger CPU comparisons - -The first table compares against the current native backend. A second A/B/A -compares GPU with CPU heap and Dial-style cyclic-bucket searches using cached -eligibility and one-pass DAG assembly. Each CPU worker completes both search and -DAG for a query, retaining locality. This is a comparable experimental contract -to the GPU: exact distances and canonical-equivalent positive-cost DAG edge sets, -without CPU discovery-order parity. Both devices exclude eligibility preparation. -The weighted random graphs have integer costs 1–31; the CPU Dial prototype is -explicitly limited to maximum edge weight at most 1,024, and retains int64 sums. - -| Workload | CPU cached heap + DAG A1 / A2, ms | CPU Dial + DAG A1 / A2, ms | Best GPU first / second B, ms | -|---|---:|---:|---:| -| Random 1,024 / 8,192, one query | 0.053 / 0.059 | 0.028 / 0.030 | 0.599 / 0.634 | -| Random 4,096 / 32,768, 256 queries | 13.030 / 13.024 | 7.056 / 6.925 | 7.003 / 4.001 | -| Random 16,384 / 131,072, 64 queries | 17.500 / 17.268 | 7.696 / 7.677 | 7.473 / 3.851 | -| Random 65,536 / 524,288, one query | 11.474 / 11.530 | 4.384 / 4.372 | 1.373 / 1.244 | -| Random 4,096 / 131,072, 64 queries | 6.672 / 6.662 | 3.854 / 3.865 | 2.051 / 2.215 | -| Unit-cost fabric 800 / 320,000, 64 queries | 5.125 / 5.391 | 5.421 / 5.537 | 8.185 / 8.372 | - -The second B repeated all 216 optimized GPU rows, unchanged binary, between -CPU alternative A1/A2. [All second-pass results](results/cpu-algorithms-aba/summary.md) -and [raw GPU samples](results/cpu-algorithms-aba/b.csv) are retained. The winning -GPU method changed on the 4k/256 and 16k/64 batches: 32-bit pull plus GPU DAG -beat the first pass's winning 64-bit pull. Background CPU idle reached 59.82% -before one phase. We have not attributed this variability to a specific cause; -it prevents a precise crossover rule or a stable speedup claim. - -This strengthens the practical recommendation: CPU optimizations are worthwhile -independently of GPU support. GPU remains promising for large single-source and -some batch workloads, but comparisons only against the existing CPU heap backend -overstate the advantage over a better CPU implementation. On the dense fabric, -the fused cached CPU heap + DAG is faster than either GPU pass. On the grid, -the earlier CPU BFS result remains faster than the heap and Dial alternatives. - -## Optimizations we missed - -1. **Result construction on the CPU.** The first prototype scanned every edge - twice and built every query's DAG serially, while the CPU search comparator - already had ten workers. A cached-eligibility, one-pass incoming-CSR DAG - builder, parallel across queries, reduced the 16k/64-query persistent-group - case from approximately 53–58 ms to 13.239 ms without changing the GPU search - algorithm. This variant bundles three changes; it does not isolate the - contribution of threading from one-pass construction or cached eligibility. -2. **Batch memory layout.** Making query the contiguous dimension reduced the - same workload from 15.959 ms with the query-major 64-bit pull to 7.473 ms. - Device time fell from 10.305 to 1.950 ms. Both versions use the same CPU DAG - builder and identical relaxation counts. This improvement does not require - narrowing distances or a new graph algorithm. -3. **Device-side DAG extraction.** Counting tight incoming edges, host prefix - sums, then GPU filling helped the large single-query and higher-degree batch - cases. It is not universally better: query-major frontier plus GPU DAG took - 15.944 ms on the 800-node fabric batch, versus 9.324 ms with CPU DAG assembly. - Layout and output volume require their own decision, separate from SSSP. -4. **Exact 32-bit specialization.** Narrow distances only when - `max_edge_cost * node_count < UINT32_MAX`; otherwise run the 64-bit pull. - This conservatively proves that the sentinel cannot collide with finite - shortest distances. No floating-point conversion is used. It helped some - cases, but 64-bit pull beat 32-bit pull on the 16k batch, so narrower is not - automatically faster. Eligibility still uses CPU double-precision residuals. -5. **Command and host overhead.** Multiple dispatches share a compute encoder - with explicit buffer barriers; convergence flags are aggregated by SIMD - group. These changes were tested as a bundle. An initial screening defect - repeatedly invoked the Objective-C buffer accessor inside the conversion - loop. Hoisting it removed large batch host overhead before final timings. - The retained [screening notes](results/screen-known-limitations.txt) distinguish - those preliminary measurements from the final matrix. - -Apple's compute documentation supports batching dispatches and respecting -resource synchronization. Its Metal 4 command allocators and reusable command -infrastructure offer another way to reduce encoding overhead. Metal 4 itself -was **not implemented or timed** here. -[Metal calculations](https://developer.apple.com/documentation/metal/performing-calculations-on-a-gpu), -[Metal 4 command management](https://developer.apple.com/videos/play/wwdc2025/254/). - -## Should we change algorithms? - -**Weighted, general graphs: retain both pull and frontier candidates.** -Davidson et al. study Workfront Sweep, Near-Far and Bucketing as different -balances between redundant edge work and scheduling overhead. That is the right -comparison space for this problem; ordinary parallel Dijkstra is not an obvious -GPU default. Their NVIDIA results are not M4 speed predictions. -[2014 SSSP paper](https://escholarship.org/uc/item/8qr166v2). - -The new active-frontier implementation supports one thread or one SIMD group -per active vertex, indirect dispatch and queue deduplication. It reduced the -16k/64-query relaxation work from 201.327 million adjacency entries to about -21–22 million. Yet its best full time was 10.144 ms, versus 7.473 ms for -interleaved pull. On the 10k grid it reduced edge scans from 7.920 million to -39,600, but still took 4.040 ms versus pull's 3.378 ms. It retained 200 rounds -and 25 host waits. Less edge work is real; the latency win is not automatic. - -**Distance buckets remain worth investigating, but our simple version is not a -win.** The delta-4 experiment reduced the 16k batch to 8.451 million edge visits, -but needed 72 rounds and 16.825 ms. Delta-16 and delta-64 were also slower than -the best pull. This implementation scans all pending vertex slots to find and -select each bucket, uses a batch-wide minimum and bulk synchronization, and has -neither a light/heavy edge split nor a sophisticated asynchronous queue. -These results reject this implementation as the default, not delta-stepping. - -ADDS specifically addresses bucket management, asynchronous work scheduling -and dynamic delta selection. It is a plausible next algorithmic investigation, -but a correct Metal port needs a progress-safe queue and synchronization design; -copying CUDA persistent-worker assumptions would not establish correctness. -Its published RTX 2080 Ti speedups do not establish gains on Apple hardware. -[ADDS, PPoPP 2021](https://www.cs.utexas.edu/~lin/papers/ppopp21.pdf). - -**Uniform positive costs: test BFS on the CPU as well as the GPU.** The new CPU -BFS comparator was checked against native Dijkstra and uses the same cached -eligibility and optimized DAG construction as the GPU variants. It is a major -improvement on grids and removes most of the apparent GPU advantage on the -dense fabric. Direction-optimizing BFS can switch between sparse frontiers and -pulling from unvisited vertices. Its early exit after finding one parent applies -to distance discovery; all ECMP predecessors still require extraction. -[Direction-optimizing BFS](https://people.eecs.berkeley.edu/~krste/papers/beamer-sc2012.pdf). - -The CPU plan now preserves predecessor order by construction: the ordered -bucket queue reproduces the heap's pop order exactly, and the unit-cost case is -its `W = 2` instance. Under that full contract the sequential gain on uniform-cost -graphs is 1.1–1.3x and inconclusive on the dense fabric, where edge scanning -dominates; the larger research BFS numbers came from the relaxed contract. - -**Stable, road-like graphs with many repeated queries: consider preprocessing.** -PHAST uses contraction-hierarchy preprocessing followed by an ordered sweep. -The paper explicitly requires enough queries to amortize preprocessing and -focuses on low-highway-dimension graphs. For NetGraph, changing masks, residual -eligibility and weights mean shortcut validity or customization must be handled; -that applicability assessment is our inference, not a measured PHAST result. -It is not the first choice for arbitrary changing network simulations. -[PHAST](https://www.microsoft.com/en-us/research/wp-content/uploads/2011/01/phast_ipdps.pdf). - -**Small or destination-limited searches: keep the CPU path.** These tests are -full-source searches; they do not compare against target-early-exit Dijkstra. -Bidirectional search or A* with a proven admissible lower bound can be relevant -to point-to-point workloads, but neither was implemented or timed here. Likewise, -CPU radix queues for wider integer costs remain an unmeasured stronger comparator; -the bounded-weight Dial alternative is measured above. - -## Recommended implementation order - -Follow the [CPU plan](../../../perf/spf_queue/PLAN.md): add the differential parity test with a -forced heap route, land the ordered bucket queue and hybrid-reset workspace in -the forward search as one change gated on the existing output hashes, extend to -the reverse search, freeze the width limit, then validate downstream. Batching -and a new distance-only API are conditional on actual callers, not prerequisites -for single-query CPU gains. - -After those gates, revisit the GPU candidates against the accepted CPU baseline: -interleaved exact 64-bit pull, guarded 32-bit paths, and separately selected result -construction. Adaptive frontier/pull selection and improved bucket scheduling -remain later GPU research. The existing evidence does not establish full-backend -compatibility or justify automatic CPU/GPU dispatch. - -## Validation and remaining limits - -- Final A/B/A: 384 rows, 124,704 checked query results including warmups. -- Correctness: 12 seeded masked graphs and 12 boundary fixtures, all 14 variants, - 11,760 checked query results. Fixtures exercise forward/reverse traversal, - parallel edges, self-loops, blocked sources, unreachable nodes, residuals just - below and at `kMinCap`, distances near the 32-bit boundary, guarded fallback, - and integer costs greater than `2^53`. -- Normal stress: 39,424 checked results. ASan/UBSan plus Metal API and GPU shader - validation: another 11,760 correctness and 8,960 stress results. No reported - sanitizer or validation errors. These repetitions are not independent graphs. -- The separate CPU comparison repeats masked positive-cost checks against native - Dijkstra, and runs ASan/UBSan over both its masked fixtures and all seven - performance workloads. See the [audit](results/audit.json) for exact counts. -- Across both final timing sequences and the listed correctness/stress runs: - 290,192 checked query results, including CPU comparator rows and warmups. -- Final repository check rebuilt the Python extension and passed lint, types, - 170 C++ tests and 465 Python tests. `make sanitize-test` passed all 170 C++ - tests. An initial formatting failure in the new summarizer was corrected and - both subsequent workspace checks passed. These are local checks; CI was not - run. No production tracked files changed, and no commit or push was made. -- All distances compare exactly. Positive-cost predecessor edge sets compare - canonically to native CPU results; CPU discovery order is not reproduced. -- Graphs are synthetic and modest in size. No realistic demand traces, cold - production latency, concurrent calls, per-query mask batches, cost-factor flows, - zero-cost DAG parity or complete backend compatibility were established. -- GPU buffers and cached eligibility are prepared before timing. `upload_ms` - includes the superset of buffers needed by all experimental variants, not a - minimal production upload. Printed `setup_ms` is inherited phase-one pipeline - setup and excludes the additional optimization library; it is not total cold - initialization. GPU timestamps and distance timing are diagnostic subsets, - not substitutes for full wall time. Do not subtract separate medians to claim - an exact component cost. -- Queue atomics, bucket scans, vertex initialization and DAG extraction are not - included in the `edge_visits` counter. The counter measures SSSP adjacency work. -- The prototype requires SIMD width 32 and 256-thread groups, checked at runtime, - and imposes experimental size limits. It is not a portable library implementation. - -Reproduce from the repository root: - -```sh -bash dev/research/metal_pathfinding/optimization/build.sh -build/metal_pathfinding/optimized all correctness 3 -build/metal_pathfinding/optimized all stress 20 -bash dev/research/metal_pathfinding/optimization/run_aba.sh -python3 dev/research/metal_pathfinding/optimization/summarize.py -bash dev/research/metal_pathfinding/optimization/build.sh sanitize -MTL_DEBUG_LAYER=1 MTL_SHADER_VALIDATION=1 ASAN_OPTIONS=detect_leaks=0 \ - build/metal_pathfinding/optimized-sanitize all correctness 3 -MTL_DEBUG_LAYER=1 MTL_SHADER_VALIDATION=1 ASAN_OPTIONS=detect_leaks=0 \ - build/metal_pathfinding/optimized-sanitize all stress 3 -bash dev/research/metal_pathfinding/optimization/build_cpu.sh -build/metal_pathfinding/cpu-algorithms 3 correctness -bash dev/research/metal_pathfinding/optimization/run_cpu_aba.sh -python3 dev/research/metal_pathfinding/optimization/summarize_cpu.py -bash dev/research/metal_pathfinding/optimization/build_cpu.sh sanitize -ASAN_OPTIONS=detect_leaks=0 build/metal_pathfinding/cpu-algorithms-sanitize 3 correctness -ASAN_OPTIONS=detect_leaks=0 build/metal_pathfinding/cpu-algorithms-sanitize 2 -make sanitize-test -bash .superset/workspace.sh check -``` - -Run timings sequentially without builds or tests in parallel. The compiler emits -one known warning because the reused phase-one `main` is renamed for inclusion; -that unused helper lacks an explicit return after its success path. It is not -called by this harness. The original experiment and shader hashes are unchanged. diff --git a/dev/research/metal_pathfinding/optimization/build.sh b/dev/research/metal_pathfinding/optimization/build.sh deleted file mode 100644 index d3cd00a..0000000 --- a/dev/research/metal_pathfinding/optimization/build.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -cd "$(dirname "$0")/../../.." -mkdir -p build/metal_pathfinding -flags=(-std=c++20 -O3 -flto -funroll-loops -fno-math-errno -fno-trapping-math) -if [[ ${1:-} == sanitize ]]; then - flags=(-std=c++20 -O1 -g -fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer) -fi -xcrun clang++ "${flags[@]}" -fobjc-arc -Iinclude \ - dev/research/metal_pathfinding/optimization/experiment.mm src/strict_multidigraph.cpp \ - src/shortest_paths.cpp src/profiling.cpp -framework Foundation -framework Metal \ - -o "build/metal_pathfinding/optimized${1:+-$1}" diff --git a/dev/research/metal_pathfinding/optimization/build_cpu.sh b/dev/research/metal_pathfinding/optimization/build_cpu.sh deleted file mode 100644 index e8b95a1..0000000 --- a/dev/research/metal_pathfinding/optimization/build_cpu.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -cd "$(dirname "$0")/../../.." -flags=(-std=c++20 -O3 -flto -funroll-loops -fno-math-errno -fno-trapping-math) -if [[ ${1:-} == sanitize ]]; then - flags=(-std=c++20 -O1 -g -fsanitize=address,undefined -fno-sanitize-recover=undefined -fno-omit-frame-pointer) -fi -xcrun clang++ "${flags[@]}" -fobjc-arc -Iinclude \ - dev/research/metal_pathfinding/optimization/cpu_algorithms.mm src/strict_multidigraph.cpp \ - src/shortest_paths.cpp src/profiling.cpp -framework Foundation -framework Metal \ - -o "build/metal_pathfinding/cpu-algorithms${1:+-$1}" diff --git a/dev/research/metal_pathfinding/optimization/cpu_algorithms.mm b/dev/research/metal_pathfinding/optimization/cpu_algorithms.mm deleted file mode 100644 index 58dc6ae..0000000 --- a/dev/research/metal_pathfinding/optimization/cpu_algorithms.mm +++ /dev/null @@ -1,102 +0,0 @@ -// Stronger CPU algorithm comparators for the same positive-cost result contract. -#define main phase_one_main -#include "../experiment.mm" -#undef main - -struct PreparedCpu { - const Case& c; - std::vector enabled; - Cost max_weight=0; - explicit PreparedCpu(const Case& graph):c(graph),enabled(graph.graph.num_edges()) { - const auto& g=c.graph; - for(int e=0;e=kMinCap; - max_weight=std::max(max_weight,g.cost_view()[e]); - } - } - Result solve(NodeId source,bool dial) const { - const auto& g=c.graph; int n=g.num_nodes(); - auto row=g.row_offsets_view(),col=g.col_indices_view(),ids=g.adj_edge_index_view(); - auto costs=g.cost_view(); - Result result; auto& dist=result.first; auto& dag=result.second; - dist.assign(n,INF); - using Item=std::pair; - auto visit=[&](Cost d,NodeId u,auto&& push) { - if(d!=dist[u]) return; - for(int j=row[u];j1024) throw std::runtime_error("Dial comparison requires max weight <= 1024"); - size_t width=size_t(max_weight)+1,pending=1; - std::vector> buckets(width); - buckets[0].emplace_back(0,source); Cost current=0; - auto push=[&](Item x){buckets[size_t(x.first)%width].push_back(x);++pending;}; - while(pending) { - auto& bucket=buckets[size_t(current)%width]; - if(bucket.empty()) {++current;continue;} - auto [d,u]=bucket.back();bucket.pop_back();--pending; - if(d!=current) throw std::runtime_error("Dial ordering invariant failed"); - visit(d,u,push); - } - } else { - std::priority_queue,std::greater> heap; - heap.emplace(0,source); - auto push=[&](Item x){heap.push(x);}; - while(!heap.empty()) {auto [d,u]=heap.top();heap.pop();visit(d,u,push);} - } - } - auto inrow=g.in_row_offsets_view(),incol=g.in_col_indices_view(),inids=g.in_adj_edge_index_view(); - dag.parent_offsets.resize(n+1);dag.parents.reserve(n);dag.via_edges.reserve(n); - for(int v=0;v1 ? std::stoi(argv[1]) : 7; bool correctness=argc>2; - Pool pool(10); - std::cout<<"case,n,e,queries,method,wall_ms,samples,correct,wall_samples_ms\n"; - auto run=[&](Case c,int q) { - std::vector expected(q); std::vector sources(q); - for(int i=0;i times; - for(int rep=-2;rep actual(q); - auto query=[&](int i){actual[i]=prepared.solve(sources[i],dial);}; - if(q==1) query(0);else pool.run(q,query); - double elapsed=ms(start);verify(expected,actual);if(rep>=0)times.push_back(elapsed); - } - std::cout< - -struct OptimizedMetal : Metal { - std::map> pipelines; - OptimizedMetal() : Metal("dev/research/metal_pathfinding/sssp.metal") { - NSError* error = nil; - NSString* code = [NSString stringWithContentsOfFile:@"dev/research/metal_pathfinding/optimization/kernels.metal" - encoding:NSUTF8StringEncoding error:&error]; - auto lib = [device newLibraryWithSource:code options:nil error:&error]; - if (!lib) throw std::runtime_error(error.localizedDescription.UTF8String); - for (const char* name : {"pull64","pull32","prepare","expand","bucket_reset", - "bucket_min","bucket_select","dag_count64","dag_fill64", - "dag_count32","dag_fill32"}) { - auto fn = [lib newFunctionWithName:[NSString stringWithUTF8String:name]]; - auto pipeline = [device newComputePipelineStateWithFunction:fn error:&error]; - if (!pipeline) throw std::runtime_error(error.localizedDescription.UTF8String); - if (pipeline.threadExecutionWidth != 32 || pipeline.maxTotalThreadsPerThreadgroup < 256) - throw std::runtime_error("this research configuration requires SIMD32 and 256-thread groups"); - pipelines.emplace(name, pipeline); - } - } -}; - -struct Config { - std::string name, solver; - bool layout = false, narrow = false, gpu_dag = false; - int lanes = 1, delta = 0; -}; - -struct OptimizedGraph { - const Case& c; - OptimizedMetal& m; - Pool& pool; - GpuGraph old; - std::vector sources; - std::vector in_costs; - std::vector in_enabled; - id a64,b64,a32,b32,weights32; - id outrow,outcol,outcost,outenabled,queueA,queueB,counts,marks,args,work,minimum; - id inrow,incol,incost,inids,inenabled,offsets,parents,via; - size_t n, q, e; - bool fits32; - double upload_ms=0, distance_ms=0, gpu_ms=0; - uint64_t edge_visits=0; - int rounds=0, waits=0; - bool fallback=false; - struct P { uint32_t n,q,round,layout,current,epoch,lanes,delta; }; - - OptimizedGraph(const Case& testcase, OptimizedMetal& metal, Pool& workers, - std::vector query_sources) - : c(testcase),m(metal),pool(workers),old(c,m,query_sources),sources(std::move(query_sources)), - n(c.graph.num_nodes()),q(sources.size()),e(c.graph.num_edges()) { - auto begin=Clock::now(); - if (!n || n*q>UINT32_MAX/32 || e*q>UINT32_MAX/8) throw std::runtime_error("experiment size limit"); - auto& g=c.graph; - Cost largest=0; - for (Cost cost:g.cost_view()) largest=std::max(largest,cost); - // Strictly less than the sentinel; the multiplication is bounded in uint64. - fits32=largest < Cost(UINT32_MAX) && uint64_t(largest)*n < UINT32_MAX; - a64=m.buffer(nullptr,n*q*8); b64=m.buffer(nullptr,n*q*8); - a32=m.buffer(nullptr,n*q*4); b32=m.buffer(nullptr,n*q*4); - std::vector ws(e); - auto pull_ids=c.reverse ? g.adj_edge_index_view() : g.in_adj_edge_index_view(); - for(size_t j=0;j oe(e); - for(size_t j=0;j=kMinCap; - } - outrow=m.buffer(orow.data(),orow.size_bytes()); outcol=m.buffer(ocol.data(),ocol.size_bytes()); - outcost=m.buffer(ws.data(),e*4); outenabled=m.buffer(oe.data(),e); - queueA=m.buffer(nullptr,n*q*4); queueB=m.buffer(nullptr,n*q*4); - counts=m.buffer(nullptr,8); marks=m.buffer(nullptr,n*q*4); - args=m.buffer(nullptr,12); work=m.buffer(nullptr,32); minimum=m.buffer(nullptr,4); - in_costs.resize(e); in_enabled.resize(e); - auto ids=g.in_adj_edge_index_view(); - for(size_t j=0;j=kMinCap; - } - inrow=m.buffer(g.in_row_offsets_view().data(),g.in_row_offsets_view().size_bytes()); - incol=m.buffer(g.in_col_indices_view().data(),g.in_col_indices_view().size_bytes()); - inids=m.buffer(ids.data(),ids.size_bytes()); incost=m.buffer(in_costs.data(),e*8); - inenabled=m.buffer(in_enabled.data(),e); offsets=m.buffer(nullptr,(n+1)*q*4); - // Reserve output capacity once; only actual DAG entries are copied to host. - parents=m.buffer(nullptr,e*q*4); via=m.buffer(nullptr,e*q*4); - upload_ms=old.upload_ms+ms(begin); - } - - void wait(id cb) { - [cb commit]; [cb waitUntilCompleted]; ++waits; - if(cb.status==MTLCommandBufferStatusError) throw std::runtime_error(cb.error.localizedDescription.UTF8String); - gpu_ms+=(cb.GPUEndTime-cb.GPUStartTime)*1000; - } - void pipeline(id enc,const char* name) { - [enc setComputePipelineState:m.pipelines.at(name)]; - } - void dispatch(id enc,size_t size) { - [enc dispatchThreads:MTLSizeMake(size,1,1) threadsPerThreadgroup:MTLSizeMake(256,1,1)]; - [enc memoryBarrierWithScope:MTLBarrierScopeBuffers]; - } - void one(id enc) { - [enc dispatchThreadgroups:MTLSizeMake(1,1,1) threadsPerThreadgroup:MTLSizeMake(1,1,1)]; - [enc memoryBarrierWithScope:MTLBarrierScopeBuffers]; - } - - void cpu_dags(std::vector& results) { - auto row=c.graph.in_row_offsets_view(); auto col=c.graph.in_col_indices_view(); - auto ids=c.graph.in_adj_edge_index_view(); - auto build=[&](int query) { - auto& d=results[query].first; auto& dag=results[query].second; - dag.parent_offsets.resize(n+1); - // Append in incoming-CSR order in one scan. Eligibility was cached at upload. - dag.parents.reserve(n); dag.via_edges.reserve(n); - for(size_t v=0;v pull(Config cfg) { - id before=cfg.narrow ? a32 : a64, after=cfg.narrow ? b32 : b64; - if(cfg.narrow) std::fill_n(static_cast(before.contents),n*q,UINT32_MAX); - else std::fill_n(static_cast(before.contents),n*q,INF); - for(size_t i=0;i(before.contents)[index]=0; - else static_cast(before.contents)[index]=0; - } - P p{uint32_t(n),uint32_t(q),0,uint32_t(cfg.layout),0,0,1,0}; - while(rounds(old.changed.contents)[steps-1]==0) break; - } - return before; - } - - id frontier(Config cfg) { - std::fill_n(static_cast(a32.contents),n*q,UINT32_MAX); - std::memset(marks.contents,0,n*q*4); std::memset(counts.contents,0,8); - uint32_t active=0; - for(size_t query=0;query(a32.contents)[index]=0; - static_cast(queueA.contents)[active++]=index; - if(cfg.delta) static_cast(marks.contents)[index]=1; - } - static_cast(counts.contents)[0]=active; - uint32_t current=0; - id input=queueA, output=queueB; - P p{uint32_t(n),uint32_t(q),0,0,0,1,uint32_t(cfg.lanes),uint32_t(cfg.delta)}; - // Fail rather than returning unconverged data if an implementation bug or - // pathological scheduler exceeds this research harness's operational limit. - while(rounds<1000000) { - std::memset(work.contents,0,32); - auto cb=[m.queue commandBuffer]; auto enc=[cb computeCommandEncoder]; - for(int j=0;j<8;++j) { - p.round=j; p.current=cfg.delta ? 0 : current; p.epoch=uint32_t(rounds+j+1); - [enc setBytes:&p length:sizeof(p) atIndex:7]; - if(cfg.delta) { - input=queueA; output=queueB; - pipeline(enc,"bucket_reset"); - [enc setBuffer:counts offset:0 atIndex:0]; [enc setBuffer:minimum offset:0 atIndex:1]; one(enc); - pipeline(enc,"bucket_min"); - [enc setBuffer:a32 offset:0 atIndex:0]; [enc setBuffer:marks offset:0 atIndex:1]; - [enc setBuffer:minimum offset:0 atIndex:2]; dispatch(enc,n*q); - pipeline(enc,"bucket_select"); - [enc setBuffer:a32 offset:0 atIndex:0]; [enc setBuffer:marks offset:0 atIndex:1]; - [enc setBuffer:minimum offset:0 atIndex:2]; [enc setBuffer:input offset:0 atIndex:3]; - [enc setBuffer:counts offset:0 atIndex:4]; dispatch(enc,n*q); - } - pipeline(enc,"prepare"); - [enc setBuffer:counts offset:0 atIndex:0]; [enc setBuffer:args offset:0 atIndex:1]; one(enc); - pipeline(enc,"expand"); - [enc setBuffer:outrow offset:0 atIndex:0]; [enc setBuffer:outcol offset:0 atIndex:1]; - [enc setBuffer:outcost offset:0 atIndex:2]; [enc setBuffer:outenabled offset:0 atIndex:3]; - [enc setBuffer:a32 offset:0 atIndex:4]; [enc setBuffer:input offset:0 atIndex:5]; - [enc setBuffer:output offset:0 atIndex:6]; [enc setBuffer:counts offset:0 atIndex:8]; - [enc setBuffer:marks offset:0 atIndex:9]; [enc setBuffer:work offset:0 atIndex:10]; - [enc dispatchThreadgroupsWithIndirectBuffer:args indirectBufferOffset:0 threadsPerThreadgroup:MTLSizeMake(256,1,1)]; - [enc memoryBarrierWithScope:MTLBarrierScopeBuffers]; - if(!cfg.delta) { std::swap(input,output); current=1-current; } - } - [enc endEncoding]; wait(cb); rounds+=8; - for(int j=0;j<8;++j) edge_visits+=static_cast(work.contents)[j]; - if(static_cast(counts.contents)[cfg.delta ? 0 : current]==0) return a32; - } - throw std::runtime_error("frontier failed to converge"); - } - - void gpu_dags(std::vector& results,id distances,bool narrow,bool layout) { - struct DP { uint32_t n,q,e,layout,reverse,a,b,c; } p{uint32_t(n),uint32_t(q),uint32_t(e),uint32_t(layout),uint32_t(c.reverse),0,0,0}; - auto encode=[&](bool fill) { - auto cb=[m.queue commandBuffer]; auto enc=[cb computeCommandEncoder]; - pipeline(enc,fill ? (narrow ? "dag_fill32" : "dag_fill64") : (narrow ? "dag_count32" : "dag_count64")); - [enc setBuffer:inrow offset:0 atIndex:0]; [enc setBuffer:incol offset:0 atIndex:1]; - [enc setBuffer:incost offset:0 atIndex:2]; [enc setBuffer:inids offset:0 atIndex:3]; - [enc setBuffer:inenabled offset:0 atIndex:4]; [enc setBuffer:distances offset:0 atIndex:5]; - [enc setBuffer:offsets offset:0 atIndex:6]; [enc setBytes:&p length:sizeof(p) atIndex:7]; - [enc setBuffer:parents offset:0 atIndex:8]; [enc setBuffer:via offset:0 atIndex:9]; - dispatch(enc,n*q); [enc endEncoding]; wait(cb); - }; - encode(false); - pool.run(int(q),[&](int query) { - auto ptr=static_cast(offsets.contents)+query*(n+1); - ptr[0]=0; std::partial_sum(ptr,ptr+n+1,ptr); - results[query].second.parent_offsets.assign(ptr,ptr+n+1); - }); - encode(true); - pool.run(int(q),[&](int query) { - auto& dag=results[query].second; int size=dag.parent_offsets.back(); - auto ps=static_cast(parents.contents)+query*e; - auto es=static_cast(via.contents)+query*e; - dag.parents.assign(ps,ps+size); dag.via_edges.assign(es,es+size); - }); - } - - std::vector run(Config cfg) { - auto begin=Clock::now(); gpu_ms=0; waits=0; rounds=0; edge_visits=0; fallback=false; - if(cfg.solver=="old_pull" || cfg.solver=="old_group") { - auto results=old.run(cfg.solver=="old_group",cfg.name.ends_with("serial")); - gpu_ms=old.gpu_ms; distance_ms=old.distance_ms; rounds=old.rounds; - waits=cfg.solver=="old_pull" ? (rounds+7)/8 : 1; - // For persistent groups, per-query round counts can differ. - if(cfg.solver=="old_pull") edge_visits=uint64_t(rounds)*e*q; - else for(size_t i=0;i(old.changed.contents)[i])*e; - if(!cfg.name.ends_with("serial")) cpu_dags(results); - return results; - } - if(cfg.narrow && !fits32) { cfg.narrow=false; cfg.solver="pull"; fallback=true; } - if(cfg.solver!="pull") cfg.layout=false; - auto data=cfg.solver=="pull" ? pull(cfg) : frontier(cfg); - std::vector results(q); - // Hoist Objective-C accessors out of the per-distance conversion loop. - // Calling .contents for every element dominated large-batch host time. - auto data32=static_cast(data.contents); - auto data64=static_cast(data.contents); - auto copy=[&](int query) { - auto& dist=results[query].first; dist.resize(n); - if(!cfg.layout && !cfg.narrow) { - auto ptr=data64+query*n; - std::copy_n(ptr,n,dist.begin()); - } else for(size_t v=0;v configurations() { - return { - {"old_pull_serial","old_pull"}, {"old_group_serial","old_group"}, - {"old_pull_parallel_dag","old_pull"}, {"old_group_parallel_dag","old_group"}, - {"pull64_barrier","pull"}, {"pull64_interleaved","pull",true}, - {"pull32_interleaved","pull",true,true}, - {"pull32_gpu_dag","pull",true,true,true}, - {"frontier32_vertex","frontier",false,true,false,1}, - {"frontier32_simd","frontier",false,true,false,32}, - {"frontier32_gpu_dag","frontier",false,true,true,32}, - {"bucket32_d4","frontier",false,true,false,32,4}, - {"bucket32_d16","frontier",false,true,false,32,16}, - {"bucket32_d64","frontier",false,true,false,32,64} - }; -} - -Case hub_case(int n) { - std::vector a,b; std::vector w; std::vector cap; - auto add=[&](int u,int v,int cost) {a.push_back(u);b.push_back(v);w.push_back(cost);cap.push_back(1);}; - for(int i=0;i(w.size()); std::fill_n(c.edge_mask.get(),w.size(),true); - c.residual.assign(w.size(),1); return c; -} - -// A stronger comparator for the uniform positive-weight grid/fabric workloads. -// This uses the same cached eligibility and one-pass parallel DAG builder as -// the optimized GPU variants. All output is checked against native Dijkstra. -std::vector cpu_bfs(const Case& c, const OptimizedGraph& gpu, NodeId source) { - int n=c.graph.num_nodes(); - auto row=c.reverse ? c.graph.in_row_offsets_view() : c.graph.row_offsets_view(); - auto col=c.reverse ? c.graph.in_col_indices_view() : c.graph.col_indices_view(); - auto enabled=static_cast(gpu.outenabled.contents); - Cost weight=c.graph.cost_view()[0]; - std::vector dist(n,INF); - if(!c.node_mask[source]) return dist; - std::vector queue(n); size_t head=0,tail=0; - dist[source]=0; queue[tail++]=source; - while(head a{0,0,1,1,2,3,4,0,1,2},b{1,1,2,2,3,4,5,5,1,5}; - std::vector w(a.size(),weight); - std::vector cap(a.size(),1); - auto c=make_case("random",n); - c.name="boundary_"+std::to_string(weight)+(reverse ? "_reverse" : "")+(blocked ? "_masked" : ""); - c.graph=StrictMultiDiGraph::from_arrays(n,a,b,cap,w); - c.edge_mask=std::make_unique(w.size()); std::fill_n(c.edge_mask.get(),w.size(),true); - c.residual.assign(w.size(),1); c.reverse=reverse; - // Competing direct/parallel edges, a self-loop, threshold-adjacent residuals, - // and blocked sources/unreachable nodes. Eligibility is evaluated in double. - c.residual[1]=std::nextafter(kMinCap,0.0); c.residual[3]=kMinCap; - c.edge_mask[7]=false; c.edge_mask[9]=false; - if(blocked) c.node_mask[2]=false; - return c; -} - -void run_case(Case c,int queries,std::string mode,int samples,OptimizedMetal& metal,Pool& pool) { - std::vector sources(queries); - std::vector expected(queries); - for(int i=0;i0 && std::all_of(costs.begin(),costs.end(),[&](Cost w){return w==costs[0];})) { - configs.push_back({"cpu_bfs_full","cpu"}); configs.push_back({"cpu_bfs_distance","cpu"}); - } - } - else if(mode=="optimized") configs.erase(configs.begin(),configs.begin()+2); - else if(mode!="all") throw std::runtime_error("bad mode"); - for(auto cfg:configs) { - std::vector times,dt,gt,work,rounds,waits; - int fallback=0; - for(int rep=-2;rep result; - if(mode=="cpu") { - result.resize(queries); - auto query=[&](int i) { - if(cfg.name.starts_with("cpu_bfs")) result[i].first=cpu_bfs(c,gpu,sources[i]); - else if(cfg.name=="cpu_full") result[i]=cpu(c,sources[i]); - else result[i].first=cpu_distance(c,sources[i]); - }; - if(queries==1) query(0); else pool.run(queries,query); - if(cfg.name=="cpu_bfs_full") gpu.cpu_dags(result); - } else result=gpu.run(cfg); - double elapsed=ms(begin); - if(cfg.name.ends_with("distance")) { - for(int i=0;i=0) { - times.push_back(elapsed); dt.push_back(mode=="cpu" ? elapsed : gpu.distance_ms); - gt.push_back(mode=="cpu" ? 0 : gpu.gpu_ms); work.push_back(mode=="cpu" ? 0 : double(gpu.edge_visits)); - rounds.push_back(mode=="cpu" ? 0 : gpu.rounds); waits.push_back(mode=="cpu" ? 0 : gpu.waits); - fallback=mode=="cpu" ? 0 : gpu.fallback; - } - } - std::cout<3 ? std::stoi(argv[3]) : 7; - if(samples<1) throw std::runtime_error("invalid samples"); - OptimizedMetal metal; Pool pool(10); - std::cout<<"case,n,e,queries,method,wall_ms,distance_ms,gpu_ms,edge_visits,rounds,waits,fallback64,upload_ms,samples,correct,wall_samples_ms\n"; - auto run=[&](Case c,int q) { run_case(std::move(c),q,mode,samples,metal,pool); }; - if(suite=="correctness") { - for(unsigned seed=1;seed<=12;++seed) run(make_case("random",37+seed*3,6,true,seed%2,seed%3==0,seed),8); - for(Cost weight:{Cost(700000000),Cost(715827883),(Cost(1)<<53)+1}) - for(bool reverse:{false,true}) for(bool blocked:{false,true}) run(boundary_case(weight,reverse,blocked),6); - } else if(suite=="stress") { - run(make_case("grid",1024),64); run(make_case("random",1024,8,true,false,true),64); - } else { - run(make_case("random",1024),1); run(make_case("random",1024),64); - run(make_case("clos",200),64); run(make_case("grid",1024),1); - run(hub_case(1024),1); run(make_case("random",1024,8,true,true,true),8); - if(suite=="full") { - run(make_case("random",4096),256); - run(make_case("random",16384),1); run(make_case("random",16384),64); - run(make_case("random",65536),1); - run(make_case("clos",800),1); run(make_case("clos",800),64); - run(make_case("grid",10000),1); run(make_case("grid",10000),8); - run(make_case("chain",2048),1); run(hub_case(4096),64); - run(make_case("random",4096,32),1); run(make_case("random",4096,32),64); - } else if(suite!="smoke") throw std::runtime_error("bad suite"); - } - } catch(const std::exception& e) {std::cerr<<"FAIL: "< -using namespace metal; - -struct P { - uint n, q, round, layout; - uint current, epoch, lanes, delta; -}; -constant long INF64 = 0x7fffffffffffffffL; -constant uint INF32 = 0xffffffffu; - -// Same relaxation as phase one, with optional query-fast layout and 32-bit -// distances only after the host proves the entire finite-distance domain fits. -template -void pull(device const int* row, device const int* col, - device const T* weight, device const uchar* enabled, - device const T* a, device T* b, device atomic_uint* changed, - constant P& p, uint tid, uint lane, T inf) { - uint total = p.n * p.q; - bool dirty = false; - if (tid < total) { - uint v = p.layout ? tid / p.q : tid % p.n; - uint query = p.layout ? tid % p.q : tid / p.n; - T best = a[tid]; - for (int j = row[v]; j < row[v + 1]; ++j) { - uint index = p.layout ? uint(col[j]) * p.q + query : query * p.n + uint(col[j]); - T d = a[index], w = weight[j]; - if (enabled[j] && d != inf && d <= inf - w) best = min(best, T(d + w)); - } - b[tid] = best; - dirty = best != a[tid]; - } - // One flag operation per SIMD group, instead of per changed vertex. - if (simd_any(dirty) && lane == 0) - atomic_store_explicit(changed + p.round, 1u, memory_order_relaxed); -} - -#define PULL_ARGS(T) device const int* row [[buffer(0)]], device const int* col [[buffer(1)]], \ - device const T* weight [[buffer(2)]], device const uchar* enabled [[buffer(3)]], \ - device const T* a [[buffer(4)]], device T* b [[buffer(5)]], \ - device atomic_uint* changed [[buffer(6)]], constant P& p [[buffer(7)]], \ - uint tid [[thread_position_in_grid]], uint lane [[thread_index_in_simdgroup]] -kernel void pull64(PULL_ARGS(long)) { pull(row,col,weight,enabled,a,b,changed,p,tid,lane,INF64); } -kernel void pull32(PULL_ARGS(uint)) { pull(row,col,weight,enabled,a,b,changed,p,tid,lane,INF32); } - -// Frontier queues contain flattened (query * N + node) IDs. Only 32-bit integer -// atomics are required. All queue consumers run after producer dispatch barriers. -kernel void prepare(device atomic_uint* counts [[buffer(0)]], - device uint* indirect [[buffer(1)]], - constant P& p [[buffer(7)]]) { - uint count = atomic_load_explicit(counts + p.current, memory_order_relaxed); - atomic_store_explicit(counts + (1 - p.current), 0u, memory_order_relaxed); - indirect[0] = max(1u, (count * p.lanes + 255) / 256); - indirect[1] = 1; indirect[2] = 1; -} - -kernel void expand(device const int* row [[buffer(0)]], - device const int* col [[buffer(1)]], - device const uint* weight [[buffer(2)]], - device const uchar* enabled [[buffer(3)]], - device atomic_uint* dist [[buffer(4)]], - device const uint* in_queue [[buffer(5)]], - device uint* out_queue [[buffer(6)]], - constant P& p [[buffer(7)]], - device atomic_uint* counts [[buffer(8)]], - device atomic_uint* marks [[buffer(9)]], - device atomic_uint* work [[buffer(10)]], - uint tid [[thread_position_in_grid]]) { - uint slot = tid / p.lanes, lane = tid % p.lanes; - uint count = atomic_load_explicit(counts + p.current, memory_order_relaxed); - if (slot >= count) return; - uint index = in_queue[slot], u = index % p.n, base = index - u; - uint d = atomic_load_explicit(dist + index, memory_order_relaxed); - if (lane == 0) atomic_fetch_add_explicit(work + p.round, uint(row[u+1]-row[u]), memory_order_relaxed); - for (uint j = uint(row[u]) + lane; j < uint(row[u+1]); j += p.lanes) { - uint w = weight[j]; - if (!enabled[j] || d == INF32 || d > INF32 - w) continue; - uint target = base + uint(col[j]); - uint candidate = d + w; - uint old = atomic_fetch_min_explicit(dist + target, candidate, memory_order_relaxed); - if (candidate < old) { - if (p.delta) { - // Bucket mode: mark pending. Selection and clearing happen in a later - // dispatch, so a late improvement cannot be lost behind an early reset. - atomic_store_explicit(marks + target, 1u, memory_order_relaxed); - } else if (atomic_exchange_explicit(marks + target, p.epoch, memory_order_relaxed) != p.epoch) { - uint pos = atomic_fetch_add_explicit(counts + (1-p.current), 1u, memory_order_relaxed); - out_queue[pos] = target; // Epoch deduplication bounds this queue by N*Q. - } - } - } -} - -// A simple bucketed label-correcting scheduler. This deliberately does not claim -// the optimized light/heavy split or asynchronous scheduler of published ADDS. -kernel void bucket_reset(device atomic_uint* counts [[buffer(0)]], - device atomic_uint* min_distance [[buffer(1)]]) { - atomic_store_explicit(counts, 0u, memory_order_relaxed); - atomic_store_explicit(min_distance, INF32, memory_order_relaxed); -} -kernel void bucket_min(device const atomic_uint* dist [[buffer(0)]], - device const atomic_uint* pending [[buffer(1)]], - device atomic_uint* minimum [[buffer(2)]], - constant P& p [[buffer(7)]], uint tid [[thread_position_in_grid]]) { - if (tid >= p.n*p.q) return; - if (atomic_load_explicit(pending+tid, memory_order_relaxed)) - atomic_fetch_min_explicit(minimum, atomic_load_explicit(dist+tid, memory_order_relaxed), memory_order_relaxed); -} -kernel void bucket_select(device const atomic_uint* dist [[buffer(0)]], - device atomic_uint* pending [[buffer(1)]], - device const atomic_uint* minimum [[buffer(2)]], - device uint* queue [[buffer(3)]], - device atomic_uint* counts [[buffer(4)]], - constant P& p [[buffer(7)]], uint tid [[thread_position_in_grid]]) { - if (tid >= p.n*p.q) return; - uint lowest = atomic_load_explicit(minimum, memory_order_relaxed); - if (lowest == INF32) return; - ulong upper = (ulong(lowest) / p.delta + 1) * p.delta; - if (atomic_load_explicit(pending+tid, memory_order_relaxed) && - ulong(atomic_load_explicit(dist+tid, memory_order_relaxed)) < upper) { - atomic_store_explicit(pending+tid, 0u, memory_order_relaxed); - uint pos = atomic_fetch_add_explicit(counts, 1u, memory_order_relaxed); - queue[pos] = tid; - } -} - -// Counts and fills are deterministic within each node's incoming CSR row. -// The host prefix-sums N+1 counts per query; it never scans edges for GPU DAGs. -struct DP { uint n, q, edges, layout, reverse, unused0, unused1, unused2; }; -template -void dag(device const int* row, device const int* col, device const long* costs, - device const int* ids, device const uchar* enabled, device const T* distances, - device int* offsets, device int* parents, device int* via, - constant DP& p, uint tid, T inf) { - if (tid >= p.n*p.q) return; - // Match distance layout. Query-major frontiers must not be read with a - // query-fast thread layout (which also scatters the output writes). - uint v = p.layout ? tid / p.q : tid % p.n; - uint query = p.layout ? tid % p.q : tid / p.n; - uint index = p.layout ? v*p.q+query : query*p.n+v; - T dv = distances[index]; - uint count = 0; - for (int j=row[v]; j(row,col,costs,ids,enabled,distances,offsets,parents,via,p,tid,INF64);} -kernel void dag_fill64(DAG_ARGS(long)) {dag(row,col,costs,ids,enabled,distances,offsets,parents,via,p,tid,INF64);} -kernel void dag_count32(DAG_ARGS(uint)) {dag(row,col,costs,ids,enabled,distances,offsets,parents,via,p,tid,INF32);} -kernel void dag_fill32(DAG_ARGS(uint)) {dag(row,col,costs,ids,enabled,distances,offsets,parents,via,p,tid,INF32);} diff --git a/dev/research/metal_pathfinding/optimization/results/aba/base-a1-stderr.txt b/dev/research/metal_pathfinding/optimization/results/aba/base-a1-stderr.txt deleted file mode 100644 index 7bb0bd9..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/base-a1-stderr.txt +++ /dev/null @@ -1 +0,0 @@ -device=Apple M4 Max setup_ms=22.61 execution_width=32 diff --git a/dev/research/metal_pathfinding/optimization/results/aba/base-a1.csv b/dev/research/metal_pathfinding/optimization/results/aba/base-a1.csv deleted file mode 100644 index e5ad76f..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/base-a1.csv +++ /dev/null @@ -1,37 +0,0 @@ -case,n,e,queries,method,wall_ms,distance_ms,gpu_ms,edge_visits,rounds,waits,fallback64,upload_ms,samples,correct,wall_samples_ms -random,1024,8192,1,old_pull_serial,1.218542,1.184500,0.434750,131072,16,2,0,0.163666,7,pass,1.000000;1.328541;1.493417;0.990875;1.405333;1.218542;0.689625 -random,1024,8192,1,old_group_serial,0.914792,0.838625,0.680833,106496,13,1,0,0.163666,7,pass,1.091958;1.201000;0.947125;0.901458;0.828708;0.911875;0.914792 -random,1024,8192,64,old_pull_serial,4.582958,2.376417,1.597625,12582912,24,3,0,0.191292,7,pass,4.323625;4.577125;4.633041;4.582291;4.727458;4.582958;4.598000 -random,1024,8192,64,old_group_serial,3.817917,1.374833,0.888208,7217152,17,1,0,0.191292,7,pass,3.695125;3.650917;3.798041;3.875042;3.905208;3.916333;3.817917 -clos,200,20000,64,old_pull_serial,5.426542,1.323042,1.016625,10240000,8,1,0,0.336458,7,pass,5.426542;6.071792;5.522958;5.353000;5.670708;5.278334;5.226583 -clos,200,20000,64,old_group_serial,4.768459,0.824667,0.297875,3840000,3,1,0,0.336458,7,pass,4.593416;4.673916;4.953792;4.768459;4.814791;4.615209;4.778209 -grid,1024,3968,1,old_pull_serial,2.331167,2.316292,0.674458,253952,64,8,0,0.136791,7,pass,2.582333;2.420166;3.165750;1.632958;2.331167;1.751666;2.164334 -grid,1024,3968,1,old_group_serial,1.177083,1.162041,1.011542,249984,63,1,0,0.136791,7,pass,1.177083;1.213291;1.289917;1.256459;0.749750;0.362417;0.363500 -hub,1024,3070,1,old_pull_serial,6.268542,6.232250,5.561125,98240,32,4,0,0.106126,7,pass,6.262917;6.289917;6.268542;6.305542;6.316250;6.211292;6.225167 -hub,1024,3070,1,old_group_serial,4.908083,4.871666,4.720917,95170,31,1,0,0.106126,7,pass,4.920042;4.880292;4.908083;4.920708;4.874500;4.864417;4.935875 -random_masked_int64_reverse,1024,8192,8,old_pull_serial,0.517667,0.177166,0.034542,524288,8,1,0,0.204624,7,pass,0.581375;0.517667;0.506875;0.530667;0.517625;0.515750;0.530000 -random_masked_int64_reverse,1024,8192,8,old_group_serial,0.540875,0.194500,0.053500,368640,8,1,0,0.204624,7,pass,0.528417;0.557417;0.540875;0.509500;0.529500;0.548333;0.542875 -random,4096,32768,256,old_pull_serial,49.824334,11.526875,10.145042,201326592,24,3,0,0.545166,7,pass,49.824334;48.800334;48.571958;50.309667;60.286459;59.534375;48.844875 -random,4096,32768,256,old_group_serial,47.811583,10.256667,9.388917,136544256,20,1,0,0.545166,7,pass,55.883000;48.061916;47.811583;46.029625;52.619583;44.779875;46.107125 -random,16384,131072,1,old_pull_serial,2.098167,1.418667,0.204500,3145728,24,3,0,1.713958,7,pass,2.129833;2.098167;1.690500;1.244875;1.214875;2.128125;2.209500 -random,16384,131072,1,old_group_serial,5.719417,5.032459,4.894625,2359296,18,1,0,1.713958,7,pass,6.076542;6.048834;5.709042;5.723250;5.613625;5.697458;5.719417 -random,16384,131072,64,old_pull_serial,58.904791,13.714458,11.617375,201326592,24,3,0,1.789209,7,pass,58.790875;58.904791;58.887125;59.272084;60.799708;57.959042;60.167875 -random,16384,131072,64,old_group_serial,57.747584,12.236500,11.485375,158203904,22,1,0,1.789209,7,pass,58.655667;57.747584;59.414083;57.772666;56.986792;54.688833;54.776958 -random,65536,524288,1,old_pull_serial,5.184250,2.258042,1.213708,12582912,24,3,0,7.745292,7,pass,4.513042;5.060667;5.293625;4.965250;5.271292;5.197875;5.184250 -random,65536,524288,1,old_group_serial,31.832791,28.881959,28.222000,11534336,22,1,0,7.745292,7,pass,31.485125;32.601083;31.832791;32.451750;31.760709;31.991375;31.556166 -clos,800,320000,1,old_pull_serial,1.914708,0.940584,0.731375,2560000,8,1,0,3.524250,7,pass,2.299041;1.914708;2.434625;1.906125;1.881542;1.843375;2.396875 -clos,800,320000,1,old_group_serial,2.133459,1.204000,0.911125,960000,3,1,0,3.524250,7,pass,2.036042;2.133459;2.484125;2.030542;2.462916;2.034041;2.559167 -clos,800,320000,64,old_pull_serial,80.507125,16.727542,16.177125,163840000,8,1,0,3.424624,7,pass,82.494541;80.253417;77.992667;81.233334;80.507125;79.556875;82.401250 -clos,800,320000,64,old_group_serial,69.896166,5.788917,5.249208,61440000,3,1,0,3.424624,7,pass,69.789625;70.156958;69.876959;69.579166;71.083958;70.201209;69.896166 -grid,10000,39600,1,old_pull_serial,3.836417,3.719583,0.559000,7920000,200,25,0,0.502708,7,pass,5.548333;3.887375;3.836417;3.699709;3.756375;4.001833;3.686625 -grid,10000,39600,1,old_group_serial,7.867833,7.721208,7.582500,7880400,199,1,0,0.502708,7,pass,7.953000;7.827709;7.980666;7.854667;7.867833;7.806167;7.943125 -grid,10000,39600,8,old_pull_serial,5.952708,4.955667,1.338125,63360000,200,25,0,0.507209,7,pass,5.595708;5.952708;6.127416;6.336792;6.075833;5.918666;5.732208 -grid,10000,39600,8,old_group_serial,9.001917,8.070500,7.435958,51480000,199,1,0,0.507209,7,pass,8.705167;8.996542;9.001917;9.232250;9.057666;8.974042;9.170042 -chain,2048,2047,1,old_pull_serial,30.622833,30.613000,4.401709,4192256,2048,256,0,0.106750,7,pass,30.267459;30.836959;30.523334;31.464417;30.622833;29.359666;30.867458 -chain,2048,2047,1,old_group_serial,4.834291,4.825792,4.700625,4192256,2048,1,0,0.106750,7,pass,4.829625;4.934042;4.834042;4.889959;4.834291;4.936625;4.812917 -hub,4096,12286,64,old_pull_serial,35.138250,32.326583,30.902083,31452160,40,5,0,0.252917,7,pass,35.515125;35.051541;35.533042;35.235333;35.138250;35.127750;34.555833 -hub,4096,12286,64,old_group_serial,28.558708,25.740416,24.993833,27176632,38,1,0,0.252917,7,pass,27.957792;28.558708;28.586458;28.663542;28.538000;28.549500;28.668834 -random,4096,131072,1,old_pull_serial,0.906250,0.525417,0.266958,2097152,16,2,0,1.621291,7,pass,0.935750;0.866084;0.857208;0.905250;0.920208;0.915709;0.906250 -random,4096,131072,1,old_group_serial,2.808042,2.361958,2.183542,1572864,12,1,0,1.621291,7,pass,2.680625;2.884875;2.723166;2.877167;2.812500;2.808042;2.712208 -random,4096,131072,64,old_pull_serial,34.709250,7.647459,6.259584,134217728,16,2,0,1.701542,7,pass,34.723375;34.123167;34.742875;34.709250;34.173084;34.822959;34.691500 -random,4096,131072,64,old_group_serial,31.930417,5.221542,4.481375,105644032,15,1,0,1.701542,7,pass,30.898458;31.379041;31.775500;31.930417;34.155750;35.345083;39.651000 diff --git a/dev/research/metal_pathfinding/optimization/results/aba/base-a2-stderr.txt b/dev/research/metal_pathfinding/optimization/results/aba/base-a2-stderr.txt deleted file mode 100644 index cb44dce..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/base-a2-stderr.txt +++ /dev/null @@ -1 +0,0 @@ -device=Apple M4 Max setup_ms=23.7174 execution_width=32 diff --git a/dev/research/metal_pathfinding/optimization/results/aba/base-a2.csv b/dev/research/metal_pathfinding/optimization/results/aba/base-a2.csv deleted file mode 100644 index fd30af0..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/base-a2.csv +++ /dev/null @@ -1,37 +0,0 @@ -case,n,e,queries,method,wall_ms,distance_ms,gpu_ms,edge_visits,rounds,waits,fallback64,upload_ms,samples,correct,wall_samples_ms -random,1024,8192,1,old_pull_serial,0.994334,0.952458,0.399000,131072,16,2,0,0.171417,7,pass,1.577916;0.994334;0.656417;0.664875;0.658917;1.304083;1.546834 -random,1024,8192,1,old_group_serial,0.971833,0.906041,0.680458,106496,13,1,0,0.171417,7,pass,1.023334;1.103292;0.903125;0.840208;0.971833;1.167083;0.936791 -random,1024,8192,64,old_pull_serial,4.691209,2.347542,1.613625,12582912,24,3,0,0.210375,7,pass,4.444834;4.580166;4.754000;5.102500;4.831000;4.639291;4.691209 -random,1024,8192,64,old_group_serial,3.768791,1.364167,0.890125,7217152,17,1,0,0.210375,7,pass,3.744584;3.776792;3.828208;3.773291;3.758709;3.535167;3.768791 -clos,200,20000,64,old_pull_serial,5.650709,1.616708,1.020042,10240000,8,1,0,0.356209,7,pass,5.676875;5.621667;5.220125;6.236833;5.650709;6.217083;5.466333 -clos,200,20000,64,old_group_serial,4.869958,0.831250,0.297000,3840000,3,1,0,0.356209,7,pass,4.959333;4.867584;4.914292;4.869958;4.947166;4.766166;4.391625 -grid,1024,3968,1,old_pull_serial,1.665000,1.652209,0.655292,253952,64,8,0,0.141624,7,pass,1.644250;1.733125;1.603333;1.672625;1.665000;1.664375;1.721208 -grid,1024,3968,1,old_group_serial,1.165667,1.143208,1.005833,249984,63,1,0,0.141624,7,pass,1.263334;1.182333;1.165667;1.273125;0.885333;0.363959;0.362584 -hub,1024,3070,1,old_pull_serial,6.226792,6.189458,5.559000,98240,32,4,0,0.111583,7,pass,6.469750;6.205875;6.281875;6.313250;6.206291;6.226792;6.218000 -hub,1024,3070,1,old_group_serial,4.938125,4.898500,4.725042,95170,31,1,0,0.111583,7,pass,4.938125;4.984083;4.930291;5.019042;4.921959;4.917917;4.942417 -random_masked_int64_reverse,1024,8192,8,old_pull_serial,0.551250,0.183875,0.034625,524288,8,1,0,0.234124,7,pass,0.562916;0.611584;0.507541;0.584208;0.551250;0.545666;0.529500 -random_masked_int64_reverse,1024,8192,8,old_group_serial,0.545000,0.189625,0.053708,368640,8,1,0,0.234124,7,pass,0.550208;0.545000;0.559584;0.519125;0.599333;0.510333;0.538333 -random,4096,32768,256,old_pull_serial,50.688208,12.960833,11.693708,201326592,24,3,0,0.566458,7,pass,49.485708;50.688208;50.617541;52.575375;52.112584;51.623333;49.864333 -random,4096,32768,256,old_group_serial,49.310208,10.459583,9.632500,136544256,20,1,0,0.566458,7,pass,50.547542;49.310208;50.391084;48.556292;47.586917;48.562834;50.677916 -random,16384,131072,1,old_pull_serial,1.870458,1.257416,0.473583,3145728,24,3,0,1.747834,7,pass,1.870458;1.471084;2.038875;2.288666;2.138458;1.621583;1.435041 -random,16384,131072,1,old_group_serial,5.578000,4.954917,4.794625,2359296,18,1,0,1.747834,7,pass,5.681334;5.572917;5.537625;5.559917;5.578000;5.596708;5.730166 -random,16384,131072,64,old_pull_serial,57.437709,12.714583,11.452709,201326592,24,3,0,1.729083,7,pass,60.324959;56.396250;57.583125;56.772667;57.974125;57.437709;56.800125 -random,16384,131072,64,old_group_serial,53.105750,9.701208,8.881250,158203904,22,1,0,1.729083,7,pass,56.427459;54.848291;53.105750;52.603333;52.648291;53.011417;53.238417 -random,65536,524288,1,old_pull_serial,5.853750,3.010042,1.934167,12582912,24,3,0,7.061209,7,pass,5.853750;6.207166;7.395542;7.228292;4.723625;4.678916;4.148125 -random,65536,524288,1,old_group_serial,31.751625,28.941250,28.602500,11534336,22,1,0,7.061209,7,pass,32.139125;31.751625;31.628667;32.141583;31.560792;32.478667;31.455667 -clos,800,320000,1,old_pull_serial,2.377209,1.431583,0.753875,2560000,8,1,0,3.556167,7,pass,2.377209;1.955250;2.455833;2.367417;2.430083;2.385417;2.145208 -clos,800,320000,1,old_group_serial,2.556458,1.576792,0.968750,960000,3,1,0,3.556167,7,pass,2.589084;2.650250;2.526500;2.137334;2.679083;2.422542;2.556458 -clos,800,320000,64,old_pull_serial,83.521542,17.978958,17.478500,163840000,8,1,0,3.485250,7,pass,81.691167;84.700792;84.167000;81.558666;84.714084;83.126750;83.521542 -clos,800,320000,64,old_group_serial,70.227500,6.031083,5.390125,61440000,3,1,0,3.485250,7,pass,70.227500;70.059209;71.906542;69.788417;69.684208;71.298291;70.334458 -grid,10000,39600,1,old_pull_serial,5.873791,5.752500,2.571416,7920000,200,25,0,0.517166,7,pass,5.931959;5.884000;5.702542;6.143625;5.873791;4.747083;3.651417 -grid,10000,39600,1,old_group_serial,7.900583,7.766667,7.575625,7880400,199,1,0,0.517166,7,pass,7.784292;7.930583;7.900125;7.962542;7.925792;7.900583;7.879125 -grid,10000,39600,8,old_pull_serial,5.979167,5.041833,1.348542,63360000,200,25,0,0.506958,7,pass,5.915125;6.136083;6.013709;6.170083;5.979167;5.367833;5.483750 -grid,10000,39600,8,old_group_serial,8.833417,7.841084,7.497625,51480000,199,1,0,0.506958,7,pass,9.022208;8.728042;9.240834;8.833417;9.221417;8.709959;8.703625 -chain,2048,2047,1,old_pull_serial,28.500458,28.490334,4.103082,4192256,2048,256,0,0.107834,7,pass,28.967458;28.705334;28.500458;28.192459;28.413625;28.474459;29.089833 -chain,2048,2047,1,old_group_serial,4.908333,4.887208,4.718333,4192256,2048,1,0,0.107834,7,pass,4.940458;4.827959;4.908333;4.908542;4.900500;4.872958;4.930334 -hub,4096,12286,64,old_pull_serial,35.024208,32.034125,30.893042,31452160,40,5,0,0.415751,7,pass,35.024208;34.828667;34.493209;35.587167;35.316875;35.020542;35.557792 -hub,4096,12286,64,old_group_serial,28.801291,25.839084,25.138125,27176632,38,1,0,0.415751,7,pass,28.757334;28.801291;29.161167;28.600792;28.971625;29.009417;28.756875 -random,4096,131072,1,old_pull_serial,1.039917,0.523167,0.231750,2097152,16,2,0,1.780084,7,pass,0.989042;1.005208;1.085208;1.040166;1.076500;1.039917;0.952042 -random,4096,131072,1,old_group_serial,2.897000,2.410042,2.244375,1572864,12,1,0,1.780084,7,pass,2.997791;2.892875;2.918125;3.060042;2.897000;2.823458;2.822250 -random,4096,131072,64,old_pull_serial,36.476625,7.182083,6.285833,134217728,16,2,0,1.895875,7,pass,36.498208;36.726250;36.476625;36.892708;36.295291;35.935750;36.099417 -random,4096,131072,64,old_group_serial,37.791458,8.716333,8.064625,105644032,15,1,0,1.895875,7,pass,34.829667;37.791458;38.231041;40.189750;39.685208;34.319125;35.647958 diff --git a/dev/research/metal_pathfinding/optimization/results/aba/cpu-a1-stderr.txt b/dev/research/metal_pathfinding/optimization/results/aba/cpu-a1-stderr.txt deleted file mode 100644 index 26cb375..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/cpu-a1-stderr.txt +++ /dev/null @@ -1 +0,0 @@ -device=Apple M4 Max setup_ms=28.925 execution_width=32 diff --git a/dev/research/metal_pathfinding/optimization/results/aba/cpu-a1.csv b/dev/research/metal_pathfinding/optimization/results/aba/cpu-a1.csv deleted file mode 100644 index c3fa171..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/cpu-a1.csv +++ /dev/null @@ -1,49 +0,0 @@ -case,n,e,queries,method,wall_ms,distance_ms,gpu_ms,edge_visits,rounds,waits,fallback64,upload_ms,samples,correct,wall_samples_ms -random,1024,8192,1,cpu_full,0.085792,0.085792,0.000000,0,0,0,0,0.156042,7,pass,0.090042;0.086958;0.084917;0.085792;0.085166;0.084084;0.086291 -random,1024,8192,1,cpu_distance,0.050625,0.050625,0.000000,0,0,0,0,0.156042,7,pass,0.053458;0.050625;0.050750;0.050083;0.050833;0.049417;0.049792 -random,1024,8192,64,cpu_full,1.247667,1.247667,0.000000,0,0,0,0,0.179625,7,pass,1.311875;1.253000;1.283916;1.234333;1.247667;1.222625;1.230167 -random,1024,8192,64,cpu_distance,0.637834,0.637834,0.000000,0,0,0,0,0.179625,7,pass,0.659916;0.641583;0.637500;0.636542;0.635000;0.637834;0.673583 -clos,200,20000,64,cpu_full,1.072125,1.072125,0.000000,0,0,0,0,0.341708,7,pass,1.046542;1.066708;1.182333;1.086334;1.083958;1.070209;1.072125 -clos,200,20000,64,cpu_distance,0.236500,0.236500,0.000000,0,0,0,0,0.341708,7,pass,0.340000;0.352417;0.270083;0.211791;0.207666;0.210625;0.236500 -clos,200,20000,64,cpu_bfs_full,0.668917,0.668917,0.000000,0,0,0,0,0.341708,7,pass,0.584625;0.551000;0.668917;0.572917;1.059250;0.709625;0.824917 -clos,200,20000,64,cpu_bfs_distance,0.107959,0.107959,0.000000,0,0,0,0,0.341708,7,pass,0.121083;0.121792;0.125125;0.107959;0.098875;0.106792;0.099500 -grid,1024,3968,1,cpu_full,0.039042,0.039042,0.000000,0,0,0,0,0.131500,7,pass,0.042125;0.040916;0.039708;0.039000;0.039042;0.038708;0.038750 -grid,1024,3968,1,cpu_distance,0.012000,0.012000,0.000000,0,0,0,0,0.131500,7,pass,0.012334;0.012292;0.012000;0.011833;0.011834;0.011750;0.013917 -grid,1024,3968,1,cpu_bfs_full,0.009417,0.009417,0.000000,0,0,0,0,0.131500,7,pass,0.009667;0.009417;0.009333;0.011209;0.009375;0.009459;0.009416 -grid,1024,3968,1,cpu_bfs_distance,0.002250,0.002250,0.000000,0,0,0,0,0.131500,7,pass,0.002208;0.002250;0.002250;0.002250;0.002292;0.002250;0.002167 -hub,1024,3070,1,cpu_full,0.052333,0.052333,0.000000,0,0,0,0,0.088167,7,pass,0.054625;0.053500;0.052458;0.052250;0.051917;0.052250;0.052333 -hub,1024,3070,1,cpu_distance,0.024250,0.024250,0.000000,0,0,0,0,0.088167,7,pass,0.032333;0.027417;0.027333;0.024250;0.023000;0.022833;0.023333 -random_masked_int64_reverse,1024,8192,8,cpu_full,0.160083,0.160083,0.000000,0,0,0,0,0.211583,7,pass,0.160083;0.176334;0.154083;0.136833;0.218792;0.161000;0.148417 -random_masked_int64_reverse,1024,8192,8,cpu_distance,0.097875,0.097875,0.000000,0,0,0,0,0.211583,7,pass,0.104167;0.102375;0.089417;0.097500;0.119042;0.097875;0.091625 -random,4096,32768,256,cpu_full,21.390792,21.390792,0.000000,0,0,0,0,0.522792,7,pass,21.548084;21.431583;21.365709;21.390792;21.338292;21.801750;21.308459 -random,4096,32768,256,cpu_distance,10.971792,10.971792,0.000000,0,0,0,0,0.522792,7,pass,10.941667;10.993375;10.984292;10.964500;10.961292;10.982416;10.971792 -random,16384,131072,1,cpu_full,3.661958,3.661958,0.000000,0,0,0,0,1.746959,7,pass,3.746667;3.661083;3.750292;3.732167;3.661958;3.647583;3.608417 -random,16384,131072,1,cpu_distance,1.892958,1.892958,0.000000,0,0,0,0,1.746959,7,pass,1.928917;1.904500;1.910375;1.871917;1.892958;1.860250;1.857916 -random,16384,131072,64,cpu_full,27.692792,27.692792,0.000000,0,0,0,0,1.649084,7,pass,27.658708;27.723542;27.748375;27.639459;27.587250;27.692792;27.800584 -random,16384,131072,64,cpu_distance,15.163333,15.163333,0.000000,0,0,0,0,1.649084,7,pass,15.208709;15.168833;15.147959;15.182041;15.112375;15.163333;15.138666 -random,65536,524288,1,cpu_full,19.316000,19.316000,0.000000,0,0,0,0,8.086334,7,pass,19.478875;19.102083;19.316000;18.730125;19.269000;19.888458;19.416750 -random,65536,524288,1,cpu_distance,9.973291,9.973291,0.000000,0,0,0,0,8.086334,7,pass,9.973291;9.828167;9.980083;9.915125;10.174167;9.670458;10.011000 -clos,800,320000,1,cpu_full,2.177875,2.177875,0.000000,0,0,0,0,3.236458,7,pass,2.187833;2.149125;2.179167;2.127750;2.174667;2.177875;2.179375 -clos,800,320000,1,cpu_distance,0.353375,0.353375,0.000000,0,0,0,0,3.236458,7,pass,0.349791;0.351583;0.355959;0.350875;0.353375;0.356084;0.358625 -clos,800,320000,1,cpu_bfs_full,0.908000,0.908000,0.000000,0,0,0,0,3.236458,7,pass,0.894250;0.924792;0.909333;0.897084;0.879083;0.908000;0.937042 -clos,800,320000,1,cpu_bfs_distance,0.113125,0.113125,0.000000,0,0,0,0,3.236458,7,pass,0.108000;0.117208;0.118334;0.118125;0.111917;0.113083;0.113125 -clos,800,320000,64,cpu_full,17.041958,17.041958,0.000000,0,0,0,0,3.481041,7,pass,16.388250;16.388000;16.345833;17.041958;17.097833;17.276417;17.355125 -clos,800,320000,64,cpu_distance,2.677875,2.677875,0.000000,0,0,0,0,3.481041,7,pass,2.692500;2.691917;2.692916;2.664458;2.666875;2.677875;2.672917 -clos,800,320000,64,cpu_bfs_full,8.295667,8.295667,0.000000,0,0,0,0,3.481041,7,pass,8.552542;8.295667;8.069958;8.153791;8.517708;9.268417;7.606500 -clos,800,320000,64,cpu_bfs_distance,0.860834,0.860834,0.000000,0,0,0,0,3.481041,7,pass,0.877208;0.856917;0.860834;0.852542;0.853625;0.865167;0.862500 -grid,10000,39600,1,cpu_full,0.570333,0.570333,0.000000,0,0,0,0,0.470375,7,pass,0.594125;0.580542;0.570333;0.581792;0.562917;0.567750;0.565416 -grid,10000,39600,1,cpu_distance,0.195500,0.195500,0.000000,0,0,0,0,0.470375,7,pass,0.196333;0.195500;0.204250;0.205834;0.178250;0.179542;0.178500 -grid,10000,39600,1,cpu_bfs_full,0.089292,0.089292,0.000000,0,0,0,0,0.470375,7,pass,0.096875;0.091833;0.094041;0.089000;0.088291;0.089292;0.088625 -grid,10000,39600,1,cpu_bfs_distance,0.022333,0.022333,0.000000,0,0,0,0,0.470375,7,pass,0.022250;0.022458;0.022375;0.022333;0.022292;0.022333;0.022417 -grid,10000,39600,8,cpu_full,0.834084,0.834084,0.000000,0,0,0,0,0.487875,7,pass,0.822042;0.837667;0.834084;0.824375;0.947250;0.820791;0.905875 -grid,10000,39600,8,cpu_distance,0.247208,0.247208,0.000000,0,0,0,0,0.487875,7,pass,0.312416;0.251083;0.242958;0.247208;0.249833;0.242250;0.241583 -grid,10000,39600,8,cpu_bfs_full,0.286042,0.286042,0.000000,0,0,0,0,0.487875,7,pass,0.208750;0.380291;0.228291;0.305208;0.289917;0.286042;0.280583 -grid,10000,39600,8,cpu_bfs_distance,0.073084,0.073084,0.000000,0,0,0,0,0.487875,7,pass,0.073084;0.080750;0.059917;0.073875;0.061250;0.070417;0.078917 -chain,2048,2047,1,cpu_full,0.017209,0.017209,0.000000,0,0,0,0,0.136084,7,pass,0.017417;0.017042;0.017292;0.017125;0.017209;0.017042;0.017333 -chain,2048,2047,1,cpu_distance,0.003875,0.003875,0.000000,0,0,0,0,0.136084,7,pass,0.004292;0.003791;0.003875;0.003625;0.003834;0.004250;0.004292 -hub,4096,12286,64,cpu_full,2.437875,2.437875,0.000000,0,0,0,0,0.237500,7,pass,2.619125;2.383792;2.399750;2.449375;2.437875;2.494959;2.427084 -hub,4096,12286,64,cpu_distance,1.061875,1.061875,0.000000,0,0,0,0,0.237500,7,pass,1.033625;1.061875;1.169542;1.037792;1.101834;1.179625;1.025375 -random,4096,131072,1,cpu_full,1.589583,1.589583,0.000000,0,0,0,0,1.789499,7,pass,1.588625;1.589583;1.700625;1.634417;1.570459;1.556250;1.602333 -random,4096,131072,1,cpu_distance,0.648334,0.648334,0.000000,0,0,0,0,1.789499,7,pass,0.680583;0.654458;0.646000;0.670125;0.646000;0.631709;0.648334 -random,4096,131072,64,cpu_full,12.301875,12.301875,0.000000,0,0,0,0,1.617916,7,pass,12.273209;12.301875;12.218958;12.418250;12.324125;12.225125;14.931792 -random,4096,131072,64,cpu_distance,6.966334,6.966334,0.000000,0,0,0,0,1.617916,7,pass,7.551125;6.966334;6.212750;7.608792;7.220041;6.408000;6.150125 diff --git a/dev/research/metal_pathfinding/optimization/results/aba/cpu-a2-stderr.txt b/dev/research/metal_pathfinding/optimization/results/aba/cpu-a2-stderr.txt deleted file mode 100644 index 6703d2b..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/cpu-a2-stderr.txt +++ /dev/null @@ -1 +0,0 @@ -device=Apple M4 Max setup_ms=27.1879 execution_width=32 diff --git a/dev/research/metal_pathfinding/optimization/results/aba/cpu-a2.csv b/dev/research/metal_pathfinding/optimization/results/aba/cpu-a2.csv deleted file mode 100644 index 7a31e8d..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/cpu-a2.csv +++ /dev/null @@ -1,49 +0,0 @@ -case,n,e,queries,method,wall_ms,distance_ms,gpu_ms,edge_visits,rounds,waits,fallback64,upload_ms,samples,correct,wall_samples_ms -random,1024,8192,1,cpu_full,0.096875,0.096875,0.000000,0,0,0,0,0.174083,7,pass,0.102042;0.099000;0.096875;0.108042;0.094084;0.093875;0.095250 -random,1024,8192,1,cpu_distance,0.060958,0.060958,0.000000,0,0,0,0,0.174083,7,pass,0.071208;0.062958;0.064875;0.060958;0.057042;0.058167;0.058333 -random,1024,8192,64,cpu_full,1.340459,1.340459,0.000000,0,0,0,0,0.233833,7,pass,1.339292;1.340459;1.461292;1.366334;1.339458;1.339334;1.369500 -random,1024,8192,64,cpu_distance,0.663584,0.663584,0.000000,0,0,0,0,0.233833,7,pass,0.685834;0.674000;0.727500;0.663250;0.652916;0.639375;0.663584 -clos,200,20000,64,cpu_full,1.075958,1.075958,0.000000,0,0,0,0,0.372958,7,pass,1.282000;1.112375;1.074500;1.068083;1.078375;1.075958;1.045042 -clos,200,20000,64,cpu_distance,0.213417,0.213417,0.000000,0,0,0,0,0.372958,7,pass,0.221042;0.213417;0.207542;0.234416;0.213833;0.205459;0.213333 -clos,200,20000,64,cpu_bfs_full,0.633500,0.633500,0.000000,0,0,0,0,0.372958,7,pass,0.537833;0.574250;0.596792;0.647125;0.669458;0.633500;0.654042 -clos,200,20000,64,cpu_bfs_distance,0.086959,0.086959,0.000000,0,0,0,0,0.372958,7,pass,0.095250;0.097583;0.086166;0.086959;0.086875;0.119708;0.083708 -grid,1024,3968,1,cpu_full,0.038708,0.038708,0.000000,0,0,0,0,0.141166,7,pass,0.041292;0.039500;0.038708;0.038417;0.038209;0.041375;0.038584 -grid,1024,3968,1,cpu_distance,0.011750,0.011750,0.000000,0,0,0,0,0.141166,7,pass,0.012375;0.012125;0.011917;0.011750;0.011625;0.011458;0.011417 -grid,1024,3968,1,cpu_bfs_full,0.009375,0.009375,0.000000,0,0,0,0,0.141166,7,pass,0.011583;0.009375;0.009333;0.009375;0.009375;0.009250;0.009375 -grid,1024,3968,1,cpu_bfs_distance,0.002250,0.002250,0.000000,0,0,0,0,0.141166,7,pass,0.002417;0.002292;0.002209;0.002250;0.002208;0.002250;0.002208 -hub,1024,3070,1,cpu_full,0.052000,0.052000,0.000000,0,0,0,0,0.089875,7,pass,0.054083;0.052791;0.052584;0.052000;0.051250;0.050500;0.049667 -hub,1024,3070,1,cpu_distance,0.021875,0.021875,0.000000,0,0,0,0,0.089875,7,pass,0.023083;0.022209;0.021792;0.021875;0.021792;0.021708;0.021875 -random_masked_int64_reverse,1024,8192,8,cpu_full,0.153250,0.153250,0.000000,0,0,0,0,0.195541,7,pass,0.146583;0.156583;0.153250;0.150208;0.152708;0.154500;0.161333 -random_masked_int64_reverse,1024,8192,8,cpu_distance,0.096792,0.096792,0.000000,0,0,0,0,0.195541,7,pass,0.096792;0.099792;0.098333;0.100208;0.091708;0.095875;0.096000 -random,4096,32768,256,cpu_full,21.281458,21.281458,0.000000,0,0,0,0,0.549125,7,pass,21.277042;21.258375;21.281458;21.314917;21.324333;21.278875;21.311791 -random,4096,32768,256,cpu_distance,11.067666,11.067666,0.000000,0,0,0,0,0.549125,7,pass,10.994334;11.005000;11.026500;11.067666;11.480084;11.362334;11.215292 -random,16384,131072,1,cpu_full,3.885666,3.885666,0.000000,0,0,0,0,1.800708,7,pass,3.967125;3.796625;3.860291;4.262833;3.917000;3.885666;3.851709 -random,16384,131072,1,cpu_distance,1.976417,1.976417,0.000000,0,0,0,0,1.800708,7,pass,2.147667;1.979625;1.973542;1.929709;1.987833;1.976417;1.926417 -random,16384,131072,64,cpu_full,27.721375,27.721375,0.000000,0,0,0,0,1.755499,7,pass,27.835834;27.645166;28.465958;27.722000;27.721375;27.572417;27.613583 -random,16384,131072,64,cpu_distance,15.114541,15.114541,0.000000,0,0,0,0,1.755499,7,pass,15.135625;15.110792;15.146000;15.110208;15.084625;15.162208;15.114541 -random,65536,524288,1,cpu_full,19.240125,19.240125,0.000000,0,0,0,0,7.505250,7,pass,18.942084;19.240125;19.137625;18.829333;23.886875;23.010209;19.430541 -random,65536,524288,1,cpu_distance,11.143334,11.143334,0.000000,0,0,0,0,7.505250,7,pass,11.894666;11.143334;11.423291;11.711209;10.922250;10.678833;10.548333 -clos,800,320000,1,cpu_full,2.226791,2.226791,0.000000,0,0,0,0,3.538083,7,pass,2.209250;2.216750;2.188458;2.298666;2.251083;2.226791;2.260083 -clos,800,320000,1,cpu_distance,0.355500,0.355500,0.000000,0,0,0,0,3.538083,7,pass,0.357458;0.365666;0.355209;0.360042;0.355041;0.355500;0.354166 -clos,800,320000,1,cpu_bfs_full,0.937625,0.937625,0.000000,0,0,0,0,3.538083,7,pass,0.936500;0.937625;0.945042;0.929250;0.915417;0.948750;0.957208 -clos,800,320000,1,cpu_bfs_distance,0.123750,0.123750,0.000000,0,0,0,0,3.538083,7,pass,0.123666;0.123750;0.124541;0.124209;0.123875;0.123708;0.123667 -clos,800,320000,64,cpu_full,16.524625,16.524625,0.000000,0,0,0,0,3.487750,7,pass,16.632500;16.422167;16.362041;16.524625;16.451709;16.538916;17.851792 -clos,800,320000,64,cpu_distance,2.695875,2.695875,0.000000,0,0,0,0,3.487750,7,pass,2.691334;2.695875;2.701042;2.697666;2.720417;2.685208;2.679041 -clos,800,320000,64,cpu_bfs_full,7.884833,7.884833,0.000000,0,0,0,0,3.487750,7,pass,8.302500;7.823334;7.566250;8.220417;8.318000;7.284708;7.884833 -clos,800,320000,64,cpu_bfs_distance,0.852750,0.852750,0.000000,0,0,0,0,3.487750,7,pass,0.852750;0.845167;0.850709;0.855333;0.866959;0.852250;0.856625 -grid,10000,39600,1,cpu_full,0.582250,0.582250,0.000000,0,0,0,0,0.491958,7,pass,0.607667;0.600166;0.618917;0.582250;0.578916;0.577333;0.576292 -grid,10000,39600,1,cpu_distance,0.180667,0.180667,0.000000,0,0,0,0,0.491958,7,pass,0.182792;0.183417;0.180667;0.180958;0.175250;0.180667;0.180458 -grid,10000,39600,1,cpu_bfs_full,0.088459,0.088459,0.000000,0,0,0,0,0.491958,7,pass,0.088291;0.088917;0.090834;0.088459;0.088792;0.088250;0.088458 -grid,10000,39600,1,cpu_bfs_distance,0.022917,0.022917,0.000000,0,0,0,0,0.491958,7,pass,0.023041;0.022958;0.022625;0.022917;0.023000;0.022833;0.022750 -grid,10000,39600,8,cpu_full,0.822667,0.822667,0.000000,0,0,0,0,0.511542,7,pass,0.832125;0.829417;0.827291;0.794500;0.813292;0.776792;0.822667 -grid,10000,39600,8,cpu_distance,0.276209,0.276209,0.000000,0,0,0,0,0.511542,7,pass,0.276209;0.304541;0.321125;0.287250;0.269333;0.243208;0.248250 -grid,10000,39600,8,cpu_bfs_full,0.311708,0.311708,0.000000,0,0,0,0,0.511542,7,pass,0.265791;0.314416;0.360625;0.248334;0.311708;0.313541;0.259708 -grid,10000,39600,8,cpu_bfs_distance,0.066208,0.066208,0.000000,0,0,0,0,0.511542,7,pass,0.067666;0.066208;0.068375;0.060500;0.061917;0.083833;0.063875 -chain,2048,2047,1,cpu_full,0.017375,0.017375,0.000000,0,0,0,0,0.145041,7,pass,0.017333;0.019333;0.019292;0.019292;0.017333;0.017375;0.017208 -chain,2048,2047,1,cpu_distance,0.004250,0.004250,0.000000,0,0,0,0,0.145041,7,pass,0.004292;0.004250;0.004209;0.003667;0.004250;0.004250;0.004208 -hub,4096,12286,64,cpu_full,2.338125,2.338125,0.000000,0,0,0,0,0.253291,7,pass,2.335292;2.332416;2.317459;2.338125;2.381084;2.358125;2.366458 -hub,4096,12286,64,cpu_distance,1.035625,1.035625,0.000000,0,0,0,0,0.253291,7,pass,1.045917;1.049042;1.030417;1.033500;1.035625;1.039625;1.031583 -random,4096,131072,1,cpu_full,1.517583,1.517583,0.000000,0,0,0,0,1.896875,7,pass,1.623958;1.576583;1.545917;1.489375;1.478709;1.517583;1.455083 -random,4096,131072,1,cpu_distance,0.688208,0.688208,0.000000,0,0,0,0,1.896875,7,pass,0.716000;0.710083;0.689833;0.688208;0.669541;0.657958;0.656333 -random,4096,131072,64,cpu_full,12.817167,12.817167,0.000000,0,0,0,0,1.777875,7,pass,12.910500;12.817167;12.927917;12.482625;13.703958;12.457333;12.349375 -random,4096,131072,64,cpu_distance,5.703500,5.703500,0.000000,0,0,0,0,1.777875,7,pass,5.703500;5.924666;5.729834;5.690042;5.765166;5.701958;5.690917 diff --git a/dev/research/metal_pathfinding/optimization/results/aba/environment.txt b/dev/research/metal_pathfinding/optimization/results/aba/environment.txt deleted file mode 100644 index 12e4344..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/environment.txt +++ /dev/null @@ -1,20 +0,0 @@ -2026-09-26T23:29:08Z -fbab119e794c843838f4f3f96d75cb7f32f06845 -ProductName: macOS -ProductVersion: 26.6.1 -BuildVersion: 25G76 -Apple M4 Max -38654705664 -14 -Apple clang version 21.0.0 (clang-2100.1.1.101) -Target: arm64-apple-darwin25.6.0 -Thread model: posix -InstalledDir: /Library/Developer/CommandLineTools/usr/bin -1da9ff77bd2ad97e95e951454821b25005859c6f75c27a3c56d925e5fc99f6fc dev/research/metal_pathfinding/experiment.mm -2e36b1623a3940bf1f19f5d81141bb1def14f5873fe2a98aab0101bbe68618a3 dev/research/metal_pathfinding/sssp.metal -7717db8d791d17f7f0ef0b672461ad82afb2ae99cc2ab6c8cb8b33b35e74dd25 dev/research/metal_pathfinding/optimization/experiment.mm -bed6c9a836e85520101659471c62b7c4c9eb6f4954cb7bb74c971f10c44f37ff dev/research/metal_pathfinding/optimization/kernels.metal -7bb649ffcd34e6be02ab14c35767b816b0b32ce3ee3c2fd91c199e4b37a837ce build/metal_pathfinding/optimized -Note: No thermal warning level has been recorded -Note: No performance warning level has been recorded -Note: No CPU power status has been recorded diff --git a/dev/research/metal_pathfinding/optimization/results/aba/gpu-load-base-a1.txt b/dev/research/metal_pathfinding/optimization/results/aba/gpu-load-base-a1.txt deleted file mode 100644 index e35f762..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/gpu-load-base-a1.txt +++ /dev/null @@ -1 +0,0 @@ - | "PerformanceStatistics" = {"In use system memory (driver)"=0,"Alloc system memory"=11174330368,"Tiler Utilization %"=5,"recoveryCount"=0,"lastRecoveryTime"=0,"Renderer Utilization %"=8,"TiledSceneBytes"=2228224,"Device Utilization %"=9,"SplitSceneCount"=0,"Allocated PB Size"=194379776,"In use system memory"=1583366144} diff --git a/dev/research/metal_pathfinding/optimization/results/aba/gpu-load-base-a2.txt b/dev/research/metal_pathfinding/optimization/results/aba/gpu-load-base-a2.txt deleted file mode 100644 index 3dfce12..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/gpu-load-base-a2.txt +++ /dev/null @@ -1 +0,0 @@ - | "PerformanceStatistics" = {"In use system memory (driver)"=0,"Alloc system memory"=10970677248,"Tiler Utilization %"=3,"recoveryCount"=0,"lastRecoveryTime"=0,"Renderer Utilization %"=5,"TiledSceneBytes"=1507328,"Device Utilization %"=63,"SplitSceneCount"=0,"Allocated PB Size"=189661184,"In use system memory"=1386561536} diff --git a/dev/research/metal_pathfinding/optimization/results/aba/gpu-load-cpu-a1.txt b/dev/research/metal_pathfinding/optimization/results/aba/gpu-load-cpu-a1.txt deleted file mode 100644 index aff1c2d..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/gpu-load-cpu-a1.txt +++ /dev/null @@ -1 +0,0 @@ - | "PerformanceStatistics" = {"In use system memory (driver)"=0,"Alloc system memory"=10953162752,"Tiler Utilization %"=4,"recoveryCount"=0,"lastRecoveryTime"=0,"Renderer Utilization %"=6,"TiledSceneBytes"=2326528,"Device Utilization %"=30,"SplitSceneCount"=0,"Allocated PB Size"=191889408,"In use system memory"=1362182144} diff --git a/dev/research/metal_pathfinding/optimization/results/aba/gpu-load-cpu-a2.txt b/dev/research/metal_pathfinding/optimization/results/aba/gpu-load-cpu-a2.txt deleted file mode 100644 index 191180b..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/gpu-load-cpu-a2.txt +++ /dev/null @@ -1 +0,0 @@ - | "PerformanceStatistics" = {"In use system memory (driver)"=0,"Alloc system memory"=11218386944,"Tiler Utilization %"=3,"recoveryCount"=0,"lastRecoveryTime"=0,"Renderer Utilization %"=5,"TiledSceneBytes"=1441792,"Device Utilization %"=66,"SplitSceneCount"=0,"Allocated PB Size"=193069056,"In use system memory"=1351647232} diff --git a/dev/research/metal_pathfinding/optimization/results/aba/gpu-load-optimized-b.txt b/dev/research/metal_pathfinding/optimization/results/aba/gpu-load-optimized-b.txt deleted file mode 100644 index 3a68cce..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/gpu-load-optimized-b.txt +++ /dev/null @@ -1 +0,0 @@ - | "PerformanceStatistics" = {"In use system memory (driver)"=0,"Alloc system memory"=10995400704,"Tiler Utilization %"=2,"recoveryCount"=0,"lastRecoveryTime"=0,"Renderer Utilization %"=3,"TiledSceneBytes"=1474560,"Device Utilization %"=66,"SplitSceneCount"=0,"Allocated PB Size"=192282624,"In use system memory"=1355710464} diff --git a/dev/research/metal_pathfinding/optimization/results/aba/load-base-a1.txt b/dev/research/metal_pathfinding/optimization/results/aba/load-base-a1.txt deleted file mode 100644 index dd02bc0..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/load-base-a1.txt +++ /dev/null @@ -1,40 +0,0 @@ -Processes: 1024 total, 3 running, 1021 sleeping, 5250 threads -2026/09/27 00:29:21 -Load Avg: 3.21, 3.60, 4.27 -CPU usage: 6.18% user, 11.61% sys, 82.20% idle -SharedLibs: 916M resident, 168M data, 136M linkedit. -MemRegions: 638813 total, 10G resident, 741M private, 4389M shared. -PhysMem: 34G used (4239M wired, 626M compressor), 1115M unused. -VM: 453T vsize, 6144M framework vsize, 2846220(0) swapins, 4729331(0) swapouts. -Networks: packets: 191126338/161G in, 146586979/163G out. -Disks: 97827999/2477G read, 117446770/2100G written. - -PID COMMAND %CPU #TH -99753 mscamerad-xpc 0.0 2 -99752 PlugInLibrarySer 0.0 2 -99750 QuickLookUIServi 0.0 3 -99749 com.apple.appkit 0.0 3 -99710 PlugInLibrarySer 0.0 2 -99709 Preview 0.0 4 -99428 crashpad_handler 0.0 4 -98995 crashpad_handler 0.0 4 -Processes: 1025 total, 4 running, 1021 sleeping, 5258 threads -2026/09/27 00:29:22 -Load Avg: 3.21, 3.60, 4.27 -CPU usage: 6.63% user, 5.47% sys, 87.88% idle -SharedLibs: 916M resident, 168M data, 136M linkedit. -MemRegions: 152 total, 11M resident, 741M private, 4418M shared. -PhysMem: 34G used (4196M wired, 626M compressor), 1089M unused. -VM: 453T vsize, 6144M framework vsize, 2846220(0) swapins, 4729331(0) swapouts. -Networks: packets: 191126514/161G in, 146587155/163G out. -Disks: 97828002/2477G read, 117446910/2100G written. - -PID COMMAND %CPU #TH -590 WindowServer 21.6 28/1 -70968 Superset Helper 15.9 32 -0 kernel_task 14.4 850/15 -7309 top 7.7 1/1 -21666 2.1.283 7.3 27 -70942 Superset Helper 7.2 19 -70934 Superset 5.9 51 -70969 Superset 5.2 21 diff --git a/dev/research/metal_pathfinding/optimization/results/aba/load-base-a2.txt b/dev/research/metal_pathfinding/optimization/results/aba/load-base-a2.txt deleted file mode 100644 index d18990e..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/load-base-a2.txt +++ /dev/null @@ -1,40 +0,0 @@ -Processes: 1019 total, 2 running, 1017 sleeping, 5187 threads -2026/09/27 00:30:29 -Load Avg: 3.19, 3.55, 4.19 -CPU usage: 5.82% user, 8.66% sys, 85.51% idle -SharedLibs: 916M resident, 168M data, 136M linkedit. -MemRegions: 638542 total, 10G resident, 736M private, 4366M shared. -PhysMem: 34G used (3986M wired, 626M compressor), 1354M unused. -VM: 451T vsize, 6144M framework vsize, 2846220(0) swapins, 4729331(0) swapouts. -Networks: packets: 191149845/161G in, 146612635/163G out. -Disks: 97828398/2477G read, 117449998/2100G written. - -PID COMMAND %CPU #TH -99753 mscamerad-xpc 0.0 2 -99752 PlugInLibrarySer 0.0 2 -99750 QuickLookUIServi 0.0 3 -99749 com.apple.appkit 0.0 3 -99710 PlugInLibrarySer 0.0 2 -99709 Preview 0.0 4 -99428 crashpad_handler 0.0 4 -98995 crashpad_handler 0.0 4 -Processes: 1019 total, 4 running, 1015 sleeping, 5180 threads -2026/09/27 00:30:30 -Load Avg: 3.19, 3.55, 4.19 -CPU usage: 6.81% user, 6.18% sys, 86.99% idle -SharedLibs: 916M resident, 168M data, 136M linkedit. -MemRegions: 0 total, 0B resident, 736M private, 4366M shared. -PhysMem: 34G used (4002M wired, 626M compressor), 1357M unused. -VM: 451T vsize, 6144M framework vsize, 2846220(0) swapins, 4729331(0) swapouts. -Networks: packets: 191150177/161G in, 146613141/163G out. -Disks: 97828398/2477G read, 117450254/2100G written. - -PID COMMAND %CPU #TH -590 WindowServer 25.0 29/1 -70968 Superset Helper 21.6 32/1 -0 kernel_task 13.1 850/14 -1 launchd 11.9 3 -15622 top 8.2 1/1 -70942 Superset Helper 7.5 19 -81804 2.1.283 6.7 27 -70969 Superset 3.6 20 diff --git a/dev/research/metal_pathfinding/optimization/results/aba/load-cpu-a1.txt b/dev/research/metal_pathfinding/optimization/results/aba/load-cpu-a1.txt deleted file mode 100644 index 1c23bd6..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/load-cpu-a1.txt +++ /dev/null @@ -1,40 +0,0 @@ -Processes: 1032 total, 3 running, 1029 sleeping, 5281 threads -2026/09/27 00:29:09 -Load Avg: 3.51, 3.67, 4.30 -CPU usage: 10.0% user, 9.68% sys, 80.31% idle -SharedLibs: 916M resident, 168M data, 136M linkedit. -MemRegions: 639248 total, 10G resident, 749M private, 4360M shared. -PhysMem: 34G used (3986M wired, 626M compressor), 1219M unused. -VM: 456T vsize, 6144M framework vsize, 2846220(0) swapins, 4729331(0) swapouts. -Networks: packets: 191122601/161G in, 146582691/163G out. -Disks: 97827926/2477G read, 117446010/2100G written. - -PID COMMAND %CPU #TH -99753 mscamerad-xpc 0.0 2 -99752 PlugInLibrarySer 0.0 2 -99750 QuickLookUIServi 0.0 3 -99749 com.apple.appkit 0.0 3 -99710 PlugInLibrarySer 0.0 2 -99709 Preview 0.0 4 -99553 mdworker_shared 0.0 4 -99428 crashpad_handler 0.0 4 -Processes: 1032 total, 4 running, 1028 sleeping, 5264 threads -2026/09/27 00:29:10 -Load Avg: 3.51, 3.67, 4.30 -CPU usage: 11.73% user, 5.4% sys, 83.21% idle -SharedLibs: 916M resident, 168M data, 136M linkedit. -MemRegions: 0 total, 0B resident, 749M private, 4360M shared. -PhysMem: 34G used (3988M wired, 626M compressor), 1243M unused. -VM: 456T vsize, 6144M framework vsize, 2846220(0) swapins, 4729331(0) swapouts. -Networks: packets: 191123079/161G in, 146583230/163G out. -Disks: 97827926/2477G read, 117446016/2100G written. - -PID COMMAND %CPU #TH -590 WindowServer 20.6 28/1 -70968 Superset Helper 17.5 32 -0 kernel_task 12.2 850/14 -70942 Superset Helper 6.4 19 -6126 top 6.1 1/1 -21666 2.1.283 4.7 27 -70969 Superset 2.5 20 -70934 Superset 2.1 50 diff --git a/dev/research/metal_pathfinding/optimization/results/aba/load-cpu-a2.txt b/dev/research/metal_pathfinding/optimization/results/aba/load-cpu-a2.txt deleted file mode 100644 index f527810..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/load-cpu-a2.txt +++ /dev/null @@ -1,40 +0,0 @@ -Processes: 1023 total, 6 running, 1017 sleeping, 5244 threads -2026/09/27 00:30:44 -Load Avg: 3.82, 3.67, 4.23 -CPU usage: 13.90% user, 12.81% sys, 73.28% idle -SharedLibs: 916M resident, 168M data, 136M linkedit. -MemRegions: 638887 total, 10G resident, 740M private, 4419M shared. -PhysMem: 34G used (3984M wired, 626M compressor), 992M unused. -VM: 452T vsize, 6144M framework vsize, 2846220(0) swapins, 4729331(0) swapouts. -Networks: packets: 191155920/161G in, 146620711/163G out. -Disks: 97828530/2477G read, 117450792/2100G written. - -PID COMMAND %CPU #TH -99753 mscamerad-xpc 0.0 2 -99752 PlugInLibrarySer 0.0 2 -99750 QuickLookUIServi 0.0 3 -99749 com.apple.appkit 0.0 3 -99710 PlugInLibrarySer 0.0 2 -99709 Preview 0.0 4 -99428 crashpad_handler 0.0 4 -98995 crashpad_handler 0.0 4 -Processes: 1029 total, 5 running, 1 stuck, 1023 sleeping, 5261 threads -2026/09/27 00:30:45 -Load Avg: 3.82, 3.67, 4.23 -CPU usage: 16.69% user, 5.86% sys, 77.44% idle -SharedLibs: 916M resident, 168M data, 136M linkedit. -MemRegions: 282 total, 10M resident, 740M private, 4464M shared. -PhysMem: 34G used (3976M wired, 626M compressor), 1037M unused. -VM: 455T vsize, 6144M framework vsize, 2846220(0) swapins, 4729331(0) swapouts. -Networks: packets: 191156199/161G in, 146620961/163G out. -Disks: 97828530/2477G read, 117450800/2100G written. - -PID COMMAND %CPU #TH -16594 Google Chrome He 55.7 25/1 -1225 mediaanalysisd 36.4 18/2 -590 WindowServer 27.7 29 -70968 Superset Helper 25.0 32/1 -95916 Google Chrome 17.5 46 -0 kernel_task 15.2 850/14 -731 sysmond 10.4 3 -70942 Superset Helper 9.3 19 diff --git a/dev/research/metal_pathfinding/optimization/results/aba/load-optimized-b.txt b/dev/research/metal_pathfinding/optimization/results/aba/load-optimized-b.txt deleted file mode 100644 index 744da4d..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/load-optimized-b.txt +++ /dev/null @@ -1,40 +0,0 @@ -Processes: 1023 total, 3 running, 1020 sleeping, 5207 threads -2026/09/27 00:29:35 -Load Avg: 3.30, 3.60, 4.25 -CPU usage: 6.44% user, 8.69% sys, 84.86% idle -SharedLibs: 916M resident, 168M data, 136M linkedit. -MemRegions: 625656 total, 10G resident, 740M private, 4353M shared. -PhysMem: 34G used (3975M wired, 626M compressor), 1426M unused. -VM: 451T vsize, 6144M framework vsize, 2846220(0) swapins, 4729331(0) swapouts. -Networks: packets: 191131577/161G in, 146592177/163G out. -Disks: 97828052/2477G read, 117447547/2100G written. - -PID COMMAND %CPU #TH -99753 mscamerad-xpc 0.0 2 -99752 PlugInLibrarySer 0.0 2 -99750 QuickLookUIServi 0.0 3 -99749 com.apple.appkit 0.0 3 -99710 PlugInLibrarySer 0.0 2 -99709 Preview 0.0 4 -99428 crashpad_handler 0.0 4 -98995 crashpad_handler 0.0 4 -Processes: 1024 total, 4 running, 1020 sleeping, 5206 threads -2026/09/27 00:29:37 -Load Avg: 3.30, 3.60, 4.25 -CPU usage: 7.77% user, 5.29% sys, 86.92% idle -SharedLibs: 916M resident, 168M data, 136M linkedit. -MemRegions: 33 total, 80K resident, 740M private, 4354M shared. -PhysMem: 34G used (3977M wired, 626M compressor), 1411M unused. -VM: 451T vsize, 6144M framework vsize, 2846220(0) swapins, 4729331(0) swapouts. -Networks: packets: 191131879/161G in, 146592477/163G out. -Disks: 97828056/2477G read, 117447556/2100G written. - -PID COMMAND %CPU #TH -590 WindowServer 24.9 28 -70968 Superset Helper 16.7 32 -0 kernel_task 12.6 850/14 -81804 2.1.283 10.9 27 -9966 top 6.7 1/1 -70942 Superset Helper 6.5 19 -39757 codex 4.9 31 -70969 Superset 2.8 20 diff --git a/dev/research/metal_pathfinding/optimization/results/aba/optimized-b-stderr.txt b/dev/research/metal_pathfinding/optimization/results/aba/optimized-b-stderr.txt deleted file mode 100644 index fed94be..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/optimized-b-stderr.txt +++ /dev/null @@ -1 +0,0 @@ -device=Apple M4 Max setup_ms=23.3475 execution_width=32 diff --git a/dev/research/metal_pathfinding/optimization/results/aba/optimized-b.csv b/dev/research/metal_pathfinding/optimization/results/aba/optimized-b.csv deleted file mode 100644 index ceb3e3f..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/optimized-b.csv +++ /dev/null @@ -1,217 +0,0 @@ -case,n,e,queries,method,wall_ms,distance_ms,gpu_ms,edge_visits,rounds,waits,fallback64,upload_ms,samples,correct,wall_samples_ms -random,1024,8192,1,old_pull_parallel_dag,1.422708,1.375584,0.424208,131072,16,2,0,0.152791,7,pass,1.540625;1.013375;1.422708;0.980167;1.441208;1.027458;1.449958 -random,1024,8192,1,old_group_parallel_dag,0.833500,0.818833,0.679958,106496,13,1,0,0.152791,7,pass,0.833500;0.817917;1.119042;0.831833;0.925708;0.827042;0.877000 -random,1024,8192,1,pull64_barrier,0.659084,0.646375,0.390667,131072,16,2,0,0.152791,7,pass,0.644416;0.664875;0.659084;0.643459;0.654750;0.659750;0.671792 -random,1024,8192,1,pull64_interleaved,0.655584,0.643000,0.375208,131072,16,2,0,0.152791,7,pass,0.660167;0.652250;0.655584;0.672125;0.650375;0.629083;0.677083 -random,1024,8192,1,pull32_interleaved,0.599333,0.586041,0.337667,131072,16,2,0,0.152791,7,pass,0.667083;0.607875;0.599333;0.571542;0.736667;0.583833;0.566542 -random,1024,8192,1,pull32_gpu_dag,0.960083,0.603666,0.406125,131072,16,4,0,0.152791,7,pass,1.014709;0.960083;0.982375;0.981250;0.949417;0.928375;0.958334 -random,1024,8192,1,frontier32_vertex,0.923583,0.909625,0.634334,21088,16,2,0,0.152791,7,pass,0.906208;0.964958;0.872292;0.874250;1.228792;0.930208;0.923583 -random,1024,8192,1,frontier32_simd,0.661125,0.647209,0.380375,20272,16,2,0,0.152791,7,pass,0.647583;0.661125;0.686791;0.653458;0.636375;0.679750;0.674833 -random,1024,8192,1,frontier32_gpu_dag,1.002750,0.652084,0.452250,20384,16,4,0,0.152791,7,pass,0.988750;0.968709;0.979666;1.002750;1.115041;1.082875;1.185666 -random,1024,8192,1,bucket32_d4,1.895167,1.880583,1.276084,8232,32,4,0,0.152791,7,pass,2.037416;2.476041;1.853333;1.785791;1.902334;1.888917;1.895167 -random,1024,8192,1,bucket32_d16,1.421542,1.407000,0.920208,9336,24,3,0,0.152791,7,pass,1.421542;1.436167;1.369375;1.347459;1.377084;1.460625;1.463417 -random,1024,8192,1,bucket32_d64,0.953750,0.939042,0.653708,19264,16,2,0,0.152791,7,pass,0.950916;0.940167;0.953750;0.954375;0.977375;1.016334;0.913959 -random,1024,8192,64,old_pull_parallel_dag,1.972792,1.703000,1.236416,12582912,24,3,0,0.238875,7,pass,2.379583;2.263125;1.972792;1.649750;1.554584;1.804167;2.017709 -random,1024,8192,64,old_group_parallel_dag,0.852750,0.566166,0.379500,7217152,17,1,0,0.238875,7,pass,0.972542;0.909042;0.773791;0.876500;0.773208;0.852750;0.821167 -random,1024,8192,64,pull64_barrier,1.223458,0.948625,0.470458,12582912,24,3,0,0.238875,7,pass,1.215166;1.266916;1.459875;1.254708;1.100250;1.223458;1.058542 -random,1024,8192,64,pull64_interleaved,0.940417,0.670542,0.268333,12582912,24,3,0,0.238875,7,pass,1.098459;0.940417;1.008292;1.048209;0.935416;0.862625;0.937292 -random,1024,8192,64,pull32_interleaved,0.863416,0.594792,0.197417,12582912,24,3,0,0.238875,7,pass,0.897167;0.847458;0.843917;0.856791;0.925958;0.885125;0.863416 -random,1024,8192,64,pull32_gpu_dag,0.998000,0.578542,0.199083,12582912,24,5,0,0.238875,7,pass,1.014750;0.831500;0.914292;1.005500;0.940167;0.998000;1.025125 -random,1024,8192,64,frontier32_vertex,0.869625,0.628834,0.239458,1289824,16,2,0,0.238875,7,pass,0.929916;1.029083;0.811167;0.964459;0.869625;0.779625;0.783875 -random,1024,8192,64,frontier32_simd,1.028500,0.795709,0.456417,1202328,16,2,0,0.238875,7,pass,1.156250;1.092875;1.028500;0.996917;0.985625;1.019916;1.121709 -random,1024,8192,64,frontier32_gpu_dag,1.197083,0.889625,0.508875,1201208,16,4,0,0.238875,7,pass,1.042417;1.254542;1.182167;1.197083;1.099917;1.569084;2.684750 -random,1024,8192,64,bucket32_d4,5.929458,5.638042,4.210875,528528,56,7,0,0.238875,7,pass,6.084667;5.929458;5.901834;6.615042;5.929458;2.489625;2.791458 -random,1024,8192,64,bucket32_d16,1.623792,1.347083,0.712083,601456,32,4,0,0.238875,7,pass,1.662291;1.614416;1.657709;1.623792;1.578500;1.475583;1.650417 -random,1024,8192,64,bucket32_d64,1.402750,1.126334,0.671125,1154720,24,3,0,0.238875,7,pass,1.349709;1.441667;1.314791;1.402750;1.365125;1.570500;1.408750 -clos,200,20000,64,old_pull_parallel_dag,2.136750,1.514125,1.015667,10240000,8,1,0,0.322125,7,pass,2.048625;2.183375;2.117792;1.920167;2.160875;2.136750;2.711125 -clos,200,20000,64,old_group_parallel_dag,1.460750,0.833416,0.303250,3840000,3,1,0,0.322125,7,pass,1.479875;1.528583;1.339875;1.338875;1.467208;1.426625;1.460750 -clos,200,20000,64,pull64_barrier,2.071916,1.523875,1.000375,10240000,8,1,0,0.322125,7,pass,2.033375;2.132917;2.115500;2.105750;1.921250;1.814041;2.071916 -clos,200,20000,64,pull64_interleaved,1.903375,1.332458,0.757208,10240000,8,1,0,0.322125,7,pass,1.586917;1.955417;1.903375;2.048458;1.910875;1.876500;1.902250 -clos,200,20000,64,pull32_interleaved,1.893500,1.311208,0.687708,10240000,8,1,0,0.322125,7,pass,1.853250;1.915167;1.809917;1.898334;1.949167;1.893500;1.792167 -clos,200,20000,64,pull32_gpu_dag,2.091666,1.226209,1.012708,10240000,8,3,0,0.322125,7,pass,2.159458;2.626458;1.675041;2.091666;1.743709;2.184375;2.028334 -clos,200,20000,64,frontier32_vertex,2.381875,1.556917,0.961125,1280000,8,1,0,0.322125,7,pass,2.020291;2.766500;2.041667;2.792125;2.084459;2.381875;2.471625 -clos,200,20000,64,frontier32_simd,1.552459,0.924500,0.317875,1280000,8,1,0,0.322125,7,pass,1.552459;1.569875;1.528791;1.653375;1.387708;1.480917;1.568750 -clos,200,20000,64,frontier32_gpu_dag,1.979166,0.982458,0.836500,1280000,8,3,0,0.322125,7,pass,2.080875;1.972042;1.933208;2.108042;1.548334;1.979166;2.014125 -clos,200,20000,64,bucket32_d4,1.572375,1.024583,0.480750,1280000,8,1,0,0.322125,7,pass,2.073625;1.344417;1.663959;1.754875;1.275500;1.452917;1.572375 -clos,200,20000,64,bucket32_d16,1.739292,1.165625,0.491667,1280000,8,1,0,0.322125,7,pass,1.775000;1.796583;1.739292;1.315750;1.685625;1.748541;1.637916 -clos,200,20000,64,bucket32_d64,1.692708,1.072250,0.489542,1280000,8,1,0,0.322125,7,pass,1.629041;1.683584;1.586167;1.839709;1.716250;1.692708;1.699208 -grid,1024,3968,1,old_pull_parallel_dag,1.747333,1.738833,0.651333,253952,64,8,0,0.124084,7,pass,1.754625;1.747333;1.919625;1.635333;1.795750;1.629333;1.627542 -grid,1024,3968,1,old_group_parallel_dag,0.365667,0.358792,0.227375,249984,63,1,0,0.124084,7,pass,1.116750;0.360041;0.365667;0.352625;0.396042;0.372000;0.363209 -grid,1024,3968,1,pull64_barrier,1.119292,1.111458,0.177083,253952,64,8,0,0.124084,7,pass,1.136458;1.122167;1.175458;1.103917;1.119292;1.113042;1.103334 -grid,1024,3968,1,pull64_interleaved,1.144083,1.136125,0.176751,253952,64,8,0,0.124084,7,pass,1.134375;1.313625;1.215166;1.160208;1.096416;1.144083;1.113833 -grid,1024,3968,1,pull32_interleaved,1.088542,1.080667,0.163666,253952,64,8,0,0.124084,7,pass,1.101875;1.104166;1.088542;1.119625;1.051041;0.916541;1.050417 -grid,1024,3968,1,pull32_gpu_dag,1.270833,0.918458,0.173167,253952,64,10,0,0.124084,7,pass,1.277166;1.248125;1.308083;1.206458;1.379875;1.122791;1.270833 -grid,1024,3968,1,frontier32_vertex,1.312625,1.304791,0.528083,3968,64,8,0,0.124084,7,pass,1.312625;1.423958;1.307792;1.312292;1.321584;1.309625;1.349500 -grid,1024,3968,1,frontier32_simd,1.179667,1.171750,0.396291,3968,64,8,0,0.124084,7,pass,1.224375;1.179125;1.179667;1.174000;1.176875;1.186250;1.278792 -grid,1024,3968,1,frontier32_gpu_dag,1.394125,1.178458,0.407416,3968,64,10,0,0.124084,7,pass,1.432250;1.562334;1.460583;1.370833;1.394125;1.385333;1.378958 -grid,1024,3968,1,bucket32_d4,1.450166,1.441333,0.637042,3968,64,8,0,0.124084,7,pass,1.622291;1.445083;1.459416;1.450166;1.399667;1.537458;1.438791 -grid,1024,3968,1,bucket32_d16,1.451125,1.443250,0.635917,3968,64,8,0,0.124084,7,pass,1.444458;1.551500;1.423000;1.435167;1.451125;1.456500;1.464833 -grid,1024,3968,1,bucket32_d64,1.466458,1.458667,0.637167,3968,64,8,0,0.124084,7,pass,1.429375;1.439417;1.466458;1.588042;1.638959;1.476583;1.456791 -hub,1024,3070,1,old_pull_parallel_dag,6.046292,6.030084,5.544375,98240,32,4,0,0.098834,7,pass,5.969208;6.158125;6.087291;6.000167;6.046292;6.013417;6.048458 -hub,1024,3070,1,old_group_parallel_dag,4.888250,4.865667,4.721042,95170,31,1,0,0.098834,7,pass,4.863875;4.938958;4.863792;4.881375;4.888250;5.117750;4.995041 -hub,1024,3070,1,pull64_barrier,5.015916,4.988250,4.313333,98240,32,4,0,0.098834,7,pass,4.957625;5.000166;5.193458;5.034583;5.015916;5.078375;4.889250 -hub,1024,3070,1,pull64_interleaved,4.985584,4.962042,4.320833,98240,32,4,0,0.098834,7,pass,4.985584;5.048916;5.110875;4.910750;4.974542;5.079959;4.959667 -hub,1024,3070,1,pull32_interleaved,4.396791,4.373584,3.683708,98240,32,4,0,0.098834,7,pass,4.324084;4.431084;4.344417;4.461542;4.414375;4.396791;4.393666 -hub,1024,3070,1,pull32_gpu_dag,5.115583,4.357708,4.101125,98240,32,6,0,0.098834,7,pass,5.115583;5.122334;5.007417;5.289875;5.065416;5.077334;5.281125 -hub,1024,3070,1,frontier32_vertex,1.436166,1.428458,0.852042,3128,32,4,0,0.098834,7,pass,1.510208;1.436166;1.386458;1.511958;1.404167;1.438709;1.412875 -hub,1024,3070,1,frontier32_simd,0.784458,0.776500,0.233250,3128,32,4,0,0.098834,7,pass,0.761209;0.730042;0.781167;0.861667;0.786834;0.795166;0.784458 -hub,1024,3070,1,frontier32_gpu_dag,1.467375,0.769666,0.649500,3128,32,6,0,0.098834,7,pass,1.490583;1.610666;1.444583;1.509500;1.438708;1.450458;1.467375 -hub,1024,3070,1,bucket32_d4,0.869375,0.860292,0.344792,3100,32,4,0,0.098834,7,pass,0.868792;0.897709;0.906917;0.852833;0.880541;0.804416;0.869375 -hub,1024,3070,1,bucket32_d16,0.851250,0.843000,0.337292,3124,32,4,0,0.098834,7,pass,0.851250;0.846875;0.847417;0.932916;0.857625;0.875333;0.850125 -hub,1024,3070,1,bucket32_d64,0.885584,0.877625,0.326958,3128,32,4,0,0.098834,7,pass,0.848208;0.818834;0.853667;0.956041;0.885584;0.912042;0.911250 -random_masked_int64_reverse,1024,8192,8,old_pull_parallel_dag,0.231584,0.167417,0.031625,524288,8,1,0,0.225500,7,pass,0.232959;0.231584;0.263750;0.244833;0.228166;0.217625;0.224417 -random_masked_int64_reverse,1024,8192,8,old_group_parallel_dag,0.252833,0.194208,0.053500,368640,8,1,0,0.225500,7,pass,0.235500;0.300042;0.265000;0.252833;0.253417;0.241000;0.235292 -random_masked_int64_reverse,1024,8192,8,pull64_barrier,0.248458,0.190334,0.028750,524288,8,1,0,0.225500,7,pass,0.232375;0.263291;0.248458;0.237625;0.234167;0.248958;0.254792 -random_masked_int64_reverse,1024,8192,8,pull64_interleaved,0.263750,0.196708,0.029083,524288,8,1,0,0.225500,7,pass,0.231750;0.251083;0.263750;0.314917;0.275708;0.277375;0.250750 -random_masked_int64_reverse,1024,8192,8,pull32_interleaved,0.252166,0.197709,0.028833,524288,8,1,1,0.225500,7,pass,0.246500;0.296791;0.377917;0.252166;0.253166;0.239334;0.234209 -random_masked_int64_reverse,1024,8192,8,pull32_gpu_dag,0.499334,0.173875,0.048708,524288,8,3,1,0.225500,7,pass,0.453000;0.509250;0.474667;0.491292;0.499334;0.505583;0.586458 -random_masked_int64_reverse,1024,8192,8,frontier32_vertex,0.238375,0.188375,0.029167,524288,8,1,1,0.225500,7,pass,0.267584;0.267791;0.238375;0.232083;0.255083;0.227250;0.222083 -random_masked_int64_reverse,1024,8192,8,frontier32_simd,0.255750,0.192125,0.028667,524288,8,1,1,0.225500,7,pass,0.203333;0.266166;0.315666;0.257458;0.255750;0.237375;0.245750 -random_masked_int64_reverse,1024,8192,8,frontier32_gpu_dag,0.504333,0.184083,0.047458,524288,8,3,1,0.225500,7,pass,0.528250;0.504458;0.574709;0.504333;0.476834;0.476833;0.454791 -random_masked_int64_reverse,1024,8192,8,bucket32_d4,0.243083,0.197583,0.028750,524288,8,1,1,0.225500,7,pass,0.269416;0.260916;0.267750;0.237458;0.235791;0.241416;0.243083 -random_masked_int64_reverse,1024,8192,8,bucket32_d16,0.223542,0.178000,0.028875,524288,8,1,1,0.225500,7,pass,0.222750;0.232791;0.237375;0.258250;0.223542;0.215333;0.210875 -random_masked_int64_reverse,1024,8192,8,bucket32_d64,0.260750,0.198542,0.028917,524288,8,1,1,0.225500,7,pass,0.229833;0.261834;0.302792;0.260917;0.247000;0.260750;0.247750 -random,4096,32768,256,old_pull_parallel_dag,14.954834,11.144167,9.933083,201326592,24,3,0,0.530167,7,pass,15.227708;14.739917;14.645041;14.434167;15.033875;14.956792;14.954834 -random,4096,32768,256,old_group_parallel_dag,11.269917,7.608250,6.915000,136544256,20,1,0,0.530167,7,pass,11.252208;11.493083;11.614375;11.162459;11.269917;11.043583;11.470666 -random,4096,32768,256,pull64_barrier,13.565209,9.762416,8.500917,201326592,24,3,0,0.530167,7,pass,13.685167;13.552875;13.587709;13.426042;13.565209;12.961916;13.579000 -random,4096,32768,256,pull64_interleaved,7.002542,3.340208,2.080917,201326592,24,3,0,0.530167,7,pass,6.459833;7.002542;6.970958;6.819791;8.010875;7.685500;7.589250 -random,4096,32768,256,pull32_interleaved,9.000125,5.200041,3.525458,201326592,24,3,0,0.530167,7,pass,9.000125;11.030083;8.075542;6.810625;11.745583;6.810833;11.157541 -random,4096,32768,256,pull32_gpu_dag,8.790333,6.949833,7.133875,201326592,24,5,0,0.530167,7,pass,9.671167;3.518834;8.790333;9.206042;3.936125;3.961042;9.220208 -random,4096,32768,256,frontier32_vertex,7.936292,4.093958,2.380125,19766184,24,3,0,0.530167,7,pass,14.160834;7.095291;7.936292;14.078500;6.887042;6.492417;8.011875 -random,4096,32768,256,frontier32_simd,11.324125,7.658917,6.414084,20599968,24,3,0,0.530167,7,pass,11.324125;11.230792;12.628625;10.989958;12.690250;10.503958;11.417833 -random,4096,32768,256,frontier32_gpu_dag,9.247000,7.874000,7.025417,20610776,24,5,0,0.530167,7,pass,9.247000;8.839625;10.159084;8.513125;9.098458;9.902667;12.254458 -random,4096,32768,256,bucket32_d4,16.328042,12.613875,10.131208,8454072,72,9,0,0.530167,7,pass,15.820875;15.743208;17.331208;17.372667;17.149292;16.328042;15.803333 -random,4096,32768,256,bucket32_d16,13.121000,9.300750,7.673208,9728592,48,6,0,0.530167,7,pass,12.592459;12.880667;13.311583;13.121000;12.540334;13.268125;13.189209 -random,4096,32768,256,bucket32_d64,14.196416,9.622542,8.263583,18345256,24,3,0,0.530167,7,pass,13.307666;13.243458;14.604250;14.721292;16.965625;14.196416;11.675209 -random,16384,131072,1,old_pull_parallel_dag,0.963792,0.658875,0.243542,3145728,24,3,0,1.641291,7,pass,1.004958;0.949750;0.963792;0.924292;1.072083;0.879500;0.969417 -random,16384,131072,1,old_group_parallel_dag,5.289583,4.968000,4.819125,2359296,18,1,0,1.641291,7,pass,5.441666;5.132583;5.298584;5.189291;5.167667;5.335500;5.289583 -random,16384,131072,1,pull64_barrier,0.760792,0.570916,0.163584,3145728,24,3,0,1.641291,7,pass,0.754833;0.774750;0.854375;0.782167;0.760792;0.759375;0.723417 -random,16384,131072,1,pull64_interleaved,0.828250,0.564041,0.163917,3145728,24,3,0,1.641291,7,pass,0.838583;0.961166;0.811000;0.853208;0.817333;0.828250;0.752583 -random,16384,131072,1,pull32_interleaved,0.705083,0.520875,0.136959,3145728,24,3,0,1.641291,7,pass,0.692583;0.711333;0.700666;0.706750;0.705083;0.703000;0.878958 -random,16384,131072,1,pull32_gpu_dag,0.880167,0.512875,0.158000,3145728,24,5,0,1.641291,7,pass,0.880167;0.828750;0.869708;0.906792;0.831750;0.944541;0.894834 -random,16384,131072,1,frontier32_vertex,0.831833,0.618959,0.258125,377056,24,3,0,1.641291,7,pass,0.849750;0.831833;0.841916;0.827792;0.873667;0.821125;0.814084 -random,16384,131072,1,frontier32_simd,0.806417,0.593083,0.237208,335680,24,3,0,1.641291,7,pass,0.824166;0.806417;0.694583;0.641750;0.808667;0.799250;0.844083 -random,16384,131072,1,frontier32_gpu_dag,0.982833,0.612417,0.268708,334824,24,5,0,1.641291,7,pass,0.991500;0.973042;1.006625;0.951167;0.982833;1.121125;0.797541 -random,16384,131072,1,bucket32_d4,1.832833,1.546042,0.654208,132016,56,7,0,1.641291,7,pass,1.779834;1.940750;1.879708;1.832833;1.795166;1.849667;1.734208 -random,16384,131072,1,bucket32_d16,1.148208,0.916666,0.397583,148256,32,4,0,1.641291,7,pass,1.118791;1.148208;1.278792;1.143833;1.283750;1.248500;1.113291 -random,16384,131072,1,bucket32_d64,0.936833,0.711500,0.339500,268728,24,3,0,1.641291,7,pass,0.928500;0.936833;0.949541;0.932791;0.940250;0.933167;0.946042 -random,16384,131072,64,old_pull_parallel_dag,16.913292,12.650625,11.412458,201326592,24,3,0,1.869208,7,pass,17.212875;16.913292;17.536708;16.731500;16.199708;16.913375;16.278333 -random,16384,131072,64,old_group_parallel_dag,13.239041,9.052417,8.276375,158203904,22,1,0,1.869208,7,pass,13.094375;13.914166;13.783125;13.407916;13.111125;13.058417;13.239041 -random,16384,131072,64,pull64_barrier,15.959375,11.386000,10.305167,201326592,24,3,0,1.869208,7,pass,15.514584;16.078958;15.892875;15.768500;16.169292;16.315750;15.959375 -random,16384,131072,64,pull64_interleaved,7.473334,3.322042,1.949625,201326592,24,3,0,1.869208,7,pass,7.977375;7.469666;7.993750;7.469959;7.340416;8.069541;7.473334 -random,16384,131072,64,pull32_interleaved,8.709917,3.948375,2.593083,201326592,24,3,0,1.869208,7,pass,7.568334;8.709917;9.042834;8.360834;9.779833;11.890542;7.645000 -random,16384,131072,64,pull32_gpu_dag,8.809875,7.334042,7.226667,201326592,24,5,0,1.869208,7,pass,8.327042;8.982250;9.166375;8.212792;9.129458;8.809875;8.644916 -random,16384,131072,64,frontier32_vertex,10.143542,5.759583,3.640500,21296680,24,3,0,1.869208,7,pass,10.647167;8.829458;10.953250;8.470875;8.924250;11.585834;10.143542 -random,16384,131072,64,frontier32_simd,12.189208,7.940750,6.799000,22006672,24,3,0,1.869208,7,pass,12.189208;17.371042;12.150166;11.824625;17.040958;12.901583;11.954250 -random,16384,131072,64,frontier32_gpu_dag,9.202917,7.809334,7.471417,21985376,24,5,0,1.869208,7,pass,9.202917;9.145375;9.333000;9.321417;8.679416;9.192583;9.217625 -random,16384,131072,64,bucket32_d4,16.825209,12.606541,10.280166,8451400,72,9,0,1.869208,7,pass,15.831792;16.931500;16.825209;16.989583;16.937500;16.726666;16.530875 -random,16384,131072,64,bucket32_d16,13.809292,9.686834,7.926208,9572312,48,6,0,1.869208,7,pass,14.031791;13.417000;13.809292;13.634833;13.956208;12.643542;14.687833 -random,16384,131072,64,bucket32_d64,12.602167,8.439250,7.400083,17413440,24,3,0,1.869208,7,pass,12.249792;12.602167;12.763000;11.981750;12.445291;13.342709;13.471250 -random,65536,524288,1,old_pull_parallel_dag,3.545291,2.038125,1.060917,12582912,24,3,0,7.557667,7,pass,4.174416;3.781042;3.698125;3.545291;3.297792;3.206292;3.322458 -random,65536,524288,1,old_group_parallel_dag,30.358375,28.977917,28.313750,11534336,22,1,0,7.557667,7,pass,30.101000;30.358375;30.286417;30.429125;30.627750;30.470958;30.242042 -random,65536,524288,1,pull64_barrier,3.163417,1.729375,0.758083,12582912,24,3,0,7.557667,7,pass,3.113500;2.616291;3.163417;3.279250;3.173625;3.383583;3.162375 -random,65536,524288,1,pull64_interleaved,3.048417,1.518750,0.702958,12582912,24,3,0,7.557667,7,pass,3.038333;2.940000;3.048417;3.174250;3.017125;3.103000;3.113208 -random,65536,524288,1,pull32_interleaved,2.913209,1.337792,0.501667,12582912,24,3,0,7.557667,7,pass,2.913209;2.430541;3.057667;2.519625;2.842875;2.994208;2.928625 -random,65536,524288,1,pull32_gpu_dag,1.373417,0.881667,0.570875,12582912,24,5,0,7.557667,7,pass,1.391584;1.373417;1.479292;1.291209;1.337833;1.320958;1.406750 -random,65536,524288,1,frontier32_vertex,2.737250,1.259292,0.435458,1734632,24,3,0,7.557667,7,pass,2.715333;2.737250;2.858583;2.871083;2.568667;2.928166;2.330750 -random,65536,524288,1,frontier32_simd,3.045416,1.576083,0.639792,1561768,24,3,0,7.557667,7,pass,3.041292;3.007917;3.045416;3.056084;2.611792;3.060625;3.113833 -random,65536,524288,1,frontier32_gpu_dag,1.461000,1.045334,0.686000,1558160,24,5,0,7.557667,7,pass,1.657208;1.494750;1.429000;1.461000;1.452083;1.487333;1.387208 -random,65536,524288,1,bucket32_d4,4.357292,2.761166,1.174042,528256,64,8,0,7.557667,7,pass,4.461625;4.147542;4.444375;3.817792;4.357292;3.730458;4.480750 -random,65536,524288,1,bucket32_d16,3.480542,2.046791,0.835833,599256,40,5,0,7.557667,7,pass,3.480542;3.618791;3.528458;3.040125;3.475000;2.936333;3.646042 -random,65536,524288,1,bucket32_d64,3.186959,1.681334,0.676709,1050376,24,3,0,7.557667,7,pass,2.972375;3.186959;3.069917;3.187000;3.276667;3.274542;3.125584 -clos,800,320000,1,old_pull_parallel_dag,2.141750,1.366208,0.743750,2560000,8,1,0,3.583584,7,pass,2.117375;2.169292;2.141750;2.208292;2.099459;2.102417;2.219875 -clos,800,320000,1,old_group_parallel_dag,2.325833,1.576542,0.904750,960000,3,1,0,3.583584,7,pass,1.803667;2.325833;2.423834;2.367833;2.283458;2.311875;2.412417 -clos,800,320000,1,pull64_barrier,2.017334,1.252542,0.699375,2560000,8,1,0,3.583584,7,pass,1.540042;1.974542;1.998791;2.017334;2.145250;2.049167;2.183834 -clos,800,320000,1,pull64_interleaved,2.265000,1.493125,0.878292,2560000,8,1,0,3.583584,7,pass,2.248084;2.265000;2.266583;2.184625;2.588416;1.812125;2.474416 -clos,800,320000,1,pull32_interleaved,2.800709,1.979625,1.363375,2560000,8,1,0,3.583584,7,pass,2.800709;2.480667;2.804250;2.902708;2.653584;1.981000;3.096791 -clos,800,320000,1,pull32_gpu_dag,2.132833,1.256042,0.994000,2560000,8,3,0,3.583584,7,pass,2.004875;2.039500;2.228625;2.610667;1.446750;2.132833;2.185042 -clos,800,320000,1,frontier32_vertex,2.375500,1.574833,0.933250,320000,8,1,0,3.583584,7,pass,1.813500;2.375500;1.851291;5.269584;3.758292;2.390250;2.215708 -clos,800,320000,1,frontier32_simd,1.604458,0.852083,0.361417,320000,8,1,0,3.583584,7,pass,1.724958;1.809125;1.589542;1.604458;1.514125;1.668583;1.587916 -clos,800,320000,1,frontier32_gpu_dag,1.714416,0.823959,0.606042,320000,8,3,0,3.583584,7,pass,3.397708;1.759291;2.480875;1.558125;1.714416;1.648958;1.076958 -clos,800,320000,1,bucket32_d4,1.660042,0.872958,0.445875,320000,8,1,0,3.583584,7,pass,1.463167;1.412583;1.748375;1.660042;1.712083;1.768875;1.587750 -clos,800,320000,1,bucket32_d16,1.716125,0.904208,0.443542,320000,8,1,0,3.583584,7,pass,1.814125;1.647500;1.722334;1.690042;1.764750;1.716125;1.629792 -clos,800,320000,1,bucket32_d64,1.697542,0.939166,0.453417,320000,8,1,0,3.583584,7,pass,1.726542;1.697542;1.673041;1.558833;1.790583;1.782500;1.680916 -clos,800,320000,64,old_pull_parallel_dag,26.734584,19.417958,18.854209,163840000,8,1,0,3.568791,7,pass,30.062917;26.734584;27.709875;23.844042;26.751083;24.652083;23.241250 -clos,800,320000,64,old_group_parallel_dag,12.636667,5.914625,5.412125,61440000,3,1,0,3.568791,7,pass,13.336750;12.226458;12.215542;12.019334;12.982541;12.636667;13.245083 -clos,800,320000,64,pull64_barrier,24.737250,18.165041,17.724875,163840000,8,1,0,3.568791,7,pass,26.038667;23.627875;23.828875;24.737250;25.138416;20.098750;25.549208 -clos,800,320000,64,pull64_interleaved,13.270458,6.121834,5.404917,163840000,8,1,0,3.568791,7,pass,13.665208;12.286459;13.418500;13.055084;12.553250;13.305834;13.270458 -clos,800,320000,64,pull32_interleaved,10.982708,4.274208,3.646792,163840000,8,1,0,3.568791,7,pass,10.804708;10.794208;10.982708;11.027542;10.547750;11.347166;11.442166 -clos,800,320000,64,pull32_gpu_dag,8.185208,4.307834,6.163833,163840000,8,3,0,3.568791,7,pass,7.631709;7.941417;8.661667;8.185208;8.311625;8.223667;7.750583 -clos,800,320000,64,frontier32_vertex,14.231292,7.148416,6.877042,20480000,8,1,0,3.568791,7,pass,14.506375;12.344792;13.874083;13.901542;14.231292;14.353875;15.082167 -clos,800,320000,64,frontier32_simd,9.324334,2.064667,1.458000,20480000,8,1,0,3.568791,7,pass,8.174041;8.823334;9.906666;8.897125;9.324334;9.981375;9.356125 -clos,800,320000,64,frontier32_gpu_dag,15.943917,2.088583,14.063458,20480000,8,3,0,3.568791,7,pass,17.334916;15.073292;12.642375;15.943917;12.171250;16.266166;17.346541 -clos,800,320000,64,bucket32_d4,10.000292,2.528500,2.040125,20480000,8,1,0,3.568791,7,pass,10.000292;10.508458;9.561208;8.825292;10.400125;10.141750;9.762250 -clos,800,320000,64,bucket32_d16,9.609833,2.183166,1.863208,20480000,8,1,0,3.568791,7,pass,8.653417;9.125125;8.935750;10.713417;9.609833;9.966375;10.069792 -clos,800,320000,64,bucket32_d64,9.988250,2.683542,2.106250,20480000,8,1,0,3.568791,7,pass,9.028250;9.988250;10.240792;9.182333;10.137083;9.899833;10.287375 -grid,10000,39600,1,old_pull_parallel_dag,4.013208,3.946834,0.706459,7920000,200,25,0,0.518084,7,pass,5.984209;3.982416;4.445583;3.987667;4.117083;3.956167;4.013208 -grid,10000,39600,1,old_group_parallel_dag,8.014000,7.948000,7.803667,7880400,199,1,0,0.518084,7,pass,8.141042;7.945375;8.093000;7.954000;8.046667;7.961459;8.014000 -grid,10000,39600,1,pull64_barrier,4.119917,3.996000,0.681501,7920000,200,25,0,0.518084,7,pass,4.068708;4.193167;3.904375;4.360166;3.813917;4.625583;4.119917 -grid,10000,39600,1,pull64_interleaved,3.962917,3.893959,0.656292,7920000,200,25,0,0.518084,7,pass,4.197584;3.962917;3.456708;3.196333;3.866459;4.134333;4.008166 -grid,10000,39600,1,pull32_interleaved,3.378208,3.313666,0.604292,7920000,200,25,0,0.518084,7,pass,3.861917;4.153500;3.086834;3.378208;3.201917;3.100208;3.562041 -grid,10000,39600,1,pull32_gpu_dag,3.492000,3.086416,0.615749,7920000,200,27,0,0.518084,7,pass,3.126625;3.581209;3.413750;3.492000;3.813209;3.413333;3.648042 -grid,10000,39600,1,frontier32_vertex,4.642292,4.568084,1.859291,39600,200,25,0,0.518084,7,pass,4.756792;4.409125;4.959792;4.430125;4.642292;4.346791;4.889625 -grid,10000,39600,1,frontier32_simd,4.039750,3.931208,1.337458,39600,200,25,0,0.518084,7,pass,3.751375;4.175583;3.843833;4.311875;4.072000;3.882458;4.039750 -grid,10000,39600,1,frontier32_gpu_dag,4.282875,3.993000,1.301917,39600,200,27,0,0.518084,7,pass,4.187125;4.407917;4.301333;4.592167;4.274375;4.282875;4.212167 -grid,10000,39600,1,bucket32_d4,4.910500,4.826209,2.155791,39600,200,25,0,0.518084,7,pass,4.898375;4.900875;4.938208;5.008292;4.910500;4.821916;4.910875 -grid,10000,39600,1,bucket32_d16,5.335708,5.270375,2.374709,39600,200,25,0,0.518084,7,pass,4.793250;5.374833;5.328916;5.439166;5.335708;5.208875;5.532833 -grid,10000,39600,1,bucket32_d64,5.192667,5.129416,2.312959,39600,200,25,0,0.518084,7,pass,4.957750;4.982166;5.317625;5.049916;5.437000;5.192667;5.370000 -grid,10000,39600,8,old_pull_parallel_dag,4.347417,4.145458,1.435583,63360000,200,25,0,0.528251,7,pass,4.347417;4.338417;4.487333;4.302209;4.475167;4.519666;4.080167 -grid,10000,39600,8,old_group_parallel_dag,8.291541,8.082208,7.571833,51480000,199,1,0,0.528251,7,pass,8.734208;8.870542;8.262292;8.272625;8.418083;7.921541;8.291541 -grid,10000,39600,8,pull64_barrier,4.480208,4.280041,1.391291,63360000,200,25,0,0.528251,7,pass,4.360291;4.329084;4.569167;4.219833;5.201625;4.875458;4.480208 -grid,10000,39600,8,pull64_interleaved,4.328209,4.166959,1.330291,63360000,200,25,0,0.528251,7,pass,4.562583;4.513084;4.328209;4.082875;4.086834;4.255666;4.698334 -grid,10000,39600,8,pull32_interleaved,4.287208,4.032167,1.367917,63360000,200,25,0,0.528251,7,pass,4.667750;4.214625;4.673917;4.141542;4.287208;4.964583;4.134417 -grid,10000,39600,8,pull32_gpu_dag,4.435666,4.109125,1.561792,63360000,200,27,0,0.528251,7,pass,4.435666;4.378209;4.491958;4.687208;4.528041;4.315750;4.168042 -grid,10000,39600,8,frontier32_vertex,5.906291,5.656709,2.501667,316800,200,25,0,0.528251,7,pass,5.856292;5.906291;5.931083;5.554916;5.907916;5.993083;5.641541 -grid,10000,39600,8,frontier32_simd,4.525792,4.257083,1.460875,316800,200,25,0,0.528251,7,pass,4.868125;4.279667;4.525792;4.542292;4.091500;4.727041;4.396708 -grid,10000,39600,8,frontier32_gpu_dag,4.150250,3.853458,1.370458,316800,200,27,0,0.528251,7,pass,4.059542;4.118750;4.193708;4.150250;4.252083;4.145625;4.603208 -grid,10000,39600,8,bucket32_d4,7.675750,7.433167,3.707208,316800,200,25,0,0.528251,7,pass,7.743958;7.661667;7.675750;7.370209;7.665250;7.730167;8.130750 -grid,10000,39600,8,bucket32_d16,7.595750,7.402000,3.678000,316800,200,25,0,0.528251,7,pass,7.406666;7.361167;7.621917;7.785791;7.595750;7.516958;8.536167 -grid,10000,39600,8,bucket32_d64,7.603292,7.390542,3.720709,316800,200,25,0,0.528251,7,pass,8.424708;7.603292;7.761958;7.373375;7.541583;7.803750;7.529333 -chain,2048,2047,1,old_pull_parallel_dag,30.065000,30.057834,4.364000,4192256,2048,256,0,0.129708,7,pass,29.512792;30.366375;30.389250;30.065000;30.101458;29.244334;29.885125 -chain,2048,2047,1,old_group_parallel_dag,4.847291,4.824625,4.695541,4192256,2048,1,0,0.129708,7,pass,4.847291;4.859959;4.876125;4.871750;4.808833;4.800125;4.805458 -chain,2048,2047,1,pull64_barrier,28.538917,28.531792,4.712458,4192256,2048,256,0,0.129708,7,pass,30.322167;30.520625;28.461958;28.538917;29.325958;28.512125;28.277875 -chain,2048,2047,1,pull64_interleaved,32.552250,32.545292,6.166164,4192256,2048,256,0,0.129708,7,pass,31.818375;32.998209;35.086208;32.552250;32.620042;31.513041;31.235625 -chain,2048,2047,1,pull32_interleaved,33.313166,33.304166,7.667876,4192256,2048,256,0,0.129708,7,pass,33.313166;43.665625;39.094500;38.145541;32.806958;31.240000;30.591459 -chain,2048,2047,1,pull32_gpu_dag,30.618833,30.342208,4.890750,4192256,2048,258,0,0.129708,7,pass,30.596375;30.812083;30.605458;30.618833;30.346917;30.645541;30.759833 -chain,2048,2047,1,frontier32_vertex,37.678875,37.671167,12.262455,2047,2048,256,0,0.129708,7,pass,36.267542;36.667167;36.211541;37.678875;39.911292;40.296834;39.982541 -chain,2048,2047,1,frontier32_simd,41.049750,41.041042,15.763669,2047,2048,256,0,0.129708,7,pass,39.360750;39.254583;73.469250;74.576250;64.359834;41.049750;36.961291 -chain,2048,2047,1,frontier32_gpu_dag,40.147666,39.758041,13.087249,2047,2048,258,0,0.129708,7,pass,40.656333;40.270834;39.919000;39.728292;41.459084;39.793791;40.147666 -chain,2048,2047,1,bucket32_d4,49.000625,48.994042,21.207666,2047,2056,257,0,0.129708,7,pass,49.068292;48.685000;49.196583;48.832041;49.202708;49.000625;48.010625 -chain,2048,2047,1,bucket32_d16,48.754500,48.747084,21.148585,2047,2056,257,0,0.129708,7,pass,49.436750;48.830625;48.754500;49.006875;47.837792;48.425667;48.685791 -chain,2048,2047,1,bucket32_d64,48.401417,48.394833,21.022250,2047,2056,257,0,0.129708,7,pass,49.229041;48.401417;45.143750;46.486666;48.204875;49.047709;48.606625 -hub,4096,12286,64,old_pull_parallel_dag,32.651875,32.266000,30.924958,31452160,40,5,0,0.293750,7,pass,32.054959;32.157959;31.730416;32.897583;32.651875;32.842042;32.749083 -hub,4096,12286,64,old_group_parallel_dag,26.146583,25.760750,25.031750,27176632,38,1,0,0.293750,7,pass,26.043708;26.014500;26.409292;26.222333;26.138041;26.146583;26.241000 -hub,4096,12286,64,pull64_barrier,25.409625,25.028833,23.755584,31452160,40,5,0,0.293750,7,pass,25.125583;25.728459;25.886083;25.626667;25.204167;25.409625;25.196334 -hub,4096,12286,64,pull64_interleaved,33.829875,33.457333,31.908083,31452160,40,5,0,0.293750,7,pass,33.290584;34.397667;33.919000;34.763459;32.469500;33.829875;33.066583 -hub,4096,12286,64,pull32_interleaved,29.085375,28.621209,27.390333,31452160,40,5,0,0.293750,7,pass,28.808208;29.127459;28.627500;29.358417;29.398125;28.602458;29.085375 -hub,4096,12286,64,pull32_gpu_dag,31.331333,28.777667,29.382458,31452160,40,7,0,0.293750,7,pass,31.544208;31.235792;31.331333;31.250667;31.366500;31.655459;31.235541 -hub,4096,12286,64,frontier32_vertex,20.164250,19.779333,18.439125,1847854,40,5,0,0.293750,7,pass,20.370750;20.164250;20.339500;20.267708;20.128208;17.858625;20.054792 -hub,4096,12286,64,frontier32_simd,3.679709,3.293959,1.977208,1683322,40,5,0,0.293750,7,pass,3.645583;3.193958;3.704875;3.761667;3.484667;3.679709;3.840292 -hub,4096,12286,64,frontier32_gpu_dag,5.438458,3.358500,3.574666,1683332,40,7,0,0.293750,7,pass,5.438458;5.484333;5.077625;5.436834;5.303625;5.554375;5.500500 -hub,4096,12286,64,bucket32_d4,5.120292,4.739584,3.072042,788342,48,6,0,0.293750,7,pass,5.120292;4.625500;4.994666;5.342250;5.237375;5.632667;5.067500 -hub,4096,12286,64,bucket32_d16,4.910875,4.544250,3.014125,1124798,48,6,0,0.293750,7,pass,4.009541;5.038875;5.070750;4.861667;4.542542;4.910875;4.955625 -hub,4096,12286,64,bucket32_d64,4.974250,4.580000,3.350917,1929942,40,5,0,0.293750,7,pass,5.005417;5.049500;4.529125;4.683875;5.371250;4.974250;4.864500 -random,4096,131072,1,old_pull_parallel_dag,0.700083,0.520875,0.268583,2097152,16,2,0,1.684833,7,pass,0.731166;0.711875;0.720916;0.700083;0.684917;0.682334;0.675125 -random,4096,131072,1,old_group_parallel_dag,2.531042,2.326500,2.178083,1572864,12,1,0,1.684833,7,pass,2.523791;2.531042;2.708208;2.518583;2.480875;2.536250;2.532084 -random,4096,131072,1,pull64_barrier,0.670083,0.484084,0.243542,2097152,16,2,0,1.684833,7,pass,0.658292;0.670083;0.670417;0.656458;0.705666;0.666084;0.683333 -random,4096,131072,1,pull64_interleaved,0.708375,0.538000,0.266292,2097152,16,2,0,1.684833,7,pass,0.754209;0.708375;0.727000;0.718875;0.667125;0.700375;0.688541 -random,4096,131072,1,pull32_interleaved,0.602708,0.417917,0.179833,2097152,16,2,0,1.684833,7,pass,0.621042;0.626125;0.591833;0.563875;0.568041;0.602708;0.610584 -random,4096,131072,1,pull32_gpu_dag,0.827708,0.458416,0.236792,2097152,16,4,0,1.684833,7,pass,0.828209;0.781000;0.802875;0.827708;0.820042;0.839417;0.889042 -random,4096,131072,1,frontier32_vertex,0.826666,0.637916,0.358083,421728,16,2,0,1.684833,7,pass,0.752500;0.788541;0.826666;0.793041;0.834708;0.845292;0.849417 -random,4096,131072,1,frontier32_simd,0.546833,0.381416,0.130042,385248,16,2,0,1.684833,7,pass,0.606166;0.614833;0.596666;0.532584;0.506333;0.517209;0.546833 -random,4096,131072,1,frontier32_gpu_dag,0.790125,0.403958,0.187833,385888,16,4,0,1.684833,7,pass,0.839625;0.868666;0.790125;0.731833;0.751625;0.765125;0.799542 -random,4096,131072,1,bucket32_d4,0.877584,0.692667,0.278500,138720,24,3,0,1.684833,7,pass,0.796125;0.814708;0.794500;0.887917;0.897584;0.887250;0.877584 -random,4096,131072,1,bucket32_d16,0.602042,0.418417,0.176083,177024,16,2,0,1.684833,7,pass,0.609792;0.595458;0.627042;0.687000;0.588708;0.600541;0.602042 -random,4096,131072,1,bucket32_d64,0.676292,0.489459,0.213125,379328,16,2,0,1.684833,7,pass,0.630916;0.676292;0.686500;0.650000;0.656292;0.682083;0.803250 -random,4096,131072,64,old_pull_parallel_dag,9.210958,7.184000,6.254250,134217728,16,2,0,1.632958,7,pass,9.210958;8.705500;9.369750;9.924625;9.088334;9.114250;9.439791 -random,4096,131072,64,old_group_parallel_dag,6.357125,3.995125,3.300500,105644032,15,1,0,1.632958,7,pass,5.947458;6.071417;8.561834;7.417583;5.757250;6.357125;6.392709 -random,4096,131072,64,pull64_barrier,11.065500,9.020000,8.131667,134217728,16,2,0,1.632958,7,pass,11.302500;11.148667;11.423375;10.809625;11.065500;10.187500;9.389333 -random,4096,131072,64,pull64_interleaved,4.038875,1.980792,1.106083,134217728,16,2,0,1.632958,7,pass,4.038875;3.599083;4.111542;4.356333;3.584625;3.627292;4.185792 -random,4096,131072,64,pull32_interleaved,3.814209,1.788000,1.030583,134217728,16,2,0,1.632958,7,pass,3.794375;3.316542;3.413625;3.836125;4.096958;4.530583;3.814209 -random,4096,131072,64,pull32_gpu_dag,2.051166,1.481834,0.860875,134217728,16,4,0,1.632958,7,pass,5.183500;1.808125;2.051166;1.508042;2.034416;2.400834;4.045375 -random,4096,131072,64,frontier32_vertex,5.418375,3.314583,2.452125,22442464,16,2,0,1.632958,7,pass,13.355792;5.418375;5.113042;5.392083;5.442250;5.494958;5.207167 -random,4096,131072,64,frontier32_simd,5.078208,2.991333,1.970125,22729248,16,2,0,1.632958,7,pass,4.876333;5.078208;5.086084;5.029666;4.357167;11.632250;5.217792 -random,4096,131072,64,frontier32_gpu_dag,4.086083,2.827833,2.683125,22826080,16,4,0,1.632958,7,pass,4.039375;4.086083;4.209583;4.054667;4.363042;4.104958;3.480333 -random,4096,131072,64,bucket32_d4,4.731500,2.704083,1.614958,8805184,24,3,0,1.632958,7,pass,4.731500;9.958708;4.957541;4.429834;4.167167;4.010625;8.756250 -random,4096,131072,64,bucket32_d16,4.925625,2.956292,1.858750,12113536,24,3,0,1.632958,7,pass,6.896625;5.068917;4.772583;4.428167;4.925625;4.905334;11.198667 -random,4096,131072,64,bucket32_d64,5.329667,3.309542,2.396333,23278880,16,2,0,1.632958,7,pass,5.089042;5.574291;4.751666;5.494875;5.421709;5.329667;5.243667 diff --git a/dev/research/metal_pathfinding/optimization/results/aba/summary.json b/dev/research/metal_pathfinding/optimization/results/aba/summary.json deleted file mode 100644 index b8bc023..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/summary.json +++ /dev/null @@ -1,570 +0,0 @@ -{ - "phase_rows": { - "cpu-a1": 48, - "base-a1": 36, - "optimized-b": 216, - "base-a2": 36, - "cpu-a2": 48 - }, - "checked_query_results": 124704, - "summary": [ - { - "case": [ - "random", - "1024", - "8192", - "1" - ], - "cpu_a1": 0.085792, - "cpu_a2": 0.096875, - "old_method": "old_group_serial", - "old_a1": 0.914792, - "old_a2": 0.971833, - "best": { - "case": "random", - "n": "1024", - "e": "8192", - "queries": "1", - "method": "pull32_interleaved", - "wall_ms": "0.599333", - "distance_ms": "0.586041", - "gpu_ms": "0.337667", - "edge_visits": "131072", - "rounds": "16", - "waits": "2", - "fallback64": "0", - "upload_ms": "0.152791", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "0.667083;0.607875;0.599333;0.571542;0.736667;0.583833;0.566542" - } - }, - { - "case": [ - "random", - "1024", - "8192", - "64" - ], - "cpu_a1": 1.247667, - "cpu_a2": 1.340459, - "old_method": "old_group_serial", - "old_a1": 3.817917, - "old_a2": 3.768791, - "best": { - "case": "random", - "n": "1024", - "e": "8192", - "queries": "64", - "method": "old_group_parallel_dag", - "wall_ms": "0.852750", - "distance_ms": "0.566166", - "gpu_ms": "0.379500", - "edge_visits": "7217152", - "rounds": "17", - "waits": "1", - "fallback64": "0", - "upload_ms": "0.238875", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "0.972542;0.909042;0.773791;0.876500;0.773208;0.852750;0.821167" - } - }, - { - "case": [ - "clos", - "200", - "20000", - "64" - ], - "cpu_a1": 1.072125, - "cpu_a2": 1.075958, - "old_method": "old_group_serial", - "old_a1": 4.768459, - "old_a2": 4.869958, - "best": { - "case": "clos", - "n": "200", - "e": "20000", - "queries": "64", - "method": "old_group_parallel_dag", - "wall_ms": "1.460750", - "distance_ms": "0.833416", - "gpu_ms": "0.303250", - "edge_visits": "3840000", - "rounds": "3", - "waits": "1", - "fallback64": "0", - "upload_ms": "0.322125", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "1.479875;1.528583;1.339875;1.338875;1.467208;1.426625;1.460750" - } - }, - { - "case": [ - "grid", - "1024", - "3968", - "1" - ], - "cpu_a1": 0.039042, - "cpu_a2": 0.038708, - "old_method": "old_group_serial", - "old_a1": 1.177083, - "old_a2": 1.165667, - "best": { - "case": "grid", - "n": "1024", - "e": "3968", - "queries": "1", - "method": "old_group_parallel_dag", - "wall_ms": "0.365667", - "distance_ms": "0.358792", - "gpu_ms": "0.227375", - "edge_visits": "249984", - "rounds": "63", - "waits": "1", - "fallback64": "0", - "upload_ms": "0.124084", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "1.116750;0.360041;0.365667;0.352625;0.396042;0.372000;0.363209" - } - }, - { - "case": [ - "hub", - "1024", - "3070", - "1" - ], - "cpu_a1": 0.052333, - "cpu_a2": 0.052, - "old_method": "old_group_serial", - "old_a1": 4.908083, - "old_a2": 4.938125, - "best": { - "case": "hub", - "n": "1024", - "e": "3070", - "queries": "1", - "method": "frontier32_simd", - "wall_ms": "0.784458", - "distance_ms": "0.776500", - "gpu_ms": "0.233250", - "edge_visits": "3128", - "rounds": "32", - "waits": "4", - "fallback64": "0", - "upload_ms": "0.098834", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "0.761209;0.730042;0.781167;0.861667;0.786834;0.795166;0.784458" - } - }, - { - "case": [ - "random_masked_int64_reverse", - "1024", - "8192", - "8" - ], - "cpu_a1": 0.160083, - "cpu_a2": 0.15325, - "old_method": "old_pull_serial", - "old_a1": 0.517667, - "old_a2": 0.55125, - "best": { - "case": "random_masked_int64_reverse", - "n": "1024", - "e": "8192", - "queries": "8", - "method": "bucket32_d16", - "wall_ms": "0.223542", - "distance_ms": "0.178000", - "gpu_ms": "0.028875", - "edge_visits": "524288", - "rounds": "8", - "waits": "1", - "fallback64": "1", - "upload_ms": "0.225500", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "0.222750;0.232791;0.237375;0.258250;0.223542;0.215333;0.210875" - } - }, - { - "case": [ - "random", - "4096", - "32768", - "256" - ], - "cpu_a1": 21.390792, - "cpu_a2": 21.281458, - "old_method": "old_group_serial", - "old_a1": 47.811583, - "old_a2": 49.310208, - "best": { - "case": "random", - "n": "4096", - "e": "32768", - "queries": "256", - "method": "pull64_interleaved", - "wall_ms": "7.002542", - "distance_ms": "3.340208", - "gpu_ms": "2.080917", - "edge_visits": "201326592", - "rounds": "24", - "waits": "3", - "fallback64": "0", - "upload_ms": "0.530167", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "6.459833;7.002542;6.970958;6.819791;8.010875;7.685500;7.589250" - } - }, - { - "case": [ - "random", - "16384", - "131072", - "1" - ], - "cpu_a1": 3.661958, - "cpu_a2": 3.885666, - "old_method": "old_pull_serial", - "old_a1": 2.098167, - "old_a2": 1.870458, - "best": { - "case": "random", - "n": "16384", - "e": "131072", - "queries": "1", - "method": "pull32_interleaved", - "wall_ms": "0.705083", - "distance_ms": "0.520875", - "gpu_ms": "0.136959", - "edge_visits": "3145728", - "rounds": "24", - "waits": "3", - "fallback64": "0", - "upload_ms": "1.641291", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "0.692583;0.711333;0.700666;0.706750;0.705083;0.703000;0.878958" - } - }, - { - "case": [ - "random", - "16384", - "131072", - "64" - ], - "cpu_a1": 27.692792, - "cpu_a2": 27.721375, - "old_method": "old_group_serial", - "old_a1": 57.747584, - "old_a2": 53.10575, - "best": { - "case": "random", - "n": "16384", - "e": "131072", - "queries": "64", - "method": "pull64_interleaved", - "wall_ms": "7.473334", - "distance_ms": "3.322042", - "gpu_ms": "1.949625", - "edge_visits": "201326592", - "rounds": "24", - "waits": "3", - "fallback64": "0", - "upload_ms": "1.869208", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "7.977375;7.469666;7.993750;7.469959;7.340416;8.069541;7.473334" - } - }, - { - "case": [ - "random", - "65536", - "524288", - "1" - ], - "cpu_a1": 19.316, - "cpu_a2": 19.240125, - "old_method": "old_pull_serial", - "old_a1": 5.18425, - "old_a2": 5.85375, - "best": { - "case": "random", - "n": "65536", - "e": "524288", - "queries": "1", - "method": "pull32_gpu_dag", - "wall_ms": "1.373417", - "distance_ms": "0.881667", - "gpu_ms": "0.570875", - "edge_visits": "12582912", - "rounds": "24", - "waits": "5", - "fallback64": "0", - "upload_ms": "7.557667", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "1.391584;1.373417;1.479292;1.291209;1.337833;1.320958;1.406750" - } - }, - { - "case": [ - "clos", - "800", - "320000", - "1" - ], - "cpu_a1": 2.177875, - "cpu_a2": 2.226791, - "old_method": "old_pull_serial", - "old_a1": 1.914708, - "old_a2": 2.377209, - "best": { - "case": "clos", - "n": "800", - "e": "320000", - "queries": "1", - "method": "frontier32_simd", - "wall_ms": "1.604458", - "distance_ms": "0.852083", - "gpu_ms": "0.361417", - "edge_visits": "320000", - "rounds": "8", - "waits": "1", - "fallback64": "0", - "upload_ms": "3.583584", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "1.724958;1.809125;1.589542;1.604458;1.514125;1.668583;1.587916" - } - }, - { - "case": [ - "clos", - "800", - "320000", - "64" - ], - "cpu_a1": 17.041958, - "cpu_a2": 16.524625, - "old_method": "old_group_serial", - "old_a1": 69.896166, - "old_a2": 70.2275, - "best": { - "case": "clos", - "n": "800", - "e": "320000", - "queries": "64", - "method": "pull32_gpu_dag", - "wall_ms": "8.185208", - "distance_ms": "4.307834", - "gpu_ms": "6.163833", - "edge_visits": "163840000", - "rounds": "8", - "waits": "3", - "fallback64": "0", - "upload_ms": "3.568791", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "7.631709;7.941417;8.661667;8.185208;8.311625;8.223667;7.750583" - } - }, - { - "case": [ - "grid", - "10000", - "39600", - "1" - ], - "cpu_a1": 0.570333, - "cpu_a2": 0.58225, - "old_method": "old_pull_serial", - "old_a1": 3.836417, - "old_a2": 5.873791, - "best": { - "case": "grid", - "n": "10000", - "e": "39600", - "queries": "1", - "method": "pull32_interleaved", - "wall_ms": "3.378208", - "distance_ms": "3.313666", - "gpu_ms": "0.604292", - "edge_visits": "7920000", - "rounds": "200", - "waits": "25", - "fallback64": "0", - "upload_ms": "0.518084", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "3.861917;4.153500;3.086834;3.378208;3.201917;3.100208;3.562041" - } - }, - { - "case": [ - "grid", - "10000", - "39600", - "8" - ], - "cpu_a1": 0.834084, - "cpu_a2": 0.822667, - "old_method": "old_pull_serial", - "old_a1": 5.952708, - "old_a2": 5.979167, - "best": { - "case": "grid", - "n": "10000", - "e": "39600", - "queries": "8", - "method": "frontier32_gpu_dag", - "wall_ms": "4.150250", - "distance_ms": "3.853458", - "gpu_ms": "1.370458", - "edge_visits": "316800", - "rounds": "200", - "waits": "27", - "fallback64": "0", - "upload_ms": "0.528251", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "4.059542;4.118750;4.193708;4.150250;4.252083;4.145625;4.603208" - } - }, - { - "case": [ - "chain", - "2048", - "2047", - "1" - ], - "cpu_a1": 0.017209, - "cpu_a2": 0.017375, - "old_method": "old_group_serial", - "old_a1": 4.834291, - "old_a2": 4.908333, - "best": { - "case": "chain", - "n": "2048", - "e": "2047", - "queries": "1", - "method": "old_group_parallel_dag", - "wall_ms": "4.847291", - "distance_ms": "4.824625", - "gpu_ms": "4.695541", - "edge_visits": "4192256", - "rounds": "2048", - "waits": "1", - "fallback64": "0", - "upload_ms": "0.129708", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "4.847291;4.859959;4.876125;4.871750;4.808833;4.800125;4.805458" - } - }, - { - "case": [ - "hub", - "4096", - "12286", - "64" - ], - "cpu_a1": 2.437875, - "cpu_a2": 2.338125, - "old_method": "old_group_serial", - "old_a1": 28.558708, - "old_a2": 28.801291, - "best": { - "case": "hub", - "n": "4096", - "e": "12286", - "queries": "64", - "method": "frontier32_simd", - "wall_ms": "3.679709", - "distance_ms": "3.293959", - "gpu_ms": "1.977208", - "edge_visits": "1683322", - "rounds": "40", - "waits": "5", - "fallback64": "0", - "upload_ms": "0.293750", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "3.645583;3.193958;3.704875;3.761667;3.484667;3.679709;3.840292" - } - }, - { - "case": [ - "random", - "4096", - "131072", - "1" - ], - "cpu_a1": 1.589583, - "cpu_a2": 1.517583, - "old_method": "old_pull_serial", - "old_a1": 0.90625, - "old_a2": 1.039917, - "best": { - "case": "random", - "n": "4096", - "e": "131072", - "queries": "1", - "method": "frontier32_simd", - "wall_ms": "0.546833", - "distance_ms": "0.381416", - "gpu_ms": "0.130042", - "edge_visits": "385248", - "rounds": "16", - "waits": "2", - "fallback64": "0", - "upload_ms": "1.684833", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "0.606166;0.614833;0.596666;0.532584;0.506333;0.517209;0.546833" - } - }, - { - "case": [ - "random", - "4096", - "131072", - "64" - ], - "cpu_a1": 12.301875, - "cpu_a2": 12.817167, - "old_method": "old_group_serial", - "old_a1": 31.930417, - "old_a2": 37.791458, - "best": { - "case": "random", - "n": "4096", - "e": "131072", - "queries": "64", - "method": "pull32_gpu_dag", - "wall_ms": "2.051166", - "distance_ms": "1.481834", - "gpu_ms": "0.860875", - "edge_visits": "134217728", - "rounds": "16", - "waits": "4", - "fallback64": "0", - "upload_ms": "1.632958", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "5.183500;1.808125;2.051166;1.508042;2.034416;2.400834;4.045375" - } - } - ] -} diff --git a/dev/research/metal_pathfinding/optimization/results/aba/summary.md b/dev/research/metal_pathfinding/optimization/results/aba/summary.md deleted file mode 100644 index 8f1d0b6..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/summary.md +++ /dev/null @@ -1,317 +0,0 @@ -# Exploratory A/B/A measurements - -Warm full-result latency in ms, including host distances and positive-cost predecessor DAG. Seven measured samples plus two checked warmups per row. CPU batches use 10 reusable workers. Best variant is selected after measurement; this is an oracle comparison, not an implemented dispatcher. Old GPU columns cover the original pull and group variants; the shared-memory variant was not rerun. The machine was not isolated; these are not accepted performance claims. - -| Case | N / E / Q | CPU full A1 / A2 | Old GPU A1 / A2 | Best tested optimized GPU | ms | CPU BFS full A1 / A2 | -|---|---:|---:|---:|---|---:|---:| -| random | 1024 / 8192 / 1 | 0.086 / 0.097 | 0.915 / 0.972 | pull32_interleaved | 0.599 | — | -| random | 1024 / 8192 / 64 | 1.248 / 1.340 | 3.818 / 3.769 | old_group_parallel_dag | 0.853 | — | -| clos | 200 / 20000 / 64 | 1.072 / 1.076 | 4.768 / 4.870 | old_group_parallel_dag | 1.461 | 0.669 / 0.633 | -| grid | 1024 / 3968 / 1 | 0.039 / 0.039 | 1.177 / 1.166 | old_group_parallel_dag | 0.366 | 0.009 / 0.009 | -| hub | 1024 / 3070 / 1 | 0.052 / 0.052 | 4.908 / 4.938 | frontier32_simd | 0.784 | — | -| random_masked_int64_reverse | 1024 / 8192 / 8 | 0.160 / 0.153 | 0.518 / 0.551 | bucket32_d16 (64-bit pull fallback) | 0.224 | — | -| random | 4096 / 32768 / 256 | 21.391 / 21.281 | 47.812 / 49.310 | pull64_interleaved | 7.003 | — | -| random | 16384 / 131072 / 1 | 3.662 / 3.886 | 2.098 / 1.870 | pull32_interleaved | 0.705 | — | -| random | 16384 / 131072 / 64 | 27.693 / 27.721 | 57.748 / 53.106 | pull64_interleaved | 7.473 | — | -| random | 65536 / 524288 / 1 | 19.316 / 19.240 | 5.184 / 5.854 | pull32_gpu_dag | 1.373 | — | -| clos | 800 / 320000 / 1 | 2.178 / 2.227 | 1.915 / 2.377 | frontier32_simd | 1.604 | 0.908 / 0.938 | -| clos | 800 / 320000 / 64 | 17.042 / 16.525 | 69.896 / 70.228 | pull32_gpu_dag | 8.185 | 8.296 / 7.885 | -| grid | 10000 / 39600 / 1 | 0.570 / 0.582 | 3.836 / 5.874 | pull32_interleaved | 3.378 | 0.089 / 0.088 | -| grid | 10000 / 39600 / 8 | 0.834 / 0.823 | 5.953 / 5.979 | frontier32_gpu_dag | 4.150 | 0.286 / 0.312 | -| chain | 2048 / 2047 / 1 | 0.017 / 0.017 | 4.834 / 4.908 | old_group_parallel_dag | 4.847 | — | -| hub | 4096 / 12286 / 64 | 2.438 / 2.338 | 28.559 / 28.801 | frontier32_simd | 3.680 | — | -| random | 4096 / 131072 / 1 | 1.590 / 1.518 | 0.906 / 1.040 | frontier32_simd | 0.547 | — | -| random | 4096 / 131072 / 64 | 12.302 / 12.817 | 31.930 / 37.791 | pull32_gpu_dag | 2.051 | — | - -## All optimized variants - -No variants omitted. Columns: full wall / distance wall / device work, ms. Device work includes DAG kernels where selected. Edge visits count SSSP adjacency entries, excluding bucket vertex scans, queue management and DAG extraction. - -### ('random', '1024', '8192', '1') - -| Variant | Full / distance / device ms | Edge visits | Rounds | Host waits | 64-bit fallback | -|---|---:|---:|---:|---:|---| -| old_pull_parallel_dag | 1.423 / 1.376 / 0.424 | 131072 | 16 | 2 | 0 | -| old_group_parallel_dag | 0.834 / 0.819 / 0.680 | 106496 | 13 | 1 | 0 | -| pull64_barrier | 0.659 / 0.646 / 0.391 | 131072 | 16 | 2 | 0 | -| pull64_interleaved | 0.656 / 0.643 / 0.375 | 131072 | 16 | 2 | 0 | -| pull32_interleaved | 0.599 / 0.586 / 0.338 | 131072 | 16 | 2 | 0 | -| pull32_gpu_dag | 0.960 / 0.604 / 0.406 | 131072 | 16 | 4 | 0 | -| frontier32_vertex | 0.924 / 0.910 / 0.634 | 21088 | 16 | 2 | 0 | -| frontier32_simd | 0.661 / 0.647 / 0.380 | 20272 | 16 | 2 | 0 | -| frontier32_gpu_dag | 1.003 / 0.652 / 0.452 | 20384 | 16 | 4 | 0 | -| bucket32_d4 | 1.895 / 1.881 / 1.276 | 8232 | 32 | 4 | 0 | -| bucket32_d16 | 1.422 / 1.407 / 0.920 | 9336 | 24 | 3 | 0 | -| bucket32_d64 | 0.954 / 0.939 / 0.654 | 19264 | 16 | 2 | 0 | -### ('random', '1024', '8192', '64') - -| Variant | Full / distance / device ms | Edge visits | Rounds | Host waits | 64-bit fallback | -|---|---:|---:|---:|---:|---| -| old_pull_parallel_dag | 1.973 / 1.703 / 1.236 | 12582912 | 24 | 3 | 0 | -| old_group_parallel_dag | 0.853 / 0.566 / 0.380 | 7217152 | 17 | 1 | 0 | -| pull64_barrier | 1.223 / 0.949 / 0.470 | 12582912 | 24 | 3 | 0 | -| pull64_interleaved | 0.940 / 0.671 / 0.268 | 12582912 | 24 | 3 | 0 | -| pull32_interleaved | 0.863 / 0.595 / 0.197 | 12582912 | 24 | 3 | 0 | -| pull32_gpu_dag | 0.998 / 0.579 / 0.199 | 12582912 | 24 | 5 | 0 | -| frontier32_vertex | 0.870 / 0.629 / 0.239 | 1289824 | 16 | 2 | 0 | -| frontier32_simd | 1.028 / 0.796 / 0.456 | 1202328 | 16 | 2 | 0 | -| frontier32_gpu_dag | 1.197 / 0.890 / 0.509 | 1201208 | 16 | 4 | 0 | -| bucket32_d4 | 5.929 / 5.638 / 4.211 | 528528 | 56 | 7 | 0 | -| bucket32_d16 | 1.624 / 1.347 / 0.712 | 601456 | 32 | 4 | 0 | -| bucket32_d64 | 1.403 / 1.126 / 0.671 | 1154720 | 24 | 3 | 0 | -### ('clos', '200', '20000', '64') - -| Variant | Full / distance / device ms | Edge visits | Rounds | Host waits | 64-bit fallback | -|---|---:|---:|---:|---:|---| -| old_pull_parallel_dag | 2.137 / 1.514 / 1.016 | 10240000 | 8 | 1 | 0 | -| old_group_parallel_dag | 1.461 / 0.833 / 0.303 | 3840000 | 3 | 1 | 0 | -| pull64_barrier | 2.072 / 1.524 / 1.000 | 10240000 | 8 | 1 | 0 | -| pull64_interleaved | 1.903 / 1.332 / 0.757 | 10240000 | 8 | 1 | 0 | -| pull32_interleaved | 1.893 / 1.311 / 0.688 | 10240000 | 8 | 1 | 0 | -| pull32_gpu_dag | 2.092 / 1.226 / 1.013 | 10240000 | 8 | 3 | 0 | -| frontier32_vertex | 2.382 / 1.557 / 0.961 | 1280000 | 8 | 1 | 0 | -| frontier32_simd | 1.552 / 0.924 / 0.318 | 1280000 | 8 | 1 | 0 | -| frontier32_gpu_dag | 1.979 / 0.982 / 0.837 | 1280000 | 8 | 3 | 0 | -| bucket32_d4 | 1.572 / 1.025 / 0.481 | 1280000 | 8 | 1 | 0 | -| bucket32_d16 | 1.739 / 1.166 / 0.492 | 1280000 | 8 | 1 | 0 | -| bucket32_d64 | 1.693 / 1.072 / 0.490 | 1280000 | 8 | 1 | 0 | -### ('grid', '1024', '3968', '1') - -| Variant | Full / distance / device ms | Edge visits | Rounds | Host waits | 64-bit fallback | -|---|---:|---:|---:|---:|---| -| old_pull_parallel_dag | 1.747 / 1.739 / 0.651 | 253952 | 64 | 8 | 0 | -| old_group_parallel_dag | 0.366 / 0.359 / 0.227 | 249984 | 63 | 1 | 0 | -| pull64_barrier | 1.119 / 1.111 / 0.177 | 253952 | 64 | 8 | 0 | -| pull64_interleaved | 1.144 / 1.136 / 0.177 | 253952 | 64 | 8 | 0 | -| pull32_interleaved | 1.089 / 1.081 / 0.164 | 253952 | 64 | 8 | 0 | -| pull32_gpu_dag | 1.271 / 0.918 / 0.173 | 253952 | 64 | 10 | 0 | -| frontier32_vertex | 1.313 / 1.305 / 0.528 | 3968 | 64 | 8 | 0 | -| frontier32_simd | 1.180 / 1.172 / 0.396 | 3968 | 64 | 8 | 0 | -| frontier32_gpu_dag | 1.394 / 1.178 / 0.407 | 3968 | 64 | 10 | 0 | -| bucket32_d4 | 1.450 / 1.441 / 0.637 | 3968 | 64 | 8 | 0 | -| bucket32_d16 | 1.451 / 1.443 / 0.636 | 3968 | 64 | 8 | 0 | -| bucket32_d64 | 1.466 / 1.459 / 0.637 | 3968 | 64 | 8 | 0 | -### ('hub', '1024', '3070', '1') - -| Variant | Full / distance / device ms | Edge visits | Rounds | Host waits | 64-bit fallback | -|---|---:|---:|---:|---:|---| -| old_pull_parallel_dag | 6.046 / 6.030 / 5.544 | 98240 | 32 | 4 | 0 | -| old_group_parallel_dag | 4.888 / 4.866 / 4.721 | 95170 | 31 | 1 | 0 | -| pull64_barrier | 5.016 / 4.988 / 4.313 | 98240 | 32 | 4 | 0 | -| pull64_interleaved | 4.986 / 4.962 / 4.321 | 98240 | 32 | 4 | 0 | -| pull32_interleaved | 4.397 / 4.374 / 3.684 | 98240 | 32 | 4 | 0 | -| pull32_gpu_dag | 5.116 / 4.358 / 4.101 | 98240 | 32 | 6 | 0 | -| frontier32_vertex | 1.436 / 1.428 / 0.852 | 3128 | 32 | 4 | 0 | -| frontier32_simd | 0.784 / 0.776 / 0.233 | 3128 | 32 | 4 | 0 | -| frontier32_gpu_dag | 1.467 / 0.770 / 0.649 | 3128 | 32 | 6 | 0 | -| bucket32_d4 | 0.869 / 0.860 / 0.345 | 3100 | 32 | 4 | 0 | -| bucket32_d16 | 0.851 / 0.843 / 0.337 | 3124 | 32 | 4 | 0 | -| bucket32_d64 | 0.886 / 0.878 / 0.327 | 3128 | 32 | 4 | 0 | -### ('random_masked_int64_reverse', '1024', '8192', '8') - -| Variant | Full / distance / device ms | Edge visits | Rounds | Host waits | 64-bit fallback | -|---|---:|---:|---:|---:|---| -| old_pull_parallel_dag | 0.232 / 0.167 / 0.032 | 524288 | 8 | 1 | 0 | -| old_group_parallel_dag | 0.253 / 0.194 / 0.053 | 368640 | 8 | 1 | 0 | -| pull64_barrier | 0.248 / 0.190 / 0.029 | 524288 | 8 | 1 | 0 | -| pull64_interleaved | 0.264 / 0.197 / 0.029 | 524288 | 8 | 1 | 0 | -| pull32_interleaved | 0.252 / 0.198 / 0.029 | 524288 | 8 | 1 | 1 | -| pull32_gpu_dag | 0.499 / 0.174 / 0.049 | 524288 | 8 | 3 | 1 | -| frontier32_vertex | 0.238 / 0.188 / 0.029 | 524288 | 8 | 1 | 1 | -| frontier32_simd | 0.256 / 0.192 / 0.029 | 524288 | 8 | 1 | 1 | -| frontier32_gpu_dag | 0.504 / 0.184 / 0.047 | 524288 | 8 | 3 | 1 | -| bucket32_d4 | 0.243 / 0.198 / 0.029 | 524288 | 8 | 1 | 1 | -| bucket32_d16 | 0.224 / 0.178 / 0.029 | 524288 | 8 | 1 | 1 | -| bucket32_d64 | 0.261 / 0.199 / 0.029 | 524288 | 8 | 1 | 1 | -### ('random', '4096', '32768', '256') - -| Variant | Full / distance / device ms | Edge visits | Rounds | Host waits | 64-bit fallback | -|---|---:|---:|---:|---:|---| -| old_pull_parallel_dag | 14.955 / 11.144 / 9.933 | 201326592 | 24 | 3 | 0 | -| old_group_parallel_dag | 11.270 / 7.608 / 6.915 | 136544256 | 20 | 1 | 0 | -| pull64_barrier | 13.565 / 9.762 / 8.501 | 201326592 | 24 | 3 | 0 | -| pull64_interleaved | 7.003 / 3.340 / 2.081 | 201326592 | 24 | 3 | 0 | -| pull32_interleaved | 9.000 / 5.200 / 3.525 | 201326592 | 24 | 3 | 0 | -| pull32_gpu_dag | 8.790 / 6.950 / 7.134 | 201326592 | 24 | 5 | 0 | -| frontier32_vertex | 7.936 / 4.094 / 2.380 | 19766184 | 24 | 3 | 0 | -| frontier32_simd | 11.324 / 7.659 / 6.414 | 20599968 | 24 | 3 | 0 | -| frontier32_gpu_dag | 9.247 / 7.874 / 7.025 | 20610776 | 24 | 5 | 0 | -| bucket32_d4 | 16.328 / 12.614 / 10.131 | 8454072 | 72 | 9 | 0 | -| bucket32_d16 | 13.121 / 9.301 / 7.673 | 9728592 | 48 | 6 | 0 | -| bucket32_d64 | 14.196 / 9.623 / 8.264 | 18345256 | 24 | 3 | 0 | -### ('random', '16384', '131072', '1') - -| Variant | Full / distance / device ms | Edge visits | Rounds | Host waits | 64-bit fallback | -|---|---:|---:|---:|---:|---| -| old_pull_parallel_dag | 0.964 / 0.659 / 0.244 | 3145728 | 24 | 3 | 0 | -| old_group_parallel_dag | 5.290 / 4.968 / 4.819 | 2359296 | 18 | 1 | 0 | -| pull64_barrier | 0.761 / 0.571 / 0.164 | 3145728 | 24 | 3 | 0 | -| pull64_interleaved | 0.828 / 0.564 / 0.164 | 3145728 | 24 | 3 | 0 | -| pull32_interleaved | 0.705 / 0.521 / 0.137 | 3145728 | 24 | 3 | 0 | -| pull32_gpu_dag | 0.880 / 0.513 / 0.158 | 3145728 | 24 | 5 | 0 | -| frontier32_vertex | 0.832 / 0.619 / 0.258 | 377056 | 24 | 3 | 0 | -| frontier32_simd | 0.806 / 0.593 / 0.237 | 335680 | 24 | 3 | 0 | -| frontier32_gpu_dag | 0.983 / 0.612 / 0.269 | 334824 | 24 | 5 | 0 | -| bucket32_d4 | 1.833 / 1.546 / 0.654 | 132016 | 56 | 7 | 0 | -| bucket32_d16 | 1.148 / 0.917 / 0.398 | 148256 | 32 | 4 | 0 | -| bucket32_d64 | 0.937 / 0.712 / 0.340 | 268728 | 24 | 3 | 0 | -### ('random', '16384', '131072', '64') - -| Variant | Full / distance / device ms | Edge visits | Rounds | Host waits | 64-bit fallback | -|---|---:|---:|---:|---:|---| -| old_pull_parallel_dag | 16.913 / 12.651 / 11.412 | 201326592 | 24 | 3 | 0 | -| old_group_parallel_dag | 13.239 / 9.052 / 8.276 | 158203904 | 22 | 1 | 0 | -| pull64_barrier | 15.959 / 11.386 / 10.305 | 201326592 | 24 | 3 | 0 | -| pull64_interleaved | 7.473 / 3.322 / 1.950 | 201326592 | 24 | 3 | 0 | -| pull32_interleaved | 8.710 / 3.948 / 2.593 | 201326592 | 24 | 3 | 0 | -| pull32_gpu_dag | 8.810 / 7.334 / 7.227 | 201326592 | 24 | 5 | 0 | -| frontier32_vertex | 10.144 / 5.760 / 3.640 | 21296680 | 24 | 3 | 0 | -| frontier32_simd | 12.189 / 7.941 / 6.799 | 22006672 | 24 | 3 | 0 | -| frontier32_gpu_dag | 9.203 / 7.809 / 7.471 | 21985376 | 24 | 5 | 0 | -| bucket32_d4 | 16.825 / 12.607 / 10.280 | 8451400 | 72 | 9 | 0 | -| bucket32_d16 | 13.809 / 9.687 / 7.926 | 9572312 | 48 | 6 | 0 | -| bucket32_d64 | 12.602 / 8.439 / 7.400 | 17413440 | 24 | 3 | 0 | -### ('random', '65536', '524288', '1') - -| Variant | Full / distance / device ms | Edge visits | Rounds | Host waits | 64-bit fallback | -|---|---:|---:|---:|---:|---| -| old_pull_parallel_dag | 3.545 / 2.038 / 1.061 | 12582912 | 24 | 3 | 0 | -| old_group_parallel_dag | 30.358 / 28.978 / 28.314 | 11534336 | 22 | 1 | 0 | -| pull64_barrier | 3.163 / 1.729 / 0.758 | 12582912 | 24 | 3 | 0 | -| pull64_interleaved | 3.048 / 1.519 / 0.703 | 12582912 | 24 | 3 | 0 | -| pull32_interleaved | 2.913 / 1.338 / 0.502 | 12582912 | 24 | 3 | 0 | -| pull32_gpu_dag | 1.373 / 0.882 / 0.571 | 12582912 | 24 | 5 | 0 | -| frontier32_vertex | 2.737 / 1.259 / 0.435 | 1734632 | 24 | 3 | 0 | -| frontier32_simd | 3.045 / 1.576 / 0.640 | 1561768 | 24 | 3 | 0 | -| frontier32_gpu_dag | 1.461 / 1.045 / 0.686 | 1558160 | 24 | 5 | 0 | -| bucket32_d4 | 4.357 / 2.761 / 1.174 | 528256 | 64 | 8 | 0 | -| bucket32_d16 | 3.481 / 2.047 / 0.836 | 599256 | 40 | 5 | 0 | -| bucket32_d64 | 3.187 / 1.681 / 0.677 | 1050376 | 24 | 3 | 0 | -### ('clos', '800', '320000', '1') - -| Variant | Full / distance / device ms | Edge visits | Rounds | Host waits | 64-bit fallback | -|---|---:|---:|---:|---:|---| -| old_pull_parallel_dag | 2.142 / 1.366 / 0.744 | 2560000 | 8 | 1 | 0 | -| old_group_parallel_dag | 2.326 / 1.577 / 0.905 | 960000 | 3 | 1 | 0 | -| pull64_barrier | 2.017 / 1.253 / 0.699 | 2560000 | 8 | 1 | 0 | -| pull64_interleaved | 2.265 / 1.493 / 0.878 | 2560000 | 8 | 1 | 0 | -| pull32_interleaved | 2.801 / 1.980 / 1.363 | 2560000 | 8 | 1 | 0 | -| pull32_gpu_dag | 2.133 / 1.256 / 0.994 | 2560000 | 8 | 3 | 0 | -| frontier32_vertex | 2.376 / 1.575 / 0.933 | 320000 | 8 | 1 | 0 | -| frontier32_simd | 1.604 / 0.852 / 0.361 | 320000 | 8 | 1 | 0 | -| frontier32_gpu_dag | 1.714 / 0.824 / 0.606 | 320000 | 8 | 3 | 0 | -| bucket32_d4 | 1.660 / 0.873 / 0.446 | 320000 | 8 | 1 | 0 | -| bucket32_d16 | 1.716 / 0.904 / 0.444 | 320000 | 8 | 1 | 0 | -| bucket32_d64 | 1.698 / 0.939 / 0.453 | 320000 | 8 | 1 | 0 | -### ('clos', '800', '320000', '64') - -| Variant | Full / distance / device ms | Edge visits | Rounds | Host waits | 64-bit fallback | -|---|---:|---:|---:|---:|---| -| old_pull_parallel_dag | 26.735 / 19.418 / 18.854 | 163840000 | 8 | 1 | 0 | -| old_group_parallel_dag | 12.637 / 5.915 / 5.412 | 61440000 | 3 | 1 | 0 | -| pull64_barrier | 24.737 / 18.165 / 17.725 | 163840000 | 8 | 1 | 0 | -| pull64_interleaved | 13.270 / 6.122 / 5.405 | 163840000 | 8 | 1 | 0 | -| pull32_interleaved | 10.983 / 4.274 / 3.647 | 163840000 | 8 | 1 | 0 | -| pull32_gpu_dag | 8.185 / 4.308 / 6.164 | 163840000 | 8 | 3 | 0 | -| frontier32_vertex | 14.231 / 7.148 / 6.877 | 20480000 | 8 | 1 | 0 | -| frontier32_simd | 9.324 / 2.065 / 1.458 | 20480000 | 8 | 1 | 0 | -| frontier32_gpu_dag | 15.944 / 2.089 / 14.063 | 20480000 | 8 | 3 | 0 | -| bucket32_d4 | 10.000 / 2.529 / 2.040 | 20480000 | 8 | 1 | 0 | -| bucket32_d16 | 9.610 / 2.183 / 1.863 | 20480000 | 8 | 1 | 0 | -| bucket32_d64 | 9.988 / 2.684 / 2.106 | 20480000 | 8 | 1 | 0 | -### ('grid', '10000', '39600', '1') - -| Variant | Full / distance / device ms | Edge visits | Rounds | Host waits | 64-bit fallback | -|---|---:|---:|---:|---:|---| -| old_pull_parallel_dag | 4.013 / 3.947 / 0.706 | 7920000 | 200 | 25 | 0 | -| old_group_parallel_dag | 8.014 / 7.948 / 7.804 | 7880400 | 199 | 1 | 0 | -| pull64_barrier | 4.120 / 3.996 / 0.682 | 7920000 | 200 | 25 | 0 | -| pull64_interleaved | 3.963 / 3.894 / 0.656 | 7920000 | 200 | 25 | 0 | -| pull32_interleaved | 3.378 / 3.314 / 0.604 | 7920000 | 200 | 25 | 0 | -| pull32_gpu_dag | 3.492 / 3.086 / 0.616 | 7920000 | 200 | 27 | 0 | -| frontier32_vertex | 4.642 / 4.568 / 1.859 | 39600 | 200 | 25 | 0 | -| frontier32_simd | 4.040 / 3.931 / 1.337 | 39600 | 200 | 25 | 0 | -| frontier32_gpu_dag | 4.283 / 3.993 / 1.302 | 39600 | 200 | 27 | 0 | -| bucket32_d4 | 4.910 / 4.826 / 2.156 | 39600 | 200 | 25 | 0 | -| bucket32_d16 | 5.336 / 5.270 / 2.375 | 39600 | 200 | 25 | 0 | -| bucket32_d64 | 5.193 / 5.129 / 2.313 | 39600 | 200 | 25 | 0 | -### ('grid', '10000', '39600', '8') - -| Variant | Full / distance / device ms | Edge visits | Rounds | Host waits | 64-bit fallback | -|---|---:|---:|---:|---:|---| -| old_pull_parallel_dag | 4.347 / 4.145 / 1.436 | 63360000 | 200 | 25 | 0 | -| old_group_parallel_dag | 8.292 / 8.082 / 7.572 | 51480000 | 199 | 1 | 0 | -| pull64_barrier | 4.480 / 4.280 / 1.391 | 63360000 | 200 | 25 | 0 | -| pull64_interleaved | 4.328 / 4.167 / 1.330 | 63360000 | 200 | 25 | 0 | -| pull32_interleaved | 4.287 / 4.032 / 1.368 | 63360000 | 200 | 25 | 0 | -| pull32_gpu_dag | 4.436 / 4.109 / 1.562 | 63360000 | 200 | 27 | 0 | -| frontier32_vertex | 5.906 / 5.657 / 2.502 | 316800 | 200 | 25 | 0 | -| frontier32_simd | 4.526 / 4.257 / 1.461 | 316800 | 200 | 25 | 0 | -| frontier32_gpu_dag | 4.150 / 3.853 / 1.370 | 316800 | 200 | 27 | 0 | -| bucket32_d4 | 7.676 / 7.433 / 3.707 | 316800 | 200 | 25 | 0 | -| bucket32_d16 | 7.596 / 7.402 / 3.678 | 316800 | 200 | 25 | 0 | -| bucket32_d64 | 7.603 / 7.391 / 3.721 | 316800 | 200 | 25 | 0 | -### ('chain', '2048', '2047', '1') - -| Variant | Full / distance / device ms | Edge visits | Rounds | Host waits | 64-bit fallback | -|---|---:|---:|---:|---:|---| -| old_pull_parallel_dag | 30.065 / 30.058 / 4.364 | 4192256 | 2048 | 256 | 0 | -| old_group_parallel_dag | 4.847 / 4.825 / 4.696 | 4192256 | 2048 | 1 | 0 | -| pull64_barrier | 28.539 / 28.532 / 4.712 | 4192256 | 2048 | 256 | 0 | -| pull64_interleaved | 32.552 / 32.545 / 6.166 | 4192256 | 2048 | 256 | 0 | -| pull32_interleaved | 33.313 / 33.304 / 7.668 | 4192256 | 2048 | 256 | 0 | -| pull32_gpu_dag | 30.619 / 30.342 / 4.891 | 4192256 | 2048 | 258 | 0 | -| frontier32_vertex | 37.679 / 37.671 / 12.262 | 2047 | 2048 | 256 | 0 | -| frontier32_simd | 41.050 / 41.041 / 15.764 | 2047 | 2048 | 256 | 0 | -| frontier32_gpu_dag | 40.148 / 39.758 / 13.087 | 2047 | 2048 | 258 | 0 | -| bucket32_d4 | 49.001 / 48.994 / 21.208 | 2047 | 2056 | 257 | 0 | -| bucket32_d16 | 48.755 / 48.747 / 21.149 | 2047 | 2056 | 257 | 0 | -| bucket32_d64 | 48.401 / 48.395 / 21.022 | 2047 | 2056 | 257 | 0 | -### ('hub', '4096', '12286', '64') - -| Variant | Full / distance / device ms | Edge visits | Rounds | Host waits | 64-bit fallback | -|---|---:|---:|---:|---:|---| -| old_pull_parallel_dag | 32.652 / 32.266 / 30.925 | 31452160 | 40 | 5 | 0 | -| old_group_parallel_dag | 26.147 / 25.761 / 25.032 | 27176632 | 38 | 1 | 0 | -| pull64_barrier | 25.410 / 25.029 / 23.756 | 31452160 | 40 | 5 | 0 | -| pull64_interleaved | 33.830 / 33.457 / 31.908 | 31452160 | 40 | 5 | 0 | -| pull32_interleaved | 29.085 / 28.621 / 27.390 | 31452160 | 40 | 5 | 0 | -| pull32_gpu_dag | 31.331 / 28.778 / 29.382 | 31452160 | 40 | 7 | 0 | -| frontier32_vertex | 20.164 / 19.779 / 18.439 | 1847854 | 40 | 5 | 0 | -| frontier32_simd | 3.680 / 3.294 / 1.977 | 1683322 | 40 | 5 | 0 | -| frontier32_gpu_dag | 5.438 / 3.358 / 3.575 | 1683332 | 40 | 7 | 0 | -| bucket32_d4 | 5.120 / 4.740 / 3.072 | 788342 | 48 | 6 | 0 | -| bucket32_d16 | 4.911 / 4.544 / 3.014 | 1124798 | 48 | 6 | 0 | -| bucket32_d64 | 4.974 / 4.580 / 3.351 | 1929942 | 40 | 5 | 0 | -### ('random', '4096', '131072', '1') - -| Variant | Full / distance / device ms | Edge visits | Rounds | Host waits | 64-bit fallback | -|---|---:|---:|---:|---:|---| -| old_pull_parallel_dag | 0.700 / 0.521 / 0.269 | 2097152 | 16 | 2 | 0 | -| old_group_parallel_dag | 2.531 / 2.326 / 2.178 | 1572864 | 12 | 1 | 0 | -| pull64_barrier | 0.670 / 0.484 / 0.244 | 2097152 | 16 | 2 | 0 | -| pull64_interleaved | 0.708 / 0.538 / 0.266 | 2097152 | 16 | 2 | 0 | -| pull32_interleaved | 0.603 / 0.418 / 0.180 | 2097152 | 16 | 2 | 0 | -| pull32_gpu_dag | 0.828 / 0.458 / 0.237 | 2097152 | 16 | 4 | 0 | -| frontier32_vertex | 0.827 / 0.638 / 0.358 | 421728 | 16 | 2 | 0 | -| frontier32_simd | 0.547 / 0.381 / 0.130 | 385248 | 16 | 2 | 0 | -| frontier32_gpu_dag | 0.790 / 0.404 / 0.188 | 385888 | 16 | 4 | 0 | -| bucket32_d4 | 0.878 / 0.693 / 0.279 | 138720 | 24 | 3 | 0 | -| bucket32_d16 | 0.602 / 0.418 / 0.176 | 177024 | 16 | 2 | 0 | -| bucket32_d64 | 0.676 / 0.489 / 0.213 | 379328 | 16 | 2 | 0 | -### ('random', '4096', '131072', '64') - -| Variant | Full / distance / device ms | Edge visits | Rounds | Host waits | 64-bit fallback | -|---|---:|---:|---:|---:|---| -| old_pull_parallel_dag | 9.211 / 7.184 / 6.254 | 134217728 | 16 | 2 | 0 | -| old_group_parallel_dag | 6.357 / 3.995 / 3.300 | 105644032 | 15 | 1 | 0 | -| pull64_barrier | 11.066 / 9.020 / 8.132 | 134217728 | 16 | 2 | 0 | -| pull64_interleaved | 4.039 / 1.981 / 1.106 | 134217728 | 16 | 2 | 0 | -| pull32_interleaved | 3.814 / 1.788 / 1.031 | 134217728 | 16 | 2 | 0 | -| pull32_gpu_dag | 2.051 / 1.482 / 0.861 | 134217728 | 16 | 4 | 0 | -| frontier32_vertex | 5.418 / 3.315 / 2.452 | 22442464 | 16 | 2 | 0 | -| frontier32_simd | 5.078 / 2.991 / 1.970 | 22729248 | 16 | 2 | 0 | -| frontier32_gpu_dag | 4.086 / 2.828 / 2.683 | 22826080 | 16 | 4 | 0 | -| bucket32_d4 | 4.731 / 2.704 / 1.615 | 8805184 | 24 | 3 | 0 | -| bucket32_d16 | 4.926 / 2.956 / 1.859 | 12113536 | 24 | 3 | 0 | -| bucket32_d64 | 5.330 / 3.310 / 2.396 | 23278880 | 16 | 2 | 0 | diff --git a/dev/research/metal_pathfinding/optimization/results/aba/time-base-a1.txt b/dev/research/metal_pathfinding/optimization/results/aba/time-base-a1.txt deleted file mode 100644 index a1f056c..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/time-base-a1.txt +++ /dev/null @@ -1,2 +0,0 @@ -2026-09-26T23:29:22Z -2026-09-26T23:29:35Z diff --git a/dev/research/metal_pathfinding/optimization/results/aba/time-base-a2.txt b/dev/research/metal_pathfinding/optimization/results/aba/time-base-a2.txt deleted file mode 100644 index c203e1a..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/time-base-a2.txt +++ /dev/null @@ -1,2 +0,0 @@ -2026-09-26T23:30:30Z -2026-09-26T23:30:43Z diff --git a/dev/research/metal_pathfinding/optimization/results/aba/time-cpu-a1.txt b/dev/research/metal_pathfinding/optimization/results/aba/time-cpu-a1.txt deleted file mode 100644 index 96d1d9c..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/time-cpu-a1.txt +++ /dev/null @@ -1,2 +0,0 @@ -2026-09-26T23:29:10Z -2026-09-26T23:29:20Z diff --git a/dev/research/metal_pathfinding/optimization/results/aba/time-cpu-a2.txt b/dev/research/metal_pathfinding/optimization/results/aba/time-cpu-a2.txt deleted file mode 100644 index aa2e187..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/time-cpu-a2.txt +++ /dev/null @@ -1,2 +0,0 @@ -2026-09-26T23:30:45Z -2026-09-26T23:30:56Z diff --git a/dev/research/metal_pathfinding/optimization/results/aba/time-optimized-b.txt b/dev/research/metal_pathfinding/optimization/results/aba/time-optimized-b.txt deleted file mode 100644 index 9700df1..0000000 --- a/dev/research/metal_pathfinding/optimization/results/aba/time-optimized-b.txt +++ /dev/null @@ -1,2 +0,0 @@ -2026-09-26T23:29:37Z -2026-09-26T23:30:29Z diff --git a/dev/research/metal_pathfinding/optimization/results/audit.json b/dev/research/metal_pathfinding/optimization/results/audit.json deleted file mode 100644 index 99f87b6..0000000 --- a/dev/research/metal_pathfinding/optimization/results/audit.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "correctness-final.csv": { - "rows": 336, - "checked_query_results": 11760 - }, - "stress.csv": { - "rows": 28, - "checked_query_results": 39424 - }, - "correctness-sanitized.csv": { - "rows": 336, - "checked_query_results": 11760 - }, - "stress-sanitized.csv": { - "rows": 28, - "checked_query_results": 8960 - }, - "aba": { - "phase_rows": { - "cpu-a1": 48, - "base-a1": 36, - "optimized-b": 216, - "base-a2": 36, - "cpu-a2": 48 - }, - "checked_query_results": 124704 - }, - "total_checked_query_results": 290192, - "source_sha256": { - "dev/metal_pathfinding/experiment.mm": "1da9ff77bd2ad97e95e951454821b25005859c6f75c27a3c56d925e5fc99f6fc", - "dev/metal_pathfinding/sssp.metal": "2e36b1623a3940bf1f19f5d81141bb1def14f5873fe2a98aab0101bbe68618a3", - "dev/metal_pathfinding/optimization/experiment.mm": "7717db8d791d17f7f0ef0b672461ad82afb2ae99cc2ab6c8cb8b33b35e74dd25", - "dev/metal_pathfinding/optimization/kernels.metal": "bed6c9a836e85520101659471c62b7c4c9eb6f4954cb7bb74c971f10c44f37ff", - "dev/metal_pathfinding/optimization/cpu_algorithms.mm": "44962b5bd1dba6ecfc8d1449e324875e35ebe373e3f33aef779be12d9cb3fd65", - "dev/metal_pathfinding/optimization/summarize.py": "a08a937336abf0c30fbed82ac4cec970868f1d77469d5aca1a84a9407326578a", - "dev/metal_pathfinding/optimization/summarize_cpu.py": "b384cca4832b308694e1642020ef09cc5b0084c27df5ec20bd64ea793ead51a0" - }, - "cpu-algorithms-correctness.csv": { - "rows": 24, - "checked_query_results": 960 - }, - "cpu-algorithms-sanitized.csv": { - "rows": 24, - "checked_query_results": 960 - }, - "cpu-algorithms-full-sanitized.csv": { - "rows": 14, - "checked_query_results": 3608 - }, - "cpu_algorithms_aba": { - "phase_rows": { - "a1": 14, - "b": 216, - "a2": 14 - }, - "checked_query_results": 88056 - }, - "repository_checks": { - "workspace_check": "passed: rebuilt extension, lint, types, 170 C++ and 465 Python tests", - "sanitize_test": "170 C++ tests passed", - "ci": "not run", - "production_tracked_changes": false, - "commit_created": false, - "pushed": false - } -} diff --git a/dev/research/metal_pathfinding/optimization/results/correctness-final-stderr.txt b/dev/research/metal_pathfinding/optimization/results/correctness-final-stderr.txt deleted file mode 100644 index 5d79ff7..0000000 --- a/dev/research/metal_pathfinding/optimization/results/correctness-final-stderr.txt +++ /dev/null @@ -1 +0,0 @@ -device=Apple M4 Max setup_ms=51.344 execution_width=32 diff --git a/dev/research/metal_pathfinding/optimization/results/correctness-final.csv b/dev/research/metal_pathfinding/optimization/results/correctness-final.csv deleted file mode 100644 index 23c8fd2..0000000 --- a/dev/research/metal_pathfinding/optimization/results/correctness-final.csv +++ /dev/null @@ -1,337 +0,0 @@ -case,n,e,queries,method,wall_ms,distance_ms,gpu_ms,edge_visits,rounds,waits,fallback64,upload_ms,samples,correct,wall_samples_ms -random_masked_int64,40,240,8,old_pull_serial,0.295542,0.280167,0.122083,15360,8,1,0,0.093124,3,pass,0.295542;0.481375;0.254833 -random_masked_int64,40,240,8,old_group_serial,0.494583,0.487583,0.071625,9600,6,1,0,0.093124,3,pass,0.494583;0.218417;0.495042 -random_masked_int64,40,240,8,old_pull_parallel_dag,0.795000,0.716125,0.145833,15360,8,1,0,0.093124,3,pass,0.615625;0.805625;0.795000 -random_masked_int64,40,240,8,old_group_parallel_dag,0.240042,0.192250,0.068542,9600,6,1,0,0.093124,3,pass,0.227042;0.240042;0.242917 -random_masked_int64,40,240,8,pull64_barrier,0.335500,0.297875,0.112167,15360,8,1,0,0.093124,3,pass,0.354916;0.320250;0.335500 -random_masked_int64,40,240,8,pull64_interleaved,0.544875,0.511792,0.116292,15360,8,1,0,0.093124,3,pass,0.535125;0.544875;0.697958 -random_masked_int64,40,240,8,pull32_interleaved,0.321750,0.275625,0.113792,15360,8,1,1,0.093124,3,pass,0.684625;0.321750;0.294917 -random_masked_int64,40,240,8,pull32_gpu_dag,0.714125,0.310708,0.195750,15360,8,3,1,0.093124,3,pass,0.863417;0.676542;0.714125 -random_masked_int64,40,240,8,frontier32_vertex,0.313500,0.274208,0.112208,15360,8,1,1,0.093124,3,pass,0.313500;0.315958;0.307541 -random_masked_int64,40,240,8,frontier32_simd,0.448917,0.414458,0.134125,15360,8,1,1,0.093124,3,pass,0.622542;0.448917;0.341667 -random_masked_int64,40,240,8,frontier32_gpu_dag,0.730291,0.305292,0.195917,15360,8,3,1,0.093124,3,pass,0.638333;0.737416;0.730291 -random_masked_int64,40,240,8,bucket32_d4,0.335667,0.285709,0.112125,15360,8,1,1,0.093124,3,pass,0.335667;0.338542;0.313417 -random_masked_int64,40,240,8,bucket32_d16,0.346750,0.319792,0.141667,15360,8,1,1,0.093124,3,pass,0.364625;0.343333;0.346750 -random_masked_int64,40,240,8,bucket32_d64,0.295625,0.270000,0.113125,15360,8,1,1,0.093124,3,pass,0.300625;0.295625;0.295209 -random_masked,43,258,8,old_pull_serial,0.553208,0.537875,0.297833,33024,16,2,0,0.058250,3,pass,0.553208;0.532292;1.283709 -random_masked,43,258,8,old_group_serial,0.253708,0.245125,0.106625,14448,9,1,0,0.058250,3,pass,0.253708;0.254417;0.248625 -random_masked,43,258,8,old_pull_parallel_dag,0.659625,0.611375,0.315917,33024,16,2,0,0.058250,3,pass,0.659625;0.763875;0.653084 -random_masked,43,258,8,old_group_parallel_dag,0.264917,0.231916,0.105708,14448,9,1,0,0.058250,3,pass,0.279208;0.264917;0.260500 -random_masked,43,258,8,pull64_barrier,0.598083,0.582250,0.276708,33024,16,2,0,0.058250,3,pass,0.576291;0.598083;0.730833 -random_masked,43,258,8,pull64_interleaved,0.649584,0.619375,0.300167,33024,16,2,0,0.058250,3,pass,0.649584;0.693042;0.621250 -random_masked,43,258,8,pull32_interleaved,0.643791,0.610666,0.280375,33024,16,2,0,0.058250,3,pass,0.616875;1.234375;0.643791 -random_masked,43,258,8,pull32_gpu_dag,0.862916,0.498916,0.270583,33024,16,4,0,0.058250,3,pass,0.862916;0.851875;0.952791 -random_masked,43,258,8,frontier32_vertex,0.788458,0.755458,0.433333,3186,16,2,0,0.058250,3,pass,0.843375;0.745917;0.788458 -random_masked,43,258,8,frontier32_simd,0.648291,0.621291,0.322292,3186,16,2,0,0.058250,3,pass,0.648291;0.637833;0.685875 -random_masked,43,258,8,frontier32_gpu_dag,1.073125,0.610375,0.384208,3186,16,4,0,0.058250,3,pass,1.453292;1.073125;1.037708 -random_masked,43,258,8,bucket32_d4,1.775500,1.724792,1.121667,2016,32,4,0,0.058250,3,pass,1.748708;1.823084;1.775500 -random_masked,43,258,8,bucket32_d16,1.315042,1.288875,0.827166,2142,24,3,0,0.058250,3,pass,1.315042;1.332542;1.310125 -random_masked,43,258,8,bucket32_d64,0.967625,0.920750,0.555500,3096,16,2,0,0.058250,3,pass,0.967625;1.148250;0.876542 -random_masked_int64_reverse,46,276,8,old_pull_serial,0.228500,0.216209,0.095250,17664,8,1,0,0.062209,3,pass,0.227542;0.233708;0.228500 -random_masked_int64_reverse,46,276,8,old_group_serial,0.190666,0.173792,0.044500,9660,5,1,0,0.062209,3,pass,0.190666;0.191250;0.183416 -random_masked_int64_reverse,46,276,8,old_pull_parallel_dag,0.279041,0.234000,0.095750,17664,8,1,0,0.062209,3,pass,0.280542;0.277709;0.279041 -random_masked_int64_reverse,46,276,8,old_group_parallel_dag,0.215084,0.178417,0.044750,9660,5,1,0,0.062209,3,pass,0.223000;0.205708;0.215084 -random_masked_int64_reverse,46,276,8,pull64_barrier,0.297583,0.262625,0.093000,17664,8,1,0,0.062209,3,pass,0.297583;0.295959;0.299708 -random_masked_int64_reverse,46,276,8,pull64_interleaved,0.282500,0.247250,0.094750,17664,8,1,0,0.062209,3,pass,0.285875;0.282500;0.269042 -random_masked_int64_reverse,46,276,8,pull32_interleaved,0.293375,0.256125,0.095333,17664,8,1,1,0.062209,3,pass,0.276583;0.293375;0.723250 -random_masked_int64_reverse,46,276,8,pull32_gpu_dag,0.619291,0.257709,0.144750,17664,8,3,1,0.062209,3,pass,0.693292;0.619291;0.600458 -random_masked_int64_reverse,46,276,8,frontier32_vertex,0.285083,0.250750,0.092833,17664,8,1,1,0.062209,3,pass,0.279625;0.292667;0.285083 -random_masked_int64_reverse,46,276,8,frontier32_simd,0.294167,0.262416,0.094000,17664,8,1,1,0.062209,3,pass,0.294167;0.297875;0.293209 -random_masked_int64_reverse,46,276,8,frontier32_gpu_dag,0.626417,0.252917,0.144000,17664,8,3,1,0.062209,3,pass,0.626417;0.648291;0.621667 -random_masked_int64_reverse,46,276,8,bucket32_d4,0.299416,0.254583,0.092833,17664,8,1,1,0.062209,3,pass,0.299416;0.327750;0.296000 -random_masked_int64_reverse,46,276,8,bucket32_d16,0.298250,0.259125,0.093458,17664,8,1,1,0.062209,3,pass,0.298250;0.315625;0.291042 -random_masked_int64_reverse,46,276,8,bucket32_d64,0.271792,0.249875,0.093125,17664,8,1,1,0.062209,3,pass,0.302208;0.270667;0.271792 -random_masked,49,294,8,old_pull_serial,0.254500,0.245625,0.124375,18816,8,1,0,0.061041,3,pass,0.254500;0.265292;0.249375 -random_masked,49,294,8,old_group_serial,0.238708,0.229917,0.093250,14700,8,1,0,0.061041,3,pass,0.216625;0.239000;0.238708 -random_masked,49,294,8,old_pull_parallel_dag,0.299625,0.260792,0.124042,18816,8,1,0,0.061041,3,pass,0.306959;0.291250;0.299625 -random_masked,49,294,8,old_group_parallel_dag,0.266000,0.227791,0.093250,14700,8,1,0,0.061041,3,pass,0.253208;0.266000;0.305791 -random_masked,49,294,8,pull64_barrier,0.326084,0.287958,0.121875,18816,8,1,0,0.061041,3,pass,0.317208;0.333250;0.326084 -random_masked,49,294,8,pull64_interleaved,0.297000,0.266958,0.121417,18816,8,1,0,0.061041,3,pass,0.297000;0.272667;0.317333 -random_masked,49,294,8,pull32_interleaved,0.313584,0.278125,0.108625,18816,8,1,0,0.061041,3,pass,0.300375;0.947375;0.313584 -random_masked,49,294,8,pull32_gpu_dag,0.638875,0.268333,0.150750,18816,8,3,0,0.061041,3,pass,0.638875;0.636125;0.734292 -random_masked,49,294,8,frontier32_vertex,0.500083,0.467625,0.272750,3162,8,1,0,0.061041,3,pass,0.378791;0.637958;0.500083 -random_masked,49,294,8,frontier32_simd,0.368084,0.336083,0.180250,3162,8,1,0,0.061041,3,pass,0.383958;0.368084;0.357500 -random_masked,49,294,8,frontier32_gpu_dag,0.731333,0.351000,0.229584,3162,8,3,0,0.061041,3,pass,0.729833;0.842292;0.731333 -random_masked,49,294,8,bucket32_d4,1.551542,1.512250,0.792500,2022,40,5,0,0.061041,3,pass,1.533000;1.551542;1.629291 -random_masked,49,294,8,bucket32_d16,0.829375,0.768292,0.443917,2100,16,2,0,0.061041,3,pass,0.829375;0.911083;0.756209 -random_masked,49,294,8,bucket32_d64,0.655000,0.621375,0.319375,3036,16,2,0,0.061041,3,pass,0.654833;0.673291;0.655000 -random_masked_int64,52,312,8,old_pull_serial,0.206500,0.193792,0.074250,19968,8,1,0,0.089792,3,pass,0.206500;0.191000;0.219292 -random_masked_int64,52,312,8,old_group_serial,0.179167,0.169875,0.042417,12168,6,1,0,0.089792,3,pass,0.179167;0.187583;0.178459 -random_masked_int64,52,312,8,old_pull_parallel_dag,0.243125,0.207833,0.074833,19968,8,1,0,0.089792,3,pass,0.266375;0.243125;0.231208 -random_masked_int64,52,312,8,old_group_parallel_dag,0.192375,0.149375,0.033042,12168,6,1,0,0.089792,3,pass,0.201000;0.192375;0.177625 -random_masked_int64,52,312,8,pull64_barrier,0.222042,0.204000,0.054875,19968,8,1,0,0.089792,3,pass,0.222042;0.491792;0.211875 -random_masked_int64,52,312,8,pull64_interleaved,0.222875,0.206875,0.054417,19968,8,1,0,0.089792,3,pass,0.222875;0.212375;0.242667 -random_masked_int64,52,312,8,pull32_interleaved,0.243833,0.214167,0.054583,19968,8,1,1,0.089792,3,pass,0.257250;0.243833;0.235084 -random_masked_int64,52,312,8,pull32_gpu_dag,0.575083,0.218666,0.079208,19968,8,3,1,0.089792,3,pass,0.575083;0.553167;0.713500 -random_masked_int64,52,312,8,frontier32_vertex,0.236000,0.194250,0.055250,19968,8,1,1,0.089792,3,pass,0.254083;0.236000;0.205541 -random_masked_int64,52,312,8,frontier32_simd,0.231542,0.199750,0.055292,19968,8,1,1,0.089792,3,pass,0.245708;0.221708;0.231542 -random_masked_int64,52,312,8,frontier32_gpu_dag,0.565250,0.222584,0.081208,19968,8,3,1,0.089792,3,pass,0.607084;0.543250;0.565250 -random_masked_int64,52,312,8,bucket32_d4,0.274584,0.236750,0.054500,19968,8,1,1,0.089792,3,pass,0.274584;0.309834;0.267250 -random_masked_int64,52,312,8,bucket32_d16,0.244959,0.211750,0.054500,19968,8,1,1,0.089792,3,pass,0.244959;0.343625;0.226541 -random_masked_int64,52,312,8,bucket32_d64,0.229708,0.197500,0.047875,19968,8,1,1,0.089792,3,pass,0.213291;0.245875;0.229708 -random_masked_reverse,55,330,8,old_pull_serial,0.338000,0.329666,0.072125,42240,16,2,0,0.082751,3,pass,0.317459;0.338000;0.355708 -random_masked_reverse,55,330,8,old_group_serial,0.169334,0.160708,0.028000,20130,10,1,0,0.082751,3,pass,0.176292;0.169334;0.167542 -random_masked_reverse,55,330,8,old_pull_parallel_dag,0.372500,0.337959,0.062667,42240,16,2,0,0.082751,3,pass,0.413708;0.344792;0.372500 -random_masked_reverse,55,330,8,old_group_parallel_dag,0.196875,0.169125,0.024583,20130,10,1,0,0.082751,3,pass,0.206500;0.196875;0.193250 -random_masked_reverse,55,330,8,pull64_barrier,0.346083,0.325542,0.070250,42240,16,2,0,0.082751,3,pass,0.388542;0.346083;0.334208 -random_masked_reverse,55,330,8,pull64_interleaved,0.415000,0.376500,0.071708,42240,16,2,0,0.082751,3,pass,0.341334;0.415000;0.416750 -random_masked_reverse,55,330,8,pull32_interleaved,0.405333,0.352750,0.066125,42240,16,2,0,0.082751,3,pass,0.380375;0.405333;0.424959 -random_masked_reverse,55,330,8,pull32_gpu_dag,0.734834,0.375000,0.080750,42240,16,4,0,0.082751,3,pass,0.734834;0.758000;0.605875 -random_masked_reverse,55,330,8,frontier32_vertex,0.431750,0.409208,0.162250,3691,16,2,0,0.082751,3,pass,0.431750;0.427458;0.671500 -random_masked_reverse,55,330,8,frontier32_simd,0.458458,0.428500,0.120208,3691,16,2,0,0.082751,3,pass,0.432041;0.458458;0.473417 -random_masked_reverse,55,330,8,frontier32_gpu_dag,0.739958,0.433042,0.141708,3691,16,4,0,0.082751,3,pass,0.736334;0.961208;0.739958 -random_masked_reverse,55,330,8,bucket32_d4,1.017375,0.993458,0.364375,2455,32,4,0,0.082751,3,pass,0.925125;1.071250;1.017375 -random_masked_reverse,55,330,8,bucket32_d16,0.769625,0.744958,0.284083,2603,24,3,0,0.082751,3,pass,0.842375;0.769625;0.766042 -random_masked_reverse,55,330,8,bucket32_d64,0.488208,0.452667,0.167083,3605,16,2,0,0.082751,3,pass,0.490125;0.477292;0.488208 -random_masked_int64,58,348,8,old_pull_serial,0.186084,0.176958,0.033542,22272,8,1,0,0.072333,3,pass,0.190292;0.174250;0.186084 -random_masked_int64,58,348,8,old_group_serial,0.157625,0.148708,0.015125,13920,5,1,0,0.072333,3,pass,0.157917;0.148042;0.157625 -random_masked_int64,58,348,8,old_pull_parallel_dag,0.212583,0.181000,0.033375,22272,8,1,0,0.072333,3,pass,0.213917;0.209500;0.212583 -random_masked_int64,58,348,8,old_group_parallel_dag,0.189875,0.154458,0.014375,13920,5,1,0,0.072333,3,pass,0.191875;0.189875;0.188333 -random_masked_int64,58,348,8,pull64_barrier,0.226833,0.202958,0.031875,22272,8,1,0,0.072333,3,pass,0.246250;0.226833;0.221792 -random_masked_int64,58,348,8,pull64_interleaved,0.213041,0.177209,0.026792,22272,8,1,0,0.072333,3,pass,0.213041;0.217834;0.200459 -random_masked_int64,58,348,8,pull32_interleaved,0.200709,0.168250,0.027750,22272,8,1,1,0.072333,3,pass,0.200709;0.182541;0.231917 -random_masked_int64,58,348,8,pull32_gpu_dag,0.526250,0.199792,0.040250,22272,8,3,1,0.072333,3,pass,0.526250;0.513500;0.537750 -random_masked_int64,58,348,8,frontier32_vertex,0.257041,0.224708,0.031792,22272,8,1,1,0.072333,3,pass,0.253250;0.403083;0.257041 -random_masked_int64,58,348,8,frontier32_simd,0.213667,0.187792,0.027917,22272,8,1,1,0.072333,3,pass,0.222208;0.213667;0.197166 -random_masked_int64,58,348,8,frontier32_gpu_dag,0.481458,0.177333,0.041917,22272,8,3,1,0.072333,3,pass,0.487875;0.465292;0.481458 -random_masked_int64,58,348,8,bucket32_d4,0.295167,0.220041,0.026875,22272,8,1,1,0.072333,3,pass,0.184125;0.295167;0.303708 -random_masked_int64,58,348,8,bucket32_d16,0.229375,0.204917,0.030375,22272,8,1,1,0.072333,3,pass,0.228000;0.242292;0.229375 -random_masked_int64,58,348,8,bucket32_d64,0.200666,0.176000,0.026750,22272,8,1,1,0.072333,3,pass,0.218250;0.200666;0.199292 -random_masked,61,366,8,old_pull_serial,0.310042,0.300292,0.062833,46848,16,2,0,0.073625,3,pass,0.313791;0.309125;0.310042 -random_masked,61,366,8,old_group_serial,0.150208,0.140417,0.024625,23424,9,1,0,0.073625,3,pass,0.150208;0.148459;0.150875 -random_masked,61,366,8,old_pull_parallel_dag,0.322833,0.293125,0.062792,46848,16,2,0,0.073625,3,pass,0.328042;0.322833;0.313875 -random_masked,61,366,8,old_group_parallel_dag,0.171500,0.134458,0.024500,23424,9,1,0,0.073625,3,pass,0.177917;0.171500;0.163208 -random_masked,61,366,8,pull64_barrier,0.358458,0.323834,0.059333,46848,16,2,0,0.073625,3,pass,0.358667;0.351625;0.358458 -random_masked,61,366,8,pull64_interleaved,0.379750,0.353000,0.059208,46848,16,2,0,0.073625,3,pass,0.348709;0.379750;0.465916 -random_masked,61,366,8,pull32_interleaved,0.335250,0.308541,0.053625,46848,16,2,0,0.073625,3,pass,0.381041;0.330875;0.335250 -random_masked,61,366,8,pull32_gpu_dag,0.624791,0.326000,0.066625,46848,16,4,0,0.073625,3,pass,0.640166;0.619250;0.624791 -random_masked,61,366,8,frontier32_vertex,0.449708,0.419250,0.120375,4776,16,2,0,0.073625,3,pass,0.423417;0.489625;0.449708 -random_masked,61,366,8,frontier32_simd,0.393417,0.365250,0.089542,4776,16,2,0,0.073625,3,pass,0.509167;0.391625;0.393417 -random_masked,61,366,8,frontier32_gpu_dag,0.645000,0.351333,0.103791,4776,16,4,0,0.073625,3,pass,0.630083;0.659083;0.645000 -random_masked,61,366,8,bucket32_d4,1.162167,1.124208,0.384250,2760,40,5,0,0.073625,3,pass,1.162167;1.262625;1.139458 -random_masked,61,366,8,bucket32_d16,0.787250,0.722958,0.258000,2952,24,3,0,0.073625,3,pass,0.778125;0.842833;0.787250 -random_masked,61,366,8,bucket32_d64,0.569916,0.544083,0.182708,4596,16,2,0,0.073625,3,pass,0.532041;0.579167;0.569916 -random_masked_int64_reverse,64,384,8,old_pull_serial,0.166875,0.154833,0.024000,24576,8,1,0,0.073876,3,pass,0.160750;0.168750;0.166875 -random_masked_int64_reverse,64,384,8,old_group_serial,0.143667,0.133167,0.010917,13440,5,1,0,0.073876,3,pass,0.132459;0.143667;0.160542 -random_masked_int64_reverse,64,384,8,old_pull_parallel_dag,0.195500,0.166708,0.025875,24576,8,1,0,0.073876,3,pass,0.195500;0.202250;0.182875 -random_masked_int64_reverse,64,384,8,old_group_parallel_dag,0.197833,0.165125,0.010875,13440,5,1,0,0.073876,3,pass,0.207542;0.197833;0.190083 -random_masked_int64_reverse,64,384,8,pull64_barrier,0.197875,0.171750,0.022333,24576,8,1,0,0.073876,3,pass,0.190333;0.197875;0.244042 -random_masked_int64_reverse,64,384,8,pull64_interleaved,0.210333,0.168459,0.023500,24576,8,1,0,0.073876,3,pass,0.195041;0.229458;0.210333 -random_masked_int64_reverse,64,384,8,pull32_interleaved,0.197958,0.161917,0.023250,24576,8,1,1,0.073876,3,pass,0.197958;0.244083;0.178875 -random_masked_int64_reverse,64,384,8,pull32_gpu_dag,0.470333,0.161750,0.036292,24576,8,3,1,0.073876,3,pass,0.434792;0.470333;0.542959 -random_masked_int64_reverse,64,384,8,frontier32_vertex,0.222916,0.183333,0.022167,24576,8,1,1,0.073876,3,pass,0.210125;0.222916;0.240000 -random_masked_int64_reverse,64,384,8,frontier32_simd,0.227083,0.192000,0.026750,24576,8,1,1,0.073876,3,pass,0.227083;0.206833;0.236125 -random_masked_int64_reverse,64,384,8,frontier32_gpu_dag,0.524542,0.194917,0.041250,24576,8,3,1,0.073876,3,pass,0.537542;0.524542;0.522500 -random_masked_int64_reverse,64,384,8,bucket32_d4,0.219417,0.188417,0.026792,24576,8,1,1,0.073876,3,pass,0.219709;0.209292;0.219417 -random_masked_int64_reverse,64,384,8,bucket32_d16,0.205000,0.173625,0.023542,24576,8,1,1,0.073876,3,pass,0.205958;0.188167;0.205000 -random_masked_int64_reverse,64,384,8,bucket32_d64,0.224459,0.186917,0.024042,24576,8,1,1,0.073876,3,pass,0.224459;0.237625;0.203416 -random_masked,67,402,8,old_pull_serial,0.183167,0.171459,0.031000,25728,8,1,0,0.070917,3,pass,0.183167;0.187583;0.149917 -random_masked,67,402,8,old_group_serial,0.163500,0.152916,0.019458,24522,8,1,0,0.070917,3,pass,0.169500;0.160041;0.163500 -random_masked,67,402,8,old_pull_parallel_dag,0.199125,0.163417,0.028625,25728,8,1,0,0.070917,3,pass,0.224500;0.193625;0.199125 -random_masked,67,402,8,old_group_parallel_dag,0.186667,0.147834,0.019375,24522,8,1,0,0.070917,3,pass,0.186667;0.197125;0.172125 -random_masked,67,402,8,pull64_barrier,0.198917,0.174375,0.028000,25728,8,1,0,0.070917,3,pass,0.191625;0.198917;0.212833 -random_masked,67,402,8,pull64_interleaved,0.232125,0.185708,0.028458,25728,8,1,0,0.070917,3,pass,0.232125;0.275166;0.208959 -random_masked,67,402,8,pull32_interleaved,0.204667,0.174459,0.025583,25728,8,1,0,0.070917,3,pass,0.204667;0.213750;0.193791 -random_masked,67,402,8,pull32_gpu_dag,0.524541,0.201084,0.042500,25728,8,3,0,0.070917,3,pass,0.495500;0.524541;0.547167 -random_masked,67,402,8,frontier32_vertex,0.300584,0.270209,0.085750,5034,8,1,0,0.070917,3,pass,0.279500;0.300584;0.315542 -random_masked,67,402,8,frontier32_simd,0.254167,0.222250,0.057750,5004,8,1,0,0.070917,3,pass,0.214041;0.262958;0.254167 -random_masked,67,402,8,frontier32_gpu_dag,0.585250,0.250708,0.073500,5004,8,3,0,0.070917,3,pass,0.631083;0.585250;0.584417 -random_masked,67,402,8,bucket32_d4,0.964708,0.943833,0.351208,3120,32,4,0,0.070917,3,pass,0.964708;1.042791;0.926875 -random_masked,67,402,8,bucket32_d16,0.701542,0.673792,0.229458,3360,24,3,0,0.070917,3,pass,0.640625;0.701542;0.830541 -random_masked,67,402,8,bucket32_d64,0.527750,0.500917,0.165750,4908,16,2,0,0.070917,3,pass,0.527750;0.535917;0.492334 -random_masked_int64,70,420,8,old_pull_serial,0.163584,0.150708,0.031875,26880,8,1,0,0.089500,3,pass,0.180000;0.163584;0.157584 -random_masked_int64,70,420,8,old_group_serial,0.162792,0.151000,0.017500,15960,6,1,0,0.089500,3,pass,0.163125;0.162792;0.162417 -random_masked_int64,70,420,8,old_pull_parallel_dag,0.171458,0.148125,0.030958,26880,8,1,0,0.089500,3,pass,0.194000;0.171458;0.168500 -random_masked_int64,70,420,8,old_group_parallel_dag,0.167375,0.141542,0.017333,15960,6,1,0,0.089500,3,pass,0.167375;0.164500;0.169584 -random_masked_int64,70,420,8,pull64_barrier,0.247750,0.208084,0.033500,26880,8,1,0,0.089500,3,pass,0.247750;0.268042;0.236500 -random_masked_int64,70,420,8,pull64_interleaved,0.273250,0.241708,0.033458,26880,8,1,0,0.089500,3,pass,0.235541;0.282666;0.273250 -random_masked_int64,70,420,8,pull32_interleaved,0.241916,0.210000,0.033333,26880,8,1,1,0.089500,3,pass,0.234667;0.248334;0.241916 -random_masked_int64,70,420,8,pull32_gpu_dag,0.480542,0.172791,0.044000,26880,8,3,1,0.089500,3,pass,0.473417;0.480542;0.484875 -random_masked_int64,70,420,8,frontier32_vertex,0.243625,0.210542,0.033250,26880,8,1,1,0.089500,3,pass,0.220084;0.243625;0.263000 -random_masked_int64,70,420,8,frontier32_simd,0.219750,0.186459,0.030250,26880,8,1,1,0.089500,3,pass,0.265709;0.219750;0.198250 -random_masked_int64,70,420,8,frontier32_gpu_dag,0.506583,0.176333,0.045000,26880,8,3,1,0.089500,3,pass,0.506583;0.680959;0.472792 -random_masked_int64,70,420,8,bucket32_d4,0.243417,0.209584,0.029958,26880,8,1,1,0.089500,3,pass,0.243417;0.216875;0.244250 -random_masked_int64,70,420,8,bucket32_d16,0.198583,0.181292,0.031042,26880,8,1,1,0.089500,3,pass,0.207375;0.197958;0.198583 -random_masked_int64,70,420,8,bucket32_d64,0.218833,0.186375,0.031000,26880,8,1,1,0.089500,3,pass,0.218833;0.184583;0.227958 -random_masked_reverse,73,438,8,old_pull_serial,0.353750,0.341459,0.049209,56064,16,2,0,0.079167,3,pass,0.334792;0.494625;0.353750 -random_masked_reverse,73,438,8,old_group_serial,0.164125,0.153167,0.016917,30222,10,1,0,0.079167,3,pass,0.164125;0.169250;0.141750 -random_masked_reverse,73,438,8,old_pull_parallel_dag,0.366125,0.336333,0.054167,56064,16,2,0,0.079167,3,pass,0.320625;0.372000;0.366125 -random_masked_reverse,73,438,8,old_group_parallel_dag,0.186167,0.149833,0.017000,30222,10,1,0,0.079167,3,pass,0.196000;0.186167;0.170000 -random_masked_reverse,73,438,8,pull64_barrier,0.349334,0.315625,0.050958,56064,16,2,0,0.079167,3,pass,0.349334;0.371750;0.330417 -random_masked_reverse,73,438,8,pull64_interleaved,0.416208,0.388333,0.053083,56064,16,2,0,0.079167,3,pass,0.417041;0.416208;0.370042 -random_masked_reverse,73,438,8,pull32_interleaved,0.332625,0.313167,0.046292,56064,16,2,0,0.079167,3,pass,0.332625;0.380083;0.331584 -random_masked_reverse,73,438,8,pull32_gpu_dag,0.607125,0.322291,0.059750,56064,16,4,0,0.079167,3,pass,0.662083;0.607125;0.600292 -random_masked_reverse,73,438,8,frontier32_vertex,0.536875,0.491708,0.140375,5335,16,2,0,0.079167,3,pass,0.435709;0.544209;0.536875 -random_masked_reverse,73,438,8,frontier32_simd,0.399875,0.367125,0.093417,5335,16,2,0,0.079167,3,pass,0.417625;0.399875;0.374584 -random_masked_reverse,73,438,8,frontier32_gpu_dag,0.691875,0.401375,0.122375,5335,16,4,0,0.079167,3,pass,0.773583;0.691875;0.684083 -random_masked_reverse,73,438,8,bucket32_d4,0.993583,0.963916,0.359833,3179,32,4,0,0.079167,3,pass,0.953958;1.084750;0.993583 -random_masked_reverse,73,438,8,bucket32_d16,0.700041,0.663333,0.241959,3402,24,3,0,0.079167,3,pass,0.694917;0.700041;0.735375 -random_masked_reverse,73,438,8,bucket32_d64,0.539166,0.510458,0.181750,4984,16,2,0,0.079167,3,pass,0.512000;0.539166;0.542250 -boundary_700000000,6,10,6,old_pull_serial,0.152792,0.151916,0.015792,360,6,1,0,0.054751,3,pass,0.145750;0.152792;0.156417 -boundary_700000000,6,10,6,old_group_serial,0.138625,0.137875,0.007125,160,5,1,0,0.054751,3,pass,0.138625;0.138750;0.136042 -boundary_700000000,6,10,6,old_pull_parallel_dag,0.187500,0.157584,0.015625,360,6,1,0,0.054751,3,pass,0.187500;0.197583;0.167875 -boundary_700000000,6,10,6,old_group_parallel_dag,0.151583,0.121458,0.007083,160,5,1,0,0.054751,3,pass,0.151583;0.139625;0.169125 -boundary_700000000,6,10,6,pull64_barrier,0.184625,0.154042,0.015334,360,6,1,0,0.054751,3,pass,0.184625;0.178167;0.186042 -boundary_700000000,6,10,6,pull64_interleaved,0.189209,0.159333,0.016083,360,6,1,0,0.054751,3,pass,0.196167;0.189209;0.158250 -boundary_700000000,6,10,6,pull32_interleaved,0.219166,0.182416,0.017333,360,6,1,0,0.054751,3,pass,0.219166;0.276250;0.218333 -boundary_700000000,6,10,6,pull32_gpu_dag,0.541500,0.188541,0.028417,360,6,3,0,0.054751,3,pass,0.514666;0.643125;0.541500 -boundary_700000000,6,10,6,frontier32_vertex,0.250125,0.224375,0.052750,24,8,1,0,0.054751,3,pass,0.250125;0.258792;0.241292 -boundary_700000000,6,10,6,frontier32_simd,0.221708,0.195667,0.045625,24,8,1,0,0.054751,3,pass,0.212667;0.221708;0.243708 -boundary_700000000,6,10,6,frontier32_gpu_dag,0.532167,0.232834,0.061667,24,8,3,0,0.054751,3,pass,0.653500;0.532167;0.525250 -boundary_700000000,6,10,6,bucket32_d4,0.293459,0.266959,0.086500,24,8,1,0,0.054751,3,pass,0.289375;0.293459;0.297000 -boundary_700000000,6,10,6,bucket32_d16,0.323750,0.289917,0.087583,24,8,1,0,0.054751,3,pass,0.299417;0.375250;0.323750 -boundary_700000000,6,10,6,bucket32_d64,0.254333,0.230333,0.071125,24,8,1,0,0.054751,3,pass,0.254333;0.283417;0.251959 -boundary_700000000_masked,6,10,6,old_pull_serial,0.124792,0.124250,0.013125,360,6,1,0,0.045292,3,pass,0.122000;0.124792;0.278875 -boundary_700000000_masked,6,10,6,old_group_serial,0.112750,0.112125,0.004875,80,2,1,0,0.045292,3,pass,0.109500;0.112750;0.113500 -boundary_700000000_masked,6,10,6,old_pull_parallel_dag,0.199625,0.166042,0.015417,360,6,1,0,0.045292,3,pass,0.199625;0.252958;0.197333 -boundary_700000000_masked,6,10,6,old_group_parallel_dag,0.175334,0.133792,0.005000,80,2,1,0,0.045292,3,pass,0.175334;0.179041;0.153834 -boundary_700000000_masked,6,10,6,pull64_barrier,0.219209,0.191416,0.018875,360,6,1,0,0.045292,3,pass,0.219209;0.204625;0.222500 -boundary_700000000_masked,6,10,6,pull64_interleaved,0.225750,0.189542,0.018792,360,6,1,0,0.045292,3,pass,0.244375;0.225750;0.214625 -boundary_700000000_masked,6,10,6,pull32_interleaved,0.216959,0.192125,0.017458,360,6,1,0,0.045292,3,pass,0.216959;0.203875;0.235125 -boundary_700000000_masked,6,10,6,pull32_gpu_dag,0.451458,0.157875,0.025000,360,6,3,0,0.045292,3,pass,0.451458;0.471209;0.438125 -boundary_700000000_masked,6,10,6,frontier32_vertex,0.264417,0.223584,0.051167,12,8,1,0,0.045292,3,pass,0.264417;0.269000;0.252000 -boundary_700000000_masked,6,10,6,frontier32_simd,0.247542,0.221250,0.049125,12,8,1,0,0.045292,3,pass,0.253834;0.247542;0.234625 -boundary_700000000_masked,6,10,6,frontier32_gpu_dag,0.555208,0.226250,0.060417,12,8,3,0,0.045292,3,pass,0.555208;0.564708;0.530958 -boundary_700000000_masked,6,10,6,bucket32_d4,0.290042,0.257416,0.076167,12,8,1,0,0.045292,3,pass,0.290042;0.301375;0.262833 -boundary_700000000_masked,6,10,6,bucket32_d16,0.283500,0.245375,0.081125,12,8,1,0,0.045292,3,pass,0.293917;0.283500;0.276709 -boundary_700000000_masked,6,10,6,bucket32_d64,0.258958,0.222125,0.072375,12,8,1,0,0.045292,3,pass,0.263625;0.258958;0.229209 -boundary_700000000_reverse,6,10,6,old_pull_serial,0.143708,0.142041,0.014792,360,6,1,0,0.076417,3,pass,0.145041;0.143708;0.135000 -boundary_700000000_reverse,6,10,6,old_group_serial,0.126375,0.125542,0.007250,170,5,1,0,0.076417,3,pass,0.120667;0.134209;0.126375 -boundary_700000000_reverse,6,10,6,old_pull_parallel_dag,0.196500,0.159708,0.015375,360,6,1,0,0.076417,3,pass,0.231667;0.189625;0.196500 -boundary_700000000_reverse,6,10,6,old_group_parallel_dag,0.166208,0.132709,0.007333,170,5,1,0,0.076417,3,pass,0.164292;0.168125;0.166208 -boundary_700000000_reverse,6,10,6,pull64_barrier,0.246166,0.217583,0.017875,360,6,1,0,0.076417,3,pass,0.246166;0.223709;0.378167 -boundary_700000000_reverse,6,10,6,pull64_interleaved,0.175291,0.138500,0.014750,360,6,1,0,0.076417,3,pass,0.175291;0.158209;0.179625 -boundary_700000000_reverse,6,10,6,pull32_interleaved,0.176000,0.151166,0.014750,360,6,1,0,0.076417,3,pass,0.164792;0.176000;0.199208 -boundary_700000000_reverse,6,10,6,pull32_gpu_dag,0.473250,0.161208,0.024334,360,6,3,0,0.076417,3,pass,0.453500;0.529333;0.473250 -boundary_700000000_reverse,6,10,6,frontier32_vertex,0.244417,0.205125,0.053750,24,8,1,0,0.076417,3,pass,0.228750;0.244417;0.257000 -boundary_700000000_reverse,6,10,6,frontier32_simd,0.226667,0.198792,0.044042,24,8,1,0,0.076417,3,pass,0.268875;0.226667;0.218209 -boundary_700000000_reverse,6,10,6,frontier32_gpu_dag,0.500625,0.202250,0.053417,24,8,3,0,0.076417,3,pass,0.641458;0.500625;0.485167 -boundary_700000000_reverse,6,10,6,bucket32_d4,0.240000,0.218208,0.070667,24,8,1,0,0.076417,3,pass,0.233708;0.243291;0.240000 -boundary_700000000_reverse,6,10,6,bucket32_d16,0.247750,0.214792,0.070625,24,8,1,0,0.076417,3,pass,0.303042;0.247750;0.223167 -boundary_700000000_reverse,6,10,6,bucket32_d64,0.284000,0.258334,0.084417,24,8,1,0,0.076417,3,pass,0.284000;0.295583;0.280750 -boundary_700000000_reverse_masked,6,10,6,old_pull_serial,0.132916,0.132292,0.013125,360,6,1,0,0.047751,3,pass,0.134042;0.128667;0.132916 -boundary_700000000_reverse_masked,6,10,6,old_group_serial,0.121541,0.120916,0.004792,90,2,1,0,0.047751,3,pass,0.121541;0.119292;0.124375 -boundary_700000000_reverse_masked,6,10,6,old_pull_parallel_dag,0.168125,0.138584,0.013250,360,6,1,0,0.047751,3,pass,0.168833;0.168125;0.167500 -boundary_700000000_reverse_masked,6,10,6,old_group_parallel_dag,0.141583,0.111166,0.004458,90,2,1,0,0.047751,3,pass,0.160375;0.138833;0.141583 -boundary_700000000_reverse_masked,6,10,6,pull64_barrier,0.187000,0.158958,0.014708,360,6,1,0,0.047751,3,pass,0.187000;0.173541;0.189250 -boundary_700000000_reverse_masked,6,10,6,pull64_interleaved,0.192166,0.166916,0.015625,360,6,1,0,0.047751,3,pass,0.192166;0.181792;0.203958 -boundary_700000000_reverse_masked,6,10,6,pull32_interleaved,0.225291,0.188458,0.014459,360,6,1,0,0.047751,3,pass,0.263292;0.225291;0.206083 -boundary_700000000_reverse_masked,6,10,6,pull32_gpu_dag,0.485625,0.171291,0.025000,360,6,3,0,0.047751,3,pass,0.469583;0.489458;0.485625 -boundary_700000000_reverse_masked,6,10,6,frontier32_vertex,0.234334,0.204625,0.042500,9,8,1,0,0.047751,3,pass,0.234334;0.221208;0.236292 -boundary_700000000_reverse_masked,6,10,6,frontier32_simd,0.231166,0.199542,0.041958,9,8,1,0,0.047751,3,pass,0.231166;0.192791;0.239542 -boundary_700000000_reverse_masked,6,10,6,frontier32_gpu_dag,0.504916,0.207875,0.051542,9,8,3,0,0.047751,3,pass,0.467459;0.504916;0.528000 -boundary_700000000_reverse_masked,6,10,6,bucket32_d4,0.276167,0.238000,0.070000,9,8,1,0,0.047751,3,pass,0.260375;0.326041;0.276167 -boundary_700000000_reverse_masked,6,10,6,bucket32_d16,0.274875,0.248708,0.072708,9,8,1,0,0.047751,3,pass,0.274875;0.273958;0.310167 -boundary_700000000_reverse_masked,6,10,6,bucket32_d64,0.293083,0.265250,0.082750,9,8,1,0,0.047751,3,pass,0.287000;0.323625;0.293083 -boundary_715827883,6,10,6,old_pull_serial,0.138666,0.137959,0.013250,360,6,1,0,0.040916,3,pass,0.141625;0.138666;0.129000 -boundary_715827883,6,10,6,old_group_serial,0.135083,0.134416,0.007250,160,5,1,0,0.040916,3,pass,0.129958;0.135083;0.136334 -boundary_715827883,6,10,6,old_pull_parallel_dag,0.179750,0.154292,0.015875,360,6,1,0,0.040916,3,pass,0.179750;0.179458;0.269333 -boundary_715827883,6,10,6,old_group_parallel_dag,0.162583,0.132250,0.007125,160,5,1,0,0.040916,3,pass,0.158625;0.163708;0.162583 -boundary_715827883,6,10,6,pull64_barrier,0.206000,0.172750,0.018625,360,6,1,0,0.040916,3,pass,0.206000;0.199083;0.206917 -boundary_715827883,6,10,6,pull64_interleaved,0.175583,0.148791,0.014625,360,6,1,0,0.040916,3,pass,0.175583;0.176667;0.174500 -boundary_715827883,6,10,6,pull32_interleaved,0.213000,0.184167,0.016958,360,6,1,1,0.040916,3,pass,0.187083;0.213000;0.231541 -boundary_715827883,6,10,6,pull32_gpu_dag,0.478041,0.166792,0.025458,360,6,3,1,0.040916,3,pass,0.450334;0.478041;0.480208 -boundary_715827883,6,10,6,frontier32_vertex,0.205750,0.181292,0.015625,360,6,1,1,0.040916,3,pass,0.203667;0.233000;0.205750 -boundary_715827883,6,10,6,frontier32_simd,0.223875,0.177625,0.015375,360,6,1,1,0.040916,3,pass,0.190459;0.290500;0.223875 -boundary_715827883,6,10,6,frontier32_gpu_dag,0.444916,0.157208,0.025250,360,6,3,1,0.040916,3,pass,0.467583;0.444916;0.413292 -boundary_715827883,6,10,6,bucket32_d4,0.196333,0.172958,0.016125,360,6,1,1,0.040916,3,pass,0.184209;0.196333;0.215666 -boundary_715827883,6,10,6,bucket32_d16,0.242209,0.208958,0.018667,360,6,1,1,0.040916,3,pass,0.211667;0.299083;0.242209 -boundary_715827883,6,10,6,bucket32_d64,0.224458,0.195459,0.018375,360,6,1,1,0.040916,3,pass,0.210750;0.236125;0.224458 -boundary_715827883_masked,6,10,6,old_pull_serial,0.151083,0.150542,0.015625,360,6,1,0,0.050708,3,pass,0.148667;0.151083;0.152375 -boundary_715827883_masked,6,10,6,old_group_serial,0.115209,0.114500,0.004500,80,2,1,0,0.050708,3,pass,0.110166;0.115833;0.115209 -boundary_715827883_masked,6,10,6,old_pull_parallel_dag,0.161250,0.135084,0.012375,360,6,1,0,0.050708,3,pass,0.157542;0.161250;0.235291 -boundary_715827883_masked,6,10,6,old_group_parallel_dag,0.151542,0.117459,0.004583,80,2,1,0,0.050708,3,pass,0.151542;0.146416;0.166083 -boundary_715827883_masked,6,10,6,pull64_barrier,0.176667,0.157250,0.015167,360,6,1,0,0.050708,3,pass,0.170500;0.176667;0.180958 -boundary_715827883_masked,6,10,6,pull64_interleaved,0.197708,0.169916,0.018750,360,6,1,0,0.050708,3,pass,0.181917;0.371958;0.197708 -boundary_715827883_masked,6,10,6,pull32_interleaved,0.184625,0.165792,0.016000,360,6,1,1,0.050708,3,pass,0.202458;0.184625;0.152500 -boundary_715827883_masked,6,10,6,pull32_gpu_dag,0.514959,0.175000,0.027000,360,6,3,1,0.050708,3,pass,0.543917;0.514959;0.500959 -boundary_715827883_masked,6,10,6,frontier32_vertex,0.220292,0.190708,0.018667,360,6,1,1,0.050708,3,pass,0.220292;0.219792;0.224542 -boundary_715827883_masked,6,10,6,frontier32_simd,0.208333,0.178292,0.016875,360,6,1,1,0.050708,3,pass,0.208333;0.200000;0.220625 -boundary_715827883_masked,6,10,6,frontier32_gpu_dag,0.477083,0.166209,0.025792,360,6,3,1,0.050708,3,pass,0.452125;0.477083;0.483458 -boundary_715827883_masked,6,10,6,bucket32_d4,0.199750,0.170875,0.014625,360,6,1,1,0.050708,3,pass,0.207709;0.199750;0.194875 -boundary_715827883_masked,6,10,6,bucket32_d16,0.203542,0.176209,0.018458,360,6,1,1,0.050708,3,pass,0.203542;0.215333;0.194583 -boundary_715827883_masked,6,10,6,bucket32_d64,0.214750,0.187459,0.018708,360,6,1,1,0.050708,3,pass,0.214750;0.271042;0.200792 -boundary_715827883_reverse,6,10,6,old_pull_serial,0.142375,0.141583,0.015625,360,6,1,0,0.064542,3,pass,0.142375;0.141959;0.143292 -boundary_715827883_reverse,6,10,6,old_group_serial,0.131792,0.131083,0.007333,170,5,1,0,0.064542,3,pass,0.131875;0.124625;0.131792 -boundary_715827883_reverse,6,10,6,old_pull_parallel_dag,0.171792,0.147708,0.015375,360,6,1,0,0.064542,3,pass,0.171083;0.171792;0.212459 -boundary_715827883_reverse,6,10,6,old_group_parallel_dag,0.138209,0.110458,0.007375,170,5,1,0,0.064542,3,pass,0.138209;0.136042;0.163542 -boundary_715827883_reverse,6,10,6,pull64_barrier,0.197583,0.172958,0.018250,360,6,1,0,0.064542,3,pass,0.197583;0.222125;0.194500 -boundary_715827883_reverse,6,10,6,pull64_interleaved,0.165458,0.147709,0.014417,360,6,1,0,0.064542,3,pass,0.162375;0.165458;0.167875 -boundary_715827883_reverse,6,10,6,pull32_interleaved,0.198666,0.168375,0.014833,360,6,1,1,0.064542,3,pass,0.165500;0.198666;0.215500 -boundary_715827883_reverse,6,10,6,pull32_gpu_dag,0.467417,0.169875,0.025167,360,6,3,1,0.064542,3,pass,0.484209;0.463083;0.467417 -boundary_715827883_reverse,6,10,6,frontier32_vertex,0.191292,0.166917,0.015000,360,6,1,1,0.064542,3,pass,0.186208;0.191292;0.258750 -boundary_715827883_reverse,6,10,6,frontier32_simd,0.211083,0.182709,0.014833,360,6,1,1,0.064542,3,pass,0.188125;0.211083;0.254250 -boundary_715827883_reverse,6,10,6,frontier32_gpu_dag,0.491833,0.177916,0.030208,360,6,3,1,0.064542,3,pass,0.492708;0.471250;0.491833 -boundary_715827883_reverse,6,10,6,bucket32_d4,0.214209,0.183084,0.018250,360,6,1,1,0.064542,3,pass,0.220083;0.214209;0.213000 -boundary_715827883_reverse,6,10,6,bucket32_d16,0.198083,0.168416,0.015750,360,6,1,1,0.064542,3,pass,0.192959;0.201250;0.198083 -boundary_715827883_reverse,6,10,6,bucket32_d64,0.207708,0.171250,0.014625,360,6,1,1,0.064542,3,pass,0.169041;0.213625;0.207708 -boundary_715827883_reverse_masked,6,10,6,old_pull_serial,0.146167,0.145500,0.015542,360,6,1,0,0.050874,3,pass,0.146167;0.151125;0.144083 -boundary_715827883_reverse_masked,6,10,6,old_group_serial,0.121125,0.120500,0.004625,90,2,1,0,0.050874,3,pass,0.113875;0.125375;0.121125 -boundary_715827883_reverse_masked,6,10,6,old_pull_parallel_dag,0.178167,0.142541,0.015375,360,6,1,0,0.050874,3,pass,0.192833;0.178167;0.162709 -boundary_715827883_reverse_masked,6,10,6,old_group_parallel_dag,0.179375,0.116500,0.004750,90,2,1,0,0.050874,3,pass,0.260584;0.147875;0.179375 -boundary_715827883_reverse_masked,6,10,6,pull64_barrier,0.191292,0.164500,0.014750,360,6,1,0,0.050874,3,pass,0.188666;0.193417;0.191292 -boundary_715827883_reverse_masked,6,10,6,pull64_interleaved,0.185625,0.155208,0.014917,360,6,1,0,0.050874,3,pass,0.185625;0.185625;0.199875 -boundary_715827883_reverse_masked,6,10,6,pull32_interleaved,0.205125,0.184083,0.014708,360,6,1,1,0.050874,3,pass,0.205125;0.218167;0.152416 -boundary_715827883_reverse_masked,6,10,6,pull32_gpu_dag,0.456500,0.166667,0.025708,360,6,3,1,0.050874,3,pass,0.448250;0.456500;0.458416 -boundary_715827883_reverse_masked,6,10,6,frontier32_vertex,0.224834,0.192542,0.015500,360,6,1,1,0.050874,3,pass,0.196542;0.224834;0.225916 -boundary_715827883_reverse_masked,6,10,6,frontier32_simd,0.240625,0.216833,0.017708,360,6,1,1,0.050874,3,pass,0.266959;0.216208;0.240625 -boundary_715827883_reverse_masked,6,10,6,frontier32_gpu_dag,0.453500,0.159958,0.024583,360,6,3,1,0.050874,3,pass,0.492083;0.453500;0.440208 -boundary_715827883_reverse_masked,6,10,6,bucket32_d4,0.214875,0.172750,0.014792,360,6,1,1,0.050874,3,pass,0.247000;0.193750;0.214875 -boundary_715827883_reverse_masked,6,10,6,bucket32_d16,0.187625,0.160917,0.015458,360,6,1,1,0.050874,3,pass,0.187625;0.181125;0.195459 -boundary_715827883_reverse_masked,6,10,6,bucket32_d64,0.197041,0.165458,0.014458,360,6,1,1,0.050874,3,pass,0.218791;0.189042;0.197041 -boundary_9007199254740993,6,10,6,old_pull_serial,0.151375,0.150709,0.015500,360,6,1,0,0.076292,3,pass,0.151708;0.151375;0.139125 -boundary_9007199254740993,6,10,6,old_group_serial,0.130667,0.130000,0.007292,160,5,1,0,0.076292,3,pass,0.133083;0.130667;0.128084 -boundary_9007199254740993,6,10,6,old_pull_parallel_dag,0.158459,0.127875,0.013000,360,6,1,0,0.076292,3,pass,0.174083;0.158459;0.155833 -boundary_9007199254740993,6,10,6,old_group_parallel_dag,0.153917,0.114500,0.007250,160,5,1,0,0.076292,3,pass,0.139083;0.153917;0.216042 -boundary_9007199254740993,6,10,6,pull64_barrier,0.212041,0.185541,0.018250,360,6,1,0,0.076292,3,pass,0.222208;0.212041;0.211666 -boundary_9007199254740993,6,10,6,pull64_interleaved,0.220791,0.200791,0.018500,360,6,1,0,0.076292,3,pass,0.213500;0.225792;0.220791 -boundary_9007199254740993,6,10,6,pull32_interleaved,0.217666,0.176209,0.018500,360,6,1,1,0.076292,3,pass,0.219917;0.217666;0.196792 -boundary_9007199254740993,6,10,6,pull32_gpu_dag,0.463208,0.164083,0.025750,360,6,3,1,0.076292,3,pass,0.441750;0.509791;0.463208 -boundary_9007199254740993,6,10,6,frontier32_vertex,0.201125,0.172583,0.015042,360,6,1,1,0.076292,3,pass,0.201125;0.204333;0.196791 -boundary_9007199254740993,6,10,6,frontier32_simd,0.214166,0.189625,0.018167,360,6,1,1,0.076292,3,pass,0.189208;0.214166;0.248125 -boundary_9007199254740993,6,10,6,frontier32_gpu_dag,0.478042,0.168875,0.025083,360,6,3,1,0.076292,3,pass,0.478042;0.488375;0.465625 -boundary_9007199254740993,6,10,6,bucket32_d4,0.173833,0.150083,0.015000,360,6,1,1,0.076292,3,pass,0.178250;0.165500;0.173833 -boundary_9007199254740993,6,10,6,bucket32_d16,0.195291,0.176625,0.015875,360,6,1,1,0.076292,3,pass,0.195291;0.169709;0.214875 -boundary_9007199254740993,6,10,6,bucket32_d64,0.214458,0.185625,0.018375,360,6,1,1,0.076292,3,pass,0.214958;0.214458;0.209167 -boundary_9007199254740993_masked,6,10,6,old_pull_serial,0.134875,0.133917,0.013500,360,6,1,0,0.047375,3,pass,0.135166;0.131916;0.134875 -boundary_9007199254740993_masked,6,10,6,old_group_serial,0.115042,0.114542,0.004500,80,2,1,0,0.047375,3,pass,0.116834;0.115042;0.114791 -boundary_9007199254740993_masked,6,10,6,old_pull_parallel_dag,0.170084,0.138917,0.013500,360,6,1,0,0.047375,3,pass,0.176333;0.167167;0.170084 -boundary_9007199254740993_masked,6,10,6,old_group_parallel_dag,0.145709,0.115500,0.004542,80,2,1,0,0.047375,3,pass,0.150375;0.139542;0.145709 -boundary_9007199254740993_masked,6,10,6,pull64_barrier,0.190417,0.166334,0.015000,360,6,1,0,0.047375,3,pass,0.184792;0.190417;0.223041 -boundary_9007199254740993_masked,6,10,6,pull64_interleaved,0.203709,0.175542,0.015750,360,6,1,0,0.047375,3,pass,0.285042;0.176916;0.203709 -boundary_9007199254740993_masked,6,10,6,pull32_interleaved,0.203958,0.167292,0.015750,360,6,1,1,0.047375,3,pass,0.197000;0.203958;0.214125 -boundary_9007199254740993_masked,6,10,6,pull32_gpu_dag,0.539333,0.189458,0.029250,360,6,3,1,0.047375,3,pass,0.608375;0.518625;0.539333 -boundary_9007199254740993_masked,6,10,6,frontier32_vertex,0.232208,0.197458,0.018208,360,6,1,1,0.047375,3,pass,0.221042;0.240625;0.232208 -boundary_9007199254740993_masked,6,10,6,frontier32_simd,0.233917,0.196458,0.018125,360,6,1,1,0.047375,3,pass,0.223083;0.240792;0.233917 -boundary_9007199254740993_masked,6,10,6,frontier32_gpu_dag,0.422375,0.155167,0.024625,360,6,3,1,0.047375,3,pass,0.490000;0.414500;0.422375 -boundary_9007199254740993_masked,6,10,6,bucket32_d4,0.208042,0.167500,0.015833,360,6,1,1,0.047375,3,pass,0.171542;0.251167;0.208042 -boundary_9007199254740993_masked,6,10,6,bucket32_d16,0.211583,0.184792,0.018250,360,6,1,1,0.047375,3,pass,0.207250;0.211583;0.235791 -boundary_9007199254740993_masked,6,10,6,bucket32_d64,0.208375,0.179584,0.015500,360,6,1,1,0.047375,3,pass,0.218709;0.184000;0.208375 -boundary_9007199254740993_reverse,6,10,6,old_pull_serial,0.154083,0.153375,0.015792,360,6,1,0,0.049749,3,pass,0.153750;0.155167;0.154083 -boundary_9007199254740993_reverse,6,10,6,old_group_serial,0.128625,0.127917,0.007000,170,5,1,0,0.049749,3,pass,0.126834;0.128625;0.135833 -boundary_9007199254740993_reverse,6,10,6,old_pull_parallel_dag,0.193166,0.157292,0.015750,360,6,1,0,0.049749,3,pass,0.174334;0.200000;0.193166 -boundary_9007199254740993_reverse,6,10,6,old_group_parallel_dag,0.188292,0.145041,0.007125,170,5,1,0,0.049749,3,pass,0.188292;0.173708;0.291250 -boundary_9007199254740993_reverse,6,10,6,pull64_barrier,0.170083,0.155208,0.015625,360,6,1,0,0.049749,3,pass,0.170083;0.167583;0.223333 -boundary_9007199254740993_reverse,6,10,6,pull64_interleaved,0.201416,0.168208,0.015750,360,6,1,0,0.049749,3,pass,0.202792;0.188833;0.201416 -boundary_9007199254740993_reverse,6,10,6,pull32_interleaved,0.215625,0.186917,0.014750,360,6,1,1,0.049749,3,pass,0.215625;0.282458;0.209250 -boundary_9007199254740993_reverse,6,10,6,pull32_gpu_dag,0.476417,0.157792,0.024708,360,6,3,1,0.049749,3,pass,0.510917;0.476417;0.447250 -boundary_9007199254740993_reverse,6,10,6,frontier32_vertex,0.199833,0.172042,0.014625,360,6,1,1,0.049749,3,pass,0.199833;0.199333;0.214375 -boundary_9007199254740993_reverse,6,10,6,frontier32_simd,0.214166,0.186291,0.017625,360,6,1,1,0.049749,3,pass,0.214166;0.226250;0.209666 -boundary_9007199254740993_reverse,6,10,6,frontier32_gpu_dag,0.489500,0.175208,0.028917,360,6,3,1,0.049749,3,pass,0.490166;0.489500;0.489458 -boundary_9007199254740993_reverse,6,10,6,bucket32_d4,0.221417,0.177333,0.017583,360,6,1,1,0.049749,3,pass,0.224625;0.221417;0.211750 -boundary_9007199254740993_reverse,6,10,6,bucket32_d16,0.195875,0.165959,0.015583,360,6,1,1,0.049749,3,pass,0.202792;0.195875;0.184833 -boundary_9007199254740993_reverse,6,10,6,bucket32_d64,0.203959,0.175333,0.015542,360,6,1,1,0.049749,3,pass,0.217291;0.203959;0.190542 -boundary_9007199254740993_reverse_masked,6,10,6,old_pull_serial,0.133000,0.132334,0.013000,360,6,1,0,0.041167,3,pass,0.133000;0.133125;0.128958 -boundary_9007199254740993_reverse_masked,6,10,6,old_group_serial,0.119083,0.118500,0.004750,90,2,1,0,0.041167,3,pass,0.119083;0.119792;0.114042 -boundary_9007199254740993_reverse_masked,6,10,6,old_pull_parallel_dag,0.159833,0.137375,0.013125,360,6,1,0,0.041167,3,pass,0.159833;0.146084;0.166167 -boundary_9007199254740993_reverse_masked,6,10,6,old_group_parallel_dag,0.142542,0.113667,0.004625,90,2,1,0,0.041167,3,pass,0.142542;0.134041;0.152000 -boundary_9007199254740993_reverse_masked,6,10,6,pull64_barrier,0.203000,0.176166,0.014958,360,6,1,0,0.041167,3,pass,0.203000;0.223417;0.194500 -boundary_9007199254740993_reverse_masked,6,10,6,pull64_interleaved,0.216167,0.192375,0.018458,360,6,1,0,0.041167,3,pass,0.216167;0.239167;0.214833 -boundary_9007199254740993_reverse_masked,6,10,6,pull32_interleaved,0.228000,0.199625,0.018500,360,6,1,1,0.041167,3,pass,0.212208;0.251917;0.228000 -boundary_9007199254740993_reverse_masked,6,10,6,pull32_gpu_dag,0.477958,0.185875,0.028208,360,6,3,1,0.041167,3,pass,0.513500;0.477958;0.449834 -boundary_9007199254740993_reverse_masked,6,10,6,frontier32_vertex,0.200500,0.164375,0.015625,360,6,1,1,0.041167,3,pass,0.200500;0.201750;0.197334 -boundary_9007199254740993_reverse_masked,6,10,6,frontier32_simd,0.206292,0.181375,0.015500,360,6,1,1,0.041167,3,pass,0.177125;0.209125;0.206292 -boundary_9007199254740993_reverse_masked,6,10,6,frontier32_gpu_dag,0.460708,0.166541,0.024458,360,6,3,1,0.041167,3,pass,0.446750;0.543625;0.460708 -boundary_9007199254740993_reverse_masked,6,10,6,bucket32_d4,0.198792,0.168667,0.015292,360,6,1,1,0.041167,3,pass,0.195500;0.385292;0.198792 -boundary_9007199254740993_reverse_masked,6,10,6,bucket32_d16,0.194750,0.169750,0.018125,360,6,1,1,0.041167,3,pass,0.194750;0.219708;0.190083 -boundary_9007199254740993_reverse_masked,6,10,6,bucket32_d64,0.185584,0.163667,0.015417,360,6,1,1,0.041167,3,pass,0.196542;0.185584;0.179292 diff --git a/dev/research/metal_pathfinding/optimization/results/correctness-sanitized-stderr.txt b/dev/research/metal_pathfinding/optimization/results/correctness-sanitized-stderr.txt deleted file mode 100644 index d582a21..0000000 --- a/dev/research/metal_pathfinding/optimization/results/correctness-sanitized-stderr.txt +++ /dev/null @@ -1,3 +0,0 @@ -2026-09-27 00:31:08.125 optimized-sanitize[19832:38715213] Metal API Validation Enabled -2026-09-27 00:31:08.125 optimized-sanitize[19832:38715213] Metal GPU Validation Enabled -device=Apple M4 Max setup_ms=39.7833 execution_width=32 diff --git a/dev/research/metal_pathfinding/optimization/results/correctness-sanitized.csv b/dev/research/metal_pathfinding/optimization/results/correctness-sanitized.csv deleted file mode 100644 index 4837679..0000000 --- a/dev/research/metal_pathfinding/optimization/results/correctness-sanitized.csv +++ /dev/null @@ -1,337 +0,0 @@ -case,n,e,queries,method,wall_ms,distance_ms,gpu_ms,edge_visits,rounds,waits,fallback64,upload_ms,samples,correct,wall_samples_ms -random_masked_int64,40,240,8,old_pull_serial,1.305000,1.251917,0.388084,15360,8,1,0,0.187500,3,pass,1.305000;1.505917;1.293750 -random_masked_int64,40,240,8,old_group_serial,0.662375,0.612584,0.239000,9600,6,1,0,0.187500,3,pass,0.709584;0.649000;0.662375 -random_masked_int64,40,240,8,old_pull_parallel_dag,1.278583,1.221584,0.392333,15360,8,1,0,0.187500,3,pass,1.278583;1.620833;0.898625 -random_masked_int64,40,240,8,old_group_parallel_dag,0.944375,0.883166,0.242375,9600,6,1,0,0.187500,3,pass,0.684041;0.944375;0.968250 -random_masked_int64,40,240,8,pull64_barrier,0.858417,0.809750,0.387417,15360,8,1,0,0.187500,3,pass,0.840584;0.940000;0.858417 -random_masked_int64,40,240,8,pull64_interleaved,0.916834,0.887542,0.383708,15360,8,1,0,0.187500,3,pass,0.980791;0.914417;0.916834 -random_masked_int64,40,240,8,pull32_interleaved,0.893708,0.818250,0.384458,15360,8,1,1,0.187500,3,pass,0.893708;0.939958;0.780333 -random_masked_int64,40,240,8,pull32_gpu_dag,1.693708,0.810209,0.543750,15360,8,3,1,0.187500,3,pass,1.801291;1.693708;1.610209 -random_masked_int64,40,240,8,frontier32_vertex,0.870416,0.832084,0.388417,15360,8,1,1,0.187500,3,pass,0.866417;0.870416;1.194333 -random_masked_int64,40,240,8,frontier32_simd,0.861208,0.819958,0.387375,15360,8,1,1,0.187500,3,pass,0.861208;0.842833;0.864708 -random_masked_int64,40,240,8,frontier32_gpu_dag,1.861958,0.874417,0.545708,15360,8,3,1,0.187500,3,pass,1.861958;1.889208;1.675625 -random_masked_int64,40,240,8,bucket32_d4,0.833750,0.785333,0.387375,15360,8,1,1,0.187500,3,pass,0.830167;0.833750;0.892917 -random_masked_int64,40,240,8,bucket32_d16,0.863917,0.836792,0.387875,15360,8,1,1,0.187500,3,pass,0.863917;0.800167;1.445083 -random_masked_int64,40,240,8,bucket32_d64,0.807500,0.786333,0.388625,15360,8,1,1,0.187500,3,pass,0.807500;0.789000;0.919250 -random_masked,43,258,8,old_pull_serial,1.816417,1.765916,0.864583,33024,16,2,0,0.134667,3,pass,1.891250;1.752917;1.816417 -random_masked,43,258,8,old_group_serial,0.745500,0.697542,0.388875,14448,9,1,0,0.134667,3,pass,0.798208;0.745500;0.744750 -random_masked,43,258,8,old_pull_parallel_dag,1.871250,1.797750,0.864750,33024,16,2,0,0.134667,3,pass,1.871250;1.811917;1.984583 -random_masked,43,258,8,old_group_parallel_dag,0.810250,0.755792,0.389375,14448,9,1,0,0.134667,3,pass,0.793417;0.927500;0.810250 -random_masked,43,258,8,pull64_barrier,1.677125,1.634167,0.874458,33024,16,2,0,0.134667,3,pass,1.700375;1.677125;1.629541 -random_masked,43,258,8,pull64_interleaved,1.780833,1.706875,0.849500,33024,16,2,0,0.134667,3,pass,1.780833;2.399292;1.724625 -random_masked,43,258,8,pull32_interleaved,1.692917,1.629125,0.832417,33024,16,2,0,0.134667,3,pass,1.668875;1.946625;1.692917 -random_masked,43,258,8,pull32_gpu_dag,2.853875,1.804708,1.005667,33024,16,4,0,0.134667,3,pass,2.853875;2.712750;3.286375 -random_masked,43,258,8,frontier32_vertex,2.068750,2.032708,1.006875,3186,16,2,0,0.134667,3,pass,2.099875;2.068750;2.005750 -random_masked,43,258,8,frontier32_simd,1.752541,1.680541,0.715792,3186,16,2,0,0.134667,3,pass,1.767250;1.705959;1.752541 -random_masked,43,258,8,frontier32_gpu_dag,2.701125,1.739500,0.889792,3186,16,4,0,0.134667,3,pass,2.713208;2.701125;2.674958 -random_masked,43,258,8,bucket32_d4,5.472583,5.439167,2.785792,2016,32,4,0,0.134667,3,pass,5.472583;5.632459;5.401875 -random_masked,43,258,8,bucket32_d16,4.053875,4.000750,1.943625,2142,24,3,0,0.134667,3,pass,4.177917;4.053875;3.912167 -random_masked,43,258,8,bucket32_d64,2.552333,2.496084,1.264875,3096,16,2,0,0.134667,3,pass,2.517542;2.552333;2.737708 -random_masked_int64_reverse,46,276,8,old_pull_serial,0.725666,0.680958,0.277125,17664,8,1,0,0.114291,3,pass,0.725666;0.723708;0.774292 -random_masked_int64_reverse,46,276,8,old_group_serial,0.499333,0.453375,0.139292,9660,5,1,0,0.114291,3,pass,0.503500;0.499333;0.497209 -random_masked_int64_reverse,46,276,8,old_pull_parallel_dag,0.768667,0.727833,0.273125,17664,8,1,0,0.114291,3,pass,0.756125;0.768667;0.845458 -random_masked_int64_reverse,46,276,8,old_group_parallel_dag,0.537541,0.473708,0.139583,9660,5,1,0,0.114291,3,pass,0.488792;0.605750;0.537541 -random_masked_int64_reverse,46,276,8,pull64_barrier,0.699666,0.659833,0.279792,17664,8,1,0,0.114291,3,pass,0.690500;0.699666;0.770750 -random_masked_int64_reverse,46,276,8,pull64_interleaved,0.795000,0.759666,0.276500,17664,8,1,0,0.114291,3,pass,0.673833;0.795000;0.807875 -random_masked_int64_reverse,46,276,8,pull32_interleaved,0.701542,0.669667,0.277500,17664,8,1,1,0.114291,3,pass,0.738458;0.699833;0.701542 -random_masked_int64_reverse,46,276,8,pull32_gpu_dag,1.643583,0.757125,0.432958,17664,8,3,1,0.114291,3,pass,1.528334;1.643583;2.293166 -random_masked_int64_reverse,46,276,8,frontier32_vertex,0.812042,0.783500,0.278917,17664,8,1,1,0.114291,3,pass,0.789375;0.812042;0.827167 -random_masked_int64_reverse,46,276,8,frontier32_simd,0.686250,0.659958,0.278958,17664,8,1,1,0.114291,3,pass,0.769750;0.686250;0.677625 -random_masked_int64_reverse,46,276,8,frontier32_gpu_dag,1.673041,0.716709,0.444458,17664,8,3,1,0.114291,3,pass,1.672209;1.686333;1.673041 -random_masked_int64_reverse,46,276,8,bucket32_d4,0.763750,0.711875,0.279542,17664,8,1,1,0.114291,3,pass,0.850292;0.763750;0.711584 -random_masked_int64_reverse,46,276,8,bucket32_d16,0.714417,0.681666,0.279375,17664,8,1,1,0.114291,3,pass,0.730958;0.714417;0.696417 -random_masked_int64_reverse,46,276,8,bucket32_d64,0.819333,0.792500,0.287583,17664,8,1,1,0.114291,3,pass,0.708167;0.862708;0.819333 -random_masked,49,294,8,old_pull_serial,0.912166,0.849334,0.399792,18816,8,1,0,0.131500,3,pass,0.895625;0.912166;0.972667 -random_masked,49,294,8,old_group_serial,0.707958,0.650833,0.334708,14700,8,1,0,0.131500,3,pass,0.695959;0.707958;0.813875 -random_masked,49,294,8,old_pull_parallel_dag,0.953958,0.891208,0.400417,18816,8,1,0,0.131500,3,pass,0.953958;0.925708;0.999541 -random_masked,49,294,8,old_group_parallel_dag,0.732625,0.681541,0.336041,14700,8,1,0,0.131500,3,pass,0.840042;0.732625;0.710167 -random_masked,49,294,8,pull64_barrier,0.980833,0.928208,0.411917,18816,8,1,0,0.131500,3,pass,0.980833;1.019542;0.956458 -random_masked,49,294,8,pull64_interleaved,0.907834,0.873709,0.406750,18816,8,1,0,0.131500,3,pass,0.907834;0.824334;0.919709 -random_masked,49,294,8,pull32_interleaved,0.880542,0.835917,0.400792,18816,8,1,0,0.131500,3,pass,0.926958;0.865916;0.880542 -random_masked,49,294,8,pull32_gpu_dag,1.848041,0.831584,0.555000,18816,8,3,0,0.131500,3,pass,1.851708;1.848041;1.821375 -random_masked,49,294,8,frontier32_vertex,1.280584,1.250875,0.722167,3162,8,1,0,0.131500,3,pass,1.280584;1.279417;1.340833 -random_masked,49,294,8,frontier32_simd,1.044291,1.005875,0.445125,3162,8,1,0,0.131500,3,pass,1.044291;1.055583;1.007334 -random_masked,49,294,8,frontier32_gpu_dag,1.994708,1.010000,0.611417,3162,8,3,0,0.131500,3,pass,1.994708;1.904583;2.033125 -random_masked,49,294,8,bucket32_d4,6.611250,6.579000,3.411958,2022,40,5,0,0.131500,3,pass,6.611250;6.410583;6.744250 -random_masked,49,294,8,bucket32_d16,2.816417,2.782209,1.379792,2100,16,2,0,0.131500,3,pass,2.816417;2.825666;2.779209 -random_masked,49,294,8,bucket32_d64,2.587125,2.546291,1.261875,3042,16,2,0,0.131500,3,pass,2.662292;2.528083;2.587125 -random_masked_int64,52,312,8,old_pull_serial,1.008375,0.936208,0.431125,19968,8,1,0,0.130084,3,pass,0.977584;1.008375;1.090209 -random_masked_int64,52,312,8,old_group_serial,0.734125,0.678209,0.273083,12168,6,1,0,0.130084,3,pass,0.734125;0.675416;0.818375 -random_masked_int64,52,312,8,old_pull_parallel_dag,1.063833,0.981458,0.435875,19968,8,1,0,0.130084,3,pass,1.069209;1.063833;0.975292 -random_masked_int64,52,312,8,old_group_parallel_dag,0.686000,0.634333,0.273375,12168,6,1,0,0.130084,3,pass,0.686000;0.677208;0.706125 -random_masked_int64,52,312,8,pull64_barrier,0.988333,0.939583,0.442167,19968,8,1,0,0.130084,3,pass,1.042667;0.988333;0.958750 -random_masked_int64,52,312,8,pull64_interleaved,0.941458,0.907416,0.431375,19968,8,1,0,0.130084,3,pass,0.995000;0.941458;0.899333 -random_masked_int64,52,312,8,pull32_interleaved,0.905667,0.842833,0.432833,19968,8,1,1,0.130084,3,pass,0.884083;0.963625;0.905667 -random_masked_int64,52,312,8,pull32_gpu_dag,1.744917,0.859709,0.600709,19968,8,3,1,0.130084,3,pass,1.744917;1.952834;1.730417 -random_masked_int64,52,312,8,frontier32_vertex,0.942875,0.908416,0.443208,19968,8,1,1,0.130084,3,pass,1.126625;0.942875;0.903625 -random_masked_int64,52,312,8,frontier32_simd,0.930000,0.893125,0.444292,19968,8,1,1,0.130084,3,pass,1.035125;0.900375;0.930000 -random_masked_int64,52,312,8,frontier32_gpu_dag,1.817958,0.863041,0.621125,19968,8,3,1,0.130084,3,pass,1.817958;1.958042;1.713334 -random_masked_int64,52,312,8,bucket32_d4,1.009750,0.944959,0.444292,19968,8,1,1,0.130084,3,pass,1.009750;1.031416;0.967042 -random_masked_int64,52,312,8,bucket32_d16,0.990000,0.950875,0.441375,19968,8,1,1,0.130084,3,pass,0.946166;1.003458;0.990000 -random_masked_int64,52,312,8,bucket32_d64,0.956208,0.909084,0.444125,19968,8,1,1,0.130084,3,pass,0.956208;1.163417;0.916875 -random_masked_reverse,55,330,8,old_pull_serial,1.468792,1.411000,0.547750,42240,16,2,0,0.145874,3,pass,1.547333;1.468792;1.416917 -random_masked_reverse,55,330,8,old_group_serial,0.628292,0.555000,0.250792,20130,10,1,0,0.145874,3,pass,0.604167;0.628292;0.647167 -random_masked_reverse,55,330,8,old_pull_parallel_dag,1.589875,1.536666,0.563625,42240,16,2,0,0.145874,3,pass,1.558250;1.589875;1.648709 -random_masked_reverse,55,330,8,old_group_parallel_dag,0.655500,0.609875,0.254042,20130,10,1,0,0.145874,3,pass,0.655500;0.684291;0.620666 -random_masked_reverse,55,330,8,pull64_barrier,1.385209,1.349834,0.555958,42240,16,2,0,0.145874,3,pass,1.370375;1.420375;1.385209 -random_masked_reverse,55,330,8,pull64_interleaved,1.376791,1.336750,0.551458,42240,16,2,0,0.145874,3,pass,1.376791;1.485166;1.334750 -random_masked_reverse,55,330,8,pull32_interleaved,1.521625,1.473958,0.545167,42240,16,2,0,0.145874,3,pass,1.521625;1.556750;1.415291 -random_masked_reverse,55,330,8,pull32_gpu_dag,2.495833,1.471417,0.716917,42240,16,4,0,0.145874,3,pass,2.495833;2.549042;2.468584 -random_masked_reverse,55,330,8,frontier32_vertex,2.342125,2.304500,1.255875,3691,16,2,0,0.145874,3,pass,2.353792;2.315083;2.342125 -random_masked_reverse,55,330,8,frontier32_simd,1.787084,1.743208,0.743833,3691,16,2,0,0.145874,3,pass,1.787084;1.761209;2.490834 -random_masked_reverse,55,330,8,frontier32_gpu_dag,2.778750,1.799208,0.919250,3691,16,4,0,0.145874,3,pass,2.778750;2.952958;2.660834 -random_masked_reverse,55,330,8,bucket32_d4,5.850292,5.799500,2.949250,2455,32,4,0,0.145874,3,pass,5.722000;5.850292;6.357334 -random_masked_reverse,55,330,8,bucket32_d16,4.124625,4.079792,1.998667,2603,24,3,0,0.145874,3,pass,4.202209;3.872583;4.124625 -random_masked_reverse,55,330,8,bucket32_d64,2.865084,2.815584,1.337000,3605,16,2,0,0.145874,3,pass,2.865084;2.912375;2.795334 -random_masked_int64,58,348,8,old_pull_serial,0.872709,0.795709,0.367625,22272,8,1,0,0.140833,3,pass,0.872709;0.911750;0.861792 -random_masked_int64,58,348,8,old_group_serial,0.582042,0.518041,0.195208,13920,5,1,0,0.140833,3,pass,0.600833;0.582042;0.571458 -random_masked_int64,58,348,8,old_pull_parallel_dag,0.925708,0.859042,0.369625,22272,8,1,0,0.140833,3,pass,0.875667;0.925708;0.991000 -random_masked_int64,58,348,8,old_group_parallel_dag,0.591500,0.533333,0.195500,13920,5,1,0,0.140833,3,pass,0.570542;0.615000;0.591500 -random_masked_int64,58,348,8,pull64_barrier,0.888500,0.827500,0.373000,22272,8,1,0,0.140833,3,pass,0.888500;0.842500;0.918833 -random_masked_int64,58,348,8,pull64_interleaved,0.856625,0.803667,0.364000,22272,8,1,0,0.140833,3,pass,0.807459;0.856625;0.887834 -random_masked_int64,58,348,8,pull32_interleaved,0.867709,0.820791,0.365125,22272,8,1,1,0.140833,3,pass,0.867709;0.974041;0.866375 -random_masked_int64,58,348,8,pull32_gpu_dag,1.867583,0.887667,0.521750,22272,8,3,1,0.140833,3,pass,1.941917;1.784875;1.867583 -random_masked_int64,58,348,8,frontier32_vertex,0.856625,0.815334,0.371167,22272,8,1,1,0.140833,3,pass,0.856625;0.830417;0.954167 -random_masked_int64,58,348,8,frontier32_simd,0.849583,0.811958,0.373375,22272,8,1,1,0.140833,3,pass,0.831125;0.855291;0.849583 -random_masked_int64,58,348,8,frontier32_gpu_dag,1.659250,0.743042,0.533083,22272,8,3,1,0.140833,3,pass,1.560875;1.762667;1.659250 -random_masked_int64,58,348,8,bucket32_d4,0.899042,0.835083,0.373625,22272,8,1,1,0.140833,3,pass,0.966625;0.899042;0.873084 -random_masked_int64,58,348,8,bucket32_d16,0.910541,0.856459,0.373750,22272,8,1,1,0.140833,3,pass,0.910541;1.533667;0.883916 -random_masked_int64,58,348,8,bucket32_d64,0.893583,0.853333,0.372958,22272,8,1,1,0.140833,3,pass,0.803375;0.893583;0.899000 -random_masked,61,366,8,old_pull_serial,1.854125,1.769125,0.870042,46848,16,2,0,0.123125,3,pass,1.854125;1.937959;1.735333 -random_masked,61,366,8,old_group_serial,0.791958,0.719834,0.400583,23424,9,1,0,0.123125,3,pass,0.748750;0.791958;0.793584 -random_masked,61,366,8,old_pull_parallel_dag,1.979500,1.922542,0.870375,46848,16,2,0,0.123125,3,pass,1.889000;2.052833;1.979500 -random_masked,61,366,8,old_group_parallel_dag,0.820458,0.764166,0.425292,23424,9,1,0,0.123125,3,pass,0.820458;0.836709;0.781541 -random_masked,61,366,8,pull64_barrier,1.749375,1.692542,0.875958,46848,16,2,0,0.123125,3,pass,1.749375;1.930875;1.732417 -random_masked,61,366,8,pull64_interleaved,2.035125,1.985166,0.864833,46848,16,2,0,0.123125,3,pass,2.035125;2.042292;1.776708 -random_masked,61,366,8,pull32_interleaved,1.778542,1.701709,0.847667,46848,16,2,0,0.123125,3,pass,1.744000;1.778542;1.834250 -random_masked,61,366,8,pull32_gpu_dag,2.837875,1.714542,1.017125,46848,16,4,0,0.123125,3,pass,2.837875;2.750916;2.866750 -random_masked,61,366,8,frontier32_vertex,2.246167,2.191792,1.042292,4776,16,2,0,0.123125,3,pass,2.209041;2.247041;2.246167 -random_masked,61,366,8,frontier32_simd,1.770291,1.724917,0.722583,4776,16,2,0,0.123125,3,pass,1.730292;1.813792;1.770291 -random_masked,61,366,8,frontier32_gpu_dag,2.942834,1.920750,0.893500,4776,16,4,0,0.123125,3,pass,2.942834;3.091875;2.812417 -random_masked,61,366,8,bucket32_d4,6.850750,6.795292,3.736667,2760,40,5,0,0.123125,3,pass,7.512667;6.758334;6.850750 -random_masked,61,366,8,bucket32_d16,4.147667,4.106708,2.052875,2952,24,3,0,0.123125,3,pass,4.100166;4.667291;4.147667 -random_masked,61,366,8,bucket32_d64,2.798166,2.741958,1.319584,4596,16,2,0,0.123125,3,pass,2.756084;3.563709;2.798166 -random_masked_int64_reverse,64,384,8,old_pull_serial,0.886167,0.795042,0.279500,24576,8,1,0,0.147292,3,pass,0.886167;0.924708;0.759458 -random_masked_int64_reverse,64,384,8,old_group_serial,0.507208,0.449959,0.141625,13440,5,1,0,0.147292,3,pass,0.507208;0.502583;0.509542 -random_masked_int64_reverse,64,384,8,old_pull_parallel_dag,0.732416,0.688709,0.281375,24576,8,1,0,0.147292,3,pass,0.732416;1.318458;0.720583 -random_masked_int64_reverse,64,384,8,old_group_parallel_dag,0.466750,0.434750,0.142042,13440,5,1,0,0.147292,3,pass,0.491667;0.466750;0.456500 -random_masked_int64_reverse,64,384,8,pull64_barrier,0.677125,0.645208,0.289000,24576,8,1,0,0.147292,3,pass,0.678833;0.671916;0.677125 -random_masked_int64_reverse,64,384,8,pull64_interleaved,0.685625,0.651416,0.279750,24576,8,1,0,0.147292,3,pass,0.675625;0.718500;0.685625 -random_masked_int64_reverse,64,384,8,pull32_interleaved,0.719459,0.686375,0.279750,24576,8,1,1,0.147292,3,pass,0.718459;0.739583;0.719459 -random_masked_int64_reverse,64,384,8,pull32_gpu_dag,1.532500,0.645084,0.439208,24576,8,3,1,0.147292,3,pass,1.444958;1.578208;1.532500 -random_masked_int64_reverse,64,384,8,frontier32_vertex,1.130167,1.083291,0.283625,24576,8,1,1,0.147292,3,pass,0.664542;3.123333;1.130167 -random_masked_int64_reverse,64,384,8,frontier32_simd,0.741375,0.696458,0.286042,24576,8,1,1,0.147292,3,pass,0.741375;0.843708;0.697667 -random_masked_int64_reverse,64,384,8,frontier32_gpu_dag,1.513292,0.679583,0.447292,24576,8,3,1,0.147292,3,pass,1.513292;1.511542;4.620750 -random_masked_int64_reverse,64,384,8,bucket32_d4,0.714208,0.677750,0.282125,24576,8,1,1,0.147292,3,pass,0.689625;0.714208;1.098000 -random_masked_int64_reverse,64,384,8,bucket32_d16,0.723459,0.685500,0.281542,24576,8,1,1,0.147292,3,pass,0.718500;0.768875;0.723459 -random_masked_int64_reverse,64,384,8,bucket32_d64,0.729791,0.695958,0.284250,24576,8,1,1,0.147292,3,pass,0.796542;0.725416;0.729791 -random_masked,67,402,8,old_pull_serial,0.857292,0.785291,0.370417,25728,8,1,0,0.128293,3,pass,0.837916;0.857292;0.890875 -random_masked,67,402,8,old_group_serial,0.668833,0.594291,0.296083,24522,8,1,0,0.128293,3,pass,0.672292;0.664500;0.668833 -random_masked,67,402,8,old_pull_parallel_dag,0.928375,0.891000,0.373042,25728,8,1,0,0.128293,3,pass,0.888000;0.928375;0.974042 -random_masked,67,402,8,old_group_parallel_dag,0.723875,0.671833,0.297042,24522,8,1,0,0.128293,3,pass,0.723875;0.910833;0.642500 -random_masked,67,402,8,pull64_barrier,0.831834,0.788125,0.378083,25728,8,1,0,0.128293,3,pass,0.817416;0.855125;0.831834 -random_masked,67,402,8,pull64_interleaved,0.829583,0.785417,0.370458,25728,8,1,0,0.128293,3,pass,0.829583;0.820084;0.854083 -random_masked,67,402,8,pull32_interleaved,0.804083,0.767791,0.361458,25728,8,1,0,0.128293,3,pass,0.804083;0.839708;0.797292 -random_masked,67,402,8,pull32_gpu_dag,1.623708,0.745958,0.508833,25728,8,3,0,0.128293,3,pass,1.654500;1.565750;1.623708 -random_masked,67,402,8,frontier32_vertex,1.267000,1.217542,0.746125,5034,8,1,0,0.128293,3,pass,1.245000;1.289917;1.267000 -random_masked,67,402,8,frontier32_simd,1.016708,0.960041,0.448250,5034,8,1,0,0.128293,3,pass,1.627917;1.016708;1.000916 -random_masked,67,402,8,frontier32_gpu_dag,1.809334,0.957875,0.597875,5034,8,3,0,0.128293,3,pass,1.787542;1.809334;1.864500 -random_masked,67,402,8,bucket32_d4,5.585500,5.529125,2.872125,3120,32,4,0,0.128293,3,pass,5.370417;6.061667;5.585500 -random_masked,67,402,8,bucket32_d16,3.892917,3.851583,1.985375,3360,24,3,0,0.128293,3,pass,3.892917;4.481458;3.838959 -random_masked,67,402,8,bucket32_d64,2.534042,2.490375,1.256000,4914,16,2,0,0.128293,3,pass,2.534042;2.637875;2.526750 -random_masked_int64,70,420,8,old_pull_serial,0.912250,0.837084,0.427291,26880,8,1,0,0.124334,3,pass,0.898458;0.912250;0.922583 -random_masked_int64,70,420,8,old_group_serial,0.656833,0.587292,0.267500,15960,6,1,0,0.124334,3,pass,0.656833;0.635209;0.685292 -random_masked_int64,70,420,8,old_pull_parallel_dag,1.011333,0.946708,0.429375,26880,8,1,0,0.124334,3,pass,1.072625;1.011333;0.990667 -random_masked_int64,70,420,8,old_group_parallel_dag,0.727625,0.678417,0.267084,15960,6,1,0,0.124334,3,pass,0.691584;0.727625;0.745875 -random_masked_int64,70,420,8,pull64_barrier,1.078958,1.021458,0.458000,26880,8,1,0,0.124334,3,pass,1.012542;1.103167;1.078958 -random_masked_int64,70,420,8,pull64_interleaved,0.923167,0.876958,0.438125,26880,8,1,0,0.124334,3,pass,1.001875;0.923167;0.846208 -random_masked_int64,70,420,8,pull32_interleaved,1.027959,0.985125,0.443792,26880,8,1,1,0.124334,3,pass,0.960584;1.518167;1.027959 -random_masked_int64,70,420,8,pull32_gpu_dag,1.744000,0.867250,0.607000,26880,8,3,1,0.124334,3,pass,1.803875;1.744000;1.733875 -random_masked_int64,70,420,8,frontier32_vertex,0.913333,0.869584,0.444000,26880,8,1,1,0.124334,3,pass,0.849500;0.913333;1.494042 -random_masked_int64,70,420,8,frontier32_simd,1.054834,1.027084,0.473750,26880,8,1,1,0.124334,3,pass,1.054834;1.143834;0.988292 -random_masked_int64,70,420,8,frontier32_gpu_dag,1.867833,0.975291,0.639917,26880,8,3,1,0.124334,3,pass,1.995833;1.805875;1.867833 -random_masked_int64,70,420,8,bucket32_d4,0.923167,0.887625,0.468917,26880,8,1,1,0.124334,3,pass,0.840583;1.055541;0.923167 -random_masked_int64,70,420,8,bucket32_d16,0.996375,0.960125,0.446250,26880,8,1,1,0.124334,3,pass,1.016292;0.912750;0.996375 -random_masked_int64,70,420,8,bucket32_d64,0.946958,0.915833,0.462458,26880,8,1,1,0.124334,3,pass,0.946958;1.079209;0.843833 -random_masked_reverse,73,438,8,old_pull_serial,1.544708,1.479625,0.626833,56064,16,2,0,0.127917,3,pass,1.593958;1.537750;1.544708 -random_masked_reverse,73,438,8,old_group_serial,0.616500,0.552708,0.252333,30222,10,1,0,0.127917,3,pass,0.664708;0.616500;0.604208 -random_masked_reverse,73,438,8,old_pull_parallel_dag,1.573667,1.535500,0.613583,56064,16,2,0,0.127917,3,pass,1.573667;1.498833;1.613833 -random_masked_reverse,73,438,8,old_group_parallel_dag,0.605708,0.567584,0.256000,30222,10,1,0,0.127917,3,pass,0.636500;0.605708;0.572500 -random_masked_reverse,73,438,8,pull64_barrier,1.501625,1.458875,0.610542,56064,16,2,0,0.127917,3,pass,1.501625;1.509291;1.430958 -random_masked_reverse,73,438,8,pull64_interleaved,1.434334,1.403000,0.619042,56064,16,2,0,0.127917,3,pass,1.453458;1.405166;1.434334 -random_masked_reverse,73,438,8,pull32_interleaved,1.384458,1.352667,0.597333,56064,16,2,0,0.127917,3,pass,1.371959;1.692083;1.384458 -random_masked_reverse,73,438,8,pull32_gpu_dag,2.072333,1.277333,0.753209,56064,16,4,0,0.127917,3,pass,2.072333;1.848416;2.650459 -random_masked_reverse,73,438,8,frontier32_vertex,2.110666,2.066958,1.024083,5335,16,2,0,0.127917,3,pass,2.153834;2.110666;1.736583 -random_masked_reverse,73,438,8,frontier32_simd,1.530708,1.489292,0.361042,5335,16,2,0,0.127917,3,pass,1.555208;1.530708;1.270708 -random_masked_reverse,73,438,8,frontier32_gpu_dag,2.006667,1.291750,0.472458,5335,16,4,0,0.127917,3,pass,2.064667;1.931875;2.006667 -random_masked_reverse,73,438,8,bucket32_d4,3.628500,3.593417,1.164083,3179,32,4,0,0.127917,3,pass,3.628500;3.704042;3.569250 -random_masked_reverse,73,438,8,bucket32_d16,2.702375,2.656292,0.743625,3402,24,3,0,0.127917,3,pass,2.702375;2.782959;2.543667 -random_masked_reverse,73,438,8,bucket32_d64,1.697042,1.630166,0.391417,4984,16,2,0,0.127917,3,pass,1.697791;1.697042;1.622875 -boundary_700000000,6,10,6,old_pull_serial,0.450042,0.446083,0.050917,360,6,1,0,0.105417,3,pass,0.452375;0.431208;0.450042 -boundary_700000000,6,10,6,old_group_serial,0.330833,0.326875,0.025167,160,5,1,0,0.105417,3,pass,0.330833;0.332792;0.302000 -boundary_700000000,6,10,6,old_pull_parallel_dag,0.430000,0.400375,0.045375,360,6,1,0,0.105417,3,pass,0.486917;0.418167;0.430000 -boundary_700000000,6,10,6,old_group_parallel_dag,0.342333,0.314875,0.024750,160,5,1,0,0.105417,3,pass,0.322084;0.372750;0.342333 -boundary_700000000,6,10,6,pull64_barrier,0.689042,0.662291,0.044333,360,6,1,0,0.105417,3,pass,0.689042;0.763417;0.437542 -boundary_700000000,6,10,6,pull64_interleaved,0.395875,0.375292,0.042625,360,6,1,0,0.105417,3,pass,0.442584;0.395875;0.359333 -boundary_700000000,6,10,6,pull32_interleaved,0.455250,0.423209,0.046250,360,6,1,0,0.105417,3,pass,0.455250;0.649666;0.450333 -boundary_700000000,6,10,6,pull32_gpu_dag,1.057541,0.377000,0.075125,360,6,3,0,0.105417,3,pass,1.231958;1.057541;0.958209 -boundary_700000000,6,10,6,frontier32_vertex,0.632458,0.549625,0.107417,24,8,1,0,0.105417,3,pass,0.543541;0.632458;0.826583 -boundary_700000000,6,10,6,frontier32_simd,0.626792,0.597666,0.108833,24,8,1,0,0.105417,3,pass,0.626792;0.647666;0.575416 -boundary_700000000,6,10,6,frontier32_gpu_dag,1.233375,0.530292,0.134750,24,8,3,0,0.105417,3,pass,1.233375;1.230709;1.302791 -boundary_700000000,6,10,6,bucket32_d4,0.807208,0.783417,0.213333,24,8,1,0,0.105417,3,pass,0.840667;0.807208;0.804500 -boundary_700000000,6,10,6,bucket32_d16,0.702917,0.685959,0.179500,24,8,1,0,0.105417,3,pass,0.697541;0.702917;0.755500 -boundary_700000000,6,10,6,bucket32_d64,0.827875,0.783166,0.208208,24,8,1,0,0.105417,3,pass,0.827875;0.772708;0.840750 -boundary_700000000_masked,6,10,6,old_pull_serial,0.421041,0.416459,0.038000,360,6,1,0,0.080167,3,pass,0.421041;0.501542;0.378208 -boundary_700000000_masked,6,10,6,old_group_serial,0.305292,0.301667,0.014292,80,2,1,0,0.080167,3,pass,0.284583;0.305292;0.322459 -boundary_700000000_masked,6,10,6,old_pull_parallel_dag,0.439750,0.403625,0.042625,360,6,1,0,0.080167,3,pass,0.408709;0.439750;0.440084 -boundary_700000000_masked,6,10,6,old_group_parallel_dag,0.330541,0.295834,0.014417,80,2,1,0,0.080167,3,pass,0.343875;0.330541;0.304542 -boundary_700000000_masked,6,10,6,pull64_barrier,0.417709,0.383416,0.040542,360,6,1,0,0.080167,3,pass,0.417709;0.403792;0.528708 -boundary_700000000_masked,6,10,6,pull64_interleaved,0.416667,0.379250,0.040250,360,6,1,0,0.080167,3,pass,0.416667;0.397209;0.430666 -boundary_700000000_masked,6,10,6,pull32_interleaved,0.457500,0.430208,0.045333,360,6,1,0,0.080167,3,pass,0.457500;0.435708;0.598250 -boundary_700000000_masked,6,10,6,pull32_gpu_dag,1.022792,0.371750,0.067875,360,6,3,0,0.080167,3,pass,1.119458;1.022792;1.005625 -boundary_700000000_masked,6,10,6,frontier32_vertex,0.556292,0.530166,0.089250,12,8,1,0,0.080167,3,pass,0.648458;0.551208;0.556292 -boundary_700000000_masked,6,10,6,frontier32_simd,0.617417,0.551458,0.099875,12,8,1,0,0.080167,3,pass,0.628041;0.617417;0.596375 -boundary_700000000_masked,6,10,6,frontier32_gpu_dag,1.224500,0.539875,0.131542,12,8,3,0,0.080167,3,pass,1.226667;1.181042;1.224500 -boundary_700000000_masked,6,10,6,bucket32_d4,1.358083,1.303625,0.614208,12,8,1,0,0.080167,3,pass,1.364000;1.358083;1.184459 -boundary_700000000_masked,6,10,6,bucket32_d16,1.254834,1.239333,0.635208,12,8,1,0,0.080167,3,pass,1.313875;1.195792;1.254834 -boundary_700000000_masked,6,10,6,bucket32_d64,1.308541,1.216041,0.597167,12,8,1,0,0.080167,3,pass,1.308541;1.335791;0.780125 -boundary_700000000_reverse,6,10,6,old_pull_serial,0.387208,0.382958,0.039958,360,6,1,0,0.071958,3,pass,0.381959;0.389167;0.387208 -boundary_700000000_reverse,6,10,6,old_group_serial,0.310750,0.306458,0.023083,170,5,1,0,0.071958,3,pass,0.310750;0.307292;0.317333 -boundary_700000000_reverse,6,10,6,old_pull_parallel_dag,0.400125,0.378125,0.040125,360,6,1,0,0.071958,3,pass,0.406125;0.396292;0.400125 -boundary_700000000_reverse,6,10,6,old_group_parallel_dag,0.342708,0.317875,0.022125,170,5,1,0,0.071958,3,pass,0.342708;0.338833;0.484917 -boundary_700000000_reverse,6,10,6,pull64_barrier,0.374834,0.359541,0.041250,360,6,1,0,0.071958,3,pass,0.374834;0.356500;0.380417 -boundary_700000000_reverse,6,10,6,pull64_interleaved,0.420333,0.395584,0.042375,360,6,1,0,0.071958,3,pass,0.377083;0.420333;0.478541 -boundary_700000000_reverse,6,10,6,pull32_interleaved,0.458500,0.429958,0.045708,360,6,1,0,0.071958,3,pass,0.458500;0.479917;0.441708 -boundary_700000000_reverse,6,10,6,pull32_gpu_dag,1.066333,0.375875,0.073083,360,6,3,0,0.071958,3,pass,1.049834;1.066333;1.158167 -boundary_700000000_reverse,6,10,6,frontier32_vertex,0.626292,0.568834,0.109375,24,8,1,0,0.071958,3,pass,0.626292;0.847417;0.549584 -boundary_700000000_reverse,6,10,6,frontier32_simd,0.783334,0.758833,0.116333,24,8,1,0,0.071958,3,pass,0.598333;0.783334;0.790542 -boundary_700000000_reverse,6,10,6,frontier32_gpu_dag,1.511041,0.563125,0.136625,24,8,3,0,0.071958,3,pass,1.142917;1.753667;1.511041 -boundary_700000000_reverse,6,10,6,bucket32_d4,0.741583,0.723667,0.179625,24,8,1,0,0.071958,3,pass,0.767750;0.741583;0.718625 -boundary_700000000_reverse,6,10,6,bucket32_d16,0.799791,0.729834,0.179083,24,8,1,0,0.071958,3,pass,1.065958;0.799791;0.753792 -boundary_700000000_reverse,6,10,6,bucket32_d64,0.753458,0.731125,0.187875,24,8,1,0,0.071958,3,pass,0.753458;0.758500;0.740000 -boundary_700000000_reverse_masked,6,10,6,old_pull_serial,0.395500,0.391917,0.039583,360,6,1,0,0.085167,3,pass,0.363250;0.483334;0.395500 -boundary_700000000_reverse_masked,6,10,6,old_group_serial,0.307708,0.303958,0.016125,90,2,1,0,0.085167,3,pass,0.312625;0.305042;0.307708 -boundary_700000000_reverse_masked,6,10,6,old_pull_parallel_dag,0.461167,0.414625,0.043375,360,6,1,0,0.085167,3,pass,0.443875;0.461167;0.467666 -boundary_700000000_reverse_masked,6,10,6,old_group_parallel_dag,0.340417,0.297875,0.013750,90,2,1,0,0.085167,3,pass,0.340417;0.343625;0.318000 -boundary_700000000_reverse_masked,6,10,6,pull64_barrier,0.463666,0.419667,0.039750,360,6,1,0,0.085167,3,pass,0.407917;0.517917;0.463666 -boundary_700000000_reverse_masked,6,10,6,pull64_interleaved,0.455541,0.433167,0.045167,360,6,1,0,0.085167,3,pass,0.438958;0.455541;0.508042 -boundary_700000000_reverse_masked,6,10,6,pull32_interleaved,0.391958,0.371541,0.041125,360,6,1,0,0.085167,3,pass,0.426292;0.390334;0.391958 -boundary_700000000_reverse_masked,6,10,6,pull32_gpu_dag,1.347625,0.629500,0.252583,360,6,3,0,0.085167,3,pass,1.347625;1.216666;1.603875 -boundary_700000000_reverse_masked,6,10,6,frontier32_vertex,0.839042,0.817750,0.334250,9,8,1,0,0.085167,3,pass,0.833625;0.901042;0.839042 -boundary_700000000_reverse_masked,6,10,6,frontier32_simd,0.880916,0.857125,0.309667,9,8,1,0,0.085167,3,pass,0.872084;0.943459;0.880916 -boundary_700000000_reverse_masked,6,10,6,frontier32_gpu_dag,1.215042,0.522500,0.136833,9,8,3,0,0.085167,3,pass,1.459334;1.215042;1.182167 -boundary_700000000_reverse_masked,6,10,6,bucket32_d4,0.723666,0.701334,0.162125,9,8,1,0,0.085167,3,pass,0.719166;0.723666;0.868584 -boundary_700000000_reverse_masked,6,10,6,bucket32_d16,0.745666,0.728917,0.163375,9,8,1,0,0.085167,3,pass,0.695708;0.745666;0.770333 -boundary_700000000_reverse_masked,6,10,6,bucket32_d64,0.733333,0.705334,0.166125,9,8,1,0,0.085167,3,pass,0.742666;0.733333;0.720541 -boundary_715827883,6,10,6,old_pull_serial,0.377833,0.373833,0.040333,360,6,1,0,0.070542,3,pass,0.377833;0.414583;0.371959 -boundary_715827883,6,10,6,old_group_serial,0.297000,0.292750,0.022625,160,5,1,0,0.070542,3,pass,0.296334;0.304125;0.297000 -boundary_715827883,6,10,6,old_pull_parallel_dag,0.438208,0.403125,0.041333,360,6,1,0,0.070542,3,pass,0.438208;0.429416;0.459250 -boundary_715827883,6,10,6,old_group_parallel_dag,0.374375,0.335542,0.022667,160,5,1,0,0.070542,3,pass,0.468666;0.374375;0.372667 -boundary_715827883,6,10,6,pull64_barrier,0.379917,0.365583,0.041542,360,6,1,0,0.070542,3,pass,0.392208;0.379917;0.376791 -boundary_715827883,6,10,6,pull64_interleaved,0.393125,0.370625,0.041500,360,6,1,0,0.070542,3,pass,0.367208;0.393125;0.477167 -boundary_715827883,6,10,6,pull32_interleaved,0.612458,0.590750,0.047000,360,6,1,1,0.070542,3,pass,0.743291;0.438958;0.612458 -boundary_715827883,6,10,6,pull32_gpu_dag,1.074208,0.355250,0.068666,360,6,3,1,0.070542,3,pass,1.074208;0.986708;1.341750 -boundary_715827883,6,10,6,frontier32_vertex,0.412583,0.380292,0.042125,360,6,1,1,0.070542,3,pass,0.412583;0.405291;0.423417 -boundary_715827883,6,10,6,frontier32_simd,0.413166,0.384167,0.041625,360,6,1,1,0.070542,3,pass,0.413166;0.377958;0.458166 -boundary_715827883,6,10,6,frontier32_gpu_dag,1.384334,0.569333,0.282083,360,6,3,1,0.070542,3,pass,1.719209;1.384334;1.365250 -boundary_715827883,6,10,6,bucket32_d4,0.600750,0.571042,0.180750,360,6,1,1,0.070542,3,pass,0.642666;0.581958;0.600750 -boundary_715827883,6,10,6,bucket32_d16,0.668834,0.643750,0.181292,360,6,1,1,0.070542,3,pass,0.631583;0.668834;0.742334 -boundary_715827883,6,10,6,bucket32_d64,0.595917,0.559542,0.045292,360,6,1,1,0.070542,3,pass,0.631375;0.595917;0.441041 -boundary_715827883_masked,6,10,6,old_pull_serial,0.404792,0.401333,0.042542,360,6,1,0,0.082292,3,pass,0.392541;0.404792;0.415416 -boundary_715827883_masked,6,10,6,old_group_serial,0.306167,0.301917,0.013833,80,2,1,0,0.082292,3,pass,0.289250;0.306167;0.312125 -boundary_715827883_masked,6,10,6,old_pull_parallel_dag,0.438875,0.405791,0.041792,360,6,1,0,0.082292,3,pass,0.438875;0.413625;0.444334 -boundary_715827883_masked,6,10,6,old_group_parallel_dag,0.332792,0.303459,0.013875,80,2,1,0,0.082292,3,pass,0.348167;0.332792;0.306375 -boundary_715827883_masked,6,10,6,pull64_barrier,0.437084,0.401333,0.041667,360,6,1,0,0.082292,3,pass,0.437084;0.464041;0.417958 -boundary_715827883_masked,6,10,6,pull64_interleaved,0.463666,0.427625,0.045125,360,6,1,0,0.082292,3,pass,0.429875;0.469958;0.463666 -boundary_715827883_masked,6,10,6,pull32_interleaved,0.415291,0.387667,0.041375,360,6,1,1,0.082292,3,pass,0.425458;0.415291;0.401333 -boundary_715827883_masked,6,10,6,pull32_gpu_dag,1.047000,0.389375,0.070792,360,6,3,1,0.082292,3,pass,1.063250;1.047000;0.970833 -boundary_715827883_masked,6,10,6,frontier32_vertex,0.368166,0.350833,0.040250,360,6,1,1,0.082292,3,pass,0.366167;0.368166;0.414125 -boundary_715827883_masked,6,10,6,frontier32_simd,0.491625,0.463958,0.044000,360,6,1,1,0.082292,3,pass,0.436333;0.491625;0.650084 -boundary_715827883_masked,6,10,6,frontier32_gpu_dag,1.137000,0.406209,0.071458,360,6,3,1,0.082292,3,pass,1.277791;1.137000;1.065125 -boundary_715827883_masked,6,10,6,bucket32_d4,0.582667,0.549583,0.178500,360,6,1,1,0.082292,3,pass,0.582667;0.599250;0.575833 -boundary_715827883_masked,6,10,6,bucket32_d16,0.610792,0.579875,0.174542,360,6,1,1,0.082292,3,pass,0.610792;0.736708;0.537292 -boundary_715827883_masked,6,10,6,bucket32_d64,0.571958,0.552042,0.170167,360,6,1,1,0.082292,3,pass,0.579792;0.571958;0.564417 -boundary_715827883_reverse,6,10,6,old_pull_serial,0.564958,0.560750,0.177250,360,6,1,0,0.088167,3,pass,0.541583;0.576375;0.564958 -boundary_715827883_reverse,6,10,6,old_group_serial,0.376250,0.372000,0.091375,170,5,1,0,0.088167,3,pass,0.367375;0.376250;0.382041 -boundary_715827883_reverse,6,10,6,old_pull_parallel_dag,0.567417,0.539208,0.163583,360,6,1,0,0.088167,3,pass,0.573791;0.547458;0.567417 -boundary_715827883_reverse,6,10,6,old_group_parallel_dag,0.305291,0.288583,0.022750,170,5,1,0,0.088167,3,pass,0.305291;0.302125;0.309709 -boundary_715827883_reverse,6,10,6,pull64_barrier,0.460708,0.426208,0.045167,360,6,1,0,0.088167,3,pass,0.492458;0.421625;0.460708 -boundary_715827883_reverse,6,10,6,pull64_interleaved,0.435334,0.412333,0.042250,360,6,1,0,0.088167,3,pass,0.435334;0.454125;0.424500 -boundary_715827883_reverse,6,10,6,pull32_interleaved,0.385625,0.368458,0.040750,360,6,1,1,0.088167,3,pass,0.385625;0.387041;0.383000 -boundary_715827883_reverse,6,10,6,pull32_gpu_dag,1.081250,0.390458,0.075959,360,6,3,1,0.088167,3,pass,1.081250;1.069625;1.083667 -boundary_715827883_reverse,6,10,6,frontier32_vertex,0.425000,0.388166,0.040250,360,6,1,1,0.088167,3,pass,0.425000;0.412125;0.445916 -boundary_715827883_reverse,6,10,6,frontier32_simd,0.431416,0.398958,0.040792,360,6,1,1,0.088167,3,pass,0.405833;0.511792;0.431416 -boundary_715827883_reverse,6,10,6,frontier32_gpu_dag,1.369542,0.508833,0.071875,360,6,3,1,0.088167,3,pass,1.489667;1.369542;1.167458 -boundary_715827883_reverse,6,10,6,bucket32_d4,0.435250,0.395208,0.040750,360,6,1,1,0.088167,3,pass,0.445667;0.427834;0.435250 -boundary_715827883_reverse,6,10,6,bucket32_d16,0.418208,0.394292,0.041917,360,6,1,1,0.088167,3,pass,0.410542;0.448417;0.418208 -boundary_715827883_reverse,6,10,6,bucket32_d64,0.458000,0.432750,0.045125,360,6,1,1,0.088167,3,pass,0.458000;0.467375;0.439291 -boundary_715827883_reverse_masked,6,10,6,old_pull_serial,0.538584,0.534166,0.158500,360,6,1,0,0.078416,3,pass,0.578750;0.521167;0.538584 -boundary_715827883_reverse_masked,6,10,6,old_group_serial,0.358166,0.354541,0.051667,90,2,1,0,0.078416,3,pass,0.359292;0.347167;0.358166 -boundary_715827883_reverse_masked,6,10,6,old_pull_parallel_dag,0.556542,0.537291,0.158750,360,6,1,0,0.078416,3,pass,0.581250;0.556542;0.520875 -boundary_715827883_reverse_masked,6,10,6,old_group_parallel_dag,0.514667,0.475334,0.051458,90,2,1,0,0.078416,3,pass,0.514667;0.364625;0.626750 -boundary_715827883_reverse_masked,6,10,6,pull64_barrier,0.586833,0.558083,0.175083,360,6,1,0,0.078416,3,pass,0.586833;0.782458;0.585709 -boundary_715827883_reverse_masked,6,10,6,pull64_interleaved,0.609958,0.568333,0.172708,360,6,1,0,0.078416,3,pass,0.609958;0.616958;0.574458 -boundary_715827883_reverse_masked,6,10,6,pull32_interleaved,0.403750,0.376458,0.039375,360,6,1,1,0.078416,3,pass,0.393625;0.403750;0.409791 -boundary_715827883_reverse_masked,6,10,6,pull32_gpu_dag,1.112084,0.390542,0.071709,360,6,3,1,0.078416,3,pass,1.112084;1.158333;1.060042 -boundary_715827883_reverse_masked,6,10,6,frontier32_vertex,0.405625,0.376625,0.040125,360,6,1,1,0.078416,3,pass,0.397333;0.405625;0.425875 -boundary_715827883_reverse_masked,6,10,6,frontier32_simd,0.482541,0.449083,0.042833,360,6,1,1,0.078416,3,pass,0.710208;0.482541;0.406000 -boundary_715827883_reverse_masked,6,10,6,frontier32_gpu_dag,1.187208,0.422750,0.072208,360,6,3,1,0.078416,3,pass,1.187208;1.268459;1.112833 -boundary_715827883_reverse_masked,6,10,6,bucket32_d4,0.424709,0.407958,0.039625,360,6,1,1,0.078416,3,pass,0.424709;0.436584;0.367500 -boundary_715827883_reverse_masked,6,10,6,bucket32_d16,0.406375,0.381875,0.040500,360,6,1,1,0.078416,3,pass,0.406375;0.397542;0.432791 -boundary_715827883_reverse_masked,6,10,6,bucket32_d64,0.575792,0.537666,0.144083,360,6,1,1,0.078416,3,pass,0.406917;0.777833;0.575792 -boundary_9007199254740993,6,10,6,old_pull_serial,0.525500,0.521500,0.174542,360,6,1,0,0.096042,3,pass,0.521500;0.541750;0.525500 -boundary_9007199254740993,6,10,6,old_group_serial,0.387625,0.383541,0.094458,160,5,1,0,0.096042,3,pass,0.387625;0.387750;0.386791 -boundary_9007199254740993,6,10,6,old_pull_parallel_dag,0.610333,0.568208,0.175750,360,6,1,0,0.096042,3,pass,0.610333;0.697375;0.547167 -boundary_9007199254740993,6,10,6,old_group_parallel_dag,0.646583,0.606333,0.090667,160,5,1,0,0.096042,3,pass,0.427917;0.646583;1.133583 -boundary_9007199254740993,6,10,6,pull64_barrier,0.687000,0.651916,0.174125,360,6,1,0,0.096042,3,pass,0.730583;0.687000;0.581792 -boundary_9007199254740993,6,10,6,pull64_interleaved,0.377208,0.362625,0.041458,360,6,1,0,0.096042,3,pass,0.416625;0.362875;0.377208 -boundary_9007199254740993,6,10,6,pull32_interleaved,0.593708,0.549000,0.047375,360,6,1,1,0.096042,3,pass,0.413750;0.714334;0.593708 -boundary_9007199254740993,6,10,6,pull32_gpu_dag,1.076000,0.408375,0.081917,360,6,3,1,0.096042,3,pass,1.134958;1.076000;0.964667 -boundary_9007199254740993,6,10,6,frontier32_vertex,0.399500,0.367208,0.040625,360,6,1,1,0.096042,3,pass,0.429292;0.399500;0.399416 -boundary_9007199254740993,6,10,6,frontier32_simd,0.415125,0.372750,0.042375,360,6,1,1,0.096042,3,pass,0.415125;0.420542;0.406208 -boundary_9007199254740993,6,10,6,frontier32_gpu_dag,1.079083,0.393625,0.079042,360,6,3,1,0.096042,3,pass,1.150250;1.067292;1.079083 -boundary_9007199254740993,6,10,6,bucket32_d4,0.431958,0.392167,0.042083,360,6,1,1,0.096042,3,pass,0.394916;0.431958;0.539750 -boundary_9007199254740993,6,10,6,bucket32_d16,0.462542,0.422333,0.043000,360,6,1,1,0.096042,3,pass,0.374084;0.462542;0.531959 -boundary_9007199254740993,6,10,6,bucket32_d64,0.385042,0.363875,0.041333,360,6,1,1,0.096042,3,pass,0.385042;0.377375;0.395583 -boundary_9007199254740993_masked,6,10,6,old_pull_serial,0.394458,0.390833,0.041875,360,6,1,0,0.146083,3,pass,0.401709;0.384500;0.394458 -boundary_9007199254740993_masked,6,10,6,old_group_serial,0.280958,0.277500,0.013667,80,2,1,0,0.146083,3,pass,0.277625;0.280958;0.299125 -boundary_9007199254740993_masked,6,10,6,old_pull_parallel_dag,0.436292,0.417209,0.039917,360,6,1,0,0.146083,3,pass,0.436292;0.506750;0.436208 -boundary_9007199254740993_masked,6,10,6,old_group_parallel_dag,0.307708,0.285209,0.013833,80,2,1,0,0.146083,3,pass,0.387042;0.305416;0.307708 -boundary_9007199254740993_masked,6,10,6,pull64_barrier,0.420208,0.390959,0.042667,360,6,1,0,0.146083,3,pass,0.420208;0.747500;0.416834 -boundary_9007199254740993_masked,6,10,6,pull64_interleaved,0.398000,0.374541,0.040250,360,6,1,0,0.146083,3,pass,0.398000;0.392167;0.552042 -boundary_9007199254740993_masked,6,10,6,pull32_interleaved,0.387375,0.359708,0.040542,360,6,1,1,0.146083,3,pass,0.441042;0.387375;0.370584 -boundary_9007199254740993_masked,6,10,6,pull32_gpu_dag,1.014542,0.372792,0.070917,360,6,3,1,0.146083,3,pass,1.049416;1.014542;0.967250 -boundary_9007199254740993_masked,6,10,6,frontier32_vertex,0.385583,0.370250,0.040625,360,6,1,1,0.146083,3,pass,0.385583;0.359292;0.596291 -boundary_9007199254740993_masked,6,10,6,frontier32_simd,0.573042,0.536541,0.173000,360,6,1,1,0.146083,3,pass,0.611750;0.573042;0.544917 -boundary_9007199254740993_masked,6,10,6,frontier32_gpu_dag,1.241792,0.560958,0.259333,360,6,3,1,0.146083,3,pass,1.353917;1.241792;1.178917 -boundary_9007199254740993_masked,6,10,6,bucket32_d4,0.568167,0.533917,0.167250,360,6,1,1,0.146083,3,pass,0.616792;0.527041;0.568167 -boundary_9007199254740993_masked,6,10,6,bucket32_d16,0.784000,0.761333,0.048500,360,6,1,1,0.146083,3,pass,0.902250;0.784000;0.585834 -boundary_9007199254740993_masked,6,10,6,bucket32_d64,0.413292,0.393958,0.040125,360,6,1,1,0.146083,3,pass,0.459500;0.413292;0.405333 -boundary_9007199254740993_reverse,6,10,6,old_pull_serial,0.408625,0.404208,0.043375,360,6,1,0,0.099791,3,pass,0.408625;0.384708;0.412958 -boundary_9007199254740993_reverse,6,10,6,old_group_serial,0.295500,0.291250,0.022292,170,5,1,0,0.099791,3,pass,0.285750;0.349334;0.295500 -boundary_9007199254740993_reverse,6,10,6,old_pull_parallel_dag,0.406709,0.375333,0.039375,360,6,1,0,0.099791,3,pass,0.406709;0.397625;0.418583 -boundary_9007199254740993_reverse,6,10,6,old_group_parallel_dag,0.310584,0.288791,0.022250,170,5,1,0,0.099791,3,pass,0.307500;0.310584;0.383958 -boundary_9007199254740993_reverse,6,10,6,pull64_barrier,0.402291,0.370333,0.040458,360,6,1,0,0.099791,3,pass,0.402291;0.409167;0.400833 -boundary_9007199254740993_reverse,6,10,6,pull64_interleaved,0.417625,0.389083,0.040750,360,6,1,0,0.099791,3,pass,0.417625;0.407709;0.672666 -boundary_9007199254740993_reverse,6,10,6,pull32_interleaved,0.456750,0.422084,0.040875,360,6,1,1,0.099791,3,pass,0.457500;0.386250;0.456750 -boundary_9007199254740993_reverse,6,10,6,pull32_gpu_dag,1.081708,0.380083,0.074334,360,6,3,1,0.099791,3,pass,1.092959;1.081708;1.069709 -boundary_9007199254740993_reverse,6,10,6,frontier32_vertex,0.416292,0.387500,0.041125,360,6,1,1,0.099791,3,pass,0.422542;0.391916;0.416292 -boundary_9007199254740993_reverse,6,10,6,frontier32_simd,0.427916,0.387125,0.043458,360,6,1,1,0.099791,3,pass,0.427916;0.428250;0.423667 -boundary_9007199254740993_reverse,6,10,6,frontier32_gpu_dag,1.050666,0.410208,0.074458,360,6,3,1,0.099791,3,pass,1.050666;1.040667;1.417584 -boundary_9007199254740993_reverse,6,10,6,bucket32_d4,0.750875,0.705000,0.158292,360,6,1,1,0.099791,3,pass,0.750875;0.755708;0.628666 -boundary_9007199254740993_reverse,6,10,6,bucket32_d16,0.566208,0.532250,0.157958,360,6,1,1,0.099791,3,pass,0.577666;0.566208;0.550167 -boundary_9007199254740993_reverse,6,10,6,bucket32_d64,0.550459,0.522458,0.164792,360,6,1,1,0.099791,3,pass,0.809167;0.550459;0.511958 -boundary_9007199254740993_reverse_masked,6,10,6,old_pull_serial,0.526750,0.523167,0.161208,360,6,1,0,0.101250,3,pass,0.560709;0.508625;0.526750 -boundary_9007199254740993_reverse_masked,6,10,6,old_group_serial,0.320792,0.317041,0.050625,90,2,1,0,0.101250,3,pass,0.347708;0.320792;0.320625 -boundary_9007199254740993_reverse_masked,6,10,6,old_pull_parallel_dag,0.595958,0.553167,0.158750,360,6,1,0,0.101250,3,pass,0.549417;0.595958;0.602375 -boundary_9007199254740993_reverse_masked,6,10,6,old_group_parallel_dag,0.388416,0.357916,0.050125,90,2,1,0,0.101250,3,pass,0.377708;0.390167;0.388416 -boundary_9007199254740993_reverse_masked,6,10,6,pull64_barrier,0.625292,0.599542,0.165167,360,6,1,0,0.101250,3,pass,0.625292;0.691584;0.555959 -boundary_9007199254740993_reverse_masked,6,10,6,pull64_interleaved,0.379375,0.364792,0.039833,360,6,1,0,0.101250,3,pass,0.434708;0.360708;0.379375 -boundary_9007199254740993_reverse_masked,6,10,6,pull32_interleaved,0.415042,0.378542,0.040500,360,6,1,1,0.101250,3,pass,0.396708;0.415042;0.425292 -boundary_9007199254740993_reverse_masked,6,10,6,pull32_gpu_dag,1.095708,0.395375,0.069958,360,6,3,1,0.101250,3,pass,1.095708;1.093708;1.382084 -boundary_9007199254740993_reverse_masked,6,10,6,frontier32_vertex,0.426042,0.403375,0.041375,360,6,1,1,0.101250,3,pass,0.392542;0.553417;0.426042 -boundary_9007199254740993_reverse_masked,6,10,6,frontier32_simd,0.405584,0.383083,0.040500,360,6,1,1,0.101250,3,pass,0.405584;0.412833;0.404500 -boundary_9007199254740993_reverse_masked,6,10,6,frontier32_gpu_dag,1.210709,0.487375,0.256917,360,6,3,1,0.101250,3,pass,1.133708;1.210709;1.338417 -boundary_9007199254740993_reverse_masked,6,10,6,bucket32_d4,0.601500,0.567208,0.175000,360,6,1,1,0.101250,3,pass,0.602167;0.572583;0.601500 -boundary_9007199254740993_reverse_masked,6,10,6,bucket32_d16,0.529917,0.501708,0.154375,360,6,1,1,0.101250,3,pass,0.529917;0.620084;0.496500 -boundary_9007199254740993_reverse_masked,6,10,6,bucket32_d64,0.555667,0.516125,0.155667,360,6,1,1,0.101250,3,pass,0.936292;0.505125;0.555667 diff --git a/dev/research/metal_pathfinding/optimization/results/correctness-stderr.txt b/dev/research/metal_pathfinding/optimization/results/correctness-stderr.txt deleted file mode 100644 index 47b95c6..0000000 --- a/dev/research/metal_pathfinding/optimization/results/correctness-stderr.txt +++ /dev/null @@ -1 +0,0 @@ -device=Apple M4 Max setup_ms=50.79 execution_width=32 diff --git a/dev/research/metal_pathfinding/optimization/results/correctness.csv b/dev/research/metal_pathfinding/optimization/results/correctness.csv deleted file mode 100644 index 6ad6397..0000000 --- a/dev/research/metal_pathfinding/optimization/results/correctness.csv +++ /dev/null @@ -1,337 +0,0 @@ -case,n,e,queries,method,wall_ms,distance_ms,gpu_ms,edge_visits,rounds,waits,fallback64,upload_ms,samples,correct,wall_samples_ms -random_masked_int64,40,240,8,old_pull_serial,0.562958,0.541375,0.122875,15360,8,1,0,0.116249,3,pass,0.568167;0.533500;0.562958 -random_masked_int64,40,240,8,old_group_serial,0.513500,0.491542,0.071625,9600,6,1,0,0.116249,3,pass,0.513500;0.487167;0.567333 -random_masked_int64,40,240,8,old_pull_parallel_dag,0.573333,0.491583,0.122958,15360,8,1,0,0.116249,3,pass,0.573333;0.548333;0.592250 -random_masked_int64,40,240,8,old_group_parallel_dag,0.519708,0.462291,0.071125,9600,6,1,0,0.116249,3,pass,0.500084;0.526209;0.519708 -random_masked_int64,40,240,8,pull64_barrier,0.567209,0.524583,0.113625,15360,8,1,0,0.116249,3,pass,0.567209;0.586667;0.326542 -random_masked_int64,40,240,8,pull64_interleaved,0.335166,0.294625,0.113125,15360,8,1,0,0.116249,3,pass,0.326833;0.338791;0.335166 -random_masked_int64,40,240,8,pull32_interleaved,0.329584,0.277209,0.113292,15360,8,1,1,0.116249,3,pass,0.329584;0.341041;0.309250 -random_masked_int64,40,240,8,pull32_gpu_dag,0.758416,0.296667,0.163916,15360,8,3,1,0.116249,3,pass,0.694417;0.758416;0.838375 -random_masked_int64,40,240,8,frontier32_vertex,0.323083,0.273417,0.112375,15360,8,1,1,0.116249,3,pass,0.365583;0.323083;0.298250 -random_masked_int64,40,240,8,frontier32_simd,0.329375,0.284292,0.112167,15360,8,1,1,0.116249,3,pass,0.327875;0.884416;0.329375 -random_masked_int64,40,240,8,frontier32_gpu_dag,0.657125,0.282000,0.160708,15360,8,3,1,0.116249,3,pass,0.657125;0.698167;0.640042 -random_masked_int64,40,240,8,bucket32_d4,0.394209,0.348916,0.129667,15360,8,1,1,0.116249,3,pass,0.467000;0.394209;0.361750 -random_masked_int64,40,240,8,bucket32_d16,0.333709,0.284834,0.112792,15360,8,1,1,0.116249,3,pass,0.333709;0.318917;0.335542 -random_masked_int64,40,240,8,bucket32_d64,0.317500,0.278125,0.112250,15360,8,1,1,0.116249,3,pass,0.323708;0.305125;0.317500 -random_masked,43,258,8,old_pull_serial,0.572584,0.552875,0.270417,33024,16,2,0,0.083291,3,pass,0.572584;0.578375;0.561625 -random_masked,43,258,8,old_group_serial,0.255458,0.243375,0.105375,14448,9,1,0,0.083291,3,pass,0.255458;0.305583;0.231875 -random_masked,43,258,8,old_pull_parallel_dag,0.577625,0.546834,0.273417,33024,16,2,0,0.083291,3,pass,0.577625;0.608875;0.559083 -random_masked,43,258,8,old_group_parallel_dag,0.281833,0.243709,0.105583,14448,9,1,0,0.083291,3,pass,0.269000;0.293583;0.281833 -random_masked,43,258,8,pull64_barrier,0.594875,0.526042,0.244875,33024,16,2,0,0.083291,3,pass,0.594875;0.600542;0.564625 -random_masked,43,258,8,pull64_interleaved,0.686125,0.652417,0.298292,33024,16,2,0,0.083291,3,pass,0.655416;0.730000;0.686125 -random_masked,43,258,8,pull32_interleaved,0.569958,0.533916,0.219208,33024,16,2,0,0.083291,3,pass,0.690500;0.569958;0.534750 -random_masked,43,258,8,pull32_gpu_dag,1.093000,0.672000,0.337000,33024,16,4,0,0.083291,3,pass,1.089750;1.138958;1.093000 -random_masked,43,258,8,frontier32_vertex,0.811167,0.763792,0.443250,3186,16,2,0,0.083291,3,pass,0.811167;0.769541;0.873292 -random_masked,43,258,8,frontier32_simd,0.662417,0.619000,0.325375,3186,16,2,0,0.083291,3,pass,0.653209;0.662417;1.329042 -random_masked,43,258,8,frontier32_gpu_dag,1.094500,0.664041,0.371958,3186,16,4,0,0.083291,3,pass,1.044250;1.094500;1.224167 -random_masked,43,258,8,bucket32_d4,2.167333,2.099083,1.255792,2016,32,4,0,0.083291,3,pass,2.198125;2.167333;2.011750 -random_masked,43,258,8,bucket32_d16,1.541041,1.509916,0.911334,2142,24,3,0,0.083291,3,pass,1.701416;1.541041;1.509917 -random_masked,43,258,8,bucket32_d64,1.098250,1.062792,0.618375,3096,16,2,0,0.083291,3,pass,1.088084;1.098250;1.138375 -random_masked_int64_reverse,46,276,8,old_pull_serial,0.320125,0.287208,0.119125,17664,8,1,0,0.109000,3,pass,0.320125;0.288375;0.330834 -random_masked_int64_reverse,46,276,8,old_group_serial,0.231167,0.215875,0.045500,9660,5,1,0,0.109000,3,pass,0.231709;0.228834;0.231167 -random_masked_int64_reverse,46,276,8,old_pull_parallel_dag,0.284459,0.242500,0.095250,17664,8,1,0,0.109000,3,pass,0.356084;0.284459;0.282208 -random_masked_int64_reverse,46,276,8,old_group_parallel_dag,0.222583,0.169041,0.044625,9660,5,1,0,0.109000,3,pass,0.222583;0.223209;0.213208 -random_masked_int64_reverse,46,276,8,pull64_barrier,0.361209,0.313625,0.109375,17664,8,1,0,0.109000,3,pass,0.361209;0.383542;0.313542 -random_masked_int64_reverse,46,276,8,pull64_interleaved,0.314208,0.274833,0.093750,17664,8,1,0,0.109000,3,pass,0.347709;0.314208;0.300500 -random_masked_int64_reverse,46,276,8,pull32_interleaved,0.370542,0.328166,0.111917,17664,8,1,1,0.109000,3,pass,0.350250;0.943084;0.370542 -random_masked_int64_reverse,46,276,8,pull32_gpu_dag,0.791459,0.327709,0.156083,17664,8,3,1,0.109000,3,pass,0.791459;0.806792;0.742833 -random_masked_int64_reverse,46,276,8,frontier32_vertex,0.369875,0.318917,0.104333,17664,8,1,1,0.109000,3,pass,0.343583;0.369875;0.378291 -random_masked_int64_reverse,46,276,8,frontier32_simd,0.341250,0.304667,0.107625,17664,8,1,1,0.109000,3,pass,0.336959;0.341250;0.354541 -random_masked_int64_reverse,46,276,8,frontier32_gpu_dag,0.782833,0.318333,0.157750,17664,8,3,1,0.109000,3,pass,0.802541;0.772208;0.782833 -random_masked_int64_reverse,46,276,8,bucket32_d4,0.378708,0.321791,0.101500,17664,8,1,1,0.109000,3,pass,0.426667;0.378708;0.349875 -random_masked_int64_reverse,46,276,8,bucket32_d16,0.366875,0.315208,0.105917,17664,8,1,1,0.109000,3,pass,0.376708;0.366875;0.345125 -random_masked_int64_reverse,46,276,8,bucket32_d64,0.375291,0.328750,0.104875,17664,8,1,1,0.109000,3,pass,0.355542;0.375291;0.380708 -random_masked,49,294,8,old_pull_serial,0.349167,0.330875,0.142625,18816,8,1,0,0.098416,3,pass,0.355041;0.349167;0.336417 -random_masked,49,294,8,old_group_serial,0.291625,0.279125,0.094042,14700,8,1,0,0.098416,3,pass,0.274917;0.291625;0.375291 -random_masked,49,294,8,old_pull_parallel_dag,0.394000,0.325875,0.148708,18816,8,1,0,0.098416,3,pass,0.394000;0.414875;0.355791 -random_masked,49,294,8,old_group_parallel_dag,0.321250,0.275959,0.093833,14700,8,1,0,0.098416,3,pass,0.321250;0.317416;0.348875 -random_masked,49,294,8,pull64_barrier,0.371584,0.324792,0.119250,18816,8,1,0,0.098416,3,pass,0.427208;0.371584;0.358792 -random_masked,49,294,8,pull64_interleaved,0.367875,0.317042,0.145625,18816,8,1,0,0.098416,3,pass,0.325000;0.367875;0.427334 -random_masked,49,294,8,pull32_interleaved,0.331500,0.293542,0.104583,18816,8,1,0,0.098416,3,pass,0.331500;0.368292;0.325250 -random_masked,49,294,8,pull32_gpu_dag,0.853708,0.354166,0.178292,18816,8,3,0,0.098416,3,pass,0.865458;0.810500;0.853708 -random_masked,49,294,8,frontier32_vertex,0.595250,0.561125,0.310875,3162,8,1,0,0.098416,3,pass,0.589541;0.595250;0.615666 -random_masked,49,294,8,frontier32_simd,0.426833,0.374167,0.180625,3156,8,1,0,0.098416,3,pass,0.426833;0.468625;0.403334 -random_masked,49,294,8,frontier32_gpu_dag,0.900792,0.407417,0.236917,3162,8,3,0,0.098416,3,pass,0.900792;0.924666;0.781917 -random_masked,49,294,8,bucket32_d4,2.018792,1.964250,0.991125,2022,40,5,0,0.098416,3,pass,2.461125;2.018792;1.864459 -random_masked,49,294,8,bucket32_d16,0.797750,0.762875,0.338708,2100,16,2,0,0.098416,3,pass,0.834750;0.759209;0.797750 -random_masked,49,294,8,bucket32_d64,0.719584,0.669417,0.273375,3042,16,2,0,0.098416,3,pass,0.847958;0.713667;0.719584 -random_masked_int64,52,312,8,old_pull_serial,0.251500,0.230167,0.061875,19968,8,1,0,0.132541,3,pass,0.251500;0.271667;0.246209 -random_masked_int64,52,312,8,old_group_serial,0.234042,0.204916,0.033833,12168,6,1,0,0.132541,3,pass,0.217834;0.234042;0.241666 -random_masked_int64,52,312,8,old_pull_parallel_dag,0.300750,0.252917,0.062500,19968,8,1,0,0.132541,3,pass,0.258250;0.300750;0.307166 -random_masked_int64,52,312,8,old_group_parallel_dag,0.277167,0.205667,0.033958,12168,6,1,0,0.132541,3,pass,0.256584;0.369375;0.277167 -random_masked_int64,52,312,8,pull64_barrier,0.335791,0.302083,0.060083,19968,8,1,0,0.132541,3,pass,0.347916;0.330959;0.335791 -random_masked_int64,52,312,8,pull64_interleaved,0.343042,0.290542,0.057959,19968,8,1,0,0.132541,3,pass,0.326000;0.357292;0.343042 -random_masked_int64,52,312,8,pull32_interleaved,0.349375,0.298750,0.057958,19968,8,1,1,0.132541,3,pass,0.361458;0.349375;0.335000 -random_masked_int64,52,312,8,pull32_gpu_dag,0.854458,0.312000,0.082459,19968,8,3,1,0.132541,3,pass,0.752042;1.220167;0.854458 -random_masked_int64,52,312,8,frontier32_vertex,0.358917,0.321792,0.058792,19968,8,1,1,0.132541,3,pass,0.369084;0.324000;0.358917 -random_masked_int64,52,312,8,frontier32_simd,0.371084,0.329958,0.058542,19968,8,1,1,0.132541,3,pass,0.371084;0.319417;0.386417 -random_masked_int64,52,312,8,frontier32_gpu_dag,0.591750,0.231083,0.061417,19968,8,3,1,0.132541,3,pass,0.534625;0.591750;0.640417 -random_masked_int64,52,312,8,bucket32_d4,0.324334,0.287292,0.053583,19968,8,1,1,0.132541,3,pass,0.296625;0.400583;0.324334 -random_masked_int64,52,312,8,bucket32_d16,0.321666,0.274500,0.051708,19968,8,1,1,0.132541,3,pass,0.321542;0.373166;0.321666 -random_masked_int64,52,312,8,bucket32_d64,0.330334,0.267000,0.048500,19968,8,1,1,0.132541,3,pass,0.330334;0.354292;0.300292 -random_masked_reverse,55,330,8,old_pull_serial,0.381583,0.369958,0.070083,42240,16,2,0,0.136458,3,pass,0.381583;0.404583;0.378750 -random_masked_reverse,55,330,8,old_group_serial,0.165125,0.149959,0.024333,20130,10,1,0,0.136458,3,pass,0.189000;0.157875;0.165125 -random_masked_reverse,55,330,8,old_pull_parallel_dag,0.330250,0.301334,0.058333,42240,16,2,0,0.136458,3,pass,0.364000;0.322041;0.330250 -random_masked_reverse,55,330,8,old_group_parallel_dag,0.188792,0.148584,0.021917,20130,10,1,0,0.136458,3,pass,0.223500;0.188792;0.181417 -random_masked_reverse,55,330,8,pull64_barrier,0.455958,0.432416,0.060250,42240,16,2,0,0.136458,3,pass,0.463333;0.455958;0.419792 -random_masked_reverse,55,330,8,pull64_interleaved,0.477458,0.446000,0.066375,42240,16,2,0,0.136458,3,pass,0.477458;0.438542;0.577083 -random_masked_reverse,55,330,8,pull32_interleaved,0.437209,0.390750,0.059500,42240,16,2,0,0.136458,3,pass,0.437209;0.439667;0.401416 -random_masked_reverse,55,330,8,pull32_gpu_dag,0.788167,0.410958,0.084000,42240,16,4,0,0.136458,3,pass,0.879083;0.780833;0.788167 -random_masked_reverse,55,330,8,frontier32_vertex,0.558375,0.525958,0.173792,3691,16,2,0,0.136458,3,pass,0.841542;0.558375;0.547000 -random_masked_reverse,55,330,8,frontier32_simd,0.471416,0.425250,0.108125,3691,16,2,0,0.136458,3,pass,0.451917;0.471416;0.482083 -random_masked_reverse,55,330,8,frontier32_gpu_dag,0.761666,0.416041,0.127125,3691,16,4,0,0.136458,3,pass,0.819375;0.761666;0.747625 -random_masked_reverse,55,330,8,bucket32_d4,1.046625,1.021750,0.349208,2455,32,4,0,0.136458,3,pass,1.042834;1.046625;1.100042 -random_masked_reverse,55,330,8,bucket32_d16,0.837417,0.805958,0.279000,2603,24,3,0,0.136458,3,pass,0.837417;0.884334;0.757500 -random_masked_reverse,55,330,8,bucket32_d64,0.521625,0.456666,0.159333,3605,16,2,0,0.136458,3,pass,0.453458;0.526417;0.521625 -random_masked_int64,58,348,8,old_pull_serial,0.186041,0.167958,0.030833,22272,8,1,0,0.058459,3,pass,0.189041;0.178792;0.186041 -random_masked_int64,58,348,8,old_group_serial,0.156000,0.139792,0.013250,13920,5,1,0,0.058459,3,pass,0.151125;0.160167;0.156000 -random_masked_int64,58,348,8,old_pull_parallel_dag,0.197250,0.157083,0.027333,22272,8,1,0,0.058459,3,pass,0.197250;0.200458;0.190459 -random_masked_int64,58,348,8,old_group_parallel_dag,0.165625,0.132291,0.013375,13920,5,1,0,0.058459,3,pass,0.167541;0.159458;0.165625 -random_masked_int64,58,348,8,pull64_barrier,0.208458,0.176250,0.026958,22272,8,1,0,0.058459,3,pass,0.283084;0.208458;0.198500 -random_masked_int64,58,348,8,pull64_interleaved,0.194334,0.174708,0.027958,22272,8,1,0,0.058459,3,pass,0.194334;0.191875;0.202292 -random_masked_int64,58,348,8,pull32_interleaved,0.191375,0.165916,0.027875,22272,8,1,1,0.058459,3,pass,0.198125;0.190583;0.191375 -random_masked_int64,58,348,8,pull32_gpu_dag,0.588458,0.211625,0.047000,22272,8,3,1,0.058459,3,pass,0.590250;0.506208;0.588458 -random_masked_int64,58,348,8,frontier32_vertex,0.225708,0.204667,0.030375,22272,8,1,1,0.058459,3,pass,0.245209;0.223583;0.225708 -random_masked_int64,58,348,8,frontier32_simd,0.244375,0.206250,0.030792,22272,8,1,1,0.058459,3,pass,0.226125;0.274500;0.244375 -random_masked_int64,58,348,8,frontier32_gpu_dag,0.520667,0.194583,0.043542,22272,8,3,1,0.058459,3,pass,0.517875;0.520667;0.547625 -random_masked_int64,58,348,8,bucket32_d4,0.217958,0.187000,0.027875,22272,8,1,1,0.058459,3,pass,0.211583;0.220375;0.217958 -random_masked_int64,58,348,8,bucket32_d16,0.230000,0.204833,0.030375,22272,8,1,1,0.058459,3,pass,0.188792;0.270042;0.230000 -random_masked_int64,58,348,8,bucket32_d64,0.209875,0.187709,0.027333,22272,8,1,1,0.058459,3,pass,0.264500;0.209875;0.203958 -random_masked,61,366,8,old_pull_serial,0.309291,0.298834,0.063375,46848,16,2,0,0.052416,3,pass,0.309291;0.308166;0.322417 -random_masked,61,366,8,old_group_serial,0.141500,0.131333,0.024708,23424,9,1,0,0.052416,3,pass,0.150833;0.141500;0.137000 -random_masked,61,366,8,old_pull_parallel_dag,0.342834,0.306125,0.064458,46848,16,2,0,0.052416,3,pass,0.406584;0.342834;0.339459 -random_masked,61,366,8,old_group_parallel_dag,0.177291,0.145333,0.024708,23424,9,1,0,0.052416,3,pass,0.177291;0.163500;0.191833 -random_masked,61,366,8,pull64_barrier,0.422167,0.395792,0.066667,46848,16,2,0,0.052416,3,pass,0.422167;0.390583;0.431209 -random_masked,61,366,8,pull64_interleaved,0.389083,0.365958,0.066667,46848,16,2,0,0.052416,3,pass,0.389083;0.365166;0.401083 -random_masked,61,366,8,pull32_interleaved,0.353250,0.319500,0.053750,46848,16,2,0,0.052416,3,pass,0.350166;0.353250;0.455584 -random_masked,61,366,8,pull32_gpu_dag,0.685375,0.349667,0.070417,46848,16,4,0,0.052416,3,pass,0.650041;0.834458;0.685375 -random_masked,61,366,8,frontier32_vertex,0.496416,0.460000,0.137750,4776,16,2,0,0.052416,3,pass,0.496416;0.497708;0.445875 -random_masked,61,366,8,frontier32_simd,0.431584,0.383167,0.092917,4776,16,2,0,0.052416,3,pass,0.378875;0.431584;0.512042 -random_masked,61,366,8,frontier32_gpu_dag,0.768958,0.425000,0.124625,4776,16,4,0,0.052416,3,pass,0.818959;0.743625;0.768958 -random_masked,61,366,8,bucket32_d4,1.126958,1.098000,0.409250,2760,40,5,0,0.052416,3,pass,1.126958;1.091500;1.168916 -random_masked,61,366,8,bucket32_d16,0.778042,0.751250,0.267750,2952,24,3,0,0.052416,3,pass,0.778042;0.807250;0.666792 -random_masked,61,366,8,bucket32_d64,0.449792,0.426417,0.157708,4590,16,2,0,0.052416,3,pass,0.443667;0.449792;0.465417 -random_masked_int64_reverse,64,384,8,old_pull_serial,0.179208,0.167667,0.026500,24576,8,1,0,0.046333,3,pass,0.171750;0.179208;0.182792 -random_masked_int64_reverse,64,384,8,old_group_serial,0.154708,0.144500,0.011125,13440,5,1,0,0.046333,3,pass,0.151625;0.154708;0.161958 -random_masked_int64_reverse,64,384,8,old_pull_parallel_dag,0.200458,0.170542,0.026167,24576,8,1,0,0.046333,3,pass,0.209667;0.200458;0.200000 -random_masked_int64_reverse,64,384,8,old_group_parallel_dag,0.175792,0.141084,0.011125,13440,5,1,0,0.046333,3,pass,0.179041;0.175792;0.174542 -random_masked_int64_reverse,64,384,8,pull64_barrier,0.228417,0.203125,0.026667,24576,8,1,0,0.046333,3,pass,0.228417;0.234625;0.225042 -random_masked_int64_reverse,64,384,8,pull64_interleaved,0.218542,0.189875,0.024458,24576,8,1,0,0.046333,3,pass,0.198458;0.251667;0.218542 -random_masked_int64_reverse,64,384,8,pull32_interleaved,0.223083,0.188250,0.024000,24576,8,1,1,0.046333,3,pass,0.197291;0.223083;0.267416 -random_masked_int64_reverse,64,384,8,pull32_gpu_dag,0.465292,0.176792,0.035500,24576,8,3,1,0.046333,3,pass,0.465292;0.442125;0.476250 -random_masked_int64_reverse,64,384,8,frontier32_vertex,0.221250,0.186667,0.022625,24576,8,1,1,0.046333,3,pass,0.221250;0.218417;0.378333 -random_masked_int64_reverse,64,384,8,frontier32_simd,0.213667,0.181542,0.022625,24576,8,1,1,0.046333,3,pass,0.197541;0.224833;0.213667 -random_masked_int64_reverse,64,384,8,frontier32_gpu_dag,0.405958,0.149291,0.034417,24576,8,3,1,0.046333,3,pass,0.401500;0.405958;0.486750 -random_masked_int64_reverse,64,384,8,bucket32_d4,0.194334,0.164583,0.023333,24576,8,1,1,0.046333,3,pass,0.190208;0.205375;0.194334 -random_masked_int64_reverse,64,384,8,bucket32_d16,0.227500,0.203625,0.026750,24576,8,1,1,0.046333,3,pass,0.215584;0.227500;0.354667 -random_masked_int64_reverse,64,384,8,bucket32_d64,0.204667,0.171833,0.022792,24576,8,1,1,0.046333,3,pass,0.204667;0.199625;0.216875 -random_masked,67,402,8,old_pull_serial,0.156208,0.145333,0.027917,25728,8,1,0,0.078416,3,pass,0.158250;0.156208;0.154292 -random_masked,67,402,8,old_group_serial,0.157000,0.146292,0.019708,24522,8,1,0,0.078416,3,pass,0.198042;0.153458;0.157000 -random_masked,67,402,8,old_pull_parallel_dag,0.187958,0.160625,0.028542,25728,8,1,0,0.078416,3,pass,0.180917;0.199084;0.187958 -random_masked,67,402,8,old_group_parallel_dag,0.153916,0.135416,0.019375,24522,8,1,0,0.078416,3,pass,0.163708;0.153916;0.144041 -random_masked,67,402,8,pull64_barrier,0.198750,0.171208,0.027625,25728,8,1,0,0.078416,3,pass,0.217750;0.198750;0.175833 -random_masked,67,402,8,pull64_interleaved,0.237833,0.201459,0.030625,25728,8,1,0,0.078416,3,pass,0.237584;0.310625;0.237833 -random_masked,67,402,8,pull32_interleaved,0.249375,0.202667,0.025375,25728,8,1,0,0.078416,3,pass,0.228958;0.315416;0.249375 -random_masked,67,402,8,pull32_gpu_dag,0.488833,0.179459,0.039125,25728,8,3,0,0.078416,3,pass,0.479541;0.488833;0.514375 -random_masked,67,402,8,frontier32_vertex,0.313167,0.276458,0.080125,5034,8,1,0,0.078416,3,pass,0.319041;0.265166;0.313167 -random_masked,67,402,8,frontier32_simd,0.249209,0.218042,0.051417,5034,8,1,0,0.078416,3,pass,0.233792;0.255833;0.249209 -random_masked,67,402,8,frontier32_gpu_dag,0.607250,0.210917,0.064250,4998,8,3,0,0.078416,3,pass,0.607250;0.494000;0.612541 -random_masked,67,402,8,bucket32_d4,0.935291,0.893959,0.331917,3120,32,4,0,0.078416,3,pass,0.935291;0.935666;0.911458 -random_masked,67,402,8,bucket32_d16,0.785333,0.745209,0.263125,3360,24,3,0,0.078416,3,pass,0.785333;0.732792;0.858959 -random_masked,67,402,8,bucket32_d64,0.456333,0.432458,0.151583,4914,16,2,0,0.078416,3,pass,0.446917;0.511209;0.456333 -random_masked_int64,70,420,8,old_pull_serial,0.172833,0.161834,0.033625,26880,8,1,0,0.111875,3,pass,0.152208;0.172833;0.183542 -random_masked_int64,70,420,8,old_group_serial,0.152167,0.141916,0.017417,15960,6,1,0,0.111875,3,pass,0.152167;0.157459;0.140167 -random_masked_int64,70,420,8,old_pull_parallel_dag,0.184084,0.156958,0.031333,26880,8,1,0,0.111875,3,pass,0.184084;0.183667;0.186000 -random_masked_int64,70,420,8,old_group_parallel_dag,0.172209,0.148625,0.017417,15960,6,1,0,0.111875,3,pass,0.165958;0.207458;0.172209 -random_masked_int64,70,420,8,pull64_barrier,0.222541,0.191167,0.032083,26880,8,1,0,0.111875,3,pass,0.299792;0.222541;0.215792 -random_masked_int64,70,420,8,pull64_interleaved,0.263458,0.214125,0.032958,26880,8,1,0,0.111875,3,pass,0.221292;0.263458;0.273541 -random_masked_int64,70,420,8,pull32_interleaved,0.259208,0.212417,0.033292,26880,8,1,1,0.111875,3,pass,0.264333;0.242292;0.259208 -random_masked_int64,70,420,8,pull32_gpu_dag,0.487792,0.185542,0.044083,26880,8,3,1,0.111875,3,pass,0.520042;0.487792;0.476084 -random_masked_int64,70,420,8,frontier32_vertex,0.228167,0.205667,0.033167,26880,8,1,1,0.111875,3,pass,0.228167;0.278209;0.213792 -random_masked_int64,70,420,8,frontier32_simd,0.235917,0.197334,0.029875,26880,8,1,1,0.111875,3,pass,0.249584;0.235917;0.206458 -random_masked_int64,70,420,8,frontier32_gpu_dag,0.552583,0.195792,0.047541,26880,8,3,1,0.111875,3,pass,0.552583;0.514250;0.567208 -random_masked_int64,70,420,8,bucket32_d4,0.213625,0.180917,0.031042,26880,8,1,1,0.111875,3,pass,0.189583;0.229083;0.213625 -random_masked_int64,70,420,8,bucket32_d16,0.200875,0.175083,0.030667,26880,8,1,1,0.111875,3,pass,0.199792;0.212792;0.200875 -random_masked_int64,70,420,8,bucket32_d64,0.226208,0.203666,0.033042,26880,8,1,1,0.111875,3,pass,0.226208;0.289750;0.209208 -random_masked_reverse,73,438,8,old_pull_serial,0.300875,0.279750,0.049167,56064,16,2,0,0.051958,3,pass,0.297583;0.322917;0.300875 -random_masked_reverse,73,438,8,old_group_serial,0.153167,0.131333,0.016917,30222,10,1,0,0.051958,3,pass,0.162833;0.153167;0.143875 -random_masked_reverse,73,438,8,old_pull_parallel_dag,0.357000,0.316083,0.052417,56064,16,2,0,0.051958,3,pass,0.357000;0.347458;0.368750 -random_masked_reverse,73,438,8,old_group_parallel_dag,0.172250,0.133250,0.017000,30222,10,1,0,0.051958,3,pass,0.161416;0.172250;0.179584 -random_masked_reverse,73,438,8,pull64_barrier,0.355208,0.325958,0.051917,56064,16,2,0,0.051958,3,pass,0.361458;0.338917;0.355208 -random_masked_reverse,73,438,8,pull64_interleaved,0.392041,0.358292,0.055250,56064,16,2,0,0.051958,3,pass,0.387334;0.392041;0.393834 -random_masked_reverse,73,438,8,pull32_interleaved,0.431125,0.388250,0.052375,56064,16,2,0,0.051958,3,pass,0.489459;0.376000;0.431125 -random_masked_reverse,73,438,8,pull32_gpu_dag,0.582458,0.309500,0.058208,56064,16,4,0,0.051958,3,pass,0.582458;0.564583;0.654625 -random_masked_reverse,73,438,8,frontier32_vertex,0.492000,0.452417,0.152417,5335,16,2,0,0.051958,3,pass,0.605959;0.474750;0.492000 -random_masked_reverse,73,438,8,frontier32_simd,0.459959,0.426417,0.108125,5335,16,2,0,0.051958,3,pass,0.473959;0.439375;0.459959 -random_masked_reverse,73,438,8,frontier32_gpu_dag,0.697459,0.393625,0.109375,5335,16,4,0,0.051958,3,pass,0.673333;0.697459;0.753833 -random_masked_reverse,73,438,8,bucket32_d4,0.955083,0.933583,0.309833,3179,32,4,0,0.051958,3,pass,1.078375;0.909541;0.955083 -random_masked_reverse,73,438,8,bucket32_d16,0.751875,0.705958,0.260042,3402,24,3,0,0.051958,3,pass,0.647541;0.751875;0.849292 -random_masked_reverse,73,438,8,bucket32_d64,0.562917,0.525875,0.168958,4984,16,2,0,0.051958,3,pass,0.564125;0.562917;0.562417 -boundary_700000000,6,10,6,old_pull_serial,0.153083,0.152292,0.015542,360,6,1,0,0.062626,3,pass,0.153083;0.153167;0.149625 -boundary_700000000,6,10,6,old_group_serial,0.116958,0.116291,0.007083,160,5,1,0,0.062626,3,pass,0.116958;0.116375;0.242875 -boundary_700000000,6,10,6,old_pull_parallel_dag,0.160958,0.133625,0.013250,360,6,1,0,0.062626,3,pass,0.160958;0.163917;0.160375 -boundary_700000000,6,10,6,old_group_parallel_dag,0.128709,0.114500,0.007042,160,5,1,0,0.062626,3,pass,0.132541;0.128666;0.128709 -boundary_700000000,6,10,6,pull64_barrier,0.170584,0.154583,0.015000,360,6,1,0,0.062626,3,pass,0.170584;0.153833;0.171750 -boundary_700000000,6,10,6,pull64_interleaved,0.190334,0.165417,0.015666,360,6,1,0,0.062626,3,pass,0.186125;0.199333;0.190334 -boundary_700000000,6,10,6,pull32_interleaved,0.209125,0.182333,0.017250,360,6,1,0,0.062626,3,pass,0.210917;0.208625;0.209125 -boundary_700000000,6,10,6,pull32_gpu_dag,0.464958,0.162958,0.024458,360,6,3,0,0.062626,3,pass,0.460833;0.464958;0.468666 -boundary_700000000,6,10,6,frontier32_vertex,0.206792,0.182250,0.045667,24,8,1,0,0.062626,3,pass,0.243416;0.187625;0.206792 -boundary_700000000,6,10,6,frontier32_simd,0.193166,0.173584,0.042917,24,8,1,0,0.062626,3,pass,0.192167;0.212625;0.193166 -boundary_700000000,6,10,6,frontier32_gpu_dag,0.534000,0.215708,0.059417,24,8,3,0,0.062626,3,pass,0.534000;0.603250;0.530958 -boundary_700000000,6,10,6,bucket32_d4,0.290250,0.259750,0.085292,24,8,1,0,0.062626,3,pass,0.274625;0.290250;0.293083 -boundary_700000000,6,10,6,bucket32_d16,0.269250,0.236084,0.071083,24,8,1,0,0.062626,3,pass,0.265084;0.269250;0.281958 -boundary_700000000,6,10,6,bucket32_d64,0.271583,0.238833,0.073458,24,8,1,0,0.062626,3,pass,0.271583;0.256542;0.276333 -boundary_700000000_masked,6,10,6,old_pull_serial,0.132750,0.132042,0.013667,360,6,1,0,0.047500,3,pass,0.132750;0.225500;0.121625 -boundary_700000000_masked,6,10,6,old_group_serial,0.111833,0.111250,0.004667,80,2,1,0,0.047500,3,pass,0.111833;0.112875;0.109333 -boundary_700000000_masked,6,10,6,old_pull_parallel_dag,0.168000,0.135250,0.012875,360,6,1,0,0.047500,3,pass,0.168000;0.158167;0.168750 -boundary_700000000_masked,6,10,6,old_group_parallel_dag,0.151708,0.120958,0.004750,80,2,1,0,0.047500,3,pass,0.151708;0.149333;0.182959 -boundary_700000000_masked,6,10,6,pull64_barrier,0.190958,0.166041,0.015125,360,6,1,0,0.047500,3,pass,0.192667;0.190958;0.189958 -boundary_700000000_masked,6,10,6,pull64_interleaved,0.225917,0.194458,0.018250,360,6,1,0,0.047500,3,pass,0.226292;0.225917;0.211375 -boundary_700000000_masked,6,10,6,pull32_interleaved,0.214167,0.182959,0.017125,360,6,1,0,0.047500,3,pass,0.203500;0.214167;0.214666 -boundary_700000000_masked,6,10,6,pull32_gpu_dag,0.430208,0.142292,0.023583,360,6,3,0,0.047500,3,pass,0.430208;0.390208;0.433958 -boundary_700000000_masked,6,10,6,frontier32_vertex,0.235250,0.204875,0.044042,12,8,1,0,0.047500,3,pass,0.200750;0.235250;0.336958 -boundary_700000000_masked,6,10,6,frontier32_simd,0.249875,0.226667,0.048375,12,8,1,0,0.047500,3,pass,0.229167;0.284334;0.249875 -boundary_700000000_masked,6,10,6,frontier32_gpu_dag,0.571417,0.221708,0.060708,12,8,3,0,0.047500,3,pass,0.590542;0.571417;0.537625 -boundary_700000000_masked,6,10,6,bucket32_d4,0.279708,0.235917,0.070375,12,8,1,0,0.047500,3,pass,0.288167;0.279708;0.246584 -boundary_700000000_masked,6,10,6,bucket32_d16,0.228500,0.209750,0.070083,12,8,1,0,0.047500,3,pass,0.230209;0.228500;0.226459 -boundary_700000000_masked,6,10,6,bucket32_d64,0.295334,0.261667,0.068250,12,8,1,0,0.047500,3,pass,0.273459;0.310292;0.295334 -boundary_700000000_reverse,6,10,6,old_pull_serial,0.152542,0.151750,0.015375,360,6,1,0,0.044708,3,pass,0.152542;0.150625;0.153958 -boundary_700000000_reverse,6,10,6,old_group_serial,0.129375,0.128625,0.007375,170,5,1,0,0.044708,3,pass,0.126500;0.129375;0.135750 -boundary_700000000_reverse,6,10,6,old_pull_parallel_dag,0.185333,0.156667,0.015708,360,6,1,0,0.044708,3,pass,0.180541;0.191708;0.185333 -boundary_700000000_reverse,6,10,6,old_group_parallel_dag,0.170542,0.140583,0.007250,170,5,1,0,0.044708,3,pass,0.187292;0.148292;0.170542 -boundary_700000000_reverse,6,10,6,pull64_barrier,0.214917,0.190417,0.017583,360,6,1,0,0.044708,3,pass,0.213458;0.214917;0.243917 -boundary_700000000_reverse,6,10,6,pull64_interleaved,0.220292,0.166458,0.014958,360,6,1,0,0.044708,3,pass,0.222792;0.175083;0.220292 -boundary_700000000_reverse,6,10,6,pull32_interleaved,0.183500,0.156458,0.015083,360,6,1,0,0.044708,3,pass,0.183500;0.189625;0.182834 -boundary_700000000_reverse,6,10,6,pull32_gpu_dag,0.519917,0.169750,0.024042,360,6,3,0,0.044708,3,pass,0.519917;0.465250;0.537625 -boundary_700000000_reverse,6,10,6,frontier32_vertex,0.216000,0.187417,0.045292,24,8,1,0,0.044708,3,pass,0.213500;0.216000;0.237334 -boundary_700000000_reverse,6,10,6,frontier32_simd,0.246041,0.220042,0.043667,24,8,1,0,0.044708,3,pass,0.229209;0.246041;0.273709 -boundary_700000000_reverse,6,10,6,frontier32_gpu_dag,0.523833,0.220750,0.060375,24,8,3,0,0.044708,3,pass,0.523833;0.498833;0.540417 -boundary_700000000_reverse,6,10,6,bucket32_d4,0.240041,0.214875,0.074250,24,8,1,0,0.044708,3,pass,0.240041;0.244500;0.230375 -boundary_700000000_reverse,6,10,6,bucket32_d16,0.362708,0.330541,0.084500,24,8,1,0,0.044708,3,pass,0.397958;0.301208;0.362708 -boundary_700000000_reverse,6,10,6,bucket32_d64,0.333583,0.250791,0.068667,24,8,1,0,0.044708,3,pass,0.347292;0.333583;0.265750 -boundary_700000000_reverse_masked,6,10,6,old_pull_serial,0.137542,0.136750,0.012625,360,6,1,0,0.065000,3,pass,0.137542;0.134292;0.138000 -boundary_700000000_reverse_masked,6,10,6,old_group_serial,0.120291,0.119625,0.004708,90,2,1,0,0.065000,3,pass,0.125916;0.119667;0.120291 -boundary_700000000_reverse_masked,6,10,6,old_pull_parallel_dag,0.176375,0.136833,0.012625,360,6,1,0,0.065000,3,pass,0.158458;0.181459;0.176375 -boundary_700000000_reverse_masked,6,10,6,old_group_parallel_dag,0.151667,0.128583,0.004750,90,2,1,0,0.065000,3,pass,0.167542;0.151667;0.147500 -boundary_700000000_reverse_masked,6,10,6,pull64_barrier,0.200709,0.166916,0.015167,360,6,1,0,0.065000,3,pass,0.200709;0.212041;0.181625 -boundary_700000000_reverse_masked,6,10,6,pull64_interleaved,0.226166,0.190208,0.017125,360,6,1,0,0.065000,3,pass,0.279458;0.226166;0.219291 -boundary_700000000_reverse_masked,6,10,6,pull32_interleaved,0.195708,0.164333,0.014333,360,6,1,0,0.065000,3,pass,0.306500;0.195708;0.174541 -boundary_700000000_reverse_masked,6,10,6,pull32_gpu_dag,0.511625,0.188000,0.027583,360,6,3,0,0.065000,3,pass,0.499792;0.511625;0.520417 -boundary_700000000_reverse_masked,6,10,6,frontier32_vertex,0.249584,0.218250,0.049750,9,8,1,0,0.065000,3,pass,0.260375;0.237958;0.249584 -boundary_700000000_reverse_masked,6,10,6,frontier32_simd,0.192000,0.174375,0.042500,9,8,1,0,0.065000,3,pass,0.222542;0.188917;0.192000 -boundary_700000000_reverse_masked,6,10,6,frontier32_gpu_dag,0.520750,0.196708,0.051583,9,8,3,0,0.065000,3,pass,0.499458;0.557625;0.520750 -boundary_700000000_reverse_masked,6,10,6,bucket32_d4,0.279375,0.251042,0.081875,9,8,1,0,0.065000,3,pass,0.264584;0.296875;0.279375 -boundary_700000000_reverse_masked,6,10,6,bucket32_d16,0.287792,0.265666,0.081750,9,8,1,0,0.065000,3,pass,0.274875;0.287792;0.295458 -boundary_700000000_reverse_masked,6,10,6,bucket32_d64,0.289625,0.255250,0.068125,9,8,1,0,0.065000,3,pass,0.312834;0.289625;0.250416 -boundary_715827883,6,10,6,old_pull_serial,0.120209,0.119500,0.013125,360,6,1,0,0.058791,3,pass,0.127541;0.120209;0.119917 -boundary_715827883,6,10,6,old_group_serial,0.107166,0.106500,0.006875,160,5,1,0,0.058791,3,pass,0.104417;0.107166;0.108041 -boundary_715827883,6,10,6,old_pull_parallel_dag,0.154000,0.125000,0.013250,360,6,1,0,0.058791,3,pass,0.144125;0.154000;0.171916 -boundary_715827883,6,10,6,old_group_parallel_dag,0.149500,0.114125,0.006875,160,5,1,0,0.058791,3,pass,0.149500;0.140417;0.221042 -boundary_715827883,6,10,6,pull64_barrier,0.222166,0.191750,0.018375,360,6,1,0,0.058791,3,pass,0.214458;0.222166;0.228209 -boundary_715827883,6,10,6,pull64_interleaved,0.189958,0.162959,0.015000,360,6,1,0,0.058791,3,pass,0.184833;0.189958;0.197084 -boundary_715827883,6,10,6,pull32_interleaved,0.192500,0.168000,0.015125,360,6,1,1,0.058791,3,pass,0.192500;0.184500;0.219584 -boundary_715827883,6,10,6,pull32_gpu_dag,0.479250,0.158583,0.024750,360,6,3,1,0.058791,3,pass,0.643291;0.422334;0.479250 -boundary_715827883,6,10,6,frontier32_vertex,0.198375,0.168625,0.015708,360,6,1,1,0.058791,3,pass,0.198375;0.200375;0.183875 -boundary_715827883,6,10,6,frontier32_simd,0.195458,0.156708,0.015750,360,6,1,1,0.058791,3,pass,0.195458;0.203875;0.169375 -boundary_715827883,6,10,6,frontier32_gpu_dag,0.538875,0.189083,0.028750,360,6,3,1,0.058791,3,pass,0.538875;0.638625;0.527042 -boundary_715827883,6,10,6,bucket32_d4,0.226209,0.195500,0.018375,360,6,1,1,0.058791,3,pass,0.226209;0.233750;0.219250 -boundary_715827883,6,10,6,bucket32_d16,0.195584,0.172416,0.015000,360,6,1,1,0.058791,3,pass,0.194333;0.195584;0.202875 -boundary_715827883,6,10,6,bucket32_d64,0.167458,0.142375,0.015125,360,6,1,1,0.058791,3,pass,0.156583;0.167458;0.173500 -boundary_715827883_masked,6,10,6,old_pull_serial,0.122709,0.122167,0.012750,360,6,1,0,0.041375,3,pass,0.124458;0.122709;0.118166 -boundary_715827883_masked,6,10,6,old_group_serial,0.114333,0.113750,0.004750,80,2,1,0,0.041375,3,pass,0.105541;0.117458;0.114333 -boundary_715827883_masked,6,10,6,old_pull_parallel_dag,0.189625,0.160583,0.012750,360,6,1,0,0.041375,3,pass,0.239917;0.189625;0.162708 -boundary_715827883_masked,6,10,6,old_group_parallel_dag,0.177833,0.145375,0.004750,80,2,1,0,0.041375,3,pass,0.204292;0.177833;0.157375 -boundary_715827883_masked,6,10,6,pull64_barrier,0.228125,0.197083,0.018375,360,6,1,0,0.041375,3,pass,0.251083;0.210958;0.228125 -boundary_715827883_masked,6,10,6,pull64_interleaved,0.184666,0.154875,0.015125,360,6,1,0,0.041375,3,pass,0.184666;0.182042;0.220166 -boundary_715827883_masked,6,10,6,pull32_interleaved,0.194125,0.165958,0.015375,360,6,1,1,0.041375,3,pass,0.187125;0.194125;0.205083 -boundary_715827883_masked,6,10,6,pull32_gpu_dag,0.492167,0.173541,0.025833,360,6,3,1,0.041375,3,pass,0.472000;0.507250;0.492167 -boundary_715827883_masked,6,10,6,frontier32_vertex,0.177375,0.156958,0.015334,360,6,1,1,0.041375,3,pass,0.177375;0.174542;0.179959 -boundary_715827883_masked,6,10,6,frontier32_simd,0.198167,0.164041,0.016083,360,6,1,1,0.041375,3,pass,0.183417;0.198167;0.200417 -boundary_715827883_masked,6,10,6,frontier32_gpu_dag,0.472833,0.168333,0.023292,360,6,3,1,0.041375,3,pass,0.462959;0.472833;0.561042 -boundary_715827883_masked,6,10,6,bucket32_d4,0.206875,0.162125,0.015000,360,6,1,1,0.041375,3,pass,0.206875;0.183792;0.220083 -boundary_715827883_masked,6,10,6,bucket32_d16,0.220500,0.186875,0.018250,360,6,1,1,0.041375,3,pass,0.223125;0.210041;0.220500 -boundary_715827883_masked,6,10,6,bucket32_d64,0.221208,0.187541,0.018250,360,6,1,1,0.041375,3,pass,0.221208;0.235125;0.213209 -boundary_715827883_reverse,6,10,6,old_pull_serial,0.133417,0.132625,0.012792,360,6,1,0,0.054333,3,pass,0.124125;0.133417;0.135875 -boundary_715827883_reverse,6,10,6,old_group_serial,0.118458,0.117417,0.006833,170,5,1,0,0.054333,3,pass,0.122917;0.118458;0.112583 -boundary_715827883_reverse,6,10,6,old_pull_parallel_dag,0.165083,0.128666,0.013000,360,6,1,0,0.054333,3,pass,0.169708;0.165083;0.153750 -boundary_715827883_reverse,6,10,6,old_group_parallel_dag,0.142916,0.115625,0.006833,170,5,1,0,0.054333,3,pass,0.133959;0.142916;0.147958 -boundary_715827883_reverse,6,10,6,pull64_barrier,0.178375,0.160750,0.014750,360,6,1,0,0.054333,3,pass,0.178208;0.178375;0.178792 -boundary_715827883_reverse,6,10,6,pull64_interleaved,0.158041,0.141666,0.014167,360,6,1,0,0.054333,3,pass,0.156916;0.254833;0.158041 -boundary_715827883_reverse,6,10,6,pull32_interleaved,0.164167,0.149250,0.014958,360,6,1,1,0.054333,3,pass,0.164042;0.189750;0.164167 -boundary_715827883_reverse,6,10,6,pull32_gpu_dag,0.538250,0.199834,0.027083,360,6,3,1,0.054333,3,pass,0.539208;0.538250;0.507209 -boundary_715827883_reverse,6,10,6,frontier32_vertex,0.203375,0.173375,0.014250,360,6,1,1,0.054333,3,pass,0.203375;0.195083;0.226000 -boundary_715827883_reverse,6,10,6,frontier32_simd,0.196125,0.162666,0.014417,360,6,1,1,0.054333,3,pass,0.185291;0.196125;0.244042 -boundary_715827883_reverse,6,10,6,frontier32_gpu_dag,0.488000,0.175500,0.027667,360,6,3,1,0.054333,3,pass,0.488000;0.462750;0.532166 -boundary_715827883_reverse,6,10,6,bucket32_d4,0.164000,0.148625,0.014542,360,6,1,1,0.054333,3,pass,0.164000;0.152834;0.167458 -boundary_715827883_reverse,6,10,6,bucket32_d16,0.195000,0.168500,0.014708,360,6,1,1,0.054333,3,pass,0.188333;0.256042;0.195000 -boundary_715827883_reverse,6,10,6,bucket32_d64,0.212875,0.184917,0.017666,360,6,1,1,0.054333,3,pass,0.208166;0.344916;0.212875 -boundary_715827883_reverse_masked,6,10,6,old_pull_serial,0.150292,0.149625,0.015375,360,6,1,0,0.052417,3,pass,0.126208;0.150458;0.150292 -boundary_715827883_reverse_masked,6,10,6,old_group_serial,0.127916,0.127333,0.004750,90,2,1,0,0.052417,3,pass,0.127916;0.134125;0.119375 -boundary_715827883_reverse_masked,6,10,6,old_pull_parallel_dag,0.164542,0.136541,0.013125,360,6,1,0,0.052417,3,pass,0.181750;0.164542;0.151083 -boundary_715827883_reverse_masked,6,10,6,old_group_parallel_dag,0.154042,0.127375,0.004875,90,2,1,0,0.052417,3,pass,0.154042;0.156250;0.153292 -boundary_715827883_reverse_masked,6,10,6,pull64_barrier,0.200333,0.165125,0.015125,360,6,1,0,0.052417,3,pass,0.200333;0.199875;0.204416 -boundary_715827883_reverse_masked,6,10,6,pull64_interleaved,0.210792,0.185334,0.017625,360,6,1,0,0.052417,3,pass,0.227500;0.210792;0.200375 -boundary_715827883_reverse_masked,6,10,6,pull32_interleaved,0.217375,0.185084,0.017625,360,6,1,1,0.052417,3,pass,0.217833;0.217375;0.195375 -boundary_715827883_reverse_masked,6,10,6,pull32_gpu_dag,0.515250,0.196792,0.027292,360,6,3,1,0.052417,3,pass,0.515250;0.479500;0.549708 -boundary_715827883_reverse_masked,6,10,6,frontier32_vertex,0.219458,0.190250,0.017667,360,6,1,1,0.052417,3,pass,0.219458;0.250125;0.216333 -boundary_715827883_reverse_masked,6,10,6,frontier32_simd,0.212375,0.181250,0.017625,360,6,1,1,0.052417,3,pass,0.194709;0.212375;0.214000 -boundary_715827883_reverse_masked,6,10,6,frontier32_gpu_dag,0.486708,0.177208,0.027583,360,6,3,1,0.052417,3,pass,0.479000;0.493500;0.486708 -boundary_715827883_reverse_masked,6,10,6,bucket32_d4,0.193167,0.156750,0.014750,360,6,1,1,0.052417,3,pass,0.193167;0.200750;0.182042 -boundary_715827883_reverse_masked,6,10,6,bucket32_d16,0.192625,0.164209,0.015333,360,6,1,1,0.052417,3,pass,0.192625;0.185167;0.198417 -boundary_715827883_reverse_masked,6,10,6,bucket32_d64,0.208375,0.186750,0.017542,360,6,1,1,0.052417,3,pass,0.200917;0.218917;0.208375 -boundary_9007199254740993,6,10,6,old_pull_serial,0.148291,0.147583,0.015875,360,6,1,0,0.073999,3,pass,0.148291;0.147667;0.150250 -boundary_9007199254740993,6,10,6,old_group_serial,0.111833,0.111167,0.006917,160,5,1,0,0.073999,3,pass,0.109625;0.111833;0.127792 -boundary_9007199254740993,6,10,6,old_pull_parallel_dag,0.181458,0.154458,0.015750,360,6,1,0,0.073999,3,pass,0.184667;0.181458;0.176167 -boundary_9007199254740993,6,10,6,old_group_parallel_dag,0.149375,0.119625,0.007125,160,5,1,0,0.073999,3,pass,0.164833;0.147291;0.149375 -boundary_9007199254740993,6,10,6,pull64_barrier,0.200917,0.170292,0.015292,360,6,1,0,0.073999,3,pass,0.199125;0.200917;0.205000 -boundary_9007199254740993,6,10,6,pull64_interleaved,0.212291,0.186167,0.015042,360,6,1,0,0.073999,3,pass,0.212291;0.209834;0.279417 -boundary_9007199254740993,6,10,6,pull32_interleaved,0.159792,0.142750,0.014667,360,6,1,1,0.073999,3,pass,0.167709;0.159042;0.159792 -boundary_9007199254740993,6,10,6,pull32_gpu_dag,0.554792,0.159666,0.029250,360,6,3,1,0.073999,3,pass,0.554792;0.493083;0.578709 -boundary_9007199254740993,6,10,6,frontier32_vertex,0.185416,0.153667,0.017792,360,6,1,1,0.073999,3,pass,0.176292;0.185416;0.188042 -boundary_9007199254740993,6,10,6,frontier32_simd,0.187125,0.155875,0.018250,360,6,1,1,0.073999,3,pass,0.174750;0.187125;0.191708 -boundary_9007199254740993,6,10,6,frontier32_gpu_dag,0.415458,0.156042,0.025916,360,6,3,1,0.073999,3,pass,0.415458;0.400750;0.484833 -boundary_9007199254740993,6,10,6,bucket32_d4,0.176666,0.146292,0.014875,360,6,1,1,0.073999,3,pass,0.210084;0.176666;0.135250 -boundary_9007199254740993,6,10,6,bucket32_d16,0.132542,0.115500,0.014917,360,6,1,1,0.073999,3,pass,0.136166;0.132542;0.130709 -boundary_9007199254740993,6,10,6,bucket32_d64,0.137417,0.121500,0.015042,360,6,1,1,0.073999,3,pass,0.137417;0.129125;0.195834 -boundary_9007199254740993_masked,6,10,6,old_pull_serial,0.105291,0.104750,0.012833,360,6,1,0,0.054125,3,pass,0.107125;0.104375;0.105291 -boundary_9007199254740993_masked,6,10,6,old_group_serial,0.095459,0.094708,0.005041,80,2,1,0,0.054125,3,pass,0.093375;0.174292;0.095459 -boundary_9007199254740993_masked,6,10,6,old_pull_parallel_dag,0.215208,0.159625,0.014791,360,6,1,0,0.054125,3,pass,0.219666;0.215208;0.155541 -boundary_9007199254740993_masked,6,10,6,old_group_parallel_dag,0.135333,0.106834,0.004625,80,2,1,0,0.054125,3,pass,0.133708;0.135625;0.135333 -boundary_9007199254740993_masked,6,10,6,pull64_barrier,0.188291,0.151667,0.018125,360,6,1,0,0.054125,3,pass,0.188291;0.192292;0.160167 -boundary_9007199254740993_masked,6,10,6,pull64_interleaved,0.179083,0.156084,0.018250,360,6,1,0,0.054125,3,pass,0.178208;0.197125;0.179083 -boundary_9007199254740993_masked,6,10,6,pull32_interleaved,0.179709,0.150083,0.018125,360,6,1,1,0.054125,3,pass,0.179709;0.174000;0.180917 -boundary_9007199254740993_masked,6,10,6,pull32_gpu_dag,0.349458,0.133792,0.023625,360,6,3,1,0.054125,3,pass,0.546208;0.344834;0.349458 -boundary_9007199254740993_masked,6,10,6,frontier32_vertex,0.131041,0.115417,0.014500,360,6,1,1,0.054125,3,pass,0.136250;0.131041;0.129208 -boundary_9007199254740993_masked,6,10,6,frontier32_simd,0.136125,0.119875,0.015000,360,6,1,1,0.054125,3,pass,0.136125;0.128042;0.141625 -boundary_9007199254740993_masked,6,10,6,frontier32_gpu_dag,0.478416,0.157208,0.027375,360,6,3,1,0.054125,3,pass,0.553709;0.478416;0.475792 -boundary_9007199254740993_masked,6,10,6,bucket32_d4,0.214250,0.193083,0.015500,360,6,1,1,0.054125,3,pass,0.163333;0.214250;0.219959 -boundary_9007199254740993_masked,6,10,6,bucket32_d16,0.168917,0.144875,0.015000,360,6,1,1,0.054125,3,pass,0.181250;0.156250;0.168917 -boundary_9007199254740993_masked,6,10,6,bucket32_d64,0.142958,0.125459,0.015375,360,6,1,1,0.054125,3,pass,0.129083;0.147166;0.142958 -boundary_9007199254740993_reverse,6,10,6,old_pull_serial,0.105875,0.105083,0.013125,360,6,1,0,0.050126,3,pass,0.102041;0.107958;0.105875 -boundary_9007199254740993_reverse,6,10,6,old_group_serial,0.092250,0.091500,0.006917,170,5,1,0,0.050126,3,pass,0.092250;0.097667;0.092209 -boundary_9007199254740993_reverse,6,10,6,old_pull_parallel_dag,0.123375,0.107333,0.013250,360,6,1,0,0.050126,3,pass,0.150458;0.118958;0.123375 -boundary_9007199254740993_reverse,6,10,6,old_group_parallel_dag,0.132916,0.101792,0.007125,170,5,1,0,0.050126,3,pass,0.132916;0.136167;0.130209 -boundary_9007199254740993_reverse,6,10,6,pull64_barrier,0.189875,0.154125,0.017500,360,6,1,0,0.050126,3,pass,0.189209;0.189875;0.196541 -boundary_9007199254740993_reverse,6,10,6,pull64_interleaved,0.183333,0.161208,0.017625,360,6,1,0,0.050126,3,pass,0.192208;0.182208;0.183333 -boundary_9007199254740993_reverse,6,10,6,pull32_interleaved,0.188125,0.153708,0.017625,360,6,1,1,0.050126,3,pass,0.178292;0.188125;0.288375 -boundary_9007199254740993_reverse,6,10,6,pull32_gpu_dag,0.359916,0.136875,0.023292,360,6,3,1,0.050126,3,pass,0.359916;0.428209;0.350042 -boundary_9007199254740993_reverse,6,10,6,frontier32_vertex,0.152250,0.128333,0.014125,360,6,1,1,0.050126,3,pass,0.152250;0.126959;0.154625 -boundary_9007199254740993_reverse,6,10,6,frontier32_simd,0.179542,0.141500,0.014250,360,6,1,1,0.050126,3,pass,0.168500;0.179542;0.238833 -boundary_9007199254740993_reverse,6,10,6,frontier32_gpu_dag,0.379959,0.137208,0.023500,360,6,3,1,0.050126,3,pass,0.380500;0.358625;0.379959 -boundary_9007199254740993_reverse,6,10,6,bucket32_d4,0.196292,0.171875,0.014500,360,6,1,1,0.050126,3,pass,0.163083;0.196292;0.347041 -boundary_9007199254740993_reverse,6,10,6,bucket32_d16,0.175666,0.155333,0.014958,360,6,1,1,0.050126,3,pass,0.157667;0.231708;0.175666 -boundary_9007199254740993_reverse,6,10,6,bucket32_d64,0.190459,0.151084,0.015333,360,6,1,1,0.050126,3,pass,0.240750;0.190459;0.167292 -boundary_9007199254740993_reverse_masked,6,10,6,old_pull_serial,0.124667,0.124083,0.015542,360,6,1,0,0.052000,3,pass,0.124750;0.124667;0.123667 -boundary_9007199254740993_reverse_masked,6,10,6,old_group_serial,0.099584,0.098583,0.004750,90,2,1,0,0.052000,3,pass,0.100834;0.093209;0.099584 -boundary_9007199254740993_reverse_masked,6,10,6,old_pull_parallel_dag,0.153292,0.125583,0.015208,360,6,1,0,0.052000,3,pass,0.153292;0.151333;0.153875 -boundary_9007199254740993_reverse_masked,6,10,6,old_group_parallel_dag,0.127750,0.094666,0.004750,90,2,1,0,0.052000,3,pass,0.120042;0.129625;0.127750 -boundary_9007199254740993_reverse_masked,6,10,6,pull64_barrier,0.219833,0.181916,0.015375,360,6,1,0,0.052000,3,pass,0.148166;0.282542;0.219833 -boundary_9007199254740993_reverse_masked,6,10,6,pull64_interleaved,0.188667,0.157459,0.017625,360,6,1,0,0.052000,3,pass,0.188667;0.161000;0.282667 -boundary_9007199254740993_reverse_masked,6,10,6,pull32_interleaved,0.184958,0.157708,0.017792,360,6,1,1,0.052000,3,pass,0.199709;0.184958;0.184500 -boundary_9007199254740993_reverse_masked,6,10,6,pull32_gpu_dag,0.490041,0.163625,0.027375,360,6,3,1,0.052000,3,pass,0.540292;0.427375;0.490041 -boundary_9007199254740993_reverse_masked,6,10,6,frontier32_vertex,0.221041,0.194458,0.017542,360,6,1,1,0.052000,3,pass,0.221041;0.194292;0.230875 -boundary_9007199254740993_reverse_masked,6,10,6,frontier32_simd,0.172083,0.145625,0.015625,360,6,1,1,0.052000,3,pass,0.174542;0.172083;0.160959 -boundary_9007199254740993_reverse_masked,6,10,6,frontier32_gpu_dag,0.492375,0.153000,0.026167,360,6,3,1,0.052000,3,pass,0.492375;0.613458;0.419833 -boundary_9007199254740993_reverse_masked,6,10,6,bucket32_d4,0.183167,0.154000,0.014375,360,6,1,1,0.052000,3,pass,0.183167;0.158125;0.202250 -boundary_9007199254740993_reverse_masked,6,10,6,bucket32_d16,0.190542,0.156167,0.018000,360,6,1,1,0.052000,3,pass,0.190542;0.196500;0.181542 -boundary_9007199254740993_reverse_masked,6,10,6,bucket32_d64,0.186166,0.161334,0.017917,360,6,1,1,0.052000,3,pass,0.186166;0.183459;0.225917 diff --git a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/a1-stderr.txt b/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/a1-stderr.txt deleted file mode 100644 index e69de29..0000000 diff --git a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/a1.csv b/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/a1.csv deleted file mode 100644 index fdbf690..0000000 --- a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/a1.csv +++ /dev/null @@ -1,15 +0,0 @@ -case,n,e,queries,method,wall_ms,samples,correct,wall_samples_ms -random,1024,8192,1,cpu_heap_cached_full,0.052958,7,pass,0.055375;0.051625;0.052917;0.051458;0.052958;0.058583;0.058166 -random,1024,8192,1,cpu_dial_full,0.027667,7,pass,0.030167;0.030541;0.028125;0.027667;0.027250;0.026833;0.026375 -random,4096,32768,256,cpu_heap_cached_full,13.029500,7,pass,13.029500;13.040792;13.013792;13.009291;13.009958;13.036750;14.042792 -random,4096,32768,256,cpu_dial_full,7.056208,7,pass,7.469875;7.434375;7.234209;6.965958;6.996125;7.056208;7.033417 -random,16384,131072,64,cpu_heap_cached_full,17.500459,7,pass,17.562542;17.286708;17.264542;17.357083;17.652583;17.583833;17.500459 -random,16384,131072,64,cpu_dial_full,7.696333,7,pass,7.668375;7.706958;7.745167;7.718791;7.696333;7.663042;7.625000 -random,65536,524288,1,cpu_heap_cached_full,11.474125,7,pass,11.148208;11.188916;11.564208;11.558541;11.339750;11.482000;11.474125 -random,65536,524288,1,cpu_dial_full,4.384459,7,pass,4.454542;4.378833;4.385375;4.329667;4.531666;4.312333;4.384459 -random,4096,131072,64,cpu_heap_cached_full,6.672416,7,pass,6.661917;6.739334;6.643583;6.657541;6.681834;6.698125;6.672416 -random,4096,131072,64,cpu_dial_full,3.853958,7,pass,3.851458;3.853958;3.815916;3.857375;3.879833;3.874917;3.831292 -clos,800,320000,64,cpu_heap_cached_full,5.124625,7,pass,5.071875;5.124625;5.096666;5.870542;5.049958;5.355042;5.716333 -clos,800,320000,64,cpu_dial_full,5.421083,7,pass,5.830666;5.848000;5.917417;5.170042;5.150375;5.105084;5.421083 -grid,10000,39600,1,cpu_heap_cached_full,0.200833,7,pass,0.200833;0.257167;0.200416;0.202250;0.192083;0.180000;0.204292 -grid,10000,39600,1,cpu_dial_full,0.126041,7,pass,0.119416;0.116334;0.126041;0.115459;0.129583;0.129291;0.132166 diff --git a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/a2-stderr.txt b/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/a2-stderr.txt deleted file mode 100644 index e69de29..0000000 diff --git a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/a2.csv b/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/a2.csv deleted file mode 100644 index 024e84a..0000000 --- a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/a2.csv +++ /dev/null @@ -1,15 +0,0 @@ -case,n,e,queries,method,wall_ms,samples,correct,wall_samples_ms -random,1024,8192,1,cpu_heap_cached_full,0.059333,7,pass,0.061000;0.064709;0.059916;0.058375;0.058000;0.059333;0.052125 -random,1024,8192,1,cpu_dial_full,0.030000,7,pass,0.034542;0.033125;0.031083;0.029209;0.027208;0.027000;0.030000 -random,4096,32768,256,cpu_heap_cached_full,13.024459,7,pass,13.041833;13.021250;13.009584;13.024459;13.033625;13.042542;12.991958 -random,4096,32768,256,cpu_dial_full,6.924667,7,pass,7.101083;6.924792;7.021833;6.864833;6.924667;6.870667;6.819209 -random,16384,131072,64,cpu_heap_cached_full,17.268250,7,pass,17.273042;17.266000;17.288208;17.268250;17.267209;17.275833;17.255375 -random,16384,131072,64,cpu_dial_full,7.677208,7,pass,7.677208;7.706875;7.657458;7.665875;7.719333;7.661292;7.683000 -random,65536,524288,1,cpu_heap_cached_full,11.530166,7,pass,11.564417;11.470750;11.483750;11.310125;11.569583;11.530166;11.626875 -random,65536,524288,1,cpu_dial_full,4.372167,7,pass,4.378333;4.454333;4.364667;4.227000;4.345917;4.372167;4.389167 -random,4096,131072,64,cpu_heap_cached_full,6.661792,7,pass,6.657041;6.646792;6.646166;6.707916;6.693375;6.663542;6.661792 -random,4096,131072,64,cpu_dial_full,3.865375,7,pass,3.805375;3.811917;3.905625;3.897042;3.809458;3.865375;3.871209 -clos,800,320000,64,cpu_heap_cached_full,5.390792,7,pass,5.126500;5.165833;15.349250;5.390792;5.835791;5.412709;5.085000 -clos,800,320000,64,cpu_dial_full,5.537125,7,pass,5.858500;5.165833;5.832709;5.052833;5.651083;5.096667;5.537125 -grid,10000,39600,1,cpu_heap_cached_full,0.193709,7,pass,0.196375;0.197750;0.194000;0.193083;0.193208;0.193500;0.193709 -grid,10000,39600,1,cpu_dial_full,0.128833,7,pass,0.125792;0.133583;0.128833;0.130583;0.127667;0.127958;0.137959 diff --git a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/b-stderr.txt b/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/b-stderr.txt deleted file mode 100644 index 3f8d1ea..0000000 --- a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/b-stderr.txt +++ /dev/null @@ -1 +0,0 @@ -device=Apple M4 Max setup_ms=23.7095 execution_width=32 diff --git a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/b.csv b/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/b.csv deleted file mode 100644 index 0abc818..0000000 --- a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/b.csv +++ /dev/null @@ -1,217 +0,0 @@ -case,n,e,queries,method,wall_ms,distance_ms,gpu_ms,edge_visits,rounds,waits,fallback64,upload_ms,samples,correct,wall_samples_ms -random,1024,8192,1,old_pull_parallel_dag,0.735000,0.722250,0.424250,131072,16,2,0,0.152417,7,pass,1.450333;1.486334;0.967291;0.691833;0.685125;0.682417;0.735000 -random,1024,8192,1,old_group_parallel_dag,0.894458,0.850750,0.680500,106496,13,1,0,0.152417,7,pass,0.924500;0.981625;0.889666;0.890459;0.909083;0.894458;0.841666 -random,1024,8192,1,pull64_barrier,0.665000,0.652208,0.384125,131072,16,2,0,0.152417,7,pass,0.679666;0.653042;0.631167;0.737750;0.683292;0.665000;0.658708 -random,1024,8192,1,pull64_interleaved,0.659958,0.647541,0.378167,131072,16,2,0,0.152417,7,pass,0.666709;0.682333;0.666000;0.656667;0.631250;0.628625;0.659958 -random,1024,8192,1,pull32_interleaved,0.633625,0.621125,0.349458,131072,16,2,0,0.152417,7,pass,0.630000;0.633625;0.646917;0.592625;0.621542;0.675042;0.679750 -random,1024,8192,1,pull32_gpu_dag,0.990667,0.586750,0.402625,131072,16,4,0,0.152417,7,pass,0.929750;0.903791;1.223750;1.103542;0.990667;1.068833;0.932250 -random,1024,8192,1,frontier32_vertex,0.956250,0.942417,0.645375,21088,16,2,0,0.152417,7,pass,0.942042;0.919167;0.956250;0.913625;0.964292;0.977375;0.982875 -random,1024,8192,1,frontier32_simd,0.693166,0.679333,0.379875,20552,16,2,0,0.152417,7,pass,0.676959;0.682750;1.409167;0.693166;0.755167;0.726708;0.655709 -random,1024,8192,1,frontier32_gpu_dag,1.101792,0.705875,0.452875,20328,16,4,0,0.152417,7,pass,1.224167;1.188959;1.101708;1.075709;1.073083;1.115791;1.101792 -random,1024,8192,1,bucket32_d4,1.894708,1.866500,1.272125,8232,32,4,0,0.152417,7,pass,1.987625;1.809250;1.931750;1.894708;1.811083;1.865583;1.993666 -random,1024,8192,1,bucket32_d16,1.462625,1.450541,0.908583,9336,24,3,0,0.152417,7,pass,2.104125;1.320875;1.538500;1.462625;1.601084;1.308042;1.351917 -random,1024,8192,1,bucket32_d64,0.952167,0.937292,0.644333,19312,16,2,0,0.152417,7,pass,1.248750;0.942959;0.996125;1.000625;0.952167;0.918417;0.940625 -random,1024,8192,64,old_pull_parallel_dag,1.937417,1.589708,1.084625,12582912,24,3,0,0.208500,7,pass,2.420375;2.350666;1.955666;1.937417;1.594375;1.582541;1.730292 -random,1024,8192,64,old_group_parallel_dag,0.852708,0.603458,0.416792,7217152,17,1,0,0.208500,7,pass,0.899041;0.998958;0.968625;0.852708;0.836250;0.792500;0.839000 -random,1024,8192,64,pull64_barrier,1.220375,0.975833,0.486292,12582912,24,3,0,0.208500,7,pass,1.220375;1.225167;1.314583;1.287208;1.146875;1.154083;1.183792 -random,1024,8192,64,pull64_interleaved,1.019833,0.760458,0.291000,12582912,24,3,0,0.208500,7,pass,1.065083;1.019833;1.055625;1.020583;0.974209;0.890417;0.998166 -random,1024,8192,64,pull32_interleaved,0.898917,0.634625,0.200167,12582912,24,3,0,0.208500,7,pass,0.898917;0.960667;0.884000;0.879625;0.850791;0.908417;0.920541 -random,1024,8192,64,pull32_gpu_dag,0.969041,0.585417,0.211667,12582912,24,5,0,0.208500,7,pass,0.969041;0.922042;1.008667;0.936709;1.172417;0.945333;1.045625 -random,1024,8192,64,frontier32_vertex,0.813125,0.530375,0.237208,1291032,16,2,0,0.208500,7,pass,0.845167;0.832625;0.813125;0.760750;0.796042;0.843375;0.760958 -random,1024,8192,64,frontier32_simd,1.042375,0.767125,0.452000,1202792,16,2,0,0.208500,7,pass,1.040375;1.059958;0.977208;1.055125;1.042375;0.995208;1.095584 -random,1024,8192,64,frontier32_gpu_dag,1.139000,0.779834,0.484125,1201976,16,4,0,0.208500,7,pass,1.239666;1.290666;1.062875;1.191292;1.127000;1.139000;1.046209 -random,1024,8192,64,bucket32_d4,2.241625,1.943125,1.017667,528520,56,7,0,0.208500,7,pass,2.336875;2.171250;2.283750;2.244750;2.202083;2.176583;2.241625 -random,1024,8192,64,bucket32_d16,1.528958,1.240375,0.663416,601288,32,4,0,0.208500,7,pass,1.477458;1.556208;1.529250;1.455791;1.528958;1.506209;1.531625 -random,1024,8192,64,bucket32_d64,1.433209,1.182000,0.675708,1154152,24,3,0,0.208500,7,pass,1.433209;1.409208;1.480083;1.452917;1.396292;1.491125;1.332208 -clos,200,20000,64,old_pull_parallel_dag,1.969833,1.397334,0.992208,10240000,8,1,0,0.332250,7,pass,2.147375;1.969833;1.709542;2.082542;1.738042;1.786209;2.212375 -clos,200,20000,64,old_group_parallel_dag,1.303791,0.804000,0.299042,3840000,3,1,0,0.332250,7,pass,1.323042;1.303791;1.293500;1.411875;1.069792;1.187042;1.491250 -clos,200,20000,64,pull64_barrier,2.484167,1.863542,1.278250,10240000,8,1,0,0.332250,7,pass,1.892958;2.403958;2.889250;2.317666;2.823000;2.484167;2.843584 -clos,200,20000,64,pull64_interleaved,1.915375,1.354625,0.765750,10240000,8,1,0,0.332250,7,pass,2.070042;1.927292;1.970375;1.915375;1.735583;1.896833;1.832083 -clos,200,20000,64,pull32_interleaved,1.882875,1.271375,0.676375,10240000,8,1,0,0.332250,7,pass,1.980333;1.772375;2.333209;2.149583;1.880167;1.809750;1.882875 -clos,200,20000,64,pull32_gpu_dag,2.063542,1.271125,1.000041,10240000,8,3,0,0.332250,7,pass,2.062708;2.111917;1.881792;2.063542;2.313125;2.266500;1.893166 -clos,200,20000,64,frontier32_vertex,2.130125,1.556167,0.945083,1280000,8,1,0,0.332250,7,pass,2.216500;2.130125;2.210583;1.863375;2.020208;2.181958;2.088666 -clos,200,20000,64,frontier32_simd,1.526333,0.956416,0.331917,1280000,8,1,0,0.332250,7,pass,1.383833;1.526333;1.478875;1.852542;1.436500;1.554041;1.578417 -clos,200,20000,64,frontier32_gpu_dag,2.066125,0.959000,0.844250,1280000,8,3,0,0.332250,7,pass,2.224500;1.735458;1.906792;2.066125;2.169666;2.396917;1.847250 -clos,200,20000,64,bucket32_d4,1.608916,1.072709,0.487625,1280000,8,1,0,0.332250,7,pass,1.291333;1.464042;1.642750;1.661542;1.763500;1.594834;1.608916 -clos,200,20000,64,bucket32_d16,1.782834,1.158959,0.500250,1280000,8,1,0,0.332250,7,pass,1.782834;1.714834;1.623458;2.323750;1.886750;2.390166;1.669083 -clos,200,20000,64,bucket32_d64,1.644084,1.123167,0.491375,1280000,8,1,0,0.332250,7,pass,1.536000;1.733125;1.951083;1.288625;1.644084;1.687625;1.508750 -grid,1024,3968,1,old_pull_parallel_dag,1.988666,1.978791,0.655042,253952,64,8,0,0.126000,7,pass,2.192792;1.794083;1.711292;1.642333;2.324041;2.040042;1.988666 -grid,1024,3968,1,old_group_parallel_dag,0.354333,0.344792,0.227083,249984,63,1,0,0.126000,7,pass,0.357958;0.353500;0.348292;0.354667;0.367708;0.354333;0.352458 -grid,1024,3968,1,pull64_barrier,1.101208,1.092375,0.173458,253952,64,8,0,0.126000,7,pass,1.101208;1.075666;1.197417;1.305459;1.066333;1.098666;1.168958 -grid,1024,3968,1,pull64_interleaved,1.102667,1.094791,0.173334,253952,64,8,0,0.126000,7,pass,1.220083;1.096709;1.102667;1.070708;1.101917;1.107459;1.132750 -grid,1024,3968,1,pull32_interleaved,1.116333,1.109458,0.159500,253952,64,8,0,0.126000,7,pass,1.065125;1.116333;1.098625;1.164500;1.273792;1.174125;1.070041 -grid,1024,3968,1,pull32_gpu_dag,1.169000,0.897708,0.169125,253952,64,10,0,0.126000,7,pass,1.129959;1.169000;1.173791;1.234958;1.194917;1.142833;1.136208 -grid,1024,3968,1,frontier32_vertex,1.287959,1.279750,0.518750,3968,64,8,0,0.126000,7,pass,1.275167;1.336000;1.287959;1.266750;1.320542;1.339834;1.279000 -grid,1024,3968,1,frontier32_simd,1.186417,1.178375,0.389875,3968,64,8,0,0.126000,7,pass,1.234375;1.146250;1.186417;1.171416;1.193500;1.268916;1.171458 -grid,1024,3968,1,frontier32_gpu_dag,1.444166,1.178625,0.405291,3968,64,10,0,0.126000,7,pass,1.394750;1.443917;1.570542;1.444166;1.444042;1.468875;1.606291 -grid,1024,3968,1,bucket32_d4,1.458042,1.450000,0.641250,3968,64,8,0,0.126000,7,pass,1.458042;1.446916;1.443291;1.455125;1.572041;1.467250;1.462417 -grid,1024,3968,1,bucket32_d16,1.450417,1.442583,0.646250,3968,64,8,0,0.126000,7,pass,1.568000;1.441375;1.585875;1.439833;1.450417;1.446250;1.577625 -grid,1024,3968,1,bucket32_d64,1.462208,1.454292,0.645042,3968,64,8,0,0.126000,7,pass,1.447292;1.462208;1.581541;1.449167;1.615959;1.469667;1.437709 -hub,1024,3070,1,old_pull_parallel_dag,6.087125,6.074167,5.541583,98240,32,4,0,0.123417,7,pass,6.160125;6.074625;6.215417;6.087125;6.141625;5.971583;6.010708 -hub,1024,3070,1,old_group_parallel_dag,4.883667,4.859875,4.720875,95170,31,1,0,0.123417,7,pass,4.873500;4.835042;4.883667;4.900833;4.866416;5.016625;4.921584 -hub,1024,3070,1,pull64_barrier,5.015958,5.001833,4.312792,98240,32,4,0,0.123417,7,pass,5.015958;4.936375;5.042167;4.957958;5.001875;5.138125;5.110666 -hub,1024,3070,1,pull64_interleaved,4.968459,4.947375,4.313000,98240,32,4,0,0.123417,7,pass,4.968459;4.898000;5.044458;4.967958;5.057375;4.917041;4.992250 -hub,1024,3070,1,pull32_interleaved,4.399625,4.376083,3.663500,98240,32,4,0,0.123417,7,pass,4.327167;4.296584;4.399625;4.441292;4.519125;4.303584;4.557000 -hub,1024,3070,1,pull32_gpu_dag,5.252583,4.408625,4.077750,98240,32,6,0,0.123417,7,pass,5.259250;5.203416;5.021750;5.114791;5.252625;5.269875;5.252583 -hub,1024,3070,1,frontier32_vertex,1.449125,1.441083,0.861666,3128,32,4,0,0.123417,7,pass,1.366833;1.404125;1.549000;1.449125;1.427167;1.498209;1.453542 -hub,1024,3070,1,frontier32_simd,0.749834,0.741250,0.233000,3128,32,4,0,0.123417,7,pass,0.785000;0.743375;0.694084;0.702792;0.814916;0.749834;0.754667 -hub,1024,3070,1,frontier32_gpu_dag,1.518500,0.744417,0.639333,3128,32,6,0,0.123417,7,pass,1.538584;1.435000;1.477375;1.446750;1.518500;1.532209;1.567208 -hub,1024,3070,1,bucket32_d4,0.918084,0.909917,0.356500,3100,32,4,0,0.123417,7,pass,0.928541;0.925042;0.903959;0.880958;1.046625;0.918084;0.916542 -hub,1024,3070,1,bucket32_d16,0.839333,0.831125,0.341583,3124,32,4,0,0.123417,7,pass,1.026333;0.839333;0.839208;0.858334;1.002334;0.813667;0.822250 -hub,1024,3070,1,bucket32_d64,0.842500,0.834542,0.330958,3128,32,4,0,0.123417,7,pass,0.830083;0.836583;0.831417;0.847833;0.842500;0.995291;0.855042 -random_masked_int64_reverse,1024,8192,8,old_pull_parallel_dag,0.231750,0.154542,0.031792,524288,8,1,0,0.204542,7,pass,0.230500;0.220375;0.231750;0.216000;0.233416;0.288916;0.235958 -random_masked_int64_reverse,1024,8192,8,old_group_parallel_dag,0.252542,0.194250,0.053750,368640,8,1,0,0.204542,7,pass,0.236792;0.252542;0.229916;0.264084;0.240167;0.263500;0.261584 -random_masked_int64_reverse,1024,8192,8,pull64_barrier,0.257667,0.187209,0.028792,524288,8,1,0,0.204542,7,pass,0.257667;0.235125;0.238709;0.250959;0.308250;0.266958;0.260000 -random_masked_int64_reverse,1024,8192,8,pull64_interleaved,0.252833,0.195875,0.028875,524288,8,1,0,0.204542,7,pass,0.256500;0.252833;0.247791;0.248542;0.242375;0.336584;0.254375 -random_masked_int64_reverse,1024,8192,8,pull32_interleaved,0.230417,0.174959,0.029333,524288,8,1,1,0.204542,7,pass,0.241708;0.224875;0.227167;0.211125;0.296709;0.230417;0.262209 -random_masked_int64_reverse,1024,8192,8,pull32_gpu_dag,0.494833,0.182667,0.048458,524288,8,3,1,0.204542,7,pass,0.513375;0.494833;0.505500;0.482667;0.659791;0.474833;0.453875 -random_masked_int64_reverse,1024,8192,8,frontier32_vertex,0.261750,0.195208,0.029083,524288,8,1,1,0.204542,7,pass,0.251333;0.233000;0.221584;0.267291;0.300833;0.262417;0.261750 -random_masked_int64_reverse,1024,8192,8,frontier32_simd,0.247209,0.186625,0.028708,524288,8,1,1,0.204542,7,pass,0.251209;0.256500;0.234417;0.247209;0.251708;0.230375;0.246875 -random_masked_int64_reverse,1024,8192,8,frontier32_gpu_dag,0.497708,0.182250,0.047708,524288,8,3,1,0.204542,7,pass,0.480208;0.465209;0.500875;0.497708;0.503250;0.570834;0.482959 -random_masked_int64_reverse,1024,8192,8,bucket32_d4,0.252250,0.196583,0.028667,524288,8,1,1,0.204542,7,pass,0.252250;0.268500;0.232417;0.246125;0.252875;0.344208;0.244583 -random_masked_int64_reverse,1024,8192,8,bucket32_d16,0.249667,0.182000,0.028708,524288,8,1,1,0.204542,7,pass,0.216667;0.223792;0.213625;0.249667;0.295166;0.279000;0.251250 -random_masked_int64_reverse,1024,8192,8,bucket32_d64,0.251083,0.193833,0.029000,524288,8,1,1,0.204542,7,pass,0.252208;0.256959;0.260000;0.228000;0.235541;0.231000;0.251083 -random,4096,32768,256,old_pull_parallel_dag,14.830166,11.111167,9.874250,201326592,24,3,0,0.540417,7,pass,15.178958;14.550500;14.761417;14.979459;14.964792;14.391417;14.830166 -random,4096,32768,256,old_group_parallel_dag,11.281792,7.602458,6.686625,136544256,20,1,0,0.540417,7,pass,10.789875;11.281792;11.619375;11.280333;11.393125;11.590542;11.218959 -random,4096,32768,256,pull64_barrier,13.451167,9.799333,8.501000,201326592,24,3,0,0.540417,7,pass,13.391709;13.451167;13.661333;13.776250;13.298041;13.431167;13.605083 -random,4096,32768,256,pull64_interleaved,7.315959,3.659084,2.253750,201326592,24,3,0,0.540417,7,pass,6.690083;6.816459;6.842416;7.535833;7.315959;7.446084;8.355833 -random,4096,32768,256,pull32_interleaved,10.835792,7.102291,5.947375,201326592,24,3,0,0.540417,7,pass,10.835792;11.572667;6.765709;10.839125;10.506583;6.753209;11.634209 -random,4096,32768,256,pull32_gpu_dag,4.001000,3.291333,2.062708,201326592,24,5,0,0.540417,7,pass,3.925292;8.471750;4.001000;8.828083;3.363250;8.899209;3.259542 -random,4096,32768,256,frontier32_vertex,8.457208,4.745875,3.541042,19765352,24,3,0,0.540417,7,pass,13.252792;7.803167;8.457208;13.944833;6.756042;6.576625;13.206291 -random,4096,32768,256,frontier32_simd,13.882000,10.166542,8.939417,20629696,24,3,0,0.540417,7,pass,14.372958;15.367875;15.384750;13.882000;12.035083;11.831084;11.218875 -random,4096,32768,256,frontier32_gpu_dag,8.543291,7.328250,6.778500,20601952,24,5,0,0.540417,7,pass,8.543291;8.521875;8.733916;8.510375;9.335416;8.605625;8.068333 -random,4096,32768,256,bucket32_d4,15.935000,12.268334,10.134292,8454048,72,9,0,0.540417,7,pass,16.177500;15.780541;15.770917;15.839625;16.023709;15.935000;16.271625 -random,4096,32768,256,bucket32_d16,13.396000,9.696458,7.725625,9728112,48,6,0,0.540417,7,pass,12.596125;13.460833;13.158208;13.458334;13.019125;14.111666;13.396000 -random,4096,32768,256,bucket32_d64,12.571916,8.730500,7.507958,18505456,24,3,0,0.540417,7,pass,11.895375;13.142667;12.700083;12.617083;12.179083;12.571916;12.433042 -random,16384,131072,1,old_pull_parallel_dag,0.994042,0.627000,0.204333,3145728,24,3,0,1.768209,7,pass,0.993208;0.994042;1.341958;0.917791;0.912375;1.503042;1.060459 -random,16384,131072,1,old_group_parallel_dag,5.366000,5.017958,4.840375,2359296,18,1,0,1.768209,7,pass,5.798209;5.530958;5.321334;5.366000;5.353375;5.372917;5.332083 -random,16384,131072,1,pull64_barrier,0.816750,0.565125,0.164167,3145728,24,3,0,1.768209,7,pass,0.865958;0.962083;0.844750;0.816750;0.811042;0.800375;0.796875 -random,16384,131072,1,pull64_interleaved,0.813625,0.564333,0.166833,3145728,24,3,0,1.768209,7,pass,0.863166;0.939584;0.787834;0.795208;0.902917;0.805500;0.813625 -random,16384,131072,1,pull32_interleaved,0.756500,0.546042,0.141500,3145728,24,3,0,1.768209,7,pass,0.923166;0.722875;0.703750;0.756500;0.740125;0.906291;0.899500 -random,16384,131072,1,pull32_gpu_dag,0.942292,0.540000,0.164958,3145728,24,5,0,1.768209,7,pass,0.942292;0.910583;0.936042;0.956041;0.878083;0.976916;0.998791 -random,16384,131072,1,frontier32_vertex,0.931209,0.689375,0.274000,377624,24,3,0,1.768209,7,pass,0.892750;0.957458;0.894000;0.931209;0.942583;1.014958;0.892084 -random,16384,131072,1,frontier32_simd,0.906500,0.677625,0.260042,331880,24,3,0,1.768209,7,pass,0.890291;0.667916;0.906500;0.909291;0.989250;0.915625;0.724500 -random,16384,131072,1,frontier32_gpu_dag,0.965417,0.651792,0.270625,331616,24,5,0,1.768209,7,pass,0.969083;1.029875;0.945167;0.851666;0.873917;1.018416;0.965417 -random,16384,131072,1,bucket32_d4,1.929542,1.695917,0.702167,132008,56,7,0,1.768209,7,pass,1.996375;1.955291;1.796708;1.929542;1.764500;1.932792;1.741583 -random,16384,131072,1,bucket32_d16,1.202042,0.958541,0.418458,148224,32,4,0,1.768209,7,pass,1.134042;1.177041;1.210208;1.158875;1.202042;1.323416;1.255292 -random,16384,131072,1,bucket32_d64,0.969875,0.745333,0.347375,269208,24,3,0,1.768209,7,pass,0.949333;0.936333;0.969875;0.966208;0.995208;1.023459;1.016541 -random,16384,131072,64,old_pull_parallel_dag,16.722292,12.546667,11.352584,201326592,24,3,0,1.742166,7,pass,16.589625;16.782167;17.024875;16.351458;16.722292;16.532333;16.879709 -random,16384,131072,64,old_group_parallel_dag,13.326000,9.159083,8.322458,158203904,22,1,0,1.742166,7,pass,12.850500;13.103292;13.505583;13.326000;13.384959;13.249125;13.521625 -random,16384,131072,64,pull64_barrier,15.921334,11.512833,10.367083,201326592,24,3,0,1.742166,7,pass,16.106709;16.345667;15.252750;15.479000;15.981125;15.515625;15.921334 -random,16384,131072,64,pull64_interleaved,7.341541,2.985875,1.883792,201326592,24,3,0,1.742166,7,pass,7.593083;7.245708;7.427542;7.141041;7.341541;7.639042;7.271500 -random,16384,131072,64,pull32_interleaved,8.727125,4.219375,3.045833,201326592,24,3,0,1.742166,7,pass,7.861333;8.053417;8.727125;10.393375;11.416917;7.248166;11.526125 -random,16384,131072,64,pull32_gpu_dag,3.850875,3.175750,2.039125,201326592,24,5,0,1.742166,7,pass,8.952083;3.619292;3.631417;8.345083;3.803042;8.516625;3.850875 -random,16384,131072,64,frontier32_vertex,10.189167,5.571958,4.471875,21279416,24,3,0,1.742166,7,pass,10.520375;10.189167;9.001250;10.956875;8.920750;12.021917;8.927750 -random,16384,131072,64,frontier32_simd,12.411875,8.079709,7.035667,22001240,24,3,0,1.742166,7,pass,17.322791;12.816041;12.164000;12.411875;11.992167;13.813792;12.047625 -random,16384,131072,64,frontier32_gpu_dag,10.058792,8.398667,8.525333,21937792,24,5,0,1.742166,7,pass,11.663125;11.269959;11.136625;10.058792;9.125875;9.377250;9.200916 -random,16384,131072,64,bucket32_d4,16.470666,11.920958,9.881666,8451432,72,9,0,1.742166,7,pass,16.470666;16.941042;17.234083;16.376958;16.415000;16.823750;15.927083 -random,16384,131072,64,bucket32_d16,14.035834,9.407333,7.796500,9570560,48,6,0,1.742166,7,pass,14.439958;13.796667;14.274833;13.844209;14.089416;14.035834;13.437416 -random,16384,131072,64,bucket32_d64,15.618875,11.154958,10.232875,17319760,24,3,0,1.742166,7,pass,15.651209;15.288208;15.618875;16.924458;15.772500;15.598375;14.522792 -random,65536,524288,1,old_pull_parallel_dag,3.453041,1.911833,1.022000,12582912,24,3,0,7.594209,7,pass,3.374625;3.569542;3.453041;3.795125;3.498709;3.402541;2.875167 -random,65536,524288,1,old_group_parallel_dag,30.640458,28.988333,28.348000,11534336,22,1,0,7.594209,7,pass,30.396166;30.640458;30.423084;30.773792;30.675375;30.724167;30.427041 -random,65536,524288,1,pull64_barrier,3.064792,1.521959,0.700667,12582912,24,3,0,7.594209,7,pass,3.100041;3.064792;3.087541;3.007292;3.161708;2.970083;3.019500 -random,65536,524288,1,pull64_interleaved,3.002250,1.493667,0.701750,12582912,24,3,0,7.594209,7,pass,2.876709;3.019125;3.179917;3.076334;3.002250;2.974625;2.964250 -random,65536,524288,1,pull32_interleaved,2.775333,1.290542,0.500375,12582912,24,3,0,7.594209,7,pass,2.775333;2.718458;2.930792;2.810750;2.709500;2.773875;2.835042 -random,65536,524288,1,pull32_gpu_dag,1.243833,0.853000,0.567083,12582912,24,5,0,7.594209,7,pass,1.223708;1.245875;1.243833;1.338334;1.271584;1.204125;1.242625 -random,65536,524288,1,frontier32_vertex,2.706458,1.262083,0.431792,1731984,24,3,0,7.594209,7,pass,2.705084;2.798542;2.750750;2.287750;2.706458;2.682250;2.832583 -random,65536,524288,1,frontier32_simd,2.935042,1.411333,0.600541,1575616,24,3,0,7.594209,7,pass,2.868750;3.034250;2.883500;2.935042;2.851584;3.144042;3.044166 -random,65536,524288,1,frontier32_gpu_dag,1.353667,0.972709,0.677750,1567736,24,5,0,7.594209,7,pass,1.358833;1.353667;1.310958;1.462708;1.339167;1.294625;1.385500 -random,65536,524288,1,bucket32_d4,4.132292,2.583208,1.160792,528256,64,8,0,7.594209,7,pass,3.686083;4.242917;4.103209;4.106250;4.263250;4.141291;4.132292 -random,65536,524288,1,bucket32_d16,3.380042,1.838417,0.783500,599248,40,5,0,7.594209,7,pass,3.391417;2.991958;3.371750;3.412458;3.380042;3.350000;3.477709 -random,65536,524288,1,bucket32_d64,2.992250,1.473917,0.653375,1050648,24,3,0,7.594209,7,pass,3.033250;2.985792;2.895916;2.992250;3.102916;2.573667;3.099000 -clos,800,320000,1,old_pull_parallel_dag,2.085458,1.267584,0.711750,2560000,8,1,0,3.542958,7,pass,2.085458;2.114708;2.080291;2.062458;2.124833;2.068584;2.087458 -clos,800,320000,1,old_group_parallel_dag,2.281625,1.479250,0.902375,960000,3,1,0,3.542958,7,pass,1.877667;2.281625;2.305042;2.260417;2.320084;1.892458;2.320458 -clos,800,320000,1,pull64_barrier,2.020750,1.233084,0.651125,2560000,8,1,0,3.542958,7,pass,2.054792;2.014333;2.018458;2.080459;2.020750;2.032083;2.017792 -clos,800,320000,1,pull64_interleaved,2.234583,1.436416,0.885583,2560000,8,1,0,3.542958,7,pass,2.068292;2.148167;2.116125;2.469583;2.302792;2.264542;2.234583 -clos,800,320000,1,pull32_interleaved,2.611333,1.797583,1.339500,2560000,8,1,0,3.542958,7,pass,2.385750;2.671625;2.384958;2.611333;2.573208;3.723625;3.674125 -clos,800,320000,1,pull32_gpu_dag,5.207000,2.914291,4.404583,2560000,8,3,0,3.542958,7,pass,5.244041;5.231584;5.207000;5.068292;5.140417;5.302750;5.152917 -clos,800,320000,1,frontier32_vertex,5.258709,4.426625,3.977667,320000,8,1,0,3.542958,7,pass,5.258709;5.398167;5.131167;5.318000;5.253625;5.215083;5.520042 -clos,800,320000,1,frontier32_simd,1.697541,0.807708,0.318125,320000,8,1,0,3.542958,7,pass,1.290916;2.233917;1.697541;1.702208;1.604792;1.668750;2.286250 -clos,800,320000,1,frontier32_gpu_dag,3.076875,0.757875,2.224667,320000,8,3,0,3.542958,7,pass,3.078333;3.015333;3.076875;3.188792;3.013250;3.084209;3.005084 -clos,800,320000,1,bucket32_d4,1.701625,0.885750,0.410333,320000,8,1,0,3.542958,7,pass,1.666500;1.672584;1.763833;1.701625;1.749083;1.658750;1.714833 -clos,800,320000,1,bucket32_d16,1.668083,0.829166,0.417000,320000,8,1,0,3.542958,7,pass,1.432416;1.856250;1.767917;1.306834;1.348000;1.726250;1.668083 -clos,800,320000,1,bucket32_d64,1.683584,0.853250,0.411375,320000,8,1,0,3.542958,7,pass,1.704958;1.451916;1.686084;1.713750;1.683584;1.665166;1.667083 -clos,800,320000,64,old_pull_parallel_dag,27.135625,19.105583,18.632250,163840000,8,1,0,3.559834,7,pass,26.917833;27.135625;27.827833;32.153333;18.959167;27.720583;24.055417 -clos,800,320000,64,old_group_parallel_dag,12.835333,6.027625,5.311750,61440000,3,1,0,3.559834,7,pass,12.958500;12.734958;13.122792;12.778250;13.686958;12.603333;12.835333 -clos,800,320000,64,pull64_barrier,25.325959,18.258292,17.562750,163840000,8,1,0,3.559834,7,pass,24.298917;26.631375;24.368792;26.615584;24.855375;25.325959;25.410042 -clos,800,320000,64,pull64_interleaved,13.125084,6.035417,5.379583,163840000,8,1,0,3.559834,7,pass,13.326458;12.319792;13.903000;13.181250;13.125084;12.508125;12.685500 -clos,800,320000,64,pull32_interleaved,11.445000,4.363917,3.663417,163840000,8,1,0,3.559834,7,pass,12.150625;11.445000;11.930667;11.957709;10.537375;10.746750;11.351500 -clos,800,320000,64,pull32_gpu_dag,8.371875,4.354166,6.133375,163840000,8,3,0,3.559834,7,pass,7.950875;8.814666;8.371875;7.833334;8.577500;8.882584;6.996125 -clos,800,320000,64,frontier32_vertex,13.988541,7.017125,6.391750,20480000,8,1,0,3.559834,7,pass,14.034166;13.531417;13.871125;13.743541;14.692125;14.240292;13.988541 -clos,800,320000,64,frontier32_simd,9.446000,2.085000,1.444208,20480000,8,1,0,3.559834,7,pass,9.797833;9.376500;9.053084;9.446000;9.639584;9.742792;8.711625 -clos,800,320000,64,frontier32_gpu_dag,14.162083,2.178709,11.456334,20480000,8,3,0,3.559834,7,pass,13.846833;14.475709;13.987625;15.439458;13.093208;14.545959;14.162083 -clos,800,320000,64,bucket32_d4,9.695708,2.496041,1.822042,20480000,8,1,0,3.559834,7,pass,9.883500;9.820625;9.538500;9.682750;9.301291;9.897500;9.695708 -clos,800,320000,64,bucket32_d16,9.406167,2.549333,1.826625,20480000,8,1,0,3.559834,7,pass,8.794417;11.469291;9.967792;9.406167;9.324375;9.109042;9.914709 -clos,800,320000,64,bucket32_d64,9.566083,2.444625,1.837625,20480000,8,1,0,3.559834,7,pass,9.548208;10.232833;8.945042;8.924416;9.566083;16.288833;9.696625 -grid,10000,39600,1,old_pull_parallel_dag,4.192542,4.113917,0.597499,7920000,200,25,0,0.515708,7,pass,4.572333;4.194375;4.089208;4.373667;4.192542;4.166459;3.976875 -grid,10000,39600,1,old_group_parallel_dag,7.762375,7.668834,7.506125,7880400,199,1,0,0.515708,7,pass,7.792125;7.704583;7.700541;7.760000;7.817458;7.783375;7.762375 -grid,10000,39600,1,pull64_barrier,4.020833,3.951958,0.658292,7920000,200,25,0,0.515708,7,pass,3.833709;3.945542;3.954459;4.034000;4.020833;4.291834;4.056792 -grid,10000,39600,1,pull64_interleaved,4.062583,3.994833,0.661000,7920000,200,25,0,0.515708,7,pass,4.196833;3.920792;4.062583;3.845292;4.041584;4.162667;4.256709 -grid,10000,39600,1,pull32_interleaved,3.864167,3.799084,0.587875,7920000,200,25,0,0.515708,7,pass,4.174709;4.013875;3.711500;3.864167;3.757584;3.710666;4.065667 -grid,10000,39600,1,pull32_gpu_dag,3.250500,2.965875,0.577125,7920000,200,27,0,0.515708,7,pass,3.552583;3.289542;3.200750;2.978667;3.290000;3.250500;3.223000 -grid,10000,39600,1,frontier32_vertex,4.489625,4.426958,1.793416,39600,200,25,0,0.515708,7,pass,4.489625;4.444250;4.573167;4.792917;4.258459;4.404625;4.750875 -grid,10000,39600,1,frontier32_simd,4.186542,4.123625,1.378167,39600,200,25,0,0.515708,7,pass,4.386417;4.186542;4.302708;4.184458;4.260750;4.070584;4.179833 -grid,10000,39600,1,frontier32_gpu_dag,4.407875,4.018750,1.346292,39600,200,27,0,0.515708,7,pass,4.517459;4.274333;4.451167;4.378334;4.360333;4.407875;4.505625 -grid,10000,39600,1,bucket32_d4,5.179458,5.116791,2.345999,39600,200,25,0,0.515708,7,pass,5.431125;5.479208;5.013167;5.425500;5.179458;5.173708;5.173291 -grid,10000,39600,1,bucket32_d16,5.276833,5.214041,2.415250,39600,200,25,0,0.515708,7,pass,4.927291;5.050083;5.276833;5.366584;5.363833;5.166292;5.303750 -grid,10000,39600,1,bucket32_d64,5.224250,5.149959,2.362208,39600,200,25,0,0.515708,7,pass,4.964708;5.052042;5.710000;5.224250;5.281208;5.245250;4.885583 -grid,10000,39600,8,old_pull_parallel_dag,4.414333,4.189125,1.349916,63360000,200,25,0,0.520832,7,pass,4.465500;4.414333;4.455250;3.975583;4.589792;3.990375;4.386291 -grid,10000,39600,8,old_group_parallel_dag,8.339916,8.078167,7.476500,51480000,199,1,0,0.520832,7,pass,8.339916;8.429834;8.038333;8.367083;8.349417;7.842958;8.191167 -grid,10000,39600,8,pull64_barrier,5.276917,5.101834,1.466624,63360000,200,25,0,0.520832,7,pass,4.816458;5.794209;4.863750;5.935333;5.276917;5.306875;5.029750 -grid,10000,39600,8,pull64_interleaved,5.016375,4.816125,1.365750,63360000,200,25,0,0.520832,7,pass,5.232500;5.447875;5.016375;4.970041;4.848667;5.042666;4.967708 -grid,10000,39600,8,pull32_interleaved,4.915792,4.690125,1.285791,63360000,200,25,0,0.520832,7,pass,4.809417;5.023625;5.557250;4.915792;5.009958;4.751584;4.751125 -grid,10000,39600,8,pull32_gpu_dag,4.954083,4.625375,1.314542,63360000,200,27,0,0.520832,7,pass,4.815084;5.124208;5.269375;4.898458;4.947625;4.954083;5.022583 -grid,10000,39600,8,frontier32_vertex,5.589084,5.445542,1.953459,316800,200,25,0,0.520832,7,pass,5.582708;5.589084;5.674750;5.473625;5.573792;6.400167;5.717125 -grid,10000,39600,8,frontier32_simd,4.779667,4.539916,1.380791,316800,200,25,0,0.520832,7,pass,4.779667;4.719625;5.179417;4.631792;4.733250;5.285917;5.059000 -grid,10000,39600,8,frontier32_gpu_dag,5.082166,4.654750,1.477750,316800,200,27,0,0.520832,7,pass,5.017125;5.101084;5.184792;4.835041;5.082166;5.098625;4.936417 -grid,10000,39600,8,bucket32_d4,7.027917,6.850791,3.498083,316800,200,25,0,0.520832,7,pass,7.218000;7.175916;7.011250;6.907042;7.027917;7.004458;7.400208 -grid,10000,39600,8,bucket32_d16,7.438125,7.228875,3.634041,316800,200,25,0,0.520832,7,pass,7.169458;7.230125;7.647625;7.648666;7.636167;7.171459;7.438125 -grid,10000,39600,8,bucket32_d64,7.147333,6.930583,3.488042,316800,200,25,0,0.520832,7,pass,7.213916;7.047042;7.147458;7.072792;7.147333;7.110667;7.237083 -chain,2048,2047,1,old_pull_parallel_dag,30.552917,30.545834,4.321582,4192256,2048,256,0,0.125625,7,pass,30.441708;30.552917;31.920250;29.640667;30.868458;31.028000;29.277250 -chain,2048,2047,1,old_group_parallel_dag,4.833083,4.826542,4.695250,4192256,2048,1,0,0.125625,7,pass,4.804500;4.824500;4.816250;4.851208;4.856917;4.858541;4.833083 -chain,2048,2047,1,pull64_barrier,30.510625,30.503291,4.811082,4192256,2048,256,0,0.125625,7,pass,30.510625;30.138791;31.022291;31.066500;26.890709;30.860167;28.175541 -chain,2048,2047,1,pull64_interleaved,31.047791,31.040250,4.944002,4192256,2048,256,0,0.125625,7,pass,28.758083;30.971667;31.047791;31.266083;31.732750;31.417208;30.582792 -chain,2048,2047,1,pull32_interleaved,30.664125,30.657666,4.841290,4192256,2048,256,0,0.125625,7,pass,30.888709;30.521792;30.642750;30.218834;30.664125;31.991916;30.833417 -chain,2048,2047,1,pull32_gpu_dag,31.190750,30.903459,4.845919,4192256,2048,258,0,0.125625,7,pass,30.740917;32.332792;30.644417;31.678750;31.051166;31.380667;31.190750 -chain,2048,2047,1,frontier32_vertex,39.637750,39.628292,12.972585,2047,2048,256,0,0.125625,7,pass,38.372083;40.031083;39.313000;39.637750;40.304916;40.453458;39.255125 -chain,2048,2047,1,frontier32_simd,39.789917,39.782625,12.911460,2047,2048,256,0,0.125625,7,pass,39.902750;39.872792;39.987917;39.789917;39.650792;36.553375;36.089750 -chain,2048,2047,1,frontier32_gpu_dag,70.724250,70.461750,44.595246,2047,2048,258,0,0.125625,7,pass,43.906625;58.636625;70.313917;71.726083;70.724250;76.852417;73.861208 -chain,2048,2047,1,bucket32_d4,58.239375,58.233417,21.375416,2047,2056,257,0,0.125625,7,pass,62.646291;58.284083;58.239375;53.150791;53.433666;55.449542;58.410250 -chain,2048,2047,1,bucket32_d16,57.210791,57.204375,20.963166,2047,2056,257,0,0.125625,7,pass,59.173458;58.572625;55.714083;58.005042;56.776000;56.803584;57.210791 -chain,2048,2047,1,bucket32_d64,57.298833,57.291750,20.994749,2047,2056,257,0,0.125625,7,pass,58.199167;57.298833;57.227292;56.298000;57.819334;57.979250;57.193708 -hub,4096,12286,64,old_pull_parallel_dag,32.738459,32.377000,30.834542,31452160,40,5,0,0.298792,7,pass,32.488459;32.939417;32.738459;32.991250;32.959542;32.400500;32.294959 -hub,4096,12286,64,old_group_parallel_dag,26.029167,25.650958,24.950500,27176632,38,1,0,0.298792,7,pass,26.056500;25.595459;25.674875;26.214084;26.029167;25.727042;26.054042 -hub,4096,12286,64,pull64_barrier,25.705500,25.271334,23.640292,31452160,40,5,0,0.298792,7,pass,25.705500;25.445917;25.780125;25.710875;25.734959;25.565792;25.261959 -hub,4096,12286,64,pull64_interleaved,33.502333,33.108584,31.536709,31452160,40,5,0,0.298792,7,pass,33.577666;33.502333;33.523417;32.882208;33.813209;32.886458;32.949750 -hub,4096,12286,64,pull32_interleaved,28.909250,28.554916,26.900000,31452160,40,5,0,0.298792,7,pass,29.039750;29.081500;28.383209;28.651958;29.064083;28.784208;28.909250 -hub,4096,12286,64,pull32_gpu_dag,31.414959,28.520625,29.230000,31452160,40,7,0,0.298792,7,pass,31.416583;31.781542;31.181542;30.931833;30.765958;31.968583;31.414959 -hub,4096,12286,64,frontier32_vertex,20.297708,19.929541,18.721708,1860120,40,5,0,0.298792,7,pass,20.734375;20.408333;20.049834;20.663792;20.297708;20.041916;20.265708 -hub,4096,12286,64,frontier32_simd,3.727542,3.299791,1.927041,1683322,40,5,0,0.298792,7,pass,3.644167;3.743041;3.727542;3.766667;3.927708;3.695166;3.035375 -hub,4096,12286,64,frontier32_gpu_dag,4.851209,2.763917,3.502458,1683264,40,7,0,0.298792,7,pass,5.212083;5.300875;4.645584;5.398958;4.851209;4.829000;4.836417 -hub,4096,12286,64,bucket32_d4,5.104875,4.739583,3.097417,788342,48,6,0,0.298792,7,pass,5.103375;5.319250;4.142667;4.426417;5.111667;5.148000;5.104875 -hub,4096,12286,64,bucket32_d16,4.303667,3.919791,2.909334,1124798,48,6,0,0.298792,7,pass,4.303667;4.285250;4.624542;4.287916;5.023958;4.240125;4.605209 -hub,4096,12286,64,bucket32_d64,5.089709,4.662958,3.317292,1925862,40,5,0,0.298792,7,pass,5.089709;5.218125;4.996917;4.812292;5.010000;5.321542;5.098500 -random,4096,131072,1,old_pull_parallel_dag,0.658209,0.484209,0.253500,2097152,16,2,0,1.704166,7,pass,0.723083;0.796000;0.658209;0.628833;0.644125;0.789625;0.628666 -random,4096,131072,1,old_group_parallel_dag,2.587292,2.399042,2.250625,1572864,12,1,0,1.704166,7,pass,2.567417;2.631125;2.647459;2.869250;2.513167;2.540333;2.587292 -random,4096,131072,1,pull64_barrier,0.643792,0.480417,0.225417,2097152,16,2,0,1.704166,7,pass,0.735542;0.628042;0.658500;0.665917;0.606791;0.634125;0.643792 -random,4096,131072,1,pull64_interleaved,0.650875,0.473000,0.234000,2097152,16,2,0,1.704166,7,pass,0.683042;0.650875;0.649917;0.626042;0.676500;0.636708;0.745083 -random,4096,131072,1,pull32_interleaved,0.611750,0.425333,0.179125,2097152,16,2,0,1.704166,7,pass,0.597042;0.595916;0.700375;0.611750;0.666083;0.629375;0.580167 -random,4096,131072,1,pull32_gpu_dag,0.814708,0.427917,0.224333,2097152,16,4,0,1.704166,7,pass,0.887750;0.819917;0.814708;0.757125;0.782916;0.782916;0.876000 -random,4096,131072,1,frontier32_vertex,0.777709,0.585167,0.335250,421760,16,2,0,1.704166,7,pass,0.777709;0.797958;0.748917;0.738459;0.794458;0.812375;0.721792 -random,4096,131072,1,frontier32_simd,0.561292,0.368875,0.129875,380192,16,2,0,1.704166,7,pass,0.808000;0.621666;0.561292;0.586125;0.541750;0.528000;0.542708 -random,4096,131072,1,frontier32_gpu_dag,0.777250,0.385500,0.168708,383328,16,4,0,1.704166,7,pass,0.674667;0.689959;0.777250;0.722875;0.854583;0.851667;0.782792 -random,4096,131072,1,bucket32_d4,0.824625,0.642167,0.249917,138720,24,3,0,1.704166,7,pass,0.809458;0.865917;0.824625;0.797333;0.853083;0.871667;0.757959 -random,4096,131072,1,bucket32_d16,0.661125,0.483250,0.199875,177408,16,2,0,1.704166,7,pass,0.662167;0.594250;0.638917;0.688375;0.698750;0.661125;0.649625 -random,4096,131072,1,bucket32_d64,0.615709,0.447834,0.194416,379648,16,2,0,1.704166,7,pass,0.572500;0.579000;0.727541;0.596792;0.615709;0.641250;0.683708 -random,4096,131072,64,old_pull_parallel_dag,9.242792,7.142292,6.265125,134217728,16,2,0,1.900000,7,pass,9.382709;9.053834;9.288833;9.441250;9.242792;8.910334;8.492458 -random,4096,131072,64,old_group_parallel_dag,6.136250,4.016875,3.290875,105644032,15,1,0,1.900000,7,pass,6.136250;6.189875;6.174375;6.008625;6.319916;5.805792;6.047625 -random,4096,131072,64,pull64_barrier,9.428125,7.314208,6.387583,134217728,16,2,0,1.900000,7,pass,10.098667;9.488167;9.428125;9.195458;10.087334;9.325500;9.205917 -random,4096,131072,64,pull64_interleaved,4.731458,2.589250,1.742166,134217728,16,2,0,1.900000,7,pass,4.093542;4.623375;4.798500;4.829542;4.576750;4.731458;4.879333 -random,4096,131072,64,pull32_interleaved,4.225208,2.210041,1.545917,134217728,16,2,0,1.900000,7,pass,4.413500;4.056459;4.225208;5.849208;6.273542;3.443458;3.763917 -random,4096,131072,64,pull32_gpu_dag,2.215000,1.628334,0.809417,134217728,16,4,0,1.900000,7,pass,4.834667;2.066875;2.150542;2.482250;2.217375;2.215000;1.987125 -random,4096,131072,64,frontier32_vertex,5.298958,3.305375,2.517000,22687072,16,2,0,1.900000,7,pass,6.278583;5.896708;5.273833;5.298958;5.188625;5.321541;4.794333 -random,4096,131072,64,frontier32_simd,4.818459,2.921834,1.971000,22886848,16,2,0,1.900000,7,pass,4.792250;4.818459;4.646292;5.055042;5.800500;5.912625;4.697458 -random,4096,131072,64,frontier32_gpu_dag,4.070083,2.819250,2.716417,22966880,16,4,0,1.900000,7,pass,4.098292;3.579583;4.282125;4.248541;3.964916;4.070083;3.906708 -random,4096,131072,64,bucket32_d4,5.638750,3.371833,2.416875,8804704,24,3,0,1.900000,7,pass,5.033750;4.330250;5.638750;5.774958;4.382750;6.163708;5.996584 -random,4096,131072,64,bucket32_d16,6.013709,2.810958,1.843083,12114144,24,3,0,1.900000,7,pass,5.143208;6.767583;5.024625;7.537250;8.913666;5.057708;6.013709 -random,4096,131072,64,bucket32_d64,5.196750,3.178125,2.351542,23254592,16,2,0,1.900000,7,pass,4.848708;5.196750;12.365125;5.578958;5.030667;4.926417;5.383084 diff --git a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/environment.txt b/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/environment.txt deleted file mode 100644 index 0a72477..0000000 --- a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/environment.txt +++ /dev/null @@ -1,5 +0,0 @@ -2026-09-26T23:34:36Z -fbab119e794c843838f4f3f96d75cb7f32f06845 -44962b5bd1dba6ecfc8d1449e324875e35ebe373e3f33aef779be12d9cb3fd65 dev/research/metal_pathfinding/optimization/cpu_algorithms.mm -5336a17369edf018c33267e7765d20bffe8beaed3b8fb9f4e23053942cd3b73c build/metal_pathfinding/cpu-algorithms -7bb649ffcd34e6be02ab14c35767b816b0b32ce3ee3c2fd91c199e4b37a837ce build/metal_pathfinding/optimized diff --git a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/gpu-load-a1.txt b/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/gpu-load-a1.txt deleted file mode 100644 index bb14b8c..0000000 --- a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/gpu-load-a1.txt +++ /dev/null @@ -1 +0,0 @@ - | "PerformanceStatistics" = {"In use system memory (driver)"=0,"Alloc system memory"=10962583552,"Tiler Utilization %"=10,"recoveryCount"=0,"lastRecoveryTime"=0,"Renderer Utilization %"=16,"TiledSceneBytes"=1507328,"Device Utilization %"=25,"SplitSceneCount"=0,"Allocated PB Size"=188219392,"In use system memory"=1329561600} diff --git a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/gpu-load-a2.txt b/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/gpu-load-a2.txt deleted file mode 100644 index 75d8bdd..0000000 --- a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/gpu-load-a2.txt +++ /dev/null @@ -1 +0,0 @@ - | "PerformanceStatistics" = {"In use system memory (driver)"=0,"Alloc system memory"=11424284672,"Tiler Utilization %"=2,"recoveryCount"=0,"lastRecoveryTime"=0,"Renderer Utilization %"=4,"TiledSceneBytes"=1507328,"Device Utilization %"=63,"SplitSceneCount"=0,"Allocated PB Size"=191889408,"In use system memory"=1508343808} diff --git a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/gpu-load-b.txt b/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/gpu-load-b.txt deleted file mode 100644 index 137719b..0000000 --- a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/gpu-load-b.txt +++ /dev/null @@ -1 +0,0 @@ - | "PerformanceStatistics" = {"In use system memory (driver)"=0,"Alloc system memory"=11044798464,"Tiler Utilization %"=0,"recoveryCount"=0,"lastRecoveryTime"=0,"Renderer Utilization %"=0,"TiledSceneBytes"=1507328,"Device Utilization %"=0,"SplitSceneCount"=0,"Allocated PB Size"=191496192,"In use system memory"=1353957376} diff --git a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/load-a1.txt b/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/load-a1.txt deleted file mode 100644 index 087f07d..0000000 --- a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/load-a1.txt +++ /dev/null @@ -1,40 +0,0 @@ -Processes: 1028 total, 2 running, 1026 sleeping, 5245 threads -2026/09/27 00:34:36 -Load Avg: 3.83, 3.81, 4.16 -CPU usage: 5.4% user, 9.75% sys, 85.20% idle -SharedLibs: 924M resident, 170M data, 136M linkedit. -MemRegions: 626142 total, 10G resident, 746M private, 4380M shared. -PhysMem: 34G used (3950M wired, 625M compressor), 1287M unused. -VM: 453T vsize, 6144M framework vsize, 2846254(0) swapins, 4729331(0) swapouts. -Networks: packets: 191220681/161G in, 146692457/164G out. -Disks: 97851346/2478G read, 117478369/2101G written. - -PID COMMAND %CPU #TH -99753 mscamerad-xpc 0.0 2 -99752 PlugInLibrarySer 0.0 2 -99750 QuickLookUIServi 0.0 3 -99749 com.apple.appkit 0.0 3 -99710 PlugInLibrarySer 0.0 2 -99709 Preview 0.0 4 -99428 crashpad_handler 0.0 4 -98995 crashpad_handler 0.0 4 -Processes: 1028 total, 2 running, 1026 sleeping, 5225 threads -2026/09/27 00:34:37 -Load Avg: 3.83, 3.81, 4.16 -CPU usage: 6.82% user, 4.74% sys, 88.43% idle -SharedLibs: 924M resident, 170M data, 136M linkedit. -MemRegions: 0 total, 0B resident, 746M private, 4380M shared. -PhysMem: 34G used (3956M wired, 625M compressor), 1290M unused. -VM: 453T vsize, 6144M framework vsize, 2846254(0) swapins, 4729331(0) swapouts. -Networks: packets: 191220845/161G in, 146692617/164G out. -Disks: 97851346/2478G read, 117478371/2101G written. - -PID COMMAND %CPU #TH -590 WindowServer 28.7 28 -70968 Superset Helper 21.6 32 -0 kernel_task 12.1 850/14 -35069 top 9.0 1/1 -21666 2.1.283 7.7 28 -70942 Superset Helper 7.2 19 -81804 2.1.283 5.2 21 -621 airportd 3.7 9 diff --git a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/load-a2.txt b/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/load-a2.txt deleted file mode 100644 index 07d79ae..0000000 --- a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/load-a2.txt +++ /dev/null @@ -1,40 +0,0 @@ -Processes: 1021 total, 3 running, 1018 sleeping, 5225 threads -2026/09/27 00:35:39 -Load Avg: 3.30, 3.68, 4.08 -CPU usage: 23.58% user, 16.59% sys, 59.82% idle -SharedLibs: 924M resident, 170M data, 136M linkedit. -MemRegions: 638937 total, 10G resident, 739M private, 4394M shared. -PhysMem: 34G used (4923M wired, 625M compressor), 767M unused. -VM: 451T vsize, 6144M framework vsize, 2846260(0) swapins, 4729331(0) swapouts. -Networks: packets: 191239397/161G in, 146716076/164G out. -Disks: 97852171/2478G read, 117481152/2101G written. - -PID COMMAND %CPU #TH -99753 mscamerad-xpc 0.0 2 -99752 PlugInLibrarySer 0.0 2 -99750 QuickLookUIServi 0.0 3 -99749 com.apple.appkit 0.0 3 -99710 PlugInLibrarySer 0.0 2 -99709 Preview 0.0 4 -99428 crashpad_handler 0.0 4 -98995 crashpad_handler 0.0 4 -Processes: 1020 total, 7 running, 1013 sleeping, 5228 threads -2026/09/27 00:35:40 -Load Avg: 3.30, 3.68, 4.08 -CPU usage: 14.96% user, 7.41% sys, 77.61% idle -SharedLibs: 924M resident, 170M data, 136M linkedit. -MemRegions: 0 total, 0B resident, 739M private, 4394M shared. -PhysMem: 34G used (4111M wired, 625M compressor), 910M unused. -VM: 451T vsize, 6144M framework vsize, 2846260(0) swapins, 4729331(0) swapouts. -Networks: packets: 191239878/161G in, 146716829/164G out. -Disks: 97852524/2478G read, 117481160/2101G written. - -PID COMMAND %CPU #TH -41532 Google Chrome He 54.7 26/1 -590 WindowServer 26.7 28/1 -95916 Google Chrome 17.7 46 -0 kernel_task 17.2 850/14 -70968 Superset Helper 15.2 32 -21666 2.1.283 9.4 27 -41571 top 7.9 1/1 -28991 Muse 6.2 29 diff --git a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/load-b.txt b/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/load-b.txt deleted file mode 100644 index 5461de5..0000000 --- a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/load-b.txt +++ /dev/null @@ -1,40 +0,0 @@ -Processes: 1032 total, 5 running, 1027 sleeping, 5271 threads -2026/09/27 00:34:45 -Load Avg: 3.77, 3.80, 4.15 -CPU usage: 19.63% user, 13.49% sys, 66.87% idle -SharedLibs: 924M resident, 170M data, 136M linkedit. -MemRegions: 639550 total, 10G resident, 748M private, 4376M shared. -PhysMem: 34G used (3953M wired, 625M compressor), 943M unused. -VM: 456T vsize, 6144M framework vsize, 2846254(0) swapins, 4729331(0) swapouts. -Networks: packets: 191222619/161G in, 146695003/164G out. -Disks: 97851375/2478G read, 117478676/2101G written. - -PID COMMAND %CPU #TH -99753 mscamerad-xpc 0.0 2 -99752 PlugInLibrarySer 0.0 2 -99750 QuickLookUIServi 0.0 3 -99749 com.apple.appkit 0.0 3 -99710 PlugInLibrarySer 0.0 2 -99709 Preview 0.0 4 -99428 crashpad_handler 0.0 4 -98995 crashpad_handler 0.0 4 -Processes: 1029 total, 4 running, 1025 sleeping, 5262 threads -2026/09/27 00:34:46 -Load Avg: 3.77, 3.80, 4.15 -CPU usage: 17.4% user, 6.64% sys, 76.30% idle -SharedLibs: 924M resident, 170M data, 136M linkedit. -MemRegions: 0 total, 0B resident, 748M private, 4376M shared. -PhysMem: 34G used (3977M wired, 625M compressor), 1023M unused. -VM: 455T vsize, 6144M framework vsize, 2846254(0) swapins, 4729331(0) swapouts. -Networks: packets: 191223281/161G in, 146696242/164G out. -Disks: 97851377/2478G read, 117478696/2101G written. - -PID COMMAND %CPU #TH -35242 Google Chrome He 59.1 25/1 -590 WindowServer 29.1 27 -95916 Google Chrome 18.9 46 -70968 Superset Helper 15.9 32 -0 kernel_task 15.0 850/15 -81804 2.1.283 7.7 27 -35565 top 7.6 1/1 -21666 2.1.283 7.3 27 diff --git a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/summary.json b/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/summary.json deleted file mode 100644 index 9ab7903..0000000 --- a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/summary.json +++ /dev/null @@ -1,248 +0,0 @@ -{ - "phase_rows": { - "a1": 14, - "b": 216, - "a2": 14 - }, - "checked_query_results": 88056, - "summary": [ - { - "case": [ - "random", - "1024", - "8192", - "1" - ], - "heap_a1_a2": [ - 0.052958, - 0.059333 - ], - "dial_a1_a2": [ - 0.027667, - 0.03 - ], - "best_gpu": { - "case": "random", - "n": "1024", - "e": "8192", - "queries": "1", - "method": "pull32_interleaved", - "wall_ms": "0.633625", - "distance_ms": "0.621125", - "gpu_ms": "0.349458", - "edge_visits": "131072", - "rounds": "16", - "waits": "2", - "fallback64": "0", - "upload_ms": "0.152417", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "0.630000;0.633625;0.646917;0.592625;0.621542;0.675042;0.679750" - } - }, - { - "case": [ - "random", - "4096", - "32768", - "256" - ], - "heap_a1_a2": [ - 13.0295, - 13.024459 - ], - "dial_a1_a2": [ - 7.056208, - 6.924667 - ], - "best_gpu": { - "case": "random", - "n": "4096", - "e": "32768", - "queries": "256", - "method": "pull32_gpu_dag", - "wall_ms": "4.001000", - "distance_ms": "3.291333", - "gpu_ms": "2.062708", - "edge_visits": "201326592", - "rounds": "24", - "waits": "5", - "fallback64": "0", - "upload_ms": "0.540417", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "3.925292;8.471750;4.001000;8.828083;3.363250;8.899209;3.259542" - } - }, - { - "case": [ - "random", - "16384", - "131072", - "64" - ], - "heap_a1_a2": [ - 17.500459, - 17.26825 - ], - "dial_a1_a2": [ - 7.696333, - 7.677208 - ], - "best_gpu": { - "case": "random", - "n": "16384", - "e": "131072", - "queries": "64", - "method": "pull32_gpu_dag", - "wall_ms": "3.850875", - "distance_ms": "3.175750", - "gpu_ms": "2.039125", - "edge_visits": "201326592", - "rounds": "24", - "waits": "5", - "fallback64": "0", - "upload_ms": "1.742166", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "8.952083;3.619292;3.631417;8.345083;3.803042;8.516625;3.850875" - } - }, - { - "case": [ - "random", - "65536", - "524288", - "1" - ], - "heap_a1_a2": [ - 11.474125, - 11.530166 - ], - "dial_a1_a2": [ - 4.384459, - 4.372167 - ], - "best_gpu": { - "case": "random", - "n": "65536", - "e": "524288", - "queries": "1", - "method": "pull32_gpu_dag", - "wall_ms": "1.243833", - "distance_ms": "0.853000", - "gpu_ms": "0.567083", - "edge_visits": "12582912", - "rounds": "24", - "waits": "5", - "fallback64": "0", - "upload_ms": "7.594209", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "1.223708;1.245875;1.243833;1.338334;1.271584;1.204125;1.242625" - } - }, - { - "case": [ - "random", - "4096", - "131072", - "64" - ], - "heap_a1_a2": [ - 6.672416, - 6.661792 - ], - "dial_a1_a2": [ - 3.853958, - 3.865375 - ], - "best_gpu": { - "case": "random", - "n": "4096", - "e": "131072", - "queries": "64", - "method": "pull32_gpu_dag", - "wall_ms": "2.215000", - "distance_ms": "1.628334", - "gpu_ms": "0.809417", - "edge_visits": "134217728", - "rounds": "16", - "waits": "4", - "fallback64": "0", - "upload_ms": "1.900000", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "4.834667;2.066875;2.150542;2.482250;2.217375;2.215000;1.987125" - } - }, - { - "case": [ - "clos", - "800", - "320000", - "64" - ], - "heap_a1_a2": [ - 5.124625, - 5.390792 - ], - "dial_a1_a2": [ - 5.421083, - 5.537125 - ], - "best_gpu": { - "case": "clos", - "n": "800", - "e": "320000", - "queries": "64", - "method": "pull32_gpu_dag", - "wall_ms": "8.371875", - "distance_ms": "4.354166", - "gpu_ms": "6.133375", - "edge_visits": "163840000", - "rounds": "8", - "waits": "3", - "fallback64": "0", - "upload_ms": "3.559834", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "7.950875;8.814666;8.371875;7.833334;8.577500;8.882584;6.996125" - } - }, - { - "case": [ - "grid", - "10000", - "39600", - "1" - ], - "heap_a1_a2": [ - 0.200833, - 0.193709 - ], - "dial_a1_a2": [ - 0.126041, - 0.128833 - ], - "best_gpu": { - "case": "grid", - "n": "10000", - "e": "39600", - "queries": "1", - "method": "pull32_gpu_dag", - "wall_ms": "3.250500", - "distance_ms": "2.965875", - "gpu_ms": "0.577125", - "edge_visits": "7920000", - "rounds": "200", - "waits": "27", - "fallback64": "0", - "upload_ms": "0.515708", - "samples": "7", - "correct": "pass", - "wall_samples_ms": "3.552583;3.289542;3.200750;2.978667;3.290000;3.250500;3.223000" - } - } - ] -} diff --git a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/summary.md b/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/summary.md deleted file mode 100644 index ae069d3..0000000 --- a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba/summary.md +++ /dev/null @@ -1,13 +0,0 @@ -# Stronger CPU algorithm A/B/A - -All methods include exact distances and canonical-equivalent positive-cost DAG edge sets. CPU batches use 10 workers. Eligibility is cached outside timing for both devices. Seven measured samples and two checked warmups. These remain exploratory, non-isolated desktop measurements. GPU choice is an oracle across 12 tested variants. - -| Case / N / E / Q | Cached heap + DAG A1 / A2 ms | Dial + DAG A1 / A2 ms | Best GPU B ms | GPU method | -|---|---:|---:|---:|---| -| random / 1024 / 8192 / 1 | 0.053 / 0.059 | 0.028 / 0.030 | 0.634 | pull32_interleaved | -| random / 4096 / 32768 / 256 | 13.030 / 13.024 | 7.056 / 6.925 | 4.001 | pull32_gpu_dag | -| random / 16384 / 131072 / 64 | 17.500 / 17.268 | 7.696 / 7.677 | 3.851 | pull32_gpu_dag | -| random / 65536 / 524288 / 1 | 11.474 / 11.530 | 4.384 / 4.372 | 1.244 | pull32_gpu_dag | -| random / 4096 / 131072 / 64 | 6.672 / 6.662 | 3.854 / 3.865 | 2.215 | pull32_gpu_dag | -| clos / 800 / 320000 / 64 | 5.125 / 5.391 | 5.421 / 5.537 | 8.372 | pull32_gpu_dag | -| grid / 10000 / 39600 / 1 | 0.201 / 0.194 | 0.126 / 0.129 | 3.251 | pull32_gpu_dag | diff --git a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-correctness-stderr.txt b/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-correctness-stderr.txt deleted file mode 100644 index e69de29..0000000 diff --git a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-correctness.csv b/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-correctness.csv deleted file mode 100644 index e07f558..0000000 --- a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-correctness.csv +++ /dev/null @@ -1,25 +0,0 @@ -case,n,e,queries,method,wall_ms,samples,correct,wall_samples_ms -random_masked,40,240,8,cpu_heap_cached_full,0.071250,3,pass,0.071250;0.105375;0.068625 -random_masked,40,240,8,cpu_dial_full,0.098166,3,pass,0.098166;0.102000;0.074667 -random_masked,43,258,8,cpu_heap_cached_full,0.055875,3,pass,0.056250;0.055875;0.050292 -random_masked,43,258,8,cpu_dial_full,0.070917,3,pass,0.075584;0.066958;0.070917 -random_masked,46,276,8,cpu_heap_cached_full,0.046125,3,pass,0.046125;0.068375;0.039542 -random_masked,46,276,8,cpu_dial_full,0.047875,3,pass,0.047875;0.046584;0.048292 -random_masked,49,294,8,cpu_heap_cached_full,0.036167,3,pass,0.036792;0.036167;0.035959 -random_masked,49,294,8,cpu_dial_full,0.071708,3,pass,0.076000;0.066041;0.071708 -random_masked,52,312,8,cpu_heap_cached_full,0.043042,3,pass,0.043042;0.038292;0.045834 -random_masked,52,312,8,cpu_dial_full,0.073292,3,pass,0.073292;0.066792;0.132083 -random_masked,55,330,8,cpu_heap_cached_full,0.081459,3,pass,0.081459;0.110833;0.060291 -random_masked,55,330,8,cpu_dial_full,0.073916,3,pass,0.076041;0.072708;0.073916 -random_masked,58,348,8,cpu_heap_cached_full,0.051208,3,pass,0.050000;0.052500;0.051208 -random_masked,58,348,8,cpu_dial_full,0.076958,3,pass,0.072708;0.076958;0.080125 -random_masked,61,366,8,cpu_heap_cached_full,0.058750,3,pass,0.061791;0.058750;0.057625 -random_masked,61,366,8,cpu_dial_full,0.070542,3,pass,0.098166;0.070542;0.065000 -random_masked,64,384,8,cpu_heap_cached_full,0.039000,3,pass,0.042792;0.039000;0.038334 -random_masked,64,384,8,cpu_dial_full,0.050416,3,pass,0.053541;0.049709;0.050416 -random_masked,67,402,8,cpu_heap_cached_full,0.044000,3,pass,0.044000;0.046917;0.041500 -random_masked,67,402,8,cpu_dial_full,0.060459,3,pass,0.060459;0.064542;0.057333 -random_masked,70,420,8,cpu_heap_cached_full,0.042750,3,pass,0.039958;0.042750;0.046542 -random_masked,70,420,8,cpu_dial_full,0.056625,3,pass,0.056958;0.053167;0.056625 -random_masked,73,438,8,cpu_heap_cached_full,0.043666,3,pass,0.046833;0.043666;0.041209 -random_masked,73,438,8,cpu_dial_full,0.067417,3,pass,0.067417;0.065042;0.072167 diff --git a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-full-sanitized-stderr.txt b/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-full-sanitized-stderr.txt deleted file mode 100644 index e69de29..0000000 diff --git a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-full-sanitized.csv b/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-full-sanitized.csv deleted file mode 100644 index 54cbd5e..0000000 --- a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-full-sanitized.csv +++ /dev/null @@ -1,15 +0,0 @@ -case,n,e,queries,method,wall_ms,samples,correct,wall_samples_ms -random,1024,8192,1,cpu_heap_cached_full,0.293125,2,pass,0.293125;0.276083 -random,1024,8192,1,cpu_dial_full,0.255416,2,pass,0.230334;0.255416 -random,4096,32768,256,cpu_heap_cached_full,36.254208,2,pass,35.992750;36.254208 -random,4096,32768,256,cpu_dial_full,25.138833,2,pass,25.138833;25.111958 -random,16384,131072,64,cpu_heap_cached_full,40.832542,2,pass,40.832542;40.449167 -random,16384,131072,64,cpu_dial_full,26.089500,2,pass,26.089500;25.896417 -random,65536,524288,1,cpu_heap_cached_full,31.633041,2,pass,31.633041;29.508083 -random,65536,524288,1,cpu_dial_full,18.166167,2,pass,17.720125;18.166167 -random,4096,131072,64,cpu_heap_cached_full,19.184083,2,pass,19.184083;19.154291 -random,4096,131072,64,cpu_dial_full,15.231375,2,pass,15.231375;14.640042 -clos,800,320000,64,cpu_heap_cached_full,43.773750,2,pass,43.773750;43.312208 -clos,800,320000,64,cpu_dial_full,43.499792,2,pass,43.499792;42.404333 -grid,10000,39600,1,cpu_heap_cached_full,1.385875,2,pass,1.385875;1.377625 -grid,10000,39600,1,cpu_dial_full,1.141959,2,pass,1.141959;1.111958 diff --git a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-sanitized-stderr.txt b/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-sanitized-stderr.txt deleted file mode 100644 index e69de29..0000000 diff --git a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-sanitized.csv b/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-sanitized.csv deleted file mode 100644 index d524588..0000000 --- a/dev/research/metal_pathfinding/optimization/results/cpu-algorithms-sanitized.csv +++ /dev/null @@ -1,25 +0,0 @@ -case,n,e,queries,method,wall_ms,samples,correct,wall_samples_ms -random_masked,40,240,8,cpu_heap_cached_full,0.043625,3,pass,0.109250;0.043625;0.035917 -random_masked,40,240,8,cpu_dial_full,0.049208,3,pass,0.049208;0.050166;0.036667 -random_masked,43,258,8,cpu_heap_cached_full,0.038792,3,pass,0.035000;0.038792;0.054958 -random_masked,43,258,8,cpu_dial_full,0.046375,3,pass,0.039625;0.057541;0.046375 -random_masked,46,276,8,cpu_heap_cached_full,0.043917,3,pass,0.046292;0.037916;0.043917 -random_masked,46,276,8,cpu_dial_full,0.041042,3,pass,0.038416;0.042791;0.041042 -random_masked,49,294,8,cpu_heap_cached_full,0.037500,3,pass,0.038541;0.037500;0.035583 -random_masked,49,294,8,cpu_dial_full,0.049791,3,pass,0.049791;0.036833;0.064375 -random_masked,52,312,8,cpu_heap_cached_full,0.042708,3,pass,0.042708;0.046125;0.035916 -random_masked,52,312,8,cpu_dial_full,0.068458,3,pass,0.054041;0.072625;0.068458 -random_masked,55,330,8,cpu_heap_cached_full,0.066625,3,pass,0.057458;0.066625;0.068417 -random_masked,55,330,8,cpu_dial_full,0.065458,3,pass,0.076583;0.064750;0.065458 -random_masked,58,348,8,cpu_heap_cached_full,0.055541,3,pass,0.054750;0.055541;0.057458 -random_masked,58,348,8,cpu_dial_full,0.053125,3,pass,0.078167;0.053125;0.052375 -random_masked,61,366,8,cpu_heap_cached_full,0.050625,3,pass,0.050625;0.052167;0.039917 -random_masked,61,366,8,cpu_dial_full,0.058291,3,pass,0.058291;0.039958;0.058917 -random_masked,64,384,8,cpu_heap_cached_full,0.038958,3,pass,0.038958;0.044500;0.037500 -random_masked,64,384,8,cpu_dial_full,0.050000,3,pass,0.050000;0.058875;0.045875 -random_masked,67,402,8,cpu_heap_cached_full,0.042708,3,pass,0.042708;0.039458;0.051000 -random_masked,67,402,8,cpu_dial_full,0.046167,3,pass,0.050625;0.046167;0.045709 -random_masked,70,420,8,cpu_heap_cached_full,0.044000,3,pass,0.047750;0.040458;0.044000 -random_masked,70,420,8,cpu_dial_full,0.053875,3,pass,0.053875;0.053875;0.050334 -random_masked,73,438,8,cpu_heap_cached_full,0.054250,3,pass,0.056334;0.054250;0.046958 -random_masked,73,438,8,cpu_dial_full,0.065000,3,pass,0.069834;0.065000;0.060791 diff --git a/dev/research/metal_pathfinding/optimization/results/sanitize-test.txt b/dev/research/metal_pathfinding/optimization/results/sanitize-test.txt deleted file mode 100644 index 7502c0a..0000000 --- a/dev/research/metal_pathfinding/optimization/results/sanitize-test.txt +++ /dev/null @@ -1,352 +0,0 @@ --- pybind11 not found via config; using FetchContent --- pybind11 v3.0.0 --- Enabling sanitizers (disables optimizations) --- Configuring done (0.6s) --- Generating done (0.0s) --- Build files have been written to: /Users/networmix/ws/NetGraph-Core/build/cpp-sanitize -ninja: no work to do. -Test project /Users/networmix/ws/NetGraph-Core/build/cpp-sanitize - Start 51: ShortestPathsTo.DistancesMatchForwardDistancesToDst - Start 52: ShortestPathsTo.ForwardSpfFromPseudoSourceKeepsOnlyNearestSource - Start 77: FlowState.EqualBalanced_SaturatedMemberBlocksAdmission - Start 78: FlowState.EqualBalanced_ProgressesOnlyThroughResidualAwareSpf - 1/170 Test #77: FlowState.EqualBalanced_SaturatedMemberBlocksAdmission ............................ Passed 0.08 sec - 2/170 Test #78: FlowState.EqualBalanced_ProgressesOnlyThroughResidualAwareSpf ..................... Passed 0.08 sec - Start 54: ShortestPathsTo.FanoutSkipsUnreachableAndMaskedHeads - Start 53: ShortestPathsTo.FanoutEdgesForceEverySourceIntoTheDag - 3/170 Test #51: ShortestPathsTo.DistancesMatchForwardDistancesToDst ............................... Passed 0.09 sec - 4/170 Test #52: ShortestPathsTo.ForwardSpfFromPseudoSourceKeepsOnlyNearestSource .................. Passed 0.09 sec - Start 55: ShortestPathsTo.FanoutFromInteriorNodeIsRejected - Start 56: ShortestPathsTo.SinglePathModeKeepsOneSuccessorPerNode - 5/170 Test #54: ShortestPathsTo.FanoutSkipsUnreachableAndMaskedHeads .............................. Passed 0.05 sec - Start 80: FlowState.EqualBalancedLossy_DeficitPropagatesDownstream - 6/170 Test #53: ShortestPathsTo.FanoutEdgesForceEverySourceIntoTheDag ............................. Passed 0.05 sec - 7/170 Test #55: ShortestPathsTo.FanoutFromInteriorNodeIsRejected .................................. Passed 0.05 sec - Start 79: FlowState.EqualBalancedLossy_FillAndDropReportsDrops - Start 81: FlowState.EqualBalancedLossy_InfiniteRequestFillsWithoutDrops - 8/170 Test #56: ShortestPathsTo.SinglePathModeKeepsOneSuccessorPerNode ............................ Passed 0.05 sec - Start 82: FlowGraph.LossyPlacementIsReversibleAndDropsStayOutOfLedger - 9/170 Test #80: FlowState.EqualBalancedLossy_DeficitPropagatesDownstream .......................... Passed 0.05 sec - 10/170 Test #79: FlowState.EqualBalancedLossy_FillAndDropReportsDrops .............................. Passed 0.05 sec - 11/170 Test #81: FlowState.EqualBalancedLossy_InfiniteRequestFillsWithoutDrops ..................... Passed 0.05 sec - Start 93: FlowGraphStamp.CopiesAndMovesGetFreshUids - Start 94: FlowGraphStamp.EqualVersionDivergentCopiesCompareUnequal - Start 16: FlowPolicyCostBounds.MaxPathCostFactor_HugeProduct_DropsBoundInsteadOfWrapping - 12/170 Test #82: FlowGraph.LossyPlacementIsReversibleAndDropsStayOutOfLedger ....................... Passed 0.05 sec - Start 15: FlowPolicyCostBounds.MaxPathCostFactor_UnreachableDst_KeepsSentinelOutOfTheCast - 13/170 Test #94: FlowGraphStamp.EqualVersionDivergentCopiesCompareUnequal .......................... Passed 0.06 sec - Start 92: FlowGraph.LedgerMicroFlows_RemovalRestoresResidual - 14/170 Test #93: FlowGraphStamp.CopiesAndMovesGetFreshUids ......................................... Passed 0.06 sec - 15/170 Test #16: FlowPolicyCostBounds.MaxPathCostFactor_HugeProduct_DropsBoundInsteadOfWrapping .... Passed 0.06 sec - 16/170 Test #15: FlowPolicyCostBounds.MaxPathCostFactor_UnreachableDst_KeepsSentinelOutOfTheCast ... Passed 0.06 sec - Start 47: ShortestPaths.ResolveToPathsWithMaxPathsLimit - Start 156: MaskingTests.CombinedNodeAndEdgeMasks - Start 169: MaskingTests.AlgorithmsAPIMaskedSource - 17/170 Test #47: ShortestPaths.ResolveToPathsWithMaxPathsLimit ..................................... Passed 0.06 sec - Start 46: ShortestPaths.ResolveToPathsSplittingParallelEdges - 18/170 Test #156: MaskingTests.CombinedNodeAndEdgeMasks ............................................. Passed 0.06 sec - 19/170 Test #169: MaskingTests.AlgorithmsAPIMaskedSource ............................................ Passed 0.06 sec - Start 48: ShortestPaths.LargeGraphStressTest - Start 86: FlowGraph.RemoveByClassFiltersCorrectly - 20/170 Test #92: FlowGraph.LedgerMicroFlows_RemovalRestoresResidual ................................ Passed 0.07 sec - Start 90: FlowGraph.CoalescesDuplicateEdgeIds - 21/170 Test #46: ShortestPaths.ResolveToPathsSplittingParallelEdges ................................ Passed 0.06 sec - Start 163: MaskingTests.MaxFlowWithEdgeMask - 22/170 Test #86: FlowGraph.RemoveByClassFiltersCorrectly ........................................... Passed 0.06 sec - Start 125: MaxFlow.Clos_3Tier_Topology_EqualBalanced_Shortest - 23/170 Test #48: ShortestPaths.LargeGraphStressTest ................................................ Passed 0.06 sec - Start 164: MaskingTests.MaxFlowAllPathsMasked - 24/170 Test #90: FlowGraph.CoalescesDuplicateEdgeIds ............................................... Passed 0.06 sec - Start 128: MaxFlow.ShortestPath_MustSaturateAllEqualCostPaths - 25/170 Test #125: MaxFlow.Clos_3Tier_Topology_EqualBalanced_Shortest ................................ Passed 0.06 sec - Start 98: MaxFlow.DegenerateCase_SourceEqualsSink - 26/170 Test #163: MaskingTests.MaxFlowWithEdgeMask .................................................. Passed 0.06 sec - Start 118: MaxFlow.OptionalOutputs_ResidualAndReachable - 27/170 Test #164: MaskingTests.MaxFlowAllPathsMasked ................................................ Passed 0.06 sec - Start 69: FlowState.ApplyDeltasClampsWithinCapacityBounds - 28/170 Test #128: MaxFlow.ShortestPath_MustSaturateAllEqualCostPaths ................................ Passed 0.06 sec - Start 26: StrictMultiDiGraph.ZeroCostEdges - 29/170 Test #98: MaxFlow.DegenerateCase_SourceEqualsSink ........................................... Passed 0.06 sec - 30/170 Test #118: MaxFlow.OptionalOutputs_ResidualAndReachable ...................................... Passed 0.06 sec - 31/170 Test #69: FlowState.ApplyDeltasClampsWithinCapacityBounds ................................... Passed 0.06 sec - Start 68: FlowState.ResetWithInvalidResidualThrows - Start 168: MaskingTests.AllEdgesMaskedNoReachability - Start 149: MaskingTests.MaskedSourceInComputeMinCut - 32/170 Test #26: StrictMultiDiGraph.ZeroCostEdges .................................................. Passed 0.06 sec - Start 142: KShortestPaths.LooplessPaths - 33/170 Test #68: FlowState.ResetWithInvalidResidualThrows .......................................... Passed 0.06 sec - Start 122: MaxFlow.GridGraph_3x3_MultipleEqualCostPaths - 34/170 Test #168: MaskingTests.AllEdgesMaskedNoReachability ......................................... Passed 0.06 sec - 35/170 Test #149: MaskingTests.MaskedSourceInComputeMinCut .......................................... Passed 0.06 sec - Start 25: StrictMultiDiGraph.MaxCapacityEdges - Start 106: MaxFlow.AsymmetricCapacity_1to1M_NumericalStability - 36/170 Test #142: KShortestPaths.LooplessPaths ...................................................... Passed 0.06 sec - Start 116: MaxFlow.MinCut_ValidEdgeIdentification - 37/170 Test #122: MaxFlow.GridGraph_3x3_MultipleEqualCostPaths ...................................... Passed 0.06 sec - 38/170 Test #25: StrictMultiDiGraph.MaxCapacityEdges ............................................... Passed 0.06 sec - Start 141: KShortestPaths.DisconnectedReturnsEmpty - Start 29: StrictMultiDiGraph.ExtEdgeIdsWrongLengthThrows - 39/170 Test #106: MaxFlow.AsymmetricCapacity_1to1M_NumericalStability ............................... Passed 0.06 sec - Start 96: MaxFlow.SinglePath_BottleneckConstraint - 40/170 Test #116: MaxFlow.MinCut_ValidEdgeIdentification ............................................ Passed 0.06 sec - Start 60: FlowState.EqualBalancedDistribution - 41/170 Test #29: StrictMultiDiGraph.ExtEdgeIdsWrongLengthThrows .................................... Passed 0.06 sec - 42/170 Test #96: MaxFlow.SinglePath_BottleneckConstraint ........................................... Passed 0.06 sec - Start 154: MaskingTests.MaskedEdgeSelectsAlternativePath - Start 95: MaxFlow.SinglePath_BasicLinear - 43/170 Test #141: KShortestPaths.DisconnectedReturnsEmpty ........................................... Passed 0.06 sec - Start 121: MaxFlow.BatchMaxFlow_IndependentMaskApplication - 44/170 Test #60: FlowState.EqualBalancedDistribution ............................................... Passed 0.06 sec - Start 39: ShortestPaths.ResidualAwareFiltering - 45/170 Test #95: MaxFlow.SinglePath_BasicLinear .................................................... Passed 0.06 sec - 46/170 Test #121: MaxFlow.BatchMaxFlow_IndependentMaskApplication ................................... Passed 0.06 sec - Start 63: FlowState.ApplyDeltasSubtractive - Start 136: KShortestPaths.KEqualsZeroReturnsEmpty - 47/170 Test #154: MaskingTests.MaskedEdgeSelectsAlternativePath ..................................... Passed 0.07 sec - 48/170 Test #39: ShortestPaths.ResidualAwareFiltering .............................................. Passed 0.06 sec - Start 117: MaxFlow.CostDistribution_AccountsForAllFlow - Start 44: ShortestPaths.TieBreakingDeterminism - 49/170 Test #63: FlowState.ApplyDeltasSubtractive .................................................. Passed 0.06 sec - Start 151: MaskingTests.MaskedDestinationUnreachable - 50/170 Test #136: KShortestPaths.KEqualsZeroReturnsEmpty ............................................ Passed 0.06 sec - 51/170 Test #117: MaxFlow.CostDistribution_AccountsForAllFlow ....................................... Passed 0.06 sec - 52/170 Test #44: ShortestPaths.TieBreakingDeterminism .............................................. Passed 0.06 sec - Start 104: MaxFlow.ECMP_TenPaths_ScalabilityValidation - Start 85: FlowGraph.RemovalDeletesEntry - Start 138: KShortestPaths.UniqueModeFiltering - 53/170 Test #104: MaxFlow.ECMP_TenPaths_ScalabilityValidation ....................................... Passed 0.06 sec - 54/170 Test #85: FlowGraph.RemovalDeletesEntry ..................................................... Passed 0.06 sec - Start 111: MaxFlow.TwoCostTiers_FullMaxFlow_AllTiers - Start 14: FlowPolicyStatic.ValidationRejectsForeignAndDisconnectedBundles - 55/170 Test #151: MaskingTests.MaskedDestinationUnreachable ......................................... Passed 0.06 sec - 56/170 Test #138: KShortestPaths.UniqueModeFiltering ................................................ Passed 0.06 sec - Start 165: MaskingTests.EmptyNodeMaskAllowsAllNodes - Start 132: MaxFlow.Sensitivity_ShortestPathVsMaxFlow - 57/170 Test #111: MaxFlow.TwoCostTiers_FullMaxFlow_AllTiers ......................................... Passed 0.06 sec - Start 37: ShortestPaths.DisconnectedComponents - 58/170 Test #14: FlowPolicyStatic.ValidationRejectsForeignAndDisconnectedBundles ................... Passed 0.06 sec - 59/170 Test #165: MaskingTests.EmptyNodeMaskAllowsAllNodes .......................................... Passed 0.06 sec - 60/170 Test #132: MaxFlow.Sensitivity_ShortestPathVsMaxFlow ......................................... Passed 0.06 sec - Start 89: FlowGraph.GetFlowPathForDAGReturnsEmpty - Start 124: MaxFlow.Clos_3Tier_Topology_Proportional_Shortest - Start 129: MaxFlow.Sensitivity_SinglePath - 61/170 Test #124: MaxFlow.Clos_3Tier_Topology_Proportional_Shortest ................................. Passed 0.06 sec - 62/170 Test #129: MaxFlow.Sensitivity_SinglePath .................................................... Passed 0.06 sec - Start 91: FlowGraph.LedgerHistory_RemovalRestoresResidual - Start 75: FlowState.Proportional_ZeroCapacityOnShortestPath_ReturnsZero - 63/170 Test #37: ShortestPaths.DisconnectedComponents .............................................. Passed 0.06 sec - 64/170 Test #89: FlowGraph.GetFlowPathForDAGReturnsEmpty ........................................... Passed 0.06 sec - Start 27: StrictMultiDiGraph.InvalidNumNodesThrows - Start 76: FlowState.RepeatedPlacementAfterResetAndIndependentInstancesStayStable - 65/170 Test #91: FlowGraph.LedgerHistory_RemovalRestoresResidual ................................... Passed 0.07 sec - 66/170 Test #75: FlowState.Proportional_ZeroCapacityOnShortestPath_ReturnsZero ..................... Passed 0.07 sec - Start 153: MaskingTests.MaskedEdgeBlocksPath - Start 99: MaxFlow.ECMP_TwoPaths_Proportional_ShortestPath - 67/170 Test #27: StrictMultiDiGraph.InvalidNumNodesThrows .......................................... Passed 0.07 sec - 68/170 Test #76: FlowState.RepeatedPlacementAfterResetAndIndependentInstancesStayStable ............ Passed 0.07 sec - Start 120: MaxFlow.EdgeMask_SelectiveEdgeFiltering - Start 101: MaxFlow.ECMP_TwoPaths_EqualBalanced_ShortestPath - 69/170 Test #120: MaxFlow.EdgeMask_SelectiveEdgeFiltering ........................................... Passed 0.06 sec - Start 35: StrictMultiDiGraph.MultipleCostTiers - 70/170 Test #153: MaskingTests.MaskedEdgeBlocksPath ................................................. Passed 0.06 sec - 71/170 Test #99: MaxFlow.ECMP_TwoPaths_Proportional_ShortestPath ................................... Passed 0.06 sec - 72/170 Test #101: MaxFlow.ECMP_TwoPaths_EqualBalanced_ShortestPath .................................. Passed 0.06 sec - Start 161: MaskingTests.KSPWithEdgeMask - Start 102: MaxFlow.ECMP_ThreePaths_Proportional_ShortestPath - Start 2: FlowPolicyCore.Proportional_SingleDemand_UsesShortestPath - 73/170 Test #35: StrictMultiDiGraph.MultipleCostTiers .............................................. Passed 0.06 sec - Start 167: MaskingTests.AllNodesMaskedNoReachability - 74/170 Test #161: MaskingTests.KSPWithEdgeMask ...................................................... Passed 0.06 sec - 75/170 Test #102: MaxFlow.ECMP_ThreePaths_Proportional_ShortestPath ................................. Passed 0.06 sec - 76/170 Test #2: FlowPolicyCore.Proportional_SingleDemand_UsesShortestPath ......................... Passed 0.06 sec - Start 64: FlowState.MinCutComputation - Start 139: KShortestPaths.MaxCostFactorLimit - Start 3: FlowPolicyCore.Proportional_SingleDemand_SplitsAcrossTwoEqualCostPaths - 77/170 Test #167: MaskingTests.AllNodesMaskedNoReachability ......................................... Passed 0.06 sec - Start 11: FlowPolicyCore.EqualBalanced_ShortestPath_IgnoresHigherCostTier - 78/170 Test #64: FlowState.MinCutComputation ....................................................... Passed 0.06 sec - 79/170 Test #139: KShortestPaths.MaxCostFactorLimit ................................................. Passed 0.06 sec - 80/170 Test #3: FlowPolicyCore.Proportional_SingleDemand_SplitsAcrossTwoEqualCostPaths ............ Passed 0.06 sec - Start 133: MaxFlow.CompletionPhase_RecoversCancellation - Start 18: StrictMultiDiGraph.SingleNode - Start 49: ShortestPaths.RejectsMaskLengthMismatch - 81/170 Test #133: MaxFlow.CompletionPhase_RecoversCancellation ...................................... Passed 0.07 sec - Start 13: FlowPolicyStatic.MaskedBundleIsDown - 82/170 Test #11: FlowPolicyCore.EqualBalanced_ShortestPath_IgnoresHigherCostTier ................... Passed 0.07 sec - 83/170 Test #18: StrictMultiDiGraph.SingleNode ..................................................... Passed 0.07 sec - 84/170 Test #49: ShortestPaths.RejectsMaskLengthMismatch ........................................... Passed 0.07 sec - Start 110: MaxFlow.TwoCostTiers_ShortestPath_SingleTierOnly - Start 100: MaxFlow.ECMP_TwoPaths_Proportional_FullMaxFlow - Start 23: StrictMultiDiGraph.CSROffsetsMonotonicity - 85/170 Test #13: FlowPolicyStatic.MaskedBundleIsDown ............................................... Passed 0.07 sec - 86/170 Test #110: MaxFlow.TwoCostTiers_ShortestPath_SingleTierOnly .................................. Passed 0.07 sec - 87/170 Test #100: MaxFlow.ECMP_TwoPaths_Proportional_FullMaxFlow .................................... Passed 0.07 sec - Start 22: StrictMultiDiGraph.ExternalEdgeIdsPreserved - Start 7: FlowPolicyCore.EqualBalanced_SingleDemand_FlowCount3_SaturatesBothPaths - Start 145: KSP.MaxCostFactorBelowOne_ReturnsEmptyForAllK - 88/170 Test #23: StrictMultiDiGraph.CSROffsetsMonotonicity ......................................... Passed 0.07 sec - Start 119: MaxFlow.NodeMask_BlocksFlowThroughNode - 89/170 Test #22: StrictMultiDiGraph.ExternalEdgeIdsPreserved ....................................... Passed 0.07 sec - 90/170 Test #145: KSP.MaxCostFactorBelowOne_ReturnsEmptyForAllK ..................................... Passed 0.06 sec - Start 65: FlowState.ResetRestoresCapacity - Start 135: KShortestPaths.FindsKPaths - 91/170 Test #7: FlowPolicyCore.EqualBalanced_SingleDemand_FlowCount3_SaturatesBothPaths ........... Passed 0.07 sec - 92/170 Test #119: MaxFlow.NodeMask_BlocksFlowThroughNode ............................................ Passed 0.07 sec - Start 143: KShortestPaths.OutOfOrderNodePredDAGSemantics - Start 134: MaxFlow.CompletionPhase_RespectsNodeMask - 93/170 Test #143: KShortestPaths.OutOfOrderNodePredDAGSemantics ..................................... Passed 0.06 sec - Start 43: ShortestPaths.PredDAGIntegrity - 94/170 Test #65: FlowState.ResetRestoresCapacity ................................................... Passed 0.06 sec - 95/170 Test #135: KShortestPaths.FindsKPaths ........................................................ Passed 0.06 sec - 96/170 Test #134: MaxFlow.CompletionPhase_RespectsNodeMask .......................................... Passed 0.06 sec - Start 33: StrictMultiDiGraph.SelfLoopAllowed - Start 62: FlowState.ApplyDeltasAdditive - Start 32: StrictMultiDiGraph.NegativeCostThrows - 97/170 Test #43: ShortestPaths.PredDAGIntegrity .................................................... Passed 0.07 sec - 98/170 Test #62: FlowState.ApplyDeltasAdditive ..................................................... Passed 0.07 sec - Start 19: StrictMultiDiGraph.SingleEdge - Start 144: KShortestPaths.LargerOutOfOrderTopology - 99/170 Test #33: StrictMultiDiGraph.SelfLoopAllowed ................................................ Passed 0.07 sec -100/170 Test #32: StrictMultiDiGraph.NegativeCostThrows ............................................. Passed 0.07 sec - Start 97: MaxFlow.SinglePath_ShortestPathEquivalence - Start 114: MaxFlow.PlacementModes_EqualCapacity_IdenticalResult -101/170 Test #19: StrictMultiDiGraph.SingleEdge ..................................................... Passed 0.06 sec - Start 170: MaskingTests.AlgorithmsAPIMaxFlowWithMask -102/170 Test #144: KShortestPaths.LargerOutOfOrderTopology ........................................... Passed 0.06 sec -103/170 Test #97: MaxFlow.SinglePath_ShortestPathEquivalence ........................................ Passed 0.06 sec -104/170 Test #114: MaxFlow.PlacementModes_EqualCapacity_IdenticalResult .............................. Passed 0.06 sec - Start 4: FlowPolicyCore.Proportional_SingleDemand_MaxFlowCount1_UsesSinglePath - Start 131: MaxFlow.Sensitivity_PartialSaturation - Start 61: FlowState.ZeroCapacityHandling -105/170 Test #170: MaskingTests.AlgorithmsAPIMaxFlowWithMask ......................................... Passed 0.07 sec -106/170 Test #131: MaxFlow.Sensitivity_PartialSaturation ............................................. Passed 0.07 sec - Start 159: MaskingTests.ResidualAndNodeMaskCombined - Start 109: MaxFlow.ZeroCapacityPath_CorrectFiltering -107/170 Test #4: FlowPolicyCore.Proportional_SingleDemand_MaxFlowCount1_UsesSinglePath ............. Passed 0.07 sec -108/170 Test #61: FlowState.ZeroCapacityHandling .................................................... Passed 0.07 sec - Start 21: StrictMultiDiGraph.EdgeSortingByCost - Start 20: StrictMultiDiGraph.ParallelEdges -109/170 Test #109: MaxFlow.ZeroCapacityPath_CorrectFiltering ......................................... Passed 0.06 sec - Start 115: MaxFlow.FlowConservation_IntermediateNodes -110/170 Test #159: MaskingTests.ResidualAndNodeMaskCombined .......................................... Passed 0.07 sec -111/170 Test #21: StrictMultiDiGraph.EdgeSortingByCost .............................................. Passed 0.07 sec -112/170 Test #20: StrictMultiDiGraph.ParallelEdges .................................................. Passed 0.07 sec - Start 8: FlowPolicyCore.DiminishingReturnsCutoff - Start 147: MaskingTests.MaskedSourceWithDestination - Start 45: ShortestPaths.PreferHigherResidualTieBreak -113/170 Test #115: MaxFlow.FlowConservation_IntermediateNodes ........................................ Passed 0.07 sec -114/170 Test #147: MaskingTests.MaskedSourceWithDestination .......................................... Passed 0.06 sec -115/170 Test #45: ShortestPaths.PreferHigherResidualTieBreak ........................................ Passed 0.06 sec - Start 160: MaskingTests.KSPWithNodeMask - Start 74: FlowState.EqualBalanced_ZeroCapacityOnShortestPath_ReturnsZero - Start 42: ShortestPaths.EarlyExitOptimization -116/170 Test #8: FlowPolicyCore.DiminishingReturnsCutoff ........................................... Passed 0.07 sec - Start 126: MaxFlow.Triangle_Topology_Combine_Mode -117/170 Test #42: ShortestPaths.EarlyExitOptimization ............................................... Passed 0.06 sec - Start 84: FlowGraph.PlacementCreatesEntry -118/170 Test #160: MaskingTests.KSPWithNodeMask ...................................................... Passed 0.07 sec -119/170 Test #74: FlowState.EqualBalanced_ZeroCapacityOnShortestPath_ReturnsZero .................... Passed 0.07 sec -120/170 Test #126: MaxFlow.Triangle_Topology_Combine_Mode ............................................ Passed 0.06 sec - Start 146: MaskingTests.MaskedSourceReturnsEmptyDAG - Start 88: FlowGraph.GetFlowPathForLinearFlow - Start 72: FlowState.EqualBalanced_MinFlowGating_DoesNotPruneValidParallelEdges -121/170 Test #84: FlowGraph.PlacementCreatesEntry ................................................... Passed 0.07 sec - Start 67: FlowState.InvalidResidualInitThrows -122/170 Test #146: MaskingTests.MaskedSourceReturnsEmptyDAG .......................................... Passed 0.07 sec -123/170 Test #88: FlowGraph.GetFlowPathForLinearFlow ................................................ Passed 0.07 sec -124/170 Test #72: FlowState.EqualBalanced_MinFlowGating_DoesNotPruneValidParallelEdges .............. Passed 0.07 sec - Start 6: FlowPolicyCore.EqualBalanced_SingleDemand_FlowCount2_BalancedDistribution - Start 66: FlowState.CustomInitialResidual - Start 17: StrictMultiDiGraph.EmptyGraph -125/170 Test #66: FlowState.CustomInitialResidual ................................................... Passed 0.07 sec - Start 1: GraphSmoke.ConstructAndRunSPF -126/170 Test #67: FlowState.InvalidResidualInitThrows ............................................... Passed 0.07 sec -127/170 Test #6: FlowPolicyCore.EqualBalanced_SingleDemand_FlowCount2_BalancedDistribution ......... Passed 0.07 sec -128/170 Test #17: StrictMultiDiGraph.EmptyGraph ..................................................... Passed 0.07 sec - Start 150: MaskingTests.OutOfRangeSourceInComputeMinCut - Start 83: FlowGraph.EmptyLedgerInitially - Start 70: FlowState.EqualBalancedReconvergentDAGPlacesAll -129/170 Test #1: GraphSmoke.ConstructAndRunSPF ..................................................... Passed 0.07 sec - Start 107: MaxFlow.AsymmetricCapacity_1to100_EqualBalanced -130/170 Test #150: MaskingTests.OutOfRangeSourceInComputeMinCut ...................................... Passed 0.07 sec -131/170 Test #83: FlowGraph.EmptyLedgerInitially .................................................... Passed 0.07 sec -132/170 Test #70: FlowState.EqualBalancedReconvergentDAGPlacesAll ................................... Passed 0.07 sec - Start 24: StrictMultiDiGraph.CSRReverseAdjacency - Start 34: StrictMultiDiGraph.NegativeNodeIdThrows - Start 162: MaskingTests.MaxFlowWithNodeMask -133/170 Test #34: StrictMultiDiGraph.NegativeNodeIdThrows ........................................... Passed 0.07 sec - Start 87: FlowGraph.GetFlowEdgesReturnsDeltas -134/170 Test #107: MaxFlow.AsymmetricCapacity_1to100_EqualBalanced ................................... Passed 0.07 sec -135/170 Test #24: StrictMultiDiGraph.CSRReverseAdjacency ............................................ Passed 0.07 sec -136/170 Test #162: MaskingTests.MaxFlowWithNodeMask .................................................. Passed 0.07 sec - Start 127: MaxFlow.SimpleParallelPaths_Topology - Start 166: MaskingTests.EmptyEdgeMaskAllowsAllEdges - Start 108: MaxFlow.EqualBalanced_NodeMask_ReducesECMPWidth -137/170 Test #127: MaxFlow.SimpleParallelPaths_Topology .............................................. Passed 0.07 sec - Start 59: FlowState.ProportionalDistribution -138/170 Test #87: FlowGraph.GetFlowEdgesReturnsDeltas ............................................... Passed 0.07 sec -139/170 Test #166: MaskingTests.EmptyEdgeMaskAllowsAllEdges .......................................... Passed 0.07 sec -140/170 Test #108: MaxFlow.EqualBalanced_NodeMask_ReducesECMPWidth ................................... Passed 0.07 sec - Start 155: MaskingTests.MultipleEdgesMasked - Start 140: KShortestPaths.PathsSortedByCost - Start 50: ShortestPaths.ZeroCostEdges_PredDAGIsAcyclic -141/170 Test #59: FlowState.ProportionalDistribution ................................................ Passed 0.07 sec - Start 36: ShortestPaths.SingleSourceAllNodes -142/170 Test #155: MaskingTests.MultipleEdgesMasked .................................................. Passed 0.07 sec -143/170 Test #140: KShortestPaths.PathsSortedByCost .................................................. Passed 0.07 sec -144/170 Test #50: ShortestPaths.ZeroCostEdges_PredDAGIsAcyclic ...................................... Passed 0.07 sec - Start 105: MaxFlow.AsymmetricCapacity_1to100_Proportional - Start 30: StrictMultiDiGraph.OutOfRangeNodeThrows - Start 9: FlowPolicyCore.EqualBalanced_ShortestPath_ECMP3_SaturatesAllEqualCostPaths -145/170 Test #36: ShortestPaths.SingleSourceAllNodes ................................................ Passed 0.07 sec - Start 103: MaxFlow.ECMP_FourPaths_ClosFabric_Simulation -146/170 Test #105: MaxFlow.AsymmetricCapacity_1to100_Proportional .................................... Passed 0.07 sec -147/170 Test #30: StrictMultiDiGraph.OutOfRangeNodeThrows ........................................... Passed 0.07 sec -148/170 Test #9: FlowPolicyCore.EqualBalanced_ShortestPath_ECMP3_SaturatesAllEqualCostPaths ........ Passed 0.07 sec - Start 31: StrictMultiDiGraph.NegativeCapacityThrows - Start 137: KShortestPaths.NodeMaskBlocksPaths - Start 130: MaxFlow.Sensitivity_TwoParallelPaths -149/170 Test #103: MaxFlow.ECMP_FourPaths_ClosFabric_Simulation ...................................... Passed 0.07 sec -150/170 Test #31: StrictMultiDiGraph.NegativeCapacityThrows ......................................... Passed 0.07 sec -151/170 Test #137: KShortestPaths.NodeMaskBlocksPaths ................................................ Passed 0.07 sec - Start 38: ShortestPaths.MultipleEqualCostPaths - Start 41: ShortestPaths.EdgeMaskFiltering - Start 73: FlowState.EqualBalanced_DownstreamSplitEqualization -152/170 Test #130: MaxFlow.Sensitivity_TwoParallelPaths .............................................. Passed 0.07 sec - Start 10: FlowPolicyCore.EqualBalanced_ShortestPath_DownstreamSplit_BalancesBottleneck -153/170 Test #10: FlowPolicyCore.EqualBalanced_ShortestPath_DownstreamSplit_BalancesBottleneck ...... Passed 0.07 sec - Start 123: MaxFlow.SharedBottleneck_MultiplePathsMerge -154/170 Test #38: ShortestPaths.MultipleEqualCostPaths .............................................. Passed 0.07 sec -155/170 Test #41: ShortestPaths.EdgeMaskFiltering ................................................... Passed 0.07 sec -156/170 Test #73: FlowState.EqualBalanced_DownstreamSplitEqualization ............................... Passed 0.07 sec - Start 57: FlowState.InitialResidualEqualsCapacity - Start 40: ShortestPaths.NodeMaskIsolation - Start 71: FlowState.EqualBalanced_ECMP3_FirstHopEqualization -157/170 Test #123: MaxFlow.SharedBottleneck_MultiplePathsMerge ....................................... Passed 0.07 sec -158/170 Test #57: FlowState.InitialResidualEqualsCapacity ........................................... Passed 0.07 sec - Start 28: StrictMultiDiGraph.MismatchedArrayLengthsThrow - Start 158: MaskingTests.MaskWithResidualCapacity -159/170 Test #40: ShortestPaths.NodeMaskIsolation ................................................... Passed 0.07 sec -160/170 Test #71: FlowState.EqualBalanced_ECMP3_FirstHopEqualization ................................ Passed 0.07 sec - Start 148: MaskingTests.MaskedSourceInMaxFlow - Start 58: FlowState.PlacementReducesResidual -161/170 Test #158: MaskingTests.MaskWithResidualCapacity ............................................. Passed 0.07 sec - Start 113: MaxFlow.ThreeCostTiers_ShortestPath_LowestTierOnly -162/170 Test #28: StrictMultiDiGraph.MismatchedArrayLengthsThrow .................................... Passed 0.07 sec -163/170 Test #148: MaskingTests.MaskedSourceInMaxFlow ................................................ Passed 0.07 sec -164/170 Test #58: FlowState.PlacementReducesResidual ................................................ Passed 0.07 sec - Start 152: MaskingTests.MaskedIntermediateNode - Start 157: MaskingTests.NodeMaskOverridesEdgeMask - Start 112: MaxFlow.EqualCost_ShortestVsFull_Equivalent -165/170 Test #113: MaxFlow.ThreeCostTiers_ShortestPath_LowestTierOnly ................................ Passed 0.07 sec - Start 12: FlowPolicyStatic.PerFlowBundleBinding -166/170 Test #152: MaskingTests.MaskedIntermediateNode ............................................... Passed 0.07 sec -167/170 Test #157: MaskingTests.NodeMaskOverridesEdgeMask ............................................ Passed 0.07 sec -168/170 Test #112: MaxFlow.EqualCost_ShortestVsFull_Equivalent ....................................... Passed 0.07 sec - Start 5: FlowPolicyCore.Proportional_SingleDemand_PartialPlacement_InsufficientCapacity -169/170 Test #12: FlowPolicyStatic.PerFlowBundleBinding ............................................. Passed 0.07 sec -170/170 Test #5: FlowPolicyCore.Proportional_SingleDemand_PartialPlacement_InsufficientCapacity .... Passed 0.06 sec - -100% tests passed out of 170 - -Total Test time (real) = 2.80 sec diff --git a/dev/research/metal_pathfinding/optimization/results/screen-known-limitations.txt b/dev/research/metal_pathfinding/optimization/results/screen-known-limitations.txt deleted file mode 100644 index 87ae6b9..0000000 --- a/dev/research/metal_pathfinding/optimization/results/screen-known-limitations.txt +++ /dev/null @@ -1,6 +0,0 @@ -Exploratory screen only, not the final timing matrix. -The conversion loop invoked the Objective-C .contents accessor once per distance, -inflating host copy/conversion time on large batches. Corrected before final A/B/A. -GPU DAG thread mapping also initially used query-fast threads for query-major data. -All correctness checks passed; these were performance defects, not wrong distances. -Source hashes are in screen-source-sha256.txt. Final hashes are in aba/environment.txt. diff --git a/dev/research/metal_pathfinding/optimization/results/screen-source-sha256.txt b/dev/research/metal_pathfinding/optimization/results/screen-source-sha256.txt deleted file mode 100644 index a3f0393..0000000 --- a/dev/research/metal_pathfinding/optimization/results/screen-source-sha256.txt +++ /dev/null @@ -1,2 +0,0 @@ -0dad0787fdcf08c58ed135e27290a8012865aa8b6a369cc679e8bca622f62b78 dev/research/metal_pathfinding/optimization/experiment.mm -2cdeaf23d1f11dfd07c7092824d7e991ecd70c0c597e6e40ab84c663907a3e17 dev/research/metal_pathfinding/optimization/kernels.metal diff --git a/dev/research/metal_pathfinding/optimization/results/screen-stderr.txt b/dev/research/metal_pathfinding/optimization/results/screen-stderr.txt deleted file mode 100644 index b3f2aa5..0000000 --- a/dev/research/metal_pathfinding/optimization/results/screen-stderr.txt +++ /dev/null @@ -1 +0,0 @@ -device=Apple M4 Max setup_ms=26.8109 execution_width=32 diff --git a/dev/research/metal_pathfinding/optimization/results/screen.csv b/dev/research/metal_pathfinding/optimization/results/screen.csv deleted file mode 100644 index 6e7c168..0000000 --- a/dev/research/metal_pathfinding/optimization/results/screen.csv +++ /dev/null @@ -1,253 +0,0 @@ -case,n,e,queries,method,wall_ms,distance_ms,gpu_ms,edge_visits,rounds,waits,fallback64,upload_ms,samples,correct,wall_samples_ms -random,1024,8192,1,old_pull_serial,0.762542,0.722583,0.437500,131072,16,2,0,0.153791,2,pass,0.762542;0.745541 -random,1024,8192,1,old_group_serial,0.964084,0.894000,0.680958,106496,13,1,0,0.153791,2,pass,0.964084;0.849083 -random,1024,8192,1,old_pull_parallel_dag,0.657209,0.643500,0.419875,131072,16,2,0,0.153791,2,pass,0.657209;0.650417 -random,1024,8192,1,old_group_parallel_dag,0.939833,0.895459,0.681833,106496,13,1,0,0.153791,2,pass,0.908916;0.939833 -random,1024,8192,1,pull64_barrier,0.674375,0.661834,0.397458,131072,16,2,0,0.153791,2,pass,0.674375;0.648416 -random,1024,8192,1,pull64_interleaved,0.853959,0.841292,0.252500,131072,16,2,0,0.153791,2,pass,0.853959;0.363292 -random,1024,8192,1,pull32_interleaved,0.315500,0.302917,0.075167,131072,16,2,0,0.153791,2,pass,0.309917;0.315500 -random,1024,8192,1,pull32_gpu_dag,0.881333,0.556833,0.271792,131072,16,4,0,0.153791,2,pass,0.881333;0.724833 -random,1024,8192,1,frontier32_vertex,0.494250,0.481458,0.180125,21088,16,2,0,0.153791,2,pass,0.473500;0.494250 -random,1024,8192,1,frontier32_simd,0.547583,0.534834,0.114833,20192,16,2,0,0.153791,2,pass,0.547583;0.423667 -random,1024,8192,1,frontier32_gpu_dag,0.746000,0.396458,0.128042,20520,16,4,0,0.153791,2,pass,0.746000;0.704541 -random,1024,8192,1,bucket32_d4,0.875292,0.862667,0.336125,8232,32,4,0,0.153791,2,pass,0.875292;0.871333 -random,1024,8192,1,bucket32_d16,0.671459,0.648500,0.262375,9336,24,3,0,0.153791,2,pass,0.647792;0.671459 -random,1024,8192,1,bucket32_d64,0.512667,0.499750,0.194875,19232,16,2,0,0.153791,2,pass,0.512667;0.452000 -random,1024,8192,64,old_pull_serial,3.818625,1.461417,0.483667,12582912,24,3,0,0.240041,2,pass,3.185541;3.818625 -random,1024,8192,64,old_group_serial,3.211083,0.894833,0.200375,7217152,17,1,0,0.240041,2,pass,3.211083;2.918583 -random,1024,8192,64,old_pull_parallel_dag,3.350541,3.060875,2.247542,12582912,24,3,0,0.240041,2,pass,2.083833;3.350541 -random,1024,8192,64,old_group_parallel_dag,1.480583,1.169125,0.894125,7217152,17,1,0,0.240041,2,pass,1.480583;1.419917 -random,1024,8192,64,pull64_barrier,1.122625,0.860000,0.311584,12582912,24,3,0,0.240041,2,pass,1.011750;1.122625 -random,1024,8192,64,pull64_interleaved,6.522000,6.270875,0.893750,12582912,24,3,0,0.240041,2,pass,5.917709;6.522000 -random,1024,8192,64,pull32_interleaved,6.568875,6.294000,1.247000,12582912,24,3,0,0.240041,2,pass,6.010583;6.568875 -random,1024,8192,64,pull32_gpu_dag,6.263042,5.390250,0.853792,12582912,24,5,0,0.240041,2,pass,6.197125;6.263042 -random,1024,8192,64,frontier32_vertex,6.342208,6.096333,1.145750,1294872,24,3,0,0.240041,2,pass,6.061666;6.342208 -random,1024,8192,64,frontier32_simd,7.192625,6.939583,2.005833,1209280,24,3,0,0.240041,2,pass,6.974292;7.192625 -random,1024,8192,64,frontier32_gpu_dag,5.802583,5.041083,0.489583,1206928,16,4,0,0.240041,2,pass,5.443083;5.802583 -random,1024,8192,64,bucket32_d4,9.311792,9.060875,3.431875,528520,56,7,0,0.240041,2,pass,9.311792;7.144291 -random,1024,8192,64,bucket32_d16,6.371083,6.101125,0.695750,601784,32,4,0,0.240041,2,pass,6.371083;5.691750 -random,1024,8192,64,bucket32_d64,7.686834,7.451417,2.572083,1155632,24,3,0,0.240041,2,pass,7.686834;6.262291 -clos,200,20000,64,old_pull_serial,5.877250,1.938875,1.613708,10240000,8,1,0,0.352709,2,pass,5.877250;5.434792 -clos,200,20000,64,old_group_serial,4.889250,0.948583,0.307792,3840000,3,1,0,0.352709,2,pass,4.889250;4.813292 -clos,200,20000,64,old_pull_parallel_dag,2.647292,2.119125,1.659625,10240000,8,1,0,0.352709,2,pass,2.086042;2.647292 -clos,200,20000,64,old_group_parallel_dag,1.495584,0.891459,0.309875,3840000,3,1,0,0.352709,2,pass,1.448458;1.495584 -clos,200,20000,64,pull64_barrier,2.218833,1.683417,1.023042,10240000,8,1,0,0.352709,2,pass,2.176959;2.218833 -clos,200,20000,64,pull64_interleaved,2.864708,2.299625,0.759375,10240000,8,1,0,0.352709,2,pass,2.610125;2.864708 -clos,200,20000,64,pull32_interleaved,2.704166,2.207834,0.698500,10240000,8,1,0,0.352709,2,pass,2.192083;2.704166 -clos,200,20000,64,pull32_gpu_dag,3.827584,2.580125,1.040375,10240000,8,3,0,0.352709,2,pass,3.230459;3.827584 -clos,200,20000,64,frontier32_vertex,2.962125,2.466500,0.986083,1280000,8,1,0,0.352709,2,pass,2.831042;2.962125 -clos,200,20000,64,frontier32_simd,2.346458,1.829250,0.359958,1280000,8,1,0,0.352709,2,pass,2.346458;2.066292 -clos,200,20000,64,frontier32_gpu_dag,4.250750,2.648000,0.788458,1280000,8,3,0,0.352709,2,pass,3.753292;4.250750 -clos,200,20000,64,bucket32_d4,3.090458,2.563208,0.507375,1280000,8,1,0,0.352709,2,pass,3.090458;2.377792 -clos,200,20000,64,bucket32_d16,2.999750,2.475542,1.133083,1280000,8,1,0,0.352709,2,pass,2.999750;2.523084 -clos,200,20000,64,bucket32_d64,2.528875,1.963250,0.468000,1280000,8,1,0,0.352709,2,pass,2.084417;2.528875 -grid,1024,3968,1,old_pull_serial,1.939625,1.920792,0.651750,253952,64,8,0,0.236834,2,pass,1.939625;1.786750 -grid,1024,3968,1,old_group_serial,1.449167,1.431625,1.011500,249984,63,1,0,0.236834,2,pass,1.449167;1.185000 -grid,1024,3968,1,old_pull_parallel_dag,1.785625,1.775542,0.651499,253952,64,8,0,0.236834,2,pass,1.785625;1.649542 -grid,1024,3968,1,old_group_parallel_dag,1.162416,1.153500,1.008625,249984,63,1,0,0.236834,2,pass,1.162416;1.150083 -grid,1024,3968,1,pull64_barrier,1.762958,1.753834,0.694125,253952,64,8,0,0.236834,2,pass,1.686750;1.762958 -grid,1024,3968,1,pull64_interleaved,2.131792,2.124834,1.110167,253952,64,8,0,0.236834,2,pass,1.793583;2.131792 -grid,1024,3968,1,pull32_interleaved,1.673334,1.664458,0.629750,253952,64,8,0,0.236834,2,pass,1.673334;1.599167 -grid,1024,3968,1,pull32_gpu_dag,2.191500,1.854833,0.896833,253952,64,10,0,0.236834,2,pass,2.000917;2.191500 -grid,1024,3968,1,frontier32_vertex,3.608792,3.595083,2.566375,3968,64,8,0,0.236834,2,pass,3.608792;2.936166 -grid,1024,3968,1,frontier32_simd,2.444500,2.435167,1.407542,3968,64,8,0,0.236834,2,pass,2.444500;2.438000 -grid,1024,3968,1,frontier32_gpu_dag,2.885083,2.530792,1.446375,3968,64,10,0,0.236834,2,pass,2.885083;2.840000 -grid,1024,3968,1,bucket32_d4,3.526792,3.517083,2.379750,3968,64,8,0,0.236834,2,pass,3.444875;3.526792 -grid,1024,3968,1,bucket32_d16,4.111792,4.100167,3.000833,3968,64,8,0,0.236834,2,pass,4.111792;3.712666 -grid,1024,3968,1,bucket32_d64,4.321125,4.313125,3.175875,3968,64,8,0,0.236834,2,pass,3.525208;4.321125 -hub,1024,3070,1,old_pull_serial,6.219708,6.202666,5.564083,98240,32,4,0,0.127834,2,pass,6.219708;6.202208 -hub,1024,3070,1,old_group_serial,4.915291,4.884334,4.720250,95170,31,1,0,0.127834,2,pass,4.855542;4.915291 -hub,1024,3070,1,old_pull_parallel_dag,6.068750,6.050375,5.496250,98240,32,4,0,0.127834,2,pass,6.068750;6.043792 -hub,1024,3070,1,old_group_parallel_dag,4.942709,4.928333,4.755875,95170,31,1,0,0.127834,2,pass,4.900708;4.942709 -hub,1024,3070,1,pull64_barrier,4.900083,4.888000,4.318500,98240,32,4,0,0.127834,2,pass,4.900083;4.899708 -hub,1024,3070,1,pull64_interleaved,5.090459,5.077667,4.310625,98240,32,4,0,0.127834,2,pass,5.090459;5.011958 -hub,1024,3070,1,pull32_interleaved,4.329166,4.318959,3.718042,98240,32,4,0,0.127834,2,pass,4.329166;4.298375 -hub,1024,3070,1,pull32_gpu_dag,5.058875,4.330667,4.105667,98240,32,6,0,0.127834,2,pass,5.006209;5.058875 -hub,1024,3070,1,frontier32_vertex,1.372833,1.364084,0.840958,3128,32,4,0,0.127834,2,pass,1.372833;1.322834 -hub,1024,3070,1,frontier32_simd,0.758292,0.751125,0.233125,3128,32,4,0,0.127834,2,pass,0.752542;0.758292 -hub,1024,3070,1,frontier32_gpu_dag,1.443542,0.736125,0.653958,3128,32,6,0,0.127834,2,pass,1.382958;1.443542 -hub,1024,3070,1,bucket32_d4,0.879333,0.871000,0.348375,3100,32,4,0,0.127834,2,pass,0.879333;0.859334 -hub,1024,3070,1,bucket32_d16,0.852333,0.844083,0.342417,3124,32,4,0,0.127834,2,pass,0.843708;0.852333 -hub,1024,3070,1,bucket32_d64,1.001750,0.993250,0.334375,3128,32,4,0,0.127834,2,pass,1.001750;0.857750 -random_masked_int64_reverse,1024,8192,8,old_pull_serial,0.521375,0.157541,0.032000,524288,8,1,0,0.210333,2,pass,0.521375;0.520625 -random_masked_int64_reverse,1024,8192,8,old_group_serial,0.538292,0.183375,0.053583,368640,8,1,0,0.210333,2,pass,0.538292;0.518041 -random_masked_int64_reverse,1024,8192,8,old_pull_parallel_dag,0.232041,0.168375,0.031917,524288,8,1,0,0.210333,2,pass,0.232041;0.227625 -random_masked_int64_reverse,1024,8192,8,old_group_parallel_dag,0.282750,0.205292,0.053625,368640,8,1,0,0.210333,2,pass,0.257500;0.282750 -random_masked_int64_reverse,1024,8192,8,pull64_barrier,0.223709,0.158792,0.028750,524288,8,1,0,0.210333,2,pass,0.223709;0.214000 -random_masked_int64_reverse,1024,8192,8,pull64_interleaved,0.810750,0.752583,0.028875,524288,8,1,0,0.210333,2,pass,0.569833;0.810750 -random_masked_int64_reverse,1024,8192,8,pull32_interleaved,0.775500,0.697875,0.028875,524288,8,1,1,0.210333,2,pass,0.721208;0.775500 -random_masked_int64_reverse,1024,8192,8,pull32_gpu_dag,1.021792,0.675417,0.051667,524288,8,3,1,0.210333,2,pass,0.982083;1.021792 -random_masked_int64_reverse,1024,8192,8,frontier32_vertex,0.277333,0.223500,0.029000,524288,8,1,1,0.210333,2,pass,0.241625;0.277333 -random_masked_int64_reverse,1024,8192,8,frontier32_simd,0.243875,0.179000,0.028583,524288,8,1,1,0.210333,2,pass,0.243875;0.237500 -random_masked_int64_reverse,1024,8192,8,frontier32_gpu_dag,0.500167,0.189583,0.047458,524288,8,3,1,0.210333,2,pass,0.500167;0.488708 -random_masked_int64_reverse,1024,8192,8,bucket32_d4,0.236375,0.177042,0.028750,524288,8,1,1,0.210333,2,pass,0.228500;0.236375 -random_masked_int64_reverse,1024,8192,8,bucket32_d16,0.249125,0.192084,0.028625,524288,8,1,1,0.210333,2,pass,0.218500;0.249125 -random_masked_int64_reverse,1024,8192,8,bucket32_d64,0.335542,0.296125,0.028625,524288,8,1,1,0.210333,2,pass,0.240375;0.335542 -random,4096,32768,256,old_pull_serial,49.337625,12.467292,11.699833,201326592,24,3,0,0.540875,2,pass,48.524667;49.337625 -random,4096,32768,256,old_group_serial,49.802834,11.785459,10.993500,136544256,20,1,0,0.540875,2,pass,47.385167;49.802834 -random,4096,32768,256,old_pull_parallel_dag,19.919000,16.053916,14.734958,201326592,24,3,0,0.540875,2,pass,19.919000;15.861667 -random,4096,32768,256,old_group_parallel_dag,11.645708,7.757000,6.930917,136544256,20,1,0,0.540875,2,pass,11.483584;11.645708 -random,4096,32768,256,pull64_barrier,13.484083,9.335333,8.520750,201326592,24,3,0,0.540875,2,pass,13.484083;12.949500 -random,4096,32768,256,pull64_interleaved,90.054167,86.195000,8.617750,201326592,24,3,0,0.540875,2,pass,90.054167;86.703666 -random,4096,32768,256,pull32_interleaved,79.266000,75.508625,6.636458,201326592,24,3,0,0.540875,2,pass,78.284917;79.266000 -random,4096,32768,256,pull32_gpu_dag,98.656083,96.740417,7.475250,201326592,24,5,0,0.540875,2,pass,80.137083;98.656083 -random,4096,32768,256,frontier32_vertex,79.679042,75.814167,9.683000,19773312,24,3,0,0.540875,2,pass,79.679042;79.543375 -random,4096,32768,256,frontier32_simd,85.882083,82.195666,16.025708,20638048,24,3,0,0.540875,2,pass,85.882083;85.238500 -random,4096,32768,256,frontier32_gpu_dag,83.435958,80.312834,15.618542,20760840,24,5,0,0.540875,2,pass,79.330083;83.435958 -random,4096,32768,256,bucket32_d4,85.800500,82.027500,15.248208,8454032,72,9,0,0.540875,2,pass,85.800500;84.087875 -random,4096,32768,256,bucket32_d16,85.003125,81.021958,9.436334,9715464,48,6,0,0.540875,2,pass,79.442625;85.003125 -random,4096,32768,256,bucket32_d64,88.076375,84.327833,16.212208,18335896,24,3,0,0.540875,2,pass,88.076375;87.845292 -random,16384,131072,1,old_pull_serial,2.306125,1.628750,0.257125,3145728,24,3,0,1.669042,2,pass,2.306125;1.344208 -random,16384,131072,1,old_group_serial,6.196458,5.531125,5.320500,2359296,18,1,0,1.669042,2,pass,6.196458;5.822917 -random,16384,131072,1,old_pull_parallel_dag,0.998041,0.646917,0.200666,3145728,24,3,0,1.669042,2,pass,0.998041;0.985250 -random,16384,131072,1,old_group_parallel_dag,5.391875,5.119750,4.971208,2359296,18,1,0,1.669042,2,pass,5.391875;5.363250 -random,16384,131072,1,pull64_barrier,0.916583,0.623416,0.172917,3145728,24,3,0,1.669042,2,pass,0.916583;0.894583 -random,16384,131072,1,pull64_interleaved,0.941750,0.720250,0.172625,3145728,24,3,0,1.669042,2,pass,0.928459;0.941750 -random,16384,131072,1,pull32_interleaved,0.899875,0.689083,0.147708,3145728,24,3,0,1.669042,2,pass,0.899875;0.875000 -random,16384,131072,1,pull32_gpu_dag,1.075709,0.687333,0.166417,3145728,24,5,0,1.669042,2,pass,1.075709;1.058916 -random,16384,131072,1,frontier32_vertex,1.222458,0.879917,0.244208,377896,24,3,0,1.669042,2,pass,1.222458;1.058459 -random,16384,131072,1,frontier32_simd,1.140625,0.808417,0.237042,336576,24,3,0,1.669042,2,pass,1.036834;1.140625 -random,16384,131072,1,frontier32_gpu_dag,1.208625,0.863958,0.431667,336096,24,5,0,1.669042,2,pass,1.208625;1.090583 -random,16384,131072,1,bucket32_d4,1.975208,1.713916,0.650959,132024,56,7,0,1.669042,2,pass,1.928042;1.975208 -random,16384,131072,1,bucket32_d16,1.444792,1.120375,0.390458,148264,32,4,0,1.669042,2,pass,1.444792;1.338417 -random,16384,131072,1,bucket32_d64,1.217834,0.885958,0.336333,269064,24,3,0,1.669042,2,pass,1.217834;1.050000 -random,16384,131072,64,old_pull_serial,57.334750,14.082292,12.348333,201326592,24,3,0,1.645250,2,pass,57.334750;56.155875 -random,16384,131072,64,old_group_serial,53.978958,11.020083,10.026500,158203904,22,1,0,1.645250,2,pass,53.571042;53.978958 -random,16384,131072,64,old_pull_parallel_dag,16.908459,12.904958,11.456708,201326592,24,3,0,1.645250,2,pass,16.908459;16.703541 -random,16384,131072,64,old_group_parallel_dag,13.650125,9.371084,8.515875,158203904,22,1,0,1.645250,2,pass,13.650125;13.255167 -random,16384,131072,64,pull64_barrier,16.434583,11.461166,10.202375,201326592,24,3,0,1.645250,2,pass,16.434583;15.835125 -random,16384,131072,64,pull64_interleaved,77.366916,73.168167,2.877792,201326592,24,3,0,1.645250,2,pass,75.897916;77.366916 -random,16384,131072,64,pull32_interleaved,83.784041,79.621792,6.788625,201326592,24,3,0,1.645250,2,pass,83.784041;78.639667 -random,16384,131072,64,pull32_gpu_dag,75.935959,73.474041,7.671417,201326592,24,5,0,1.645250,2,pass,75.935959;75.331334 -random,16384,131072,64,frontier32_vertex,93.834292,88.800917,11.524583,21236872,24,3,0,1.645250,2,pass,80.318042;93.834292 -random,16384,131072,64,frontier32_simd,79.992292,75.712584,9.913500,21938424,24,3,0,1.645250,2,pass,79.992292;79.583166 -random,16384,131072,64,frontier32_gpu_dag,87.396667,84.738792,19.360333,21869744,24,5,0,1.645250,2,pass,79.213125;87.396667 -random,16384,131072,64,bucket32_d4,88.988834,84.024250,13.670625,8451432,72,9,0,1.645250,2,pass,88.988834;88.343833 -random,16384,131072,64,bucket32_d16,91.121792,86.761000,16.956541,9562072,48,6,0,1.645250,2,pass,91.121792;87.719000 -random,16384,131072,64,bucket32_d64,87.983750,83.874083,12.879167,17319032,24,3,0,1.645250,2,pass,87.983750;84.451458 -random,65536,524288,1,old_pull_serial,5.670625,2.852084,1.649333,12582912,24,3,0,7.477583,2,pass,5.572667;5.670625 -random,65536,524288,1,old_group_serial,31.835417,29.109750,28.416708,11534336,22,1,0,7.477583,2,pass,31.347666;31.835417 -random,65536,524288,1,old_pull_parallel_dag,3.276042,1.796917,0.846167,12582912,24,3,0,7.477583,2,pass,3.224125;3.276042 -random,65536,524288,1,old_group_parallel_dag,30.395334,28.999000,28.296250,11534336,22,1,0,7.477583,2,pass,30.395334;29.813041 -random,65536,524288,1,pull64_barrier,3.316500,1.854167,0.802250,12582912,24,3,0,7.477583,2,pass,3.150584;3.316500 -random,65536,524288,1,pull64_interleaved,3.792375,2.321250,0.784542,12582912,24,3,0,7.477583,2,pass,3.740834;3.792375 -random,65536,524288,1,pull32_interleaved,3.664250,2.216250,0.642667,12582912,24,3,0,7.477583,2,pass,3.664250;3.619208 -random,65536,524288,1,pull32_gpu_dag,1.946833,1.440542,0.574292,12582912,24,5,0,7.477583,2,pass,1.807458;1.946833 -random,65536,524288,1,frontier32_vertex,3.540500,2.012917,0.603250,1743784,24,3,0,7.477583,2,pass,3.064458;3.540500 -random,65536,524288,1,frontier32_simd,3.527958,2.002208,0.598125,1562256,24,3,0,7.477583,2,pass,3.527958;3.485750 -random,65536,524288,1,frontier32_gpu_dag,2.079917,1.688458,0.682125,1566928,24,5,0,7.477583,2,pass,2.079917;2.025916 -random,65536,524288,1,bucket32_d4,4.803667,3.281125,1.170083,528256,64,8,0,7.477583,2,pass,4.803667;4.388583 -random,65536,524288,1,bucket32_d16,3.979417,2.442791,0.794417,599384,40,5,0,7.477583,2,pass,3.611333;3.979417 -random,65536,524288,1,bucket32_d64,3.852667,2.205292,0.729333,1050680,24,3,0,7.477583,2,pass,3.614333;3.852667 -clos,800,320000,1,old_pull_serial,2.370625,1.339417,0.724000,2560000,8,1,0,3.496583,2,pass,2.324166;2.370625 -clos,800,320000,1,old_group_serial,2.508750,1.566834,0.924167,960000,3,1,0,3.496583,2,pass,2.038208;2.508750 -clos,800,320000,1,old_pull_parallel_dag,2.127417,1.358375,0.732417,2560000,8,1,0,3.496583,2,pass,2.127417;2.063958 -clos,800,320000,1,old_group_parallel_dag,2.450875,1.688250,1.007875,960000,3,1,0,3.496583,2,pass,2.450875;2.436166 -clos,800,320000,1,pull64_barrier,2.201333,1.408125,0.657125,2560000,8,1,0,3.496583,2,pass,2.201333;1.657750 -clos,800,320000,1,pull64_interleaved,2.063084,1.323375,0.640458,2560000,8,1,0,3.496583,2,pass,2.063084;2.047459 -clos,800,320000,1,pull32_interleaved,1.952042,1.171375,0.556000,2560000,8,1,0,3.496583,2,pass,1.952042;1.436667 -clos,800,320000,1,pull32_gpu_dag,2.170625,1.323375,1.010542,2560000,8,3,0,3.496583,2,pass,2.143209;2.170625 -clos,800,320000,1,frontier32_vertex,2.447125,1.644417,0.968083,320000,8,1,0,3.496583,2,pass,2.447125;2.101167 -clos,800,320000,1,frontier32_simd,1.531125,0.705500,0.095500,320000,8,1,0,3.496583,2,pass,1.531125;1.476083 -clos,800,320000,1,frontier32_gpu_dag,1.652708,0.837417,0.614917,320000,8,3,0,3.496583,2,pass,1.652708;1.008292 -clos,800,320000,1,bucket32_d4,1.741958,0.940916,0.191500,320000,8,1,0,3.496583,2,pass,1.622708;1.741958 -clos,800,320000,1,bucket32_d16,1.637250,0.860458,0.177750,320000,8,1,0,3.496583,2,pass,1.618125;1.637250 -clos,800,320000,1,bucket32_d64,1.648416,0.862959,0.200083,320000,8,1,0,3.496583,2,pass,1.648416;1.564459 -clos,800,320000,64,old_pull_serial,84.010917,20.151750,19.591375,163840000,8,1,0,3.460875,2,pass,79.696125;84.010917 -clos,800,320000,64,old_group_serial,70.853292,5.869375,5.387125,61440000,3,1,0,3.460875,2,pass,70.853292;69.814500 -clos,800,320000,64,old_pull_parallel_dag,25.874292,18.861542,18.216250,163840000,8,1,0,3.460875,2,pass,25.874292;23.185041 -clos,800,320000,64,old_group_parallel_dag,12.892292,5.977542,5.445750,61440000,3,1,0,3.460875,2,pass,12.794500;12.892292 -clos,800,320000,64,pull64_barrier,28.435834,21.288833,20.666500,163840000,8,1,0,3.460875,2,pass,23.741583;28.435834 -clos,800,320000,64,pull64_interleaved,17.237125,10.007500,6.063250,163840000,8,1,0,3.460875,2,pass,17.237125;17.069959 -clos,800,320000,64,pull32_interleaved,15.469333,8.212958,3.644584,163840000,8,1,0,3.460875,2,pass,14.657250;15.469333 -clos,800,320000,64,pull32_gpu_dag,12.177917,8.063667,6.185208,163840000,8,3,0,3.460875,2,pass,11.646000;12.177917 -clos,800,320000,64,frontier32_vertex,17.391792,10.431791,6.175083,20480000,8,1,0,3.460875,2,pass,17.200042;17.391792 -clos,800,320000,64,frontier32_simd,13.298500,6.201125,1.491583,20480000,8,1,0,3.460875,2,pass,11.929792;13.298500 -clos,800,320000,64,frontier32_gpu_dag,11.460292,5.960000,5.538625,20480000,8,3,0,3.460875,2,pass,10.272375;11.460292 -clos,800,320000,64,bucket32_d4,13.275834,6.647167,1.969875,20480000,8,1,0,3.460875,2,pass,13.145500;13.275834 -clos,800,320000,64,bucket32_d16,13.277042,5.984208,1.809625,20480000,8,1,0,3.460875,2,pass,13.277042;12.978417 -clos,800,320000,64,bucket32_d64,12.781000,5.587375,1.800917,20480000,8,1,0,3.460875,2,pass,12.781000;11.775459 -grid,10000,39600,1,old_pull_serial,6.308667,6.189375,2.474124,7920000,200,25,0,0.484791,2,pass,6.174667;6.308667 -grid,10000,39600,1,old_group_serial,7.964459,7.841209,7.579834,7880400,199,1,0,0.484791,2,pass,7.915500;7.964459 -grid,10000,39600,1,old_pull_parallel_dag,4.204917,4.140000,0.606917,7920000,200,25,0,0.484791,2,pass,4.204917;4.148666 -grid,10000,39600,1,old_group_parallel_dag,7.780208,7.685250,7.526250,7880400,199,1,0,0.484791,2,pass,7.773042;7.780208 -grid,10000,39600,1,pull64_barrier,3.893333,3.823000,0.630791,7920000,200,25,0,0.484791,2,pass,3.893333;3.591125 -grid,10000,39600,1,pull64_interleaved,4.129958,4.070667,0.852458,7920000,200,25,0,0.484791,2,pass,4.129958;4.114666 -grid,10000,39600,1,pull32_interleaved,4.162917,4.100792,0.642917,7920000,200,25,0,0.484791,2,pass,3.970334;4.162917 -grid,10000,39600,1,pull32_gpu_dag,4.198666,3.912834,0.646082,7920000,200,27,0,0.484791,2,pass,4.198666;3.817750 -grid,10000,39600,1,frontier32_vertex,5.159500,5.099500,1.914417,39600,200,25,0,0.484791,2,pass,5.159500;5.099333 -grid,10000,39600,1,frontier32_simd,4.911583,4.852292,1.346042,39600,200,25,0,0.484791,2,pass,4.688917;4.911583 -grid,10000,39600,1,frontier32_gpu_dag,5.137208,4.800875,1.375291,39600,200,27,0,0.484791,2,pass,5.102334;5.137208 -grid,10000,39600,1,bucket32_d4,6.035708,5.976458,2.449416,39600,200,25,0,0.484791,2,pass,6.035708;5.789417 -grid,10000,39600,1,bucket32_d16,5.723333,5.664083,2.392000,39600,200,25,0,0.484791,2,pass,5.497666;5.723333 -grid,10000,39600,1,bucket32_d64,6.215667,6.141917,2.437126,39600,200,25,0,0.484791,2,pass,5.774834;6.215667 -grid,10000,39600,8,old_pull_serial,6.342000,5.328667,1.417540,63360000,200,25,0,0.487626,2,pass,6.342000;6.058000 -grid,10000,39600,8,old_group_serial,9.010166,8.094333,7.525375,51480000,199,1,0,0.487626,2,pass,8.645000;9.010166 -grid,10000,39600,8,old_pull_parallel_dag,5.092541,4.901084,1.396958,63360000,200,25,0,0.487626,2,pass,5.022958;5.092541 -grid,10000,39600,8,old_group_parallel_dag,7.914250,7.706834,7.497542,51480000,199,1,0,0.487626,2,pass,7.893583;7.914250 -grid,10000,39600,8,pull64_barrier,5.068458,4.899667,1.411666,63360000,200,25,0,0.487626,2,pass,4.946417;5.068458 -grid,10000,39600,8,pull64_interleaved,10.357083,10.106334,1.521750,63360000,200,25,0,0.487626,2,pass,10.357083;10.304375 -grid,10000,39600,8,pull32_interleaved,11.089083,10.845167,1.357125,63360000,200,25,0,0.487626,2,pass,11.089083;10.573500 -grid,10000,39600,8,pull32_gpu_dag,10.887416,10.004708,1.333043,63360000,200,27,0,0.487626,2,pass,9.630375;10.887416 -grid,10000,39600,8,frontier32_vertex,10.730792,10.477209,1.984625,316800,200,25,0,0.487626,2,pass,10.730792;10.719541 -grid,10000,39600,8,frontier32_simd,10.836875,10.634917,1.851000,316800,200,25,0,0.487626,2,pass,10.836875;10.763708 -grid,10000,39600,8,frontier32_gpu_dag,10.641500,9.760208,1.575917,316800,200,27,0,0.487626,2,pass,10.641500;10.630333 -grid,10000,39600,8,bucket32_d4,13.203375,13.062583,3.960125,316800,200,25,0,0.487626,2,pass,13.203375;13.027500 -grid,10000,39600,8,bucket32_d16,12.623292,12.421500,3.885626,316800,200,25,0,0.487626,2,pass,12.198958;12.623292 -grid,10000,39600,8,bucket32_d64,13.461542,13.314167,3.911709,316800,200,25,0,0.487626,2,pass,13.461542;13.151334 -chain,2048,2047,1,old_pull_serial,31.251584,31.241000,4.493376,4192256,2048,256,0,0.124208,2,pass,29.572958;31.251584 -chain,2048,2047,1,old_group_serial,4.872125,4.846917,4.704000,4192256,2048,1,0,0.124208,2,pass,4.872125;4.816000 -chain,2048,2047,1,old_pull_parallel_dag,30.364166,30.357500,4.342086,4192256,2048,256,0,0.124208,2,pass,30.364166;29.797667 -chain,2048,2047,1,old_group_parallel_dag,4.862500,4.841709,4.696458,4192256,2048,1,0,0.124208,2,pass,4.821792;4.862500 -chain,2048,2047,1,pull64_barrier,31.394625,31.387958,5.149665,4192256,2048,256,0,0.124208,2,pass,31.394625;30.564667 -chain,2048,2047,1,pull64_interleaved,30.923291,30.917667,5.045705,4192256,2048,256,0,0.124208,2,pass,30.342292;30.923291 -chain,2048,2047,1,pull32_interleaved,30.950333,30.944750,5.049085,4192256,2048,256,0,0.124208,2,pass,30.950333;29.343292 -chain,2048,2047,1,pull32_gpu_dag,29.144625,28.787459,4.978918,4192256,2048,258,0,0.124208,2,pass,28.863709;29.144625 -chain,2048,2047,1,frontier32_vertex,54.826625,54.820042,27.490289,2047,2048,256,0,0.124208,2,pass,54.826625;43.290458 -chain,2048,2047,1,frontier32_simd,40.021500,40.015625,13.131166,2047,2048,256,0,0.124208,2,pass,40.021500;38.506333 -chain,2048,2047,1,frontier32_gpu_dag,39.300791,38.996459,12.897914,2047,2048,258,0,0.124208,2,pass,39.067750;39.300791 -chain,2048,2047,1,bucket32_d4,49.432875,49.426375,21.049543,2047,2056,257,0,0.124208,2,pass,47.271833;49.432875 -chain,2048,2047,1,bucket32_d16,109.948875,109.939375,79.102456,2047,2056,257,0,0.124208,2,pass,88.977000;109.948875 -chain,2048,2047,1,bucket32_d64,48.930458,48.922542,21.087253,2047,2056,257,0,0.124208,2,pass,48.352792;48.930458 -hub,4096,12286,64,old_pull_serial,35.106833,32.332625,30.932625,31452160,40,5,0,0.332584,2,pass,34.652083;35.106833 -hub,4096,12286,64,old_group_serial,28.499792,25.765708,25.115500,27176632,38,1,0,0.332584,2,pass,28.499792;28.071959 -hub,4096,12286,64,old_pull_parallel_dag,33.417042,32.452917,30.979041,31452160,40,5,0,0.332584,2,pass,33.417042;32.831750 -hub,4096,12286,64,old_group_parallel_dag,26.387625,26.007666,25.172542,27176632,38,1,0,0.332584,2,pass,26.018208;26.387625 -hub,4096,12286,64,pull64_barrier,25.788459,25.342083,23.820209,31452160,40,5,0,0.332584,2,pass,25.166291;25.788459 -hub,4096,12286,64,pull64_interleaved,49.636291,49.303542,31.618167,31452160,40,5,0,0.332584,2,pass,49.126041;49.636291 -hub,4096,12286,64,pull32_interleaved,45.148542,44.768041,27.223375,31452160,40,5,0,0.332584,2,pass,44.485166;45.148542 -hub,4096,12286,64,pull32_gpu_dag,47.747125,44.786709,29.190750,31452160,40,7,0,0.332584,2,pass,47.409125;47.747125 -hub,4096,12286,64,frontier32_vertex,36.510167,36.094917,18.531458,1872372,40,5,0,0.332584,2,pass,35.063875;36.510167 -hub,4096,12286,64,frontier32_simd,22.199209,21.820333,1.906625,1683322,40,5,0,0.332584,2,pass,22.199209;22.001000 -hub,4096,12286,64,frontier32_gpu_dag,25.051625,21.600917,4.480042,1683264,40,7,0,0.332584,2,pass,25.051625;24.298250 -hub,4096,12286,64,bucket32_d4,25.343542,24.950084,2.969750,788342,48,6,0,0.332584,2,pass,23.476292;25.343542 -hub,4096,12286,64,bucket32_d16,26.631250,26.198042,3.176833,1124810,48,6,0,0.332584,2,pass,26.631250;23.371583 -hub,4096,12286,64,bucket32_d64,25.833834,25.465917,5.076166,1913530,40,5,0,0.332584,2,pass,24.307000;25.833834 -random,4096,131072,1,old_pull_serial,1.648541,1.149500,0.571458,2097152,16,2,0,1.680791,2,pass,1.648541;1.249209 -random,4096,131072,1,old_group_serial,6.351458,5.917417,5.756042,1572864,12,1,0,1.680791,2,pass,6.351458;6.022333 -random,4096,131072,1,old_pull_parallel_dag,0.933000,0.714709,0.452875,2097152,16,2,0,1.680791,2,pass,0.933000;0.886000 -random,4096,131072,1,old_group_parallel_dag,4.209167,4.007625,3.836334,1572864,12,1,0,1.680791,2,pass,4.209167;3.968750 -random,4096,131072,1,pull64_barrier,0.788792,0.602917,0.327542,2097152,16,2,0,1.680791,2,pass,0.788792;0.780500 -random,4096,131072,1,pull64_interleaved,0.789375,0.596625,0.286000,2097152,16,2,0,1.680791,2,pass,0.789375;0.764042 -random,4096,131072,1,pull32_interleaved,0.754708,0.581125,0.278500,2097152,16,2,0,1.680791,2,pass,0.754708;0.704625 -random,4096,131072,1,pull32_gpu_dag,0.969125,0.573500,0.317292,2097152,16,4,0,1.680791,2,pass,0.969125;0.835333 -random,4096,131072,1,frontier32_vertex,0.959750,0.784708,0.448417,421824,16,2,0,1.680791,2,pass,0.959750;0.883166 -random,4096,131072,1,frontier32_simd,0.613042,0.433500,0.165208,388160,16,2,0,1.680791,2,pass,0.602375;0.613042 -random,4096,131072,1,frontier32_gpu_dag,0.792875,0.454500,0.215166,392576,16,4,0,1.680791,2,pass,0.792875;0.771708 -random,4096,131072,1,bucket32_d4,0.948833,0.779541,0.322375,138784,24,3,0,1.680791,2,pass,0.907583;0.948833 -random,4096,131072,1,bucket32_d16,0.668875,0.487292,0.205875,177152,16,2,0,1.680791,2,pass,0.668875;0.664958 -random,4096,131072,1,bucket32_d64,0.704125,0.522375,0.236958,387040,16,2,0,1.680791,2,pass,0.704125;0.692375 -random,4096,131072,64,old_pull_serial,39.707625,10.664917,9.875917,134217728,16,2,0,1.785041,2,pass,39.707625;38.615750 -random,4096,131072,64,old_group_serial,34.620125,5.432459,4.809875,105644032,15,1,0,1.785041,2,pass,33.889416;34.620125 -random,4096,131072,64,old_pull_parallel_dag,13.925708,11.805375,11.350958,134217728,16,2,0,1.785041,2,pass,13.718500;13.925708 -random,4096,131072,64,old_group_parallel_dag,6.589500,4.478333,3.871625,105644032,15,1,0,1.785041,2,pass,6.210000;6.589500 -random,4096,131072,64,pull64_barrier,11.746500,9.527458,8.613667,134217728,16,2,0,1.785041,2,pass,11.549875;11.746500 -random,4096,131072,64,pull64_interleaved,23.944917,21.617000,1.407083,134217728,16,2,0,1.785041,2,pass,23.944917;23.816000 -random,4096,131072,64,pull32_interleaved,24.723167,22.521417,2.845166,134217728,16,2,0,1.785041,2,pass,23.098042;24.723167 -random,4096,131072,64,pull32_gpu_dag,23.908541,22.219333,3.848333,134217728,16,4,0,1.785041,2,pass,23.908541;23.375417 -random,4096,131072,64,frontier32_vertex,27.957250,25.638000,4.482417,22831904,16,2,0,1.785041,2,pass,25.895125;27.957250 -random,4096,131072,64,frontier32_simd,30.802708,28.372875,8.748375,23054048,16,2,0,1.785041,2,pass,30.802708;30.681708 -random,4096,131072,64,frontier32_gpu_dag,30.596834,28.276833,5.482458,22947328,16,4,0,1.785041,2,pass,26.060875;30.596834 -random,4096,131072,64,bucket32_d4,28.980292,26.791458,7.614083,8805280,24,3,0,1.785041,2,pass,27.958958;28.980292 -random,4096,131072,64,bucket32_d16,25.097334,22.833958,2.722333,12090432,24,3,0,1.785041,2,pass,24.775834;25.097334 -random,4096,131072,64,bucket32_d64,29.428333,27.258291,7.370167,23020448,16,2,0,1.785041,2,pass,25.965250;29.428333 diff --git a/dev/research/metal_pathfinding/optimization/results/smoke-stderr.txt b/dev/research/metal_pathfinding/optimization/results/smoke-stderr.txt deleted file mode 100644 index 4b20dd4..0000000 --- a/dev/research/metal_pathfinding/optimization/results/smoke-stderr.txt +++ /dev/null @@ -1 +0,0 @@ -device=Apple M4 Max setup_ms=52.7973 execution_width=32 diff --git a/dev/research/metal_pathfinding/optimization/results/smoke.csv b/dev/research/metal_pathfinding/optimization/results/smoke.csv deleted file mode 100644 index b8c1539..0000000 --- a/dev/research/metal_pathfinding/optimization/results/smoke.csv +++ /dev/null @@ -1,85 +0,0 @@ -case,n,e,queries,method,wall_ms,distance_ms,gpu_ms,edge_visits,rounds,waits,fallback64,upload_ms,samples,correct,wall_samples_ms -random,1024,8192,1,old_pull_serial,0.690458,0.660208,0.399041,131072,16,2,0,0.232500,2,pass,0.690458;0.689292 -random,1024,8192,1,old_group_serial,0.918750,0.867792,0.684042,106496,13,1,0,0.232500,2,pass,0.918750;0.902958 -random,1024,8192,1,old_pull_parallel_dag,0.673667,0.658667,0.400125,131072,16,2,0,0.232500,2,pass,0.639000;0.673667 -random,1024,8192,1,old_group_parallel_dag,1.662917,1.644542,1.504458,106496,13,1,0,0.232500,2,pass,0.902583;1.662917 -random,1024,8192,1,pull64_barrier,0.635417,0.619083,0.358833,131072,16,2,0,0.232500,2,pass,0.635417;0.632209 -random,1024,8192,1,pull64_interleaved,0.691458,0.679500,0.358958,131072,16,2,0,0.232500,2,pass,0.662083;0.691458 -random,1024,8192,1,pull32_interleaved,1.027750,1.003250,0.736542,131072,16,2,0,0.232500,2,pass,1.027750;0.623042 -random,1024,8192,1,pull32_gpu_dag,0.965041,0.565708,0.383500,131072,16,4,0,0.232500,2,pass,0.965041;0.958458 -random,1024,8192,1,frontier32_vertex,1.215292,1.191917,0.921042,21088,16,2,0,0.232500,2,pass,1.215292;0.914417 -random,1024,8192,1,frontier32_simd,0.681375,0.663166,0.368917,20584,16,2,0,0.232500,2,pass,0.664417;0.681375 -random,1024,8192,1,frontier32_gpu_dag,1.127209,0.667792,0.435167,20512,16,4,0,0.232500,2,pass,1.127209;1.085459 -random,1024,8192,1,bucket32_d4,1.810625,1.790333,1.195875,8232,32,4,0,0.232500,2,pass,1.810625;1.789042 -random,1024,8192,1,bucket32_d16,1.298167,1.276541,0.864125,9344,24,3,0,0.232500,2,pass,1.281583;1.298167 -random,1024,8192,1,bucket32_d64,1.120875,1.108042,0.831375,19360,16,2,0,0.232500,2,pass,0.931625;1.120875 -random,1024,8192,64,old_pull_serial,4.363541,2.058709,1.582042,12582912,24,3,0,0.201166,2,pass,4.193209;4.363541 -random,1024,8192,64,old_group_serial,3.589666,1.344042,0.896042,7217152,17,1,0,0.201166,2,pass,3.573459;3.589666 -random,1024,8192,64,old_pull_parallel_dag,2.498625,2.249166,1.710833,12582912,24,3,0,0.201166,2,pass,2.332167;2.498625 -random,1024,8192,64,old_group_parallel_dag,1.373667,1.088042,0.889625,7217152,17,1,0,0.201166,2,pass,1.303750;1.373667 -random,1024,8192,64,pull64_barrier,2.200500,1.807125,1.382208,12582912,24,3,0,0.201166,2,pass,2.078292;2.200500 -random,1024,8192,64,pull64_interleaved,7.077875,6.808541,0.838292,12582912,24,3,0,0.201166,2,pass,7.077875;6.882042 -random,1024,8192,64,pull32_interleaved,6.222875,5.941417,0.684333,12582912,24,3,0,0.201166,2,pass,6.222875;6.170417 -random,1024,8192,64,pull32_gpu_dag,6.617083,5.848167,0.776542,12582912,24,5,0,0.201166,2,pass,6.617083;6.270000 -random,1024,8192,64,frontier32_vertex,6.192417,5.928291,0.991125,1294232,16,2,0,0.201166,2,pass,6.192417;5.873375 -random,1024,8192,64,frontier32_simd,7.073209,6.830167,1.888250,1209320,16,2,0,0.201166,2,pass,7.073209;6.179000 -random,1024,8192,64,frontier32_gpu_dag,7.573000,6.650750,2.009833,1200408,24,5,0,0.201166,2,pass,7.079666;7.573000 -random,1024,8192,64,bucket32_d4,7.151083,6.905833,1.128250,528520,56,7,0,0.201166,2,pass,7.096250;7.151083 -random,1024,8192,64,bucket32_d16,8.154791,7.893041,2.916583,601624,32,4,0,0.201166,2,pass,6.179417;8.154791 -random,1024,8192,64,bucket32_d64,8.615208,8.365000,3.017708,1152992,24,3,0,0.201166,2,pass,8.615208;7.997125 -clos,200,20000,64,old_pull_serial,5.706792,1.619292,1.043833,10240000,8,1,0,0.331916,2,pass,5.189416;5.706792 -clos,200,20000,64,old_group_serial,4.762833,0.842625,0.300750,3840000,3,1,0,0.331916,2,pass,4.762833;4.495333 -clos,200,20000,64,old_pull_parallel_dag,2.005375,1.468625,0.997625,10240000,8,1,0,0.331916,2,pass,2.005375;1.994625 -clos,200,20000,64,old_group_parallel_dag,1.347666,0.814917,0.300333,3840000,3,1,0,0.331916,2,pass,1.347666;1.050958 -clos,200,20000,64,pull64_barrier,2.337291,1.777542,1.019500,10240000,8,1,0,0.331916,2,pass,1.953625;2.337291 -clos,200,20000,64,pull64_interleaved,3.083042,2.474250,0.781917,10240000,8,1,0,0.331916,2,pass,3.083042;2.732916 -clos,200,20000,64,pull32_interleaved,3.211083,2.578416,0.691917,10240000,8,1,0,0.331916,2,pass,3.045292;3.211083 -clos,200,20000,64,pull32_gpu_dag,3.605125,2.331708,1.013250,10240000,8,3,0,0.331916,2,pass,3.605125;3.307625 -clos,200,20000,64,frontier32_vertex,3.004000,2.469083,0.974750,1280000,8,1,0,0.331916,2,pass,3.001875;3.004000 -clos,200,20000,64,frontier32_simd,2.437875,1.916541,0.362875,1280000,8,1,0,0.331916,2,pass,2.306875;2.437875 -clos,200,20000,64,frontier32_gpu_dag,2.945625,1.741792,0.670125,1280000,8,3,0,0.331916,2,pass,2.945625;2.463542 -clos,200,20000,64,bucket32_d4,2.549875,1.939375,0.487125,1280000,8,1,0,0.331916,2,pass,2.549875;2.447583 -clos,200,20000,64,bucket32_d16,2.159458,1.592041,0.524500,1280000,8,1,0,0.331916,2,pass,2.159458;2.139791 -clos,200,20000,64,bucket32_d64,3.199500,2.611750,0.527125,1280000,8,1,0,0.331916,2,pass,3.199500;2.366292 -grid,1024,3968,1,old_pull_serial,2.712167,2.696750,0.686625,253952,64,8,0,0.145582,2,pass,1.828792;2.712167 -grid,1024,3968,1,old_group_serial,1.282709,1.231000,1.012083,249984,63,1,0,0.145582,2,pass,1.229667;1.282709 -grid,1024,3968,1,old_pull_parallel_dag,1.768000,1.760166,0.187833,253952,64,8,0,0.145582,2,pass,1.345583;1.768000 -grid,1024,3968,1,old_group_parallel_dag,0.379875,0.371959,0.246208,249984,63,1,0,0.145582,2,pass,0.375625;0.379875 -grid,1024,3968,1,pull64_barrier,1.375250,1.367250,0.203750,253952,64,8,0,0.145582,2,pass,1.375250;1.315375 -grid,1024,3968,1,pull64_interleaved,1.326333,1.318167,0.201750,253952,64,8,0,0.145582,2,pass,1.326333;1.325542 -grid,1024,3968,1,pull32_interleaved,1.246292,1.237417,0.182542,253952,64,8,0,0.145582,2,pass,1.236292;1.246292 -grid,1024,3968,1,pull32_gpu_dag,1.628458,1.292333,0.202959,253952,64,10,0,0.145582,2,pass,1.628458;1.429875 -grid,1024,3968,1,frontier32_vertex,1.779750,1.772042,0.598750,3968,64,8,0,0.145582,2,pass,1.779750;1.696375 -grid,1024,3968,1,frontier32_simd,1.476417,1.468625,0.426625,3968,64,8,0,0.145582,2,pass,1.450834;1.476417 -grid,1024,3968,1,frontier32_gpu_dag,1.915375,1.583917,0.483375,3968,64,10,0,0.145582,2,pass,1.723792;1.915375 -grid,1024,3968,1,bucket32_d4,1.720042,1.711833,0.662750,3968,64,8,0,0.145582,2,pass,1.720042;1.676791 -grid,1024,3968,1,bucket32_d16,1.843500,1.835583,0.718250,3968,64,8,0,0.145582,2,pass,1.821583;1.843500 -grid,1024,3968,1,bucket32_d64,1.747583,1.739750,0.714041,3968,64,8,0,0.145582,2,pass,1.747583;1.700250 -hub,1024,3070,1,old_pull_serial,6.406708,6.369959,5.576208,98240,32,4,0,0.121500,2,pass,6.406708;6.319083 -hub,1024,3070,1,old_group_serial,5.014500,4.975250,4.745417,95170,31,1,0,0.121500,2,pass,5.014500;4.864000 -hub,1024,3070,1,old_pull_parallel_dag,6.516958,6.485750,5.764334,98240,32,4,0,0.121500,2,pass,6.261500;6.516958 -hub,1024,3070,1,old_group_parallel_dag,4.937042,4.912500,4.720833,95170,31,1,0,0.121500,2,pass,4.937042;4.885708 -hub,1024,3070,1,pull64_barrier,5.041500,5.016791,4.333042,98240,32,4,0,0.121500,2,pass,5.041500;5.025125 -hub,1024,3070,1,pull64_interleaved,5.097541,5.075000,4.344125,98240,32,4,0,0.121500,2,pass,5.097541;5.007375 -hub,1024,3070,1,pull32_interleaved,4.423209,4.413792,3.648959,98240,32,4,0,0.121500,2,pass,4.343750;4.423209 -hub,1024,3070,1,pull32_gpu_dag,5.119000,4.303000,4.076750,98240,32,6,0,0.121500,2,pass,4.981959;5.119000 -hub,1024,3070,1,frontier32_vertex,1.454292,1.445750,0.863709,3128,32,4,0,0.121500,2,pass,1.454292;1.402708 -hub,1024,3070,1,frontier32_simd,0.815875,0.807209,0.231791,3128,32,4,0,0.121500,2,pass,0.704792;0.815875 -hub,1024,3070,1,frontier32_gpu_dag,1.425708,0.726500,0.648833,3128,32,6,0,0.121500,2,pass,1.425708;1.421167 -hub,1024,3070,1,bucket32_d4,0.915708,0.906917,0.349667,3100,32,4,0,0.121500,2,pass,0.915708;0.836708 -hub,1024,3070,1,bucket32_d16,0.854042,0.845583,0.341000,3124,32,4,0,0.121500,2,pass,0.854042;0.837125 -hub,1024,3070,1,bucket32_d64,0.919958,0.911708,0.330000,3128,32,4,0,0.121500,2,pass,0.833583;0.919958 -random_masked_int64_reverse,1024,8192,8,old_pull_serial,0.575416,0.149542,0.031709,524288,8,1,0,0.203249,2,pass,0.575416;0.490708 -random_masked_int64_reverse,1024,8192,8,old_group_serial,0.507083,0.169958,0.053625,368640,8,1,0,0.203249,2,pass,0.507083;0.506209 -random_masked_int64_reverse,1024,8192,8,old_pull_parallel_dag,0.247417,0.183833,0.031917,524288,8,1,0,0.203249,2,pass,0.218750;0.247417 -random_masked_int64_reverse,1024,8192,8,old_group_parallel_dag,0.238708,0.178333,0.053625,368640,8,1,0,0.203249,2,pass,0.238708;0.229958 -random_masked_int64_reverse,1024,8192,8,pull64_barrier,0.242750,0.191708,0.028667,524288,8,1,0,0.203249,2,pass,0.242750;0.241417 -random_masked_int64_reverse,1024,8192,8,pull64_interleaved,0.806791,0.743125,0.028792,524288,8,1,0,0.203249,2,pass,0.614583;0.806791 -random_masked_int64_reverse,1024,8192,8,pull32_interleaved,0.789459,0.709250,0.028875,524288,8,1,1,0.203249,2,pass,0.789459;0.766166 -random_masked_int64_reverse,1024,8192,8,pull32_gpu_dag,0.975416,0.636041,0.051958,524288,8,3,1,0.203249,2,pass,0.955750;0.975416 -random_masked_int64_reverse,1024,8192,8,frontier32_vertex,0.243542,0.194917,0.029083,524288,8,1,1,0.203249,2,pass,0.240709;0.243542 -random_masked_int64_reverse,1024,8192,8,frontier32_simd,0.286791,0.227750,0.028625,524288,8,1,1,0.203249,2,pass,0.286791;0.234208 -random_masked_int64_reverse,1024,8192,8,frontier32_gpu_dag,0.494750,0.178541,0.046917,524288,8,3,1,0.203249,2,pass,0.468250;0.494750 -random_masked_int64_reverse,1024,8192,8,bucket32_d4,0.255792,0.205375,0.028875,524288,8,1,1,0.203249,2,pass,0.232625;0.255792 -random_masked_int64_reverse,1024,8192,8,bucket32_d16,0.242333,0.199541,0.028667,524288,8,1,1,0.203249,2,pass,0.242333;0.225833 -random_masked_int64_reverse,1024,8192,8,bucket32_d64,0.244958,0.195000,0.028875,524288,8,1,1,0.203249,2,pass,0.231417;0.244958 diff --git a/dev/research/metal_pathfinding/optimization/results/stress-sanitized-stderr.txt b/dev/research/metal_pathfinding/optimization/results/stress-sanitized-stderr.txt deleted file mode 100644 index ecd2dc4..0000000 --- a/dev/research/metal_pathfinding/optimization/results/stress-sanitized-stderr.txt +++ /dev/null @@ -1,3 +0,0 @@ -2026-09-27 00:31:10.901 optimized-sanitize[19987:38715567] Metal API Validation Enabled -2026-09-27 00:31:10.902 optimized-sanitize[19987:38715567] Metal GPU Validation Enabled -device=Apple M4 Max setup_ms=33.4889 execution_width=32 diff --git a/dev/research/metal_pathfinding/optimization/results/stress-sanitized.csv b/dev/research/metal_pathfinding/optimization/results/stress-sanitized.csv deleted file mode 100644 index 868411d..0000000 --- a/dev/research/metal_pathfinding/optimization/results/stress-sanitized.csv +++ /dev/null @@ -1,29 +0,0 @@ -case,n,e,queries,method,wall_ms,distance_ms,gpu_ms,edge_visits,rounds,waits,fallback64,upload_ms,samples,correct,wall_samples_ms -grid,1024,3968,64,old_pull_serial,15.422459,8.780792,4.421000,16252928,64,8,0,0.355707,3,pass,15.767625;12.079667;15.422459 -grid,1024,3968,64,old_group_serial,11.820416,4.917041,3.918292,12066688,63,1,0,0.355707,3,pass,11.266000;11.991708;11.820416 -grid,1024,3968,64,old_pull_parallel_dag,9.401541,8.708916,4.336000,16252928,64,8,0,0.355707,3,pass,5.894791;10.034959;9.401541 -grid,1024,3968,64,old_group_parallel_dag,3.223291,2.277167,1.718958,12066688,63,1,0,0.355707,3,pass,3.223291;6.094583;2.753959 -grid,1024,3968,64,pull64_barrier,9.744500,8.889250,4.838625,16252928,64,8,0,0.355707,3,pass,5.471708;9.744500;9.802958 -grid,1024,3968,64,pull64_interleaved,8.179959,7.435500,3.227125,16252928,64,8,0,0.355707,3,pass,8.179959;9.998542;5.224125 -grid,1024,3968,64,pull32_interleaved,6.581875,4.865416,1.325542,16252928,64,8,0,0.355707,3,pass,5.519000;9.256834;6.581875 -grid,1024,3968,64,pull32_gpu_dag,9.064958,7.880250,4.344791,16252928,64,10,0,0.355707,3,pass,9.542542;5.143375;9.064958 -grid,1024,3968,64,frontier32_vertex,6.553083,5.827417,1.606209,253952,64,8,0,0.355707,3,pass,6.535333;10.212125;6.553083 -grid,1024,3968,64,frontier32_simd,6.107459,5.438333,1.547667,253952,64,8,0,0.355707,3,pass,6.107459;10.554250;5.719167 -grid,1024,3968,64,frontier32_gpu_dag,9.948333,8.881708,4.683959,253952,64,10,0,0.355707,3,pass,5.949833;9.948333;10.264292 -grid,1024,3968,64,bucket32_d4,8.262958,7.353792,2.220125,253952,64,8,0,0.355707,3,pass,11.154000;7.720708;8.262958 -grid,1024,3968,64,bucket32_d16,7.660833,6.970500,2.238875,253952,64,8,0,0.355707,3,pass,8.266375;7.639000;7.660833 -grid,1024,3968,64,bucket32_d64,8.043542,7.205500,2.246292,253952,64,8,0,0.355707,3,pass,8.043542;8.121875;7.920583 -random_masked_reverse,1024,8192,64,old_pull_serial,16.570084,4.646042,2.783208,12582912,24,3,0,0.572584,3,pass,15.608625;16.570084;17.459917 -random_masked_reverse,1024,8192,64,old_group_serial,14.205167,3.149333,2.211167,7053312,19,1,0,0.572584,3,pass,14.687458;14.092833;14.205167 -random_masked_reverse,1024,8192,64,old_pull_parallel_dag,5.958625,5.081458,2.891292,12582912,24,3,0,0.572584,3,pass,5.958625;6.342875;5.587250 -random_masked_reverse,1024,8192,64,old_group_parallel_dag,4.081958,3.046750,2.223625,7053312,19,1,0,0.572584,3,pass,4.123334;4.081958;2.387042 -random_masked_reverse,1024,8192,64,pull64_barrier,3.941667,2.651792,0.613292,12582912,24,3,0,0.572584,3,pass,6.387167;3.941667;3.833542 -random_masked_reverse,1024,8192,64,pull64_interleaved,5.110292,4.362833,2.690750,12582912,24,3,0,0.572584,3,pass,5.082667;5.948709;5.110292 -random_masked_reverse,1024,8192,64,pull32_interleaved,5.086125,4.363625,2.562375,12582912,24,3,0,0.572584,3,pass,5.091459;5.086125;4.956417 -random_masked_reverse,1024,8192,64,pull32_gpu_dag,5.437875,4.244875,3.199833,12582912,24,5,0,0.572584,3,pass,5.315333;5.962417;5.437875 -random_masked_reverse,1024,8192,64,frontier32_vertex,6.363458,5.504500,3.278917,1097250,24,3,0,0.572584,3,pass,6.792125;6.197834;6.363458 -random_masked_reverse,1024,8192,64,frontier32_simd,6.249791,5.481917,3.753459,1014107,24,3,0,0.572584,3,pass,6.805167;6.204208;6.249791 -random_masked_reverse,1024,8192,64,frontier32_gpu_dag,7.568583,5.928750,4.430708,1014586,24,5,0,0.572584,3,pass,6.909084;8.284792;7.568583 -random_masked_reverse,1024,8192,64,bucket32_d4,15.467792,14.422750,8.644958,454224,64,8,0,0.572584,3,pass,15.524417;14.906917;15.467792 -random_masked_reverse,1024,8192,64,bucket32_d16,10.664084,9.545125,5.615625,510827,40,5,0,0.572584,3,pass,10.664084;11.253959;10.204459 -random_masked_reverse,1024,8192,64,bucket32_d64,8.033667,6.966500,4.823583,910477,24,3,0,0.572584,3,pass,8.232083;8.024584;8.033667 diff --git a/dev/research/metal_pathfinding/optimization/results/stress-stderr.txt b/dev/research/metal_pathfinding/optimization/results/stress-stderr.txt deleted file mode 100644 index 3cd5381..0000000 --- a/dev/research/metal_pathfinding/optimization/results/stress-stderr.txt +++ /dev/null @@ -1 +0,0 @@ -device=Apple M4 Max setup_ms=25.9229 execution_width=32 diff --git a/dev/research/metal_pathfinding/optimization/results/stress.csv b/dev/research/metal_pathfinding/optimization/results/stress.csv deleted file mode 100644 index 8354fce..0000000 --- a/dev/research/metal_pathfinding/optimization/results/stress.csv +++ /dev/null @@ -1,29 +0,0 @@ -case,n,e,queries,method,wall_ms,distance_ms,gpu_ms,edge_visits,rounds,waits,fallback64,upload_ms,samples,correct,wall_samples_ms -grid,1024,3968,64,old_pull_serial,4.089792,3.178458,1.539083,16252928,64,8,0,0.147000,20,pass,4.953459;4.561792;4.111417;3.969375;3.928708;4.098333;3.655541;4.089792;3.828166;4.460042;3.954084;3.793875;4.304750;4.094625;3.848875;3.618375;3.582916;4.091500;4.011042;4.603625 -grid,1024,3968,64,old_group_serial,2.049708,1.149625,0.723458,12066688,63,1,0,0.147000,20,pass,2.268917;2.055167;2.052042;2.374875;2.049708;2.363917;2.175417;2.370125;2.364458;2.134958;1.624000;2.040166;1.945542;1.940709;2.009708;1.442833;1.836541;1.865625;1.853000;1.448875 -grid,1024,3968,64,old_pull_parallel_dag,1.712792,1.547584,0.446167,16252928,64,8,0,0.147000,20,pass,1.840084;1.978375;1.690125;1.772375;1.885000;1.737750;1.577500;2.035459;1.716708;1.632125;1.987708;1.609333;1.494292;1.832333;1.700750;1.517792;1.659833;1.712792;1.610166;1.562125 -grid,1024,3968,64,old_group_parallel_dag,0.526708,0.374084,0.203625,12066688,63,1,0,0.147000,20,pass,0.530583;0.471917;0.559791;0.511625;0.482458;0.492542;0.479416;0.467708;0.482292;0.557958;0.506666;0.526708;0.461833;0.473834;0.789708;1.320042;1.248125;1.289875;1.233875;1.272292 -grid,1024,3968,64,pull64_barrier,1.628084,1.488833,0.407416,16252928,64,8,0,0.147000,20,pass,1.657917;1.549500;1.818042;1.596500;1.505667;1.643792;1.528208;1.576709;1.769042;1.764167;1.589500;1.591333;1.725667;1.624042;1.659500;1.628084;1.735958;1.571042;1.521709;1.657583 -grid,1024,3968,64,pull64_interleaved,1.632666,1.483417,0.382416,16252928,64,8,0,0.147000,20,pass,1.652083;1.606208;1.749042;1.648709;1.524667;1.578875;1.636000;1.632666;1.559583;1.668208;1.614084;1.573125;1.602250;1.683750;1.587083;1.657916;1.560500;1.488709;1.707916;1.844125 -grid,1024,3968,64,pull32_interleaved,1.582875,1.441625,0.362167,16252928,64,8,0,0.147000,20,pass,1.574292;1.582875;1.557667;1.794667;1.637125;1.539209;1.800500;1.498875;1.544625;1.634334;1.693666;1.379208;1.534833;1.592500;1.561708;1.472208;1.619458;1.573417;1.680500;1.666375 -grid,1024,3968,64,pull32_gpu_dag,1.834750,1.444292,0.387333,16252928,64,10,0,0.147000,20,pass,1.755042;1.845917;1.922625;1.923042;1.766792;1.818583;1.785292;1.853708;1.711834;2.019625;1.704416;1.824791;2.007458;1.839667;1.810709;1.881250;1.834750;1.772291;2.008792;1.815833 -grid,1024,3968,64,frontier32_vertex,1.841667,1.684000,0.571125,253952,64,8,0,0.147000,20,pass,1.899750;1.652584;1.945083;1.880250;1.692916;1.859542;1.871833;1.708083;1.917417;1.841667;1.914958;1.776750;1.699541;1.757333;1.975333;1.689292;1.753250;1.786292;1.894167;1.771041 -grid,1024,3968,64,frontier32_simd,1.809708,1.652416,0.558792,253952,64,8,0,0.147000,20,pass,1.804666;1.881291;1.820375;1.716083;2.049500;1.731542;1.742291;1.779792;1.809708;1.796458;1.887417;1.924208;1.640917;1.974542;1.877083;1.752584;1.892459;1.871708;1.649458;1.715084 -grid,1024,3968,64,frontier32_gpu_dag,1.883666,1.521208,0.550501,253952,64,10,0,0.147000,20,pass,1.853542;1.777416;1.955459;1.928625;1.870958;1.831166;1.801875;1.827708;1.883666;1.830833;1.827167;2.068084;1.886583;1.859041;1.891458;1.820416;1.944500;1.943500;1.907459;1.894792 -grid,1024,3968,64,bucket32_d4,2.419833,2.295292,1.195626,253952,64,8,0,0.147000,20,pass,2.394959;2.321208;2.272750;2.353125;2.377708;2.373667;2.402083;2.333958;2.419833;2.584083;2.565083;2.469291;2.569208;2.505083;2.290333;2.444084;2.406042;2.496458;2.426833;2.501042 -grid,1024,3968,64,bucket32_d16,2.418208,2.291250,1.193416,253952,64,8,0,0.147000,20,pass,2.527958;2.444500;2.298791;2.458541;2.418208;2.523250;2.407291;2.397042;2.581292;2.416000;2.600083;2.541709;2.422458;2.627208;2.386500;2.406708;2.318333;2.247709;2.413084;2.342416 -grid,1024,3968,64,bucket32_d64,6.132625,5.953208,4.671833,253952,64,8,0,0.147000,20,pass,2.698791;2.489500;2.409000;5.611875;6.176958;5.938083;6.222125;6.132625;6.057000;5.982167;6.731583;6.025333;5.934458;6.034459;6.339333;6.321000;7.091042;6.698084;7.285709;6.397250 -random_masked_reverse,1024,8192,64,old_pull_serial,5.141583,1.975875,1.167791,12582912,24,3,0,0.220624,20,pass,5.182417;4.845667;5.141583;5.061416;5.216667;5.116000;4.892583;5.019583;5.102292;5.079334;5.188291;5.694791;5.078084;5.269833;5.304208;4.971834;5.720209;5.066291;5.289042;5.186333 -random_masked_reverse,1024,8192,64,old_group_serial,4.292708,1.062416,0.590333,7053312,19,1,0,0.220624,20,pass,4.292708;3.832458;4.279750;4.245458;4.724750;5.119917;4.378208;4.438625;4.340041;4.412083;3.921083;4.287417;4.431000;4.262917;4.251250;4.246083;4.338083;3.993708;4.331333;4.264958 -random_masked_reverse,1024,8192,64,old_pull_parallel_dag,1.451333,1.212208,0.285250,12582912,24,3,0,0.220624,20,pass,1.908167;2.010541;2.503792;2.144875;1.389042;1.398459;1.607791;1.591958;1.266167;1.451333;1.415208;1.304375;1.118792;1.493541;0.874459;1.445292;1.447917;1.309208;1.493250;1.507125 -random_masked_reverse,1024,8192,64,old_group_parallel_dag,0.998083,0.775833,0.132333,7053312,19,1,0,0.220624,20,pass,0.979250;1.060583;0.551041;0.525416;0.971833;1.042917;0.529875;0.502917;0.998083;1.183958;0.959042;1.031166;1.199209;1.177500;0.983750;1.295125;1.232791;1.180709;0.984334;0.951708 -random_masked_reverse,1024,8192,64,pull64_barrier,1.291250,1.063333,0.206125,12582912,24,3,0,0.220624,20,pass,1.291250;1.395750;0.836208;1.290958;0.837125;1.189167;1.304625;1.352625;1.290166;1.416500;0.841333;0.867667;1.524709;1.542166;0.878209;0.886834;1.295416;0.852083;1.404292;1.415375 -random_masked_reverse,1024,8192,64,pull64_interleaved,1.374042,1.137334,0.190542,12582912,24,3,0,0.220624,20,pass,2.149167;1.813167;1.735083;1.695833;1.790167;1.884125;1.365292;1.297458;1.499500;1.288708;1.374042;0.861625;1.312417;0.889291;1.339084;1.458292;1.360417;1.260750;1.390708;0.919458 -random_masked_reverse,1024,8192,64,pull32_interleaved,1.282917,1.042333,0.159666,12582912,24,3,0,0.220624,20,pass,1.556209;1.153875;0.867083;1.373334;0.814375;1.282917;1.373042;1.280083;1.208750;1.409459;1.346167;1.277708;0.868333;1.497959;1.235084;1.431458;1.303000;0.885417;0.799916;1.435958 -random_masked_reverse,1024,8192,64,pull32_gpu_dag,1.035416,0.629125,0.190208,12582912,24,5,0,0.220624,20,pass,1.035416;1.046333;0.902708;0.911875;1.551500;1.599083;1.565958;1.568417;1.539000;1.734750;1.021750;0.998041;1.020667;0.967916;0.982083;0.976292;1.115375;0.888125;1.057042;0.971208 -random_masked_reverse,1024,8192,64,frontier32_vertex,1.570459,1.288500,0.412500,1072481,24,3,0,0.220624,20,pass,1.503792;1.633500;1.898250;1.644250;1.534917;1.365958;1.505667;1.105500;1.069958;1.658958;1.575375;1.457208;1.545750;1.205375;1.844875;1.639709;1.641959;1.533375;1.570459;1.642458 -random_masked_reverse,1024,8192,64,frontier32_simd,1.627334,1.355791,0.482417,997903,24,3,0,0.220624,20,pass,1.170250;1.702958;1.746458;1.552334;1.260917;1.627334;1.685833;1.570667;1.759292;1.580625;1.524500;1.693000;1.713500;1.553334;1.677291;1.241209;1.720833;1.476167;1.204750;1.671500 -random_masked_reverse,1024,8192,64,frontier32_gpu_dag,1.322917,0.915250,0.493250,999274,24,5,0,0.220624,20,pass,1.257667;1.398542;1.338167;1.158791;1.274125;1.322917;1.286250;1.569208;1.349292;1.142209;1.357542;1.414125;1.273791;1.240458;1.400250;1.258584;1.372958;1.232792;1.206208;1.336167 -random_masked_reverse,1024,8192,64,bucket32_d4,3.220708,2.936334,1.273167,454235,64,8,0,0.220624,20,pass,2.729834;3.051750;3.382959;3.244417;3.194125;3.178917;2.538958;3.416334;3.014291;3.245834;3.220708;3.263750;3.250875;3.423042;2.997125;3.231250;3.178459;2.711375;3.318209;3.145541 -random_masked_reverse,1024,8192,64,bucket32_d16,2.395875,2.110375,0.831208,509878,40,5,0,0.220624,20,pass,2.395875;2.372125;2.276792;2.404417;1.823500;2.501584;2.192125;2.579084;2.378500;2.543125;2.488792;2.434875;1.823542;2.254542;1.976667;2.757458;2.678667;2.147125;2.155334;2.437291 -random_masked_reverse,1024,8192,64,bucket32_d64,1.798709,1.538250,0.673417,904016,24,3,0,0.220624,20,pass,1.380583;1.801542;1.360542;1.311166;1.762375;1.827250;1.710291;2.149708;1.875625;1.807667;2.009209;1.820833;1.790875;1.500250;1.798709;1.776791;2.323041;1.768750;1.929041;1.412625 diff --git a/dev/research/metal_pathfinding/optimization/results/workspace-check-final.txt b/dev/research/metal_pathfinding/optimization/results/workspace-check-final.txt deleted file mode 100644 index 0e84856..0000000 --- a/dev/research/metal_pathfinding/optimization/results/workspace-check-final.txt +++ /dev/null @@ -1,391 +0,0 @@ -📦 Installing package (editable, default optimizations) -Obtaining file:///Users/networmix/ws/NetGraph-Core - Installing build dependencies: started - Installing build dependencies: finished with status 'done' - Checking if build backend supports build_editable: started - Checking if build backend supports build_editable: finished with status 'done' - Getting requirements to build editable: started - Getting requirements to build editable: finished with status 'done' - Installing backend dependencies: started - Installing backend dependencies: finished with status 'done' - Preparing editable metadata (pyproject.toml): started - Preparing editable metadata (pyproject.toml): finished with status 'done' -Requirement already satisfied: numpy>=1.23.2 in ./venv/lib/python3.14/site-packages (from netgraph-core==0.10.0) (2.5.2) -Building wheels for collected packages: netgraph-core - Building editable for netgraph-core (pyproject.toml): started - Building editable for netgraph-core (pyproject.toml): finished with status 'done' - Created wheel for netgraph-core: filename=netgraph_core-0.10.0-cp314-cp314-macosx_15_0_arm64.whl size=314893 sha256=d2510ae66a03f120aa34f71b3181e2c2bc61d37e63e6dee88b14292916e63b4e - Stored in directory: /private/var/folders/8q/438f_q5s0ys8nlnvjqkf953m0000gn/T/pip-ephem-wheel-cache-m73o7_bm/wheels/18/8c/1a/f701a43f4b69f7c794e11f1f5dd0c17e819d2c98bfd1285718 -Successfully built netgraph-core -Installing collected packages: netgraph-core - Attempting uninstall: netgraph-core - Found existing installation: netgraph-core 0.10.0 - Uninstalling netgraph-core-0.10.0: - Successfully uninstalled netgraph-core-0.10.0 -Successfully installed netgraph-core-0.10.0 -47 files already formatted -All checks passed! -0 errors, 0 warnings, 0 informations -WARNING: there is a new pyright version available (v1.1.401 -> v1.1.414). -Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` - --- pybind11 not found via config; using FetchContent --- pybind11 v3.0.0 --- LTO not supported: CMake doesn't support IPO for current C compiler --- Configuring done (0.6s) --- Generating done (0.0s) --- Build files have been written to: /Users/networmix/ws/NetGraph-Core/build/cpp-tests -ninja: no work to do. -Test project /Users/networmix/ws/NetGraph-Core/build/cpp-tests - Start 141: KShortestPaths.DisconnectedReturnsEmpty - Start 132: MaxFlow.Sensitivity_ShortestPathVsMaxFlow - Start 34: StrictMultiDiGraph.NegativeNodeIdThrows - Start 11: FlowPolicyCore.EqualBalanced_ShortestPath_IgnoresHigherCostTier - 1/170 Test #141: KShortestPaths.DisconnectedReturnsEmpty ........................................... Passed 0.00 sec - Start 152: MaskingTests.MaskedIntermediateNode - 2/170 Test #132: MaxFlow.Sensitivity_ShortestPathVsMaxFlow ......................................... Passed 0.00 sec - 3/170 Test #34: StrictMultiDiGraph.NegativeNodeIdThrows ........................................... Passed 0.00 sec - 4/170 Test #11: FlowPolicyCore.EqualBalanced_ShortestPath_IgnoresHigherCostTier ................... Passed 0.00 sec - Start 9: FlowPolicyCore.EqualBalanced_ShortestPath_ECMP3_SaturatesAllEqualCostPaths - Start 10: FlowPolicyCore.EqualBalanced_ShortestPath_DownstreamSplit_BalancesBottleneck - Start 105: MaxFlow.AsymmetricCapacity_1to100_Proportional - 5/170 Test #152: MaskingTests.MaskedIntermediateNode ............................................... Passed 0.00 sec - Start 154: MaskingTests.MaskedEdgeSelectsAlternativePath - 6/170 Test #9: FlowPolicyCore.EqualBalanced_ShortestPath_ECMP3_SaturatesAllEqualCostPaths ........ Passed 0.00 sec - Start 156: MaskingTests.CombinedNodeAndEdgeMasks - 7/170 Test #10: FlowPolicyCore.EqualBalanced_ShortestPath_DownstreamSplit_BalancesBottleneck ...... Passed 0.00 sec - Start 144: KShortestPaths.LargerOutOfOrderTopology - 8/170 Test #105: MaxFlow.AsymmetricCapacity_1to100_Proportional .................................... Passed 0.00 sec - Start 43: ShortestPaths.PredDAGIntegrity - 9/170 Test #154: MaskingTests.MaskedEdgeSelectsAlternativePath ..................................... Passed 0.00 sec - Start 148: MaskingTests.MaskedSourceInMaxFlow - 10/170 Test #156: MaskingTests.CombinedNodeAndEdgeMasks ............................................. Passed 0.00 sec - Start 118: MaxFlow.OptionalOutputs_ResidualAndReachable - 11/170 Test #144: KShortestPaths.LargerOutOfOrderTopology ........................................... Passed 0.00 sec - Start 33: StrictMultiDiGraph.SelfLoopAllowed - 12/170 Test #43: ShortestPaths.PredDAGIntegrity .................................................... Passed 0.00 sec - Start 8: FlowPolicyCore.DiminishingReturnsCutoff - 13/170 Test #148: MaskingTests.MaskedSourceInMaxFlow ................................................ Passed 0.00 sec - Start 138: KShortestPaths.UniqueModeFiltering - 14/170 Test #118: MaxFlow.OptionalOutputs_ResidualAndReachable ...................................... Passed 0.00 sec - Start 92: FlowGraph.LedgerMicroFlows_RemovalRestoresResidual - 15/170 Test #33: StrictMultiDiGraph.SelfLoopAllowed ................................................ Passed 0.00 sec - Start 83: FlowGraph.EmptyLedgerInitially - 16/170 Test #8: FlowPolicyCore.DiminishingReturnsCutoff ........................................... Passed 0.00 sec - Start 47: ShortestPaths.ResolveToPathsWithMaxPathsLimit - 17/170 Test #138: KShortestPaths.UniqueModeFiltering ................................................ Passed 0.00 sec - Start 67: FlowState.InvalidResidualInitThrows - 18/170 Test #92: FlowGraph.LedgerMicroFlows_RemovalRestoresResidual ................................ Passed 0.00 sec - Start 106: MaxFlow.AsymmetricCapacity_1to1M_NumericalStability - 19/170 Test #83: FlowGraph.EmptyLedgerInitially .................................................... Passed 0.00 sec - Start 1: GraphSmoke.ConstructAndRunSPF - 20/170 Test #47: ShortestPaths.ResolveToPathsWithMaxPathsLimit ..................................... Passed 0.00 sec - Start 17: StrictMultiDiGraph.EmptyGraph - 21/170 Test #67: FlowState.InvalidResidualInitThrows ............................................... Passed 0.00 sec - Start 41: ShortestPaths.EdgeMaskFiltering - 22/170 Test #106: MaxFlow.AsymmetricCapacity_1to1M_NumericalStability ............................... Passed 0.00 sec - Start 89: FlowGraph.GetFlowPathForDAGReturnsEmpty - 23/170 Test #1: GraphSmoke.ConstructAndRunSPF ..................................................... Passed 0.00 sec - Start 104: MaxFlow.ECMP_TenPaths_ScalabilityValidation - 24/170 Test #17: StrictMultiDiGraph.EmptyGraph ..................................................... Passed 0.00 sec - Start 61: FlowState.ZeroCapacityHandling - 25/170 Test #41: ShortestPaths.EdgeMaskFiltering ................................................... Passed 0.00 sec - Start 22: StrictMultiDiGraph.ExternalEdgeIdsPreserved - 26/170 Test #89: FlowGraph.GetFlowPathForDAGReturnsEmpty ........................................... Passed 0.00 sec - Start 155: MaskingTests.MultipleEdgesMasked - 27/170 Test #104: MaxFlow.ECMP_TenPaths_ScalabilityValidation ....................................... Passed 0.00 sec - Start 149: MaskingTests.MaskedSourceInComputeMinCut - 28/170 Test #61: FlowState.ZeroCapacityHandling .................................................... Passed 0.00 sec - Start 109: MaxFlow.ZeroCapacityPath_CorrectFiltering - 29/170 Test #22: StrictMultiDiGraph.ExternalEdgeIdsPreserved ....................................... Passed 0.00 sec - Start 126: MaxFlow.Triangle_Topology_Combine_Mode - 30/170 Test #155: MaskingTests.MultipleEdgesMasked .................................................. Passed 0.00 sec - Start 122: MaxFlow.GridGraph_3x3_MultipleEqualCostPaths - 31/170 Test #149: MaskingTests.MaskedSourceInComputeMinCut .......................................... Passed 0.00 sec - Start 103: MaxFlow.ECMP_FourPaths_ClosFabric_Simulation - 32/170 Test #109: MaxFlow.ZeroCapacityPath_CorrectFiltering ......................................... Passed 0.00 sec - Start 107: MaxFlow.AsymmetricCapacity_1to100_EqualBalanced - 33/170 Test #126: MaxFlow.Triangle_Topology_Combine_Mode ............................................ Passed 0.00 sec - Start 42: ShortestPaths.EarlyExitOptimization - 34/170 Test #122: MaxFlow.GridGraph_3x3_MultipleEqualCostPaths ...................................... Passed 0.00 sec - Start 64: FlowState.MinCutComputation - 35/170 Test #103: MaxFlow.ECMP_FourPaths_ClosFabric_Simulation ...................................... Passed 0.00 sec - Start 75: FlowState.Proportional_ZeroCapacityOnShortestPath_ReturnsZero - 36/170 Test #107: MaxFlow.AsymmetricCapacity_1to100_EqualBalanced ................................... Passed 0.00 sec - Start 29: StrictMultiDiGraph.ExtEdgeIdsWrongLengthThrows - 37/170 Test #42: ShortestPaths.EarlyExitOptimization ............................................... Passed 0.00 sec - Start 135: KShortestPaths.FindsKPaths - 38/170 Test #64: FlowState.MinCutComputation ....................................................... Passed 0.00 sec - Start 32: StrictMultiDiGraph.NegativeCostThrows - 39/170 Test #75: FlowState.Proportional_ZeroCapacityOnShortestPath_ReturnsZero ..................... Passed 0.00 sec - Start 99: MaxFlow.ECMP_TwoPaths_Proportional_ShortestPath - 40/170 Test #29: StrictMultiDiGraph.ExtEdgeIdsWrongLengthThrows .................................... Passed 0.00 sec - Start 21: StrictMultiDiGraph.EdgeSortingByCost - 41/170 Test #135: KShortestPaths.FindsKPaths ........................................................ Passed 0.00 sec - Start 7: FlowPolicyCore.EqualBalanced_SingleDemand_FlowCount3_SaturatesBothPaths - 42/170 Test #32: StrictMultiDiGraph.NegativeCostThrows ............................................. Passed 0.00 sec - Start 37: ShortestPaths.DisconnectedComponents - 43/170 Test #99: MaxFlow.ECMP_TwoPaths_Proportional_ShortestPath ................................... Passed 0.00 sec - Start 69: FlowState.ApplyDeltasClampsWithinCapacityBounds - 44/170 Test #21: StrictMultiDiGraph.EdgeSortingByCost .............................................. Passed 0.00 sec - Start 143: KShortestPaths.OutOfOrderNodePredDAGSemantics - 45/170 Test #7: FlowPolicyCore.EqualBalanced_SingleDemand_FlowCount3_SaturatesBothPaths ........... Passed 0.00 sec - Start 134: MaxFlow.CompletionPhase_RespectsNodeMask - 46/170 Test #37: ShortestPaths.DisconnectedComponents .............................................. Passed 0.00 sec - Start 68: FlowState.ResetWithInvalidResidualThrows - 47/170 Test #69: FlowState.ApplyDeltasClampsWithinCapacityBounds ................................... Passed 0.00 sec - Start 46: ShortestPaths.ResolveToPathsSplittingParallelEdges - 48/170 Test #143: KShortestPaths.OutOfOrderNodePredDAGSemantics ..................................... Passed 0.00 sec - Start 112: MaxFlow.EqualCost_ShortestVsFull_Equivalent - 49/170 Test #134: MaxFlow.CompletionPhase_RespectsNodeMask .......................................... Passed 0.00 sec - Start 157: MaskingTests.NodeMaskOverridesEdgeMask - 50/170 Test #68: FlowState.ResetWithInvalidResidualThrows .......................................... Passed 0.00 sec - Start 133: MaxFlow.CompletionPhase_RecoversCancellation - 51/170 Test #46: ShortestPaths.ResolveToPathsSplittingParallelEdges ................................ Passed 0.00 sec - Start 35: StrictMultiDiGraph.MultipleCostTiers - 52/170 Test #112: MaxFlow.EqualCost_ShortestVsFull_Equivalent ....................................... Passed 0.00 sec - Start 153: MaskingTests.MaskedEdgeBlocksPath - 53/170 Test #157: MaskingTests.NodeMaskOverridesEdgeMask ............................................ Passed 0.00 sec - Start 27: StrictMultiDiGraph.InvalidNumNodesThrows - 54/170 Test #133: MaxFlow.CompletionPhase_RecoversCancellation ...................................... Passed 0.00 sec - Start 71: FlowState.EqualBalanced_ECMP3_FirstHopEqualization - 55/170 Test #35: StrictMultiDiGraph.MultipleCostTiers .............................................. Passed 0.00 sec - Start 114: MaxFlow.PlacementModes_EqualCapacity_IdenticalResult - 56/170 Test #153: MaskingTests.MaskedEdgeBlocksPath ................................................. Passed 0.00 sec - Start 91: FlowGraph.LedgerHistory_RemovalRestoresResidual - 57/170 Test #27: StrictMultiDiGraph.InvalidNumNodesThrows .......................................... Passed 0.00 sec - Start 20: StrictMultiDiGraph.ParallelEdges - 58/170 Test #71: FlowState.EqualBalanced_ECMP3_FirstHopEqualization ................................ Passed 0.00 sec - Start 63: FlowState.ApplyDeltasSubtractive - 59/170 Test #114: MaxFlow.PlacementModes_EqualCapacity_IdenticalResult .............................. Passed 0.00 sec - Start 31: StrictMultiDiGraph.NegativeCapacityThrows - 60/170 Test #91: FlowGraph.LedgerHistory_RemovalRestoresResidual ................................... Passed 0.00 sec - Start 5: FlowPolicyCore.Proportional_SingleDemand_PartialPlacement_InsufficientCapacity - 61/170 Test #20: StrictMultiDiGraph.ParallelEdges .................................................. Passed 0.00 sec - Start 2: FlowPolicyCore.Proportional_SingleDemand_UsesShortestPath - 62/170 Test #63: FlowState.ApplyDeltasSubtractive .................................................. Passed 0.00 sec - Start 121: MaxFlow.BatchMaxFlow_IndependentMaskApplication - 63/170 Test #31: StrictMultiDiGraph.NegativeCapacityThrows ......................................... Passed 0.00 sec - Start 150: MaskingTests.OutOfRangeSourceInComputeMinCut - 64/170 Test #5: FlowPolicyCore.Proportional_SingleDemand_PartialPlacement_InsufficientCapacity .... Passed 0.00 sec - Start 117: MaxFlow.CostDistribution_AccountsForAllFlow - 65/170 Test #2: FlowPolicyCore.Proportional_SingleDemand_UsesShortestPath ......................... Passed 0.00 sec - Start 100: MaxFlow.ECMP_TwoPaths_Proportional_FullMaxFlow - 66/170 Test #150: MaskingTests.OutOfRangeSourceInComputeMinCut ...................................... Passed 0.00 sec - Start 113: MaxFlow.ThreeCostTiers_ShortestPath_LowestTierOnly - 67/170 Test #121: MaxFlow.BatchMaxFlow_IndependentMaskApplication ................................... Passed 0.00 sec - Start 76: FlowState.RepeatedPlacementAfterResetAndIndependentInstancesStayStable - 68/170 Test #117: MaxFlow.CostDistribution_AccountsForAllFlow ....................................... Passed 0.00 sec - Start 23: StrictMultiDiGraph.CSROffsetsMonotonicity - 69/170 Test #100: MaxFlow.ECMP_TwoPaths_Proportional_FullMaxFlow .................................... Passed 0.00 sec - Start 4: FlowPolicyCore.Proportional_SingleDemand_MaxFlowCount1_UsesSinglePath - 70/170 Test #113: MaxFlow.ThreeCostTiers_ShortestPath_LowestTierOnly ................................ Passed 0.00 sec - Start 58: FlowState.PlacementReducesResidual - 71/170 Test #76: FlowState.RepeatedPlacementAfterResetAndIndependentInstancesStayStable ............ Passed 0.00 sec - Start 59: FlowState.ProportionalDistribution - 72/170 Test #23: StrictMultiDiGraph.CSROffsetsMonotonicity ......................................... Passed 0.00 sec - Start 48: ShortestPaths.LargeGraphStressTest - 73/170 Test #4: FlowPolicyCore.Proportional_SingleDemand_MaxFlowCount1_UsesSinglePath ............. Passed 0.00 sec - Start 130: MaxFlow.Sensitivity_TwoParallelPaths - 74/170 Test #58: FlowState.PlacementReducesResidual ................................................ Passed 0.00 sec - Start 6: FlowPolicyCore.EqualBalanced_SingleDemand_FlowCount2_BalancedDistribution - 75/170 Test #59: FlowState.ProportionalDistribution ................................................ Passed 0.00 sec - Start 44: ShortestPaths.TieBreakingDeterminism - 76/170 Test #48: ShortestPaths.LargeGraphStressTest ................................................ Passed 0.00 sec - Start 140: KShortestPaths.PathsSortedByCost - 77/170 Test #130: MaxFlow.Sensitivity_TwoParallelPaths .............................................. Passed 0.00 sec - Start 158: MaskingTests.MaskWithResidualCapacity - 78/170 Test #6: FlowPolicyCore.EqualBalanced_SingleDemand_FlowCount2_BalancedDistribution ......... Passed 0.00 sec - Start 39: ShortestPaths.ResidualAwareFiltering - 79/170 Test #44: ShortestPaths.TieBreakingDeterminism .............................................. Passed 0.00 sec - Start 161: MaskingTests.KSPWithEdgeMask - 80/170 Test #140: KShortestPaths.PathsSortedByCost .................................................. Passed 0.00 sec - Start 49: ShortestPaths.RejectsMaskLengthMismatch - 81/170 Test #158: MaskingTests.MaskWithResidualCapacity ............................................. Passed 0.00 sec - Start 127: MaxFlow.SimpleParallelPaths_Topology - 82/170 Test #39: ShortestPaths.ResidualAwareFiltering .............................................. Passed 0.00 sec - Start 162: MaskingTests.MaxFlowWithNodeMask - 83/170 Test #161: MaskingTests.KSPWithEdgeMask ...................................................... Passed 0.00 sec - Start 137: KShortestPaths.NodeMaskBlocksPaths - 84/170 Test #49: ShortestPaths.RejectsMaskLengthMismatch ........................................... Passed 0.00 sec - Start 18: StrictMultiDiGraph.SingleNode - 85/170 Test #127: MaxFlow.SimpleParallelPaths_Topology .............................................. Passed 0.00 sec - Start 108: MaxFlow.EqualBalanced_NodeMask_ReducesECMPWidth - 86/170 Test #162: MaskingTests.MaxFlowWithNodeMask .................................................. Passed 0.00 sec - Start 88: FlowGraph.GetFlowPathForLinearFlow - 87/170 Test #137: KShortestPaths.NodeMaskBlocksPaths ................................................ Passed 0.00 sec - Start 160: MaskingTests.KSPWithNodeMask - 88/170 Test #18: StrictMultiDiGraph.SingleNode ..................................................... Passed 0.00 sec - Start 50: ShortestPaths.ZeroCostEdges_PredDAGIsAcyclic - 89/170 Test #108: MaxFlow.EqualBalanced_NodeMask_ReducesECMPWidth ................................... Passed 0.00 sec - Start 3: FlowPolicyCore.Proportional_SingleDemand_SplitsAcrossTwoEqualCostPaths - 90/170 Test #88: FlowGraph.GetFlowPathForLinearFlow ................................................ Passed 0.00 sec - Start 142: KShortestPaths.LooplessPaths - 91/170 Test #160: MaskingTests.KSPWithNodeMask ...................................................... Passed 0.00 sec - Start 116: MaxFlow.MinCut_ValidEdgeIdentification - 92/170 Test #50: ShortestPaths.ZeroCostEdges_PredDAGIsAcyclic ...................................... Passed 0.00 sec - Start 36: ShortestPaths.SingleSourceAllNodes - 93/170 Test #3: FlowPolicyCore.Proportional_SingleDemand_SplitsAcrossTwoEqualCostPaths ............ Passed 0.00 sec - Start 38: ShortestPaths.MultipleEqualCostPaths - 94/170 Test #142: KShortestPaths.LooplessPaths ...................................................... Passed 0.00 sec - Start 111: MaxFlow.TwoCostTiers_FullMaxFlow_AllTiers - 95/170 Test #116: MaxFlow.MinCut_ValidEdgeIdentification ............................................ Passed 0.00 sec - Start 136: KShortestPaths.KEqualsZeroReturnsEmpty - 96/170 Test #36: ShortestPaths.SingleSourceAllNodes ................................................ Passed 0.00 sec - Start 24: StrictMultiDiGraph.CSRReverseAdjacency - 97/170 Test #38: ShortestPaths.MultipleEqualCostPaths .............................................. Passed 0.00 sec - Start 97: MaxFlow.SinglePath_ShortestPathEquivalence - 98/170 Test #111: MaxFlow.TwoCostTiers_FullMaxFlow_AllTiers ......................................... Passed 0.00 sec - Start 146: MaskingTests.MaskedSourceReturnsEmptyDAG - 99/170 Test #136: KShortestPaths.KEqualsZeroReturnsEmpty ............................................ Passed 0.00 sec - Start 90: FlowGraph.CoalescesDuplicateEdgeIds -100/170 Test #24: StrictMultiDiGraph.CSRReverseAdjacency ............................................ Passed 0.00 sec - Start 28: StrictMultiDiGraph.MismatchedArrayLengthsThrow -101/170 Test #97: MaxFlow.SinglePath_ShortestPathEquivalence ........................................ Passed 0.00 sec - Start 129: MaxFlow.Sensitivity_SinglePath -102/170 Test #146: MaskingTests.MaskedSourceReturnsEmptyDAG .......................................... Passed 0.00 sec - Start 131: MaxFlow.Sensitivity_PartialSaturation -103/170 Test #90: FlowGraph.CoalescesDuplicateEdgeIds ............................................... Passed 0.00 sec - Start 110: MaxFlow.TwoCostTiers_ShortestPath_SingleTierOnly -104/170 Test #28: StrictMultiDiGraph.MismatchedArrayLengthsThrow .................................... Passed 0.00 sec - Start 124: MaxFlow.Clos_3Tier_Topology_Proportional_Shortest -105/170 Test #129: MaxFlow.Sensitivity_SinglePath .................................................... Passed 0.00 sec - Start 120: MaxFlow.EdgeMask_SelectiveEdgeFiltering -106/170 Test #131: MaxFlow.Sensitivity_PartialSaturation ............................................. Passed 0.00 sec - Start 45: ShortestPaths.PreferHigherResidualTieBreak -107/170 Test #124: MaxFlow.Clos_3Tier_Topology_Proportional_Shortest ................................. Passed 0.00 sec - Start 123: MaxFlow.SharedBottleneck_MultiplePathsMerge -108/170 Test #110: MaxFlow.TwoCostTiers_ShortestPath_SingleTierOnly .................................. Passed 0.00 sec - Start 57: FlowState.InitialResidualEqualsCapacity -109/170 Test #120: MaxFlow.EdgeMask_SelectiveEdgeFiltering ........................................... Passed 0.00 sec - Start 25: StrictMultiDiGraph.MaxCapacityEdges -110/170 Test #45: ShortestPaths.PreferHigherResidualTieBreak ........................................ Passed 0.00 sec - Start 40: ShortestPaths.NodeMaskIsolation -111/170 Test #123: MaxFlow.SharedBottleneck_MultiplePathsMerge ....................................... Passed 0.00 sec - Start 125: MaxFlow.Clos_3Tier_Topology_EqualBalanced_Shortest -112/170 Test #57: FlowState.InitialResidualEqualsCapacity ........................................... Passed 0.00 sec - Start 128: MaxFlow.ShortestPath_MustSaturateAllEqualCostPaths -113/170 Test #25: StrictMultiDiGraph.MaxCapacityEdges ............................................... Passed 0.00 sec - Start 74: FlowState.EqualBalanced_ZeroCapacityOnShortestPath_ReturnsZero -114/170 Test #40: ShortestPaths.NodeMaskIsolation ................................................... Passed 0.00 sec - Start 159: MaskingTests.ResidualAndNodeMaskCombined -115/170 Test #125: MaxFlow.Clos_3Tier_Topology_EqualBalanced_Shortest ................................ Passed 0.00 sec - Start 147: MaskingTests.MaskedSourceWithDestination -116/170 Test #128: MaxFlow.ShortestPath_MustSaturateAllEqualCostPaths ................................ Passed 0.00 sec - Start 70: FlowState.EqualBalancedReconvergentDAGPlacesAll -117/170 Test #74: FlowState.EqualBalanced_ZeroCapacityOnShortestPath_ReturnsZero .................... Passed 0.00 sec - Start 84: FlowGraph.PlacementCreatesEntry -118/170 Test #159: MaskingTests.ResidualAndNodeMaskCombined .......................................... Passed 0.00 sec - Start 30: StrictMultiDiGraph.OutOfRangeNodeThrows -119/170 Test #147: MaskingTests.MaskedSourceWithDestination .......................................... Passed 0.00 sec - Start 62: FlowState.ApplyDeltasAdditive -120/170 Test #70: FlowState.EqualBalancedReconvergentDAGPlacesAll ................................... Passed 0.00 sec - Start 65: FlowState.ResetRestoresCapacity -121/170 Test #84: FlowGraph.PlacementCreatesEntry ................................................... Passed 0.00 sec - Start 151: MaskingTests.MaskedDestinationUnreachable -122/170 Test #30: StrictMultiDiGraph.OutOfRangeNodeThrows ........................................... Passed 0.00 sec - Start 60: FlowState.EqualBalancedDistribution -123/170 Test #62: FlowState.ApplyDeltasAdditive ..................................................... Passed 0.00 sec - Start 139: KShortestPaths.MaxCostFactorLimit -124/170 Test #65: FlowState.ResetRestoresCapacity ................................................... Passed 0.00 sec - Start 19: StrictMultiDiGraph.SingleEdge -125/170 Test #151: MaskingTests.MaskedDestinationUnreachable ......................................... Passed 0.00 sec - Start 96: MaxFlow.SinglePath_BottleneckConstraint -126/170 Test #60: FlowState.EqualBalancedDistribution ............................................... Passed 0.00 sec - Start 66: FlowState.CustomInitialResidual -127/170 Test #139: KShortestPaths.MaxCostFactorLimit ................................................. Passed 0.00 sec - Start 115: MaxFlow.FlowConservation_IntermediateNodes -128/170 Test #19: StrictMultiDiGraph.SingleEdge ..................................................... Passed 0.00 sec - Start 101: MaxFlow.ECMP_TwoPaths_EqualBalanced_ShortestPath -129/170 Test #96: MaxFlow.SinglePath_BottleneckConstraint ........................................... Passed 0.00 sec - Start 102: MaxFlow.ECMP_ThreePaths_Proportional_ShortestPath -130/170 Test #66: FlowState.CustomInitialResidual ................................................... Passed 0.00 sec - Start 26: StrictMultiDiGraph.ZeroCostEdges -131/170 Test #115: MaxFlow.FlowConservation_IntermediateNodes ........................................ Passed 0.00 sec - Start 72: FlowState.EqualBalanced_MinFlowGating_DoesNotPruneValidParallelEdges -132/170 Test #101: MaxFlow.ECMP_TwoPaths_EqualBalanced_ShortestPath .................................. Passed 0.00 sec - Start 95: MaxFlow.SinglePath_BasicLinear -133/170 Test #102: MaxFlow.ECMP_ThreePaths_Proportional_ShortestPath ................................. Passed 0.00 sec - Start 98: MaxFlow.DegenerateCase_SourceEqualsSink -134/170 Test #26: StrictMultiDiGraph.ZeroCostEdges .................................................. Passed 0.00 sec - Start 86: FlowGraph.RemoveByClassFiltersCorrectly -135/170 Test #72: FlowState.EqualBalanced_MinFlowGating_DoesNotPruneValidParallelEdges .............. Passed 0.00 sec - Start 73: FlowState.EqualBalanced_DownstreamSplitEqualization -136/170 Test #95: MaxFlow.SinglePath_BasicLinear .................................................... Passed 0.00 sec - Start 119: MaxFlow.NodeMask_BlocksFlowThroughNode -137/170 Test #98: MaxFlow.DegenerateCase_SourceEqualsSink ........................................... Passed 0.00 sec - Start 85: FlowGraph.RemovalDeletesEntry -138/170 Test #86: FlowGraph.RemoveByClassFiltersCorrectly ........................................... Passed 0.00 sec - Start 87: FlowGraph.GetFlowEdgesReturnsDeltas -139/170 Test #73: FlowState.EqualBalanced_DownstreamSplitEqualization ............................... Passed 0.00 sec - Start 163: MaskingTests.MaxFlowWithEdgeMask -140/170 Test #119: MaxFlow.NodeMask_BlocksFlowThroughNode ............................................ Passed 0.00 sec - Start 164: MaskingTests.MaxFlowAllPathsMasked -141/170 Test #85: FlowGraph.RemovalDeletesEntry ..................................................... Passed 0.00 sec - Start 165: MaskingTests.EmptyNodeMaskAllowsAllNodes -142/170 Test #87: FlowGraph.GetFlowEdgesReturnsDeltas ............................................... Passed 0.00 sec - Start 12: FlowPolicyStatic.PerFlowBundleBinding -143/170 Test #163: MaskingTests.MaxFlowWithEdgeMask .................................................. Passed 0.00 sec - Start 169: MaskingTests.AlgorithmsAPIMaskedSource -144/170 Test #164: MaskingTests.MaxFlowAllPathsMasked ................................................ Passed 0.00 sec - Start 13: FlowPolicyStatic.MaskedBundleIsDown -145/170 Test #165: MaskingTests.EmptyNodeMaskAllowsAllNodes .......................................... Passed 0.00 sec - Start 15: FlowPolicyCostBounds.MaxPathCostFactor_UnreachableDst_KeepsSentinelOutOfTheCast -146/170 Test #12: FlowPolicyStatic.PerFlowBundleBinding ............................................. Passed 0.00 sec - Start 14: FlowPolicyStatic.ValidationRejectsForeignAndDisconnectedBundles -147/170 Test #169: MaskingTests.AlgorithmsAPIMaskedSource ............................................ Passed 0.00 sec - Start 55: ShortestPathsTo.FanoutFromInteriorNodeIsRejected -148/170 Test #13: FlowPolicyStatic.MaskedBundleIsDown ............................................... Passed 0.00 sec - Start 52: ShortestPathsTo.ForwardSpfFromPseudoSourceKeepsOnlyNearestSource -149/170 Test #15: FlowPolicyCostBounds.MaxPathCostFactor_UnreachableDst_KeepsSentinelOutOfTheCast ... Passed 0.00 sec - Start 166: MaskingTests.EmptyEdgeMaskAllowsAllEdges -150/170 Test #14: FlowPolicyStatic.ValidationRejectsForeignAndDisconnectedBundles ................... Passed 0.00 sec - Start 16: FlowPolicyCostBounds.MaxPathCostFactor_HugeProduct_DropsBoundInsteadOfWrapping -151/170 Test #55: ShortestPathsTo.FanoutFromInteriorNodeIsRejected .................................. Passed 0.00 sec - Start 51: ShortestPathsTo.DistancesMatchForwardDistancesToDst -152/170 Test #52: ShortestPathsTo.ForwardSpfFromPseudoSourceKeepsOnlyNearestSource .................. Passed 0.00 sec - Start 94: FlowGraphStamp.EqualVersionDivergentCopiesCompareUnequal -153/170 Test #166: MaskingTests.EmptyEdgeMaskAllowsAllEdges .......................................... Passed 0.00 sec - Start 81: FlowState.EqualBalancedLossy_InfiniteRequestFillsWithoutDrops -154/170 Test #16: FlowPolicyCostBounds.MaxPathCostFactor_HugeProduct_DropsBoundInsteadOfWrapping .... Passed 0.00 sec - Start 79: FlowState.EqualBalancedLossy_FillAndDropReportsDrops -155/170 Test #51: ShortestPathsTo.DistancesMatchForwardDistancesToDst ............................... Passed 0.00 sec - Start 170: MaskingTests.AlgorithmsAPIMaxFlowWithMask -156/170 Test #94: FlowGraphStamp.EqualVersionDivergentCopiesCompareUnequal .......................... Passed 0.00 sec - Start 82: FlowGraph.LossyPlacementIsReversibleAndDropsStayOutOfLedger -157/170 Test #81: FlowState.EqualBalancedLossy_InfiniteRequestFillsWithoutDrops ..................... Passed 0.00 sec - Start 167: MaskingTests.AllNodesMaskedNoReachability -158/170 Test #79: FlowState.EqualBalancedLossy_FillAndDropReportsDrops .............................. Passed 0.00 sec - Start 168: MaskingTests.AllEdgesMaskedNoReachability -159/170 Test #170: MaskingTests.AlgorithmsAPIMaxFlowWithMask ......................................... Passed 0.00 sec - Start 80: FlowState.EqualBalancedLossy_DeficitPropagatesDownstream -160/170 Test #82: FlowGraph.LossyPlacementIsReversibleAndDropsStayOutOfLedger ....................... Passed 0.00 sec - Start 53: ShortestPathsTo.FanoutEdgesForceEverySourceIntoTheDag -161/170 Test #167: MaskingTests.AllNodesMaskedNoReachability ......................................... Passed 0.00 sec - Start 93: FlowGraphStamp.CopiesAndMovesGetFreshUids -162/170 Test #168: MaskingTests.AllEdgesMaskedNoReachability ......................................... Passed 0.00 sec - Start 145: KSP.MaxCostFactorBelowOne_ReturnsEmptyForAllK -163/170 Test #80: FlowState.EqualBalancedLossy_DeficitPropagatesDownstream .......................... Passed 0.00 sec - Start 54: ShortestPathsTo.FanoutSkipsUnreachableAndMaskedHeads -164/170 Test #53: ShortestPathsTo.FanoutEdgesForceEverySourceIntoTheDag ............................. Passed 0.00 sec - Start 77: FlowState.EqualBalanced_SaturatedMemberBlocksAdmission -165/170 Test #93: FlowGraphStamp.CopiesAndMovesGetFreshUids ......................................... Passed 0.00 sec - Start 78: FlowState.EqualBalanced_ProgressesOnlyThroughResidualAwareSpf -166/170 Test #145: KSP.MaxCostFactorBelowOne_ReturnsEmptyForAllK ..................................... Passed 0.00 sec - Start 56: ShortestPathsTo.SinglePathModeKeepsOneSuccessorPerNode -167/170 Test #54: ShortestPathsTo.FanoutSkipsUnreachableAndMaskedHeads .............................. Passed 0.00 sec -168/170 Test #77: FlowState.EqualBalanced_SaturatedMemberBlocksAdmission ............................ Passed 0.00 sec -169/170 Test #78: FlowState.EqualBalanced_ProgressesOnlyThroughResidualAwareSpf ..................... Passed 0.00 sec -170/170 Test #56: ShortestPathsTo.SinglePathModeKeepsOneSuccessorPerNode ............................ Passed 0.00 sec - -100% tests passed out of 170 - -Total Test time (real) = 0.10 sec -........................................................................ [ 15%] -........................................................................ [ 30%] -........................................................................ [ 46%] -........................................................................ [ 61%] -........................................................................ [ 77%] -........................................................................ [ 92%] -................................. [100%] -465 passed in 3.88s diff --git a/dev/research/metal_pathfinding/optimization/results/workspace-check-format-failure.txt b/dev/research/metal_pathfinding/optimization/results/workspace-check-format-failure.txt deleted file mode 100644 index 5ae98d9..0000000 --- a/dev/research/metal_pathfinding/optimization/results/workspace-check-format-failure.txt +++ /dev/null @@ -1,29 +0,0 @@ -📦 Installing package (editable, default optimizations) -Obtaining file:///Users/networmix/ws/NetGraph-Core - Installing build dependencies: started - Installing build dependencies: finished with status 'done' - Checking if build backend supports build_editable: started - Checking if build backend supports build_editable: finished with status 'done' - Getting requirements to build editable: started - Getting requirements to build editable: finished with status 'done' - Installing backend dependencies: started - Installing backend dependencies: finished with status 'done' - Preparing editable metadata (pyproject.toml): started - Preparing editable metadata (pyproject.toml): finished with status 'done' -Requirement already satisfied: numpy>=1.23.2 in ./venv/lib/python3.14/site-packages (from netgraph-core==0.10.0) (2.5.2) -Building wheels for collected packages: netgraph-core - Building editable for netgraph-core (pyproject.toml): started - Building editable for netgraph-core (pyproject.toml): finished with status 'done' - Created wheel for netgraph-core: filename=netgraph_core-0.10.0-cp314-cp314-macosx_15_0_arm64.whl size=314893 sha256=45dc943b94205d14b496b501ddece08674343bf5cd918dad537b691749b3e16c - Stored in directory: /private/var/folders/8q/438f_q5s0ys8nlnvjqkf953m0000gn/T/pip-ephem-wheel-cache-jhm8vyb3/wheels/18/8c/1a/f701a43f4b69f7c794e11f1f5dd0c17e819d2c98bfd1285718 -Successfully built netgraph-core -Installing collected packages: netgraph-core - Attempting uninstall: netgraph-core - Found existing installation: netgraph-core 0.10.0 - Uninstalling netgraph-core-0.10.0: - Successfully uninstalled netgraph-core-0.10.0 -Successfully installed netgraph-core-0.10.0 -Would reformat: dev/research/metal_pathfinding/optimization/summarize.py -1 file would be reformatted, 45 files already formatted -make[1]: *** [lint] Error 1 -make: *** [check-ci] Error 2 diff --git a/dev/research/metal_pathfinding/optimization/results/workspace-check.txt b/dev/research/metal_pathfinding/optimization/results/workspace-check.txt deleted file mode 100644 index 63a2578..0000000 --- a/dev/research/metal_pathfinding/optimization/results/workspace-check.txt +++ /dev/null @@ -1,391 +0,0 @@ -📦 Installing package (editable, default optimizations) -Obtaining file:///Users/networmix/ws/NetGraph-Core - Installing build dependencies: started - Installing build dependencies: finished with status 'done' - Checking if build backend supports build_editable: started - Checking if build backend supports build_editable: finished with status 'done' - Getting requirements to build editable: started - Getting requirements to build editable: finished with status 'done' - Installing backend dependencies: started - Installing backend dependencies: finished with status 'done' - Preparing editable metadata (pyproject.toml): started - Preparing editable metadata (pyproject.toml): finished with status 'done' -Requirement already satisfied: numpy>=1.23.2 in ./venv/lib/python3.14/site-packages (from netgraph-core==0.10.0) (2.5.2) -Building wheels for collected packages: netgraph-core - Building editable for netgraph-core (pyproject.toml): started - Building editable for netgraph-core (pyproject.toml): finished with status 'done' - Created wheel for netgraph-core: filename=netgraph_core-0.10.0-cp314-cp314-macosx_15_0_arm64.whl size=314893 sha256=d1b60157a21593b4395cd742e5e77836464e64204d8e6696eaad98b9b46fcad9 - Stored in directory: /private/var/folders/8q/438f_q5s0ys8nlnvjqkf953m0000gn/T/pip-ephem-wheel-cache-pakafcm5/wheels/18/8c/1a/f701a43f4b69f7c794e11f1f5dd0c17e819d2c98bfd1285718 -Successfully built netgraph-core -Installing collected packages: netgraph-core - Attempting uninstall: netgraph-core - Found existing installation: netgraph-core 0.10.0 - Uninstalling netgraph-core-0.10.0: - Successfully uninstalled netgraph-core-0.10.0 -Successfully installed netgraph-core-0.10.0 -46 files already formatted -All checks passed! -0 errors, 0 warnings, 0 informations -WARNING: there is a new pyright version available (v1.1.401 -> v1.1.414). -Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` - --- pybind11 not found via config; using FetchContent --- pybind11 v3.0.0 --- LTO not supported: CMake doesn't support IPO for current C compiler --- Configuring done (0.5s) --- Generating done (0.0s) --- Build files have been written to: /Users/networmix/ws/NetGraph-Core/build/cpp-tests -ninja: no work to do. -Test project /Users/networmix/ws/NetGraph-Core/build/cpp-tests - Start 141: KShortestPaths.DisconnectedReturnsEmpty - Start 132: MaxFlow.Sensitivity_ShortestPathVsMaxFlow - Start 34: StrictMultiDiGraph.NegativeNodeIdThrows - Start 11: FlowPolicyCore.EqualBalanced_ShortestPath_IgnoresHigherCostTier - 1/170 Test #141: KShortestPaths.DisconnectedReturnsEmpty ........................................... Passed 0.04 sec - 2/170 Test #34: StrictMultiDiGraph.NegativeNodeIdThrows ........................................... Passed 0.03 sec - Start 152: MaskingTests.MaskedIntermediateNode - Start 9: FlowPolicyCore.EqualBalanced_ShortestPath_ECMP3_SaturatesAllEqualCostPaths - 3/170 Test #132: MaxFlow.Sensitivity_ShortestPathVsMaxFlow ......................................... Passed 0.04 sec - 4/170 Test #11: FlowPolicyCore.EqualBalanced_ShortestPath_IgnoresHigherCostTier ................... Passed 0.04 sec - Start 10: FlowPolicyCore.EqualBalanced_ShortestPath_DownstreamSplit_BalancesBottleneck - Start 105: MaxFlow.AsymmetricCapacity_1to100_Proportional - 5/170 Test #152: MaskingTests.MaskedIntermediateNode ............................................... Passed 0.00 sec - Start 154: MaskingTests.MaskedEdgeSelectsAlternativePath - 6/170 Test #9: FlowPolicyCore.EqualBalanced_ShortestPath_ECMP3_SaturatesAllEqualCostPaths ........ Passed 0.00 sec - Start 156: MaskingTests.CombinedNodeAndEdgeMasks - 7/170 Test #10: FlowPolicyCore.EqualBalanced_ShortestPath_DownstreamSplit_BalancesBottleneck ...... Passed 0.00 sec - Start 144: KShortestPaths.LargerOutOfOrderTopology - 8/170 Test #105: MaxFlow.AsymmetricCapacity_1to100_Proportional .................................... Passed 0.00 sec - Start 43: ShortestPaths.PredDAGIntegrity - 9/170 Test #154: MaskingTests.MaskedEdgeSelectsAlternativePath ..................................... Passed 0.00 sec - Start 148: MaskingTests.MaskedSourceInMaxFlow - 10/170 Test #156: MaskingTests.CombinedNodeAndEdgeMasks ............................................. Passed 0.00 sec - Start 118: MaxFlow.OptionalOutputs_ResidualAndReachable - 11/170 Test #144: KShortestPaths.LargerOutOfOrderTopology ........................................... Passed 0.00 sec - Start 33: StrictMultiDiGraph.SelfLoopAllowed - 12/170 Test #43: ShortestPaths.PredDAGIntegrity .................................................... Passed 0.00 sec - Start 8: FlowPolicyCore.DiminishingReturnsCutoff - 13/170 Test #148: MaskingTests.MaskedSourceInMaxFlow ................................................ Passed 0.00 sec - Start 138: KShortestPaths.UniqueModeFiltering - 14/170 Test #118: MaxFlow.OptionalOutputs_ResidualAndReachable ...................................... Passed 0.00 sec - Start 92: FlowGraph.LedgerMicroFlows_RemovalRestoresResidual - 15/170 Test #33: StrictMultiDiGraph.SelfLoopAllowed ................................................ Passed 0.00 sec - Start 83: FlowGraph.EmptyLedgerInitially - 16/170 Test #8: FlowPolicyCore.DiminishingReturnsCutoff ........................................... Passed 0.00 sec - Start 47: ShortestPaths.ResolveToPathsWithMaxPathsLimit - 17/170 Test #138: KShortestPaths.UniqueModeFiltering ................................................ Passed 0.00 sec - Start 67: FlowState.InvalidResidualInitThrows - 18/170 Test #92: FlowGraph.LedgerMicroFlows_RemovalRestoresResidual ................................ Passed 0.00 sec - Start 106: MaxFlow.AsymmetricCapacity_1to1M_NumericalStability - 19/170 Test #83: FlowGraph.EmptyLedgerInitially .................................................... Passed 0.00 sec - Start 17: StrictMultiDiGraph.EmptyGraph - 20/170 Test #47: ShortestPaths.ResolveToPathsWithMaxPathsLimit ..................................... Passed 0.00 sec - Start 1: GraphSmoke.ConstructAndRunSPF - 21/170 Test #67: FlowState.InvalidResidualInitThrows ............................................... Passed 0.00 sec - Start 41: ShortestPaths.EdgeMaskFiltering - 22/170 Test #106: MaxFlow.AsymmetricCapacity_1to1M_NumericalStability ............................... Passed 0.00 sec - Start 89: FlowGraph.GetFlowPathForDAGReturnsEmpty - 23/170 Test #17: StrictMultiDiGraph.EmptyGraph ..................................................... Passed 0.00 sec - Start 104: MaxFlow.ECMP_TenPaths_ScalabilityValidation - 24/170 Test #1: GraphSmoke.ConstructAndRunSPF ..................................................... Passed 0.00 sec - Start 61: FlowState.ZeroCapacityHandling - 25/170 Test #41: ShortestPaths.EdgeMaskFiltering ................................................... Passed 0.00 sec - Start 22: StrictMultiDiGraph.ExternalEdgeIdsPreserved - 26/170 Test #89: FlowGraph.GetFlowPathForDAGReturnsEmpty ........................................... Passed 0.00 sec - Start 149: MaskingTests.MaskedSourceInComputeMinCut - 27/170 Test #104: MaxFlow.ECMP_TenPaths_ScalabilityValidation ....................................... Passed 0.00 sec - Start 126: MaxFlow.Triangle_Topology_Combine_Mode - 28/170 Test #61: FlowState.ZeroCapacityHandling .................................................... Passed 0.00 sec - Start 103: MaxFlow.ECMP_FourPaths_ClosFabric_Simulation - 29/170 Test #22: StrictMultiDiGraph.ExternalEdgeIdsPreserved ....................................... Passed 0.00 sec - Start 107: MaxFlow.AsymmetricCapacity_1to100_EqualBalanced - 30/170 Test #149: MaskingTests.MaskedSourceInComputeMinCut .......................................... Passed 0.00 sec - Start 109: MaxFlow.ZeroCapacityPath_CorrectFiltering - 31/170 Test #126: MaxFlow.Triangle_Topology_Combine_Mode ............................................ Passed 0.00 sec - Start 155: MaskingTests.MultipleEdgesMasked - 32/170 Test #103: MaxFlow.ECMP_FourPaths_ClosFabric_Simulation ...................................... Passed 0.00 sec - Start 122: MaxFlow.GridGraph_3x3_MultipleEqualCostPaths - 33/170 Test #107: MaxFlow.AsymmetricCapacity_1to100_EqualBalanced ................................... Passed 0.00 sec - Start 42: ShortestPaths.EarlyExitOptimization - 34/170 Test #109: MaxFlow.ZeroCapacityPath_CorrectFiltering ......................................... Passed 0.00 sec - Start 64: FlowState.MinCutComputation - 35/170 Test #155: MaskingTests.MultipleEdgesMasked .................................................. Passed 0.00 sec - 36/170 Test #122: MaxFlow.GridGraph_3x3_MultipleEqualCostPaths ...................................... Passed 0.00 sec - Start 75: FlowState.Proportional_ZeroCapacityOnShortestPath_ReturnsZero - Start 29: StrictMultiDiGraph.ExtEdgeIdsWrongLengthThrows - 37/170 Test #42: ShortestPaths.EarlyExitOptimization ............................................... Passed 0.00 sec - Start 32: StrictMultiDiGraph.NegativeCostThrows - 38/170 Test #64: FlowState.MinCutComputation ....................................................... Passed 0.00 sec - Start 135: KShortestPaths.FindsKPaths - 39/170 Test #75: FlowState.Proportional_ZeroCapacityOnShortestPath_ReturnsZero ..................... Passed 0.00 sec - Start 99: MaxFlow.ECMP_TwoPaths_Proportional_ShortestPath - 40/170 Test #29: StrictMultiDiGraph.ExtEdgeIdsWrongLengthThrows .................................... Passed 0.00 sec - Start 21: StrictMultiDiGraph.EdgeSortingByCost - 41/170 Test #32: StrictMultiDiGraph.NegativeCostThrows ............................................. Passed 0.00 sec - Start 7: FlowPolicyCore.EqualBalanced_SingleDemand_FlowCount3_SaturatesBothPaths - 42/170 Test #135: KShortestPaths.FindsKPaths ........................................................ Passed 0.00 sec - Start 37: ShortestPaths.DisconnectedComponents - 43/170 Test #99: MaxFlow.ECMP_TwoPaths_Proportional_ShortestPath ................................... Passed 0.00 sec - Start 69: FlowState.ApplyDeltasClampsWithinCapacityBounds - 44/170 Test #21: StrictMultiDiGraph.EdgeSortingByCost .............................................. Passed 0.00 sec - Start 143: KShortestPaths.OutOfOrderNodePredDAGSemantics - 45/170 Test #7: FlowPolicyCore.EqualBalanced_SingleDemand_FlowCount3_SaturatesBothPaths ........... Passed 0.00 sec - Start 134: MaxFlow.CompletionPhase_RespectsNodeMask - 46/170 Test #37: ShortestPaths.DisconnectedComponents .............................................. Passed 0.00 sec - Start 68: FlowState.ResetWithInvalidResidualThrows - 47/170 Test #69: FlowState.ApplyDeltasClampsWithinCapacityBounds ................................... Passed 0.00 sec - Start 46: ShortestPaths.ResolveToPathsSplittingParallelEdges - 48/170 Test #143: KShortestPaths.OutOfOrderNodePredDAGSemantics ..................................... Passed 0.00 sec - Start 112: MaxFlow.EqualCost_ShortestVsFull_Equivalent - 49/170 Test #134: MaxFlow.CompletionPhase_RespectsNodeMask .......................................... Passed 0.00 sec - Start 157: MaskingTests.NodeMaskOverridesEdgeMask - 50/170 Test #68: FlowState.ResetWithInvalidResidualThrows .......................................... Passed 0.00 sec - Start 153: MaskingTests.MaskedEdgeBlocksPath - 51/170 Test #46: ShortestPaths.ResolveToPathsSplittingParallelEdges ................................ Passed 0.00 sec - Start 133: MaxFlow.CompletionPhase_RecoversCancellation - 52/170 Test #112: MaxFlow.EqualCost_ShortestVsFull_Equivalent ....................................... Passed 0.00 sec - Start 35: StrictMultiDiGraph.MultipleCostTiers - 53/170 Test #157: MaskingTests.NodeMaskOverridesEdgeMask ............................................ Passed 0.00 sec - Start 71: FlowState.EqualBalanced_ECMP3_FirstHopEqualization - 54/170 Test #153: MaskingTests.MaskedEdgeBlocksPath ................................................. Passed 0.00 sec - Start 27: StrictMultiDiGraph.InvalidNumNodesThrows - 55/170 Test #133: MaxFlow.CompletionPhase_RecoversCancellation ...................................... Passed 0.00 sec - Start 114: MaxFlow.PlacementModes_EqualCapacity_IdenticalResult - 56/170 Test #35: StrictMultiDiGraph.MultipleCostTiers .............................................. Passed 0.00 sec - Start 91: FlowGraph.LedgerHistory_RemovalRestoresResidual - 57/170 Test #71: FlowState.EqualBalanced_ECMP3_FirstHopEqualization ................................ Passed 0.00 sec - Start 20: StrictMultiDiGraph.ParallelEdges - 58/170 Test #27: StrictMultiDiGraph.InvalidNumNodesThrows .......................................... Passed 0.00 sec - Start 63: FlowState.ApplyDeltasSubtractive - 59/170 Test #114: MaxFlow.PlacementModes_EqualCapacity_IdenticalResult .............................. Passed 0.00 sec - Start 31: StrictMultiDiGraph.NegativeCapacityThrows - 60/170 Test #91: FlowGraph.LedgerHistory_RemovalRestoresResidual ................................... Passed 0.00 sec - Start 2: FlowPolicyCore.Proportional_SingleDemand_UsesShortestPath - 61/170 Test #20: StrictMultiDiGraph.ParallelEdges .................................................. Passed 0.00 sec - Start 5: FlowPolicyCore.Proportional_SingleDemand_PartialPlacement_InsufficientCapacity - 62/170 Test #63: FlowState.ApplyDeltasSubtractive .................................................. Passed 0.00 sec - Start 150: MaskingTests.OutOfRangeSourceInComputeMinCut - 63/170 Test #31: StrictMultiDiGraph.NegativeCapacityThrows ......................................... Passed 0.00 sec - Start 100: MaxFlow.ECMP_TwoPaths_Proportional_FullMaxFlow - 64/170 Test #2: FlowPolicyCore.Proportional_SingleDemand_UsesShortestPath ......................... Passed 0.00 sec - Start 76: FlowState.RepeatedPlacementAfterResetAndIndependentInstancesStayStable - 65/170 Test #5: FlowPolicyCore.Proportional_SingleDemand_PartialPlacement_InsufficientCapacity .... Passed 0.00 sec - Start 121: MaxFlow.BatchMaxFlow_IndependentMaskApplication - 66/170 Test #150: MaskingTests.OutOfRangeSourceInComputeMinCut ...................................... Passed 0.00 sec - Start 117: MaxFlow.CostDistribution_AccountsForAllFlow - 67/170 Test #100: MaxFlow.ECMP_TwoPaths_Proportional_FullMaxFlow .................................... Passed 0.00 sec - Start 113: MaxFlow.ThreeCostTiers_ShortestPath_LowestTierOnly - 68/170 Test #76: FlowState.RepeatedPlacementAfterResetAndIndependentInstancesStayStable ............ Passed 0.00 sec - Start 23: StrictMultiDiGraph.CSROffsetsMonotonicity - 69/170 Test #121: MaxFlow.BatchMaxFlow_IndependentMaskApplication ................................... Passed 0.00 sec - Start 48: ShortestPaths.LargeGraphStressTest - 70/170 Test #117: MaxFlow.CostDistribution_AccountsForAllFlow ....................................... Passed 0.00 sec - Start 58: FlowState.PlacementReducesResidual - 71/170 Test #113: MaxFlow.ThreeCostTiers_ShortestPath_LowestTierOnly ................................ Passed 0.00 sec - Start 4: FlowPolicyCore.Proportional_SingleDemand_MaxFlowCount1_UsesSinglePath - 72/170 Test #23: StrictMultiDiGraph.CSROffsetsMonotonicity ......................................... Passed 0.00 sec - Start 59: FlowState.ProportionalDistribution - 73/170 Test #48: ShortestPaths.LargeGraphStressTest ................................................ Passed 0.00 sec - Start 130: MaxFlow.Sensitivity_TwoParallelPaths - 74/170 Test #58: FlowState.PlacementReducesResidual ................................................ Passed 0.00 sec - Start 6: FlowPolicyCore.EqualBalanced_SingleDemand_FlowCount2_BalancedDistribution - 75/170 Test #4: FlowPolicyCore.Proportional_SingleDemand_MaxFlowCount1_UsesSinglePath ............. Passed 0.00 sec - Start 44: ShortestPaths.TieBreakingDeterminism - 76/170 Test #59: FlowState.ProportionalDistribution ................................................ Passed 0.00 sec - Start 140: KShortestPaths.PathsSortedByCost - 77/170 Test #130: MaxFlow.Sensitivity_TwoParallelPaths .............................................. Passed 0.00 sec - Start 158: MaskingTests.MaskWithResidualCapacity - 78/170 Test #6: FlowPolicyCore.EqualBalanced_SingleDemand_FlowCount2_BalancedDistribution ......... Passed 0.00 sec - Start 39: ShortestPaths.ResidualAwareFiltering - 79/170 Test #44: ShortestPaths.TieBreakingDeterminism .............................................. Passed 0.00 sec - Start 161: MaskingTests.KSPWithEdgeMask - 80/170 Test #140: KShortestPaths.PathsSortedByCost .................................................. Passed 0.00 sec - Start 162: MaskingTests.MaxFlowWithNodeMask - 81/170 Test #158: MaskingTests.MaskWithResidualCapacity ............................................. Passed 0.00 sec - Start 127: MaxFlow.SimpleParallelPaths_Topology - 82/170 Test #39: ShortestPaths.ResidualAwareFiltering .............................................. Passed 0.00 sec - Start 49: ShortestPaths.RejectsMaskLengthMismatch - 83/170 Test #161: MaskingTests.KSPWithEdgeMask ...................................................... Passed 0.00 sec - Start 137: KShortestPaths.NodeMaskBlocksPaths - 84/170 Test #162: MaskingTests.MaxFlowWithNodeMask .................................................. Passed 0.00 sec - Start 18: StrictMultiDiGraph.SingleNode - 85/170 Test #127: MaxFlow.SimpleParallelPaths_Topology .............................................. Passed 0.00 sec - Start 108: MaxFlow.EqualBalanced_NodeMask_ReducesECMPWidth - 86/170 Test #49: ShortestPaths.RejectsMaskLengthMismatch ........................................... Passed 0.00 sec - Start 88: FlowGraph.GetFlowPathForLinearFlow - 87/170 Test #137: KShortestPaths.NodeMaskBlocksPaths ................................................ Passed 0.00 sec - Start 160: MaskingTests.KSPWithNodeMask - 88/170 Test #18: StrictMultiDiGraph.SingleNode ..................................................... Passed 0.00 sec - Start 50: ShortestPaths.ZeroCostEdges_PredDAGIsAcyclic - 89/170 Test #108: MaxFlow.EqualBalanced_NodeMask_ReducesECMPWidth ................................... Passed 0.00 sec - Start 3: FlowPolicyCore.Proportional_SingleDemand_SplitsAcrossTwoEqualCostPaths - 90/170 Test #88: FlowGraph.GetFlowPathForLinearFlow ................................................ Passed 0.00 sec - Start 142: KShortestPaths.LooplessPaths - 91/170 Test #160: MaskingTests.KSPWithNodeMask ...................................................... Passed 0.00 sec - Start 116: MaxFlow.MinCut_ValidEdgeIdentification - 92/170 Test #50: ShortestPaths.ZeroCostEdges_PredDAGIsAcyclic ...................................... Passed 0.00 sec - Start 36: ShortestPaths.SingleSourceAllNodes - 93/170 Test #3: FlowPolicyCore.Proportional_SingleDemand_SplitsAcrossTwoEqualCostPaths ............ Passed 0.00 sec - Start 136: KShortestPaths.KEqualsZeroReturnsEmpty - 94/170 Test #142: KShortestPaths.LooplessPaths ...................................................... Passed 0.00 sec - Start 38: ShortestPaths.MultipleEqualCostPaths - 95/170 Test #116: MaxFlow.MinCut_ValidEdgeIdentification ............................................ Passed 0.00 sec - Start 111: MaxFlow.TwoCostTiers_FullMaxFlow_AllTiers - 96/170 Test #36: ShortestPaths.SingleSourceAllNodes ................................................ Passed 0.00 sec - Start 24: StrictMultiDiGraph.CSRReverseAdjacency - 97/170 Test #136: KShortestPaths.KEqualsZeroReturnsEmpty ............................................ Passed 0.00 sec - Start 97: MaxFlow.SinglePath_ShortestPathEquivalence - 98/170 Test #38: ShortestPaths.MultipleEqualCostPaths .............................................. Passed 0.00 sec - Start 90: FlowGraph.CoalescesDuplicateEdgeIds - 99/170 Test #111: MaxFlow.TwoCostTiers_FullMaxFlow_AllTiers ......................................... Passed 0.00 sec - Start 146: MaskingTests.MaskedSourceReturnsEmptyDAG -100/170 Test #24: StrictMultiDiGraph.CSRReverseAdjacency ............................................ Passed 0.00 sec - Start 28: StrictMultiDiGraph.MismatchedArrayLengthsThrow -101/170 Test #97: MaxFlow.SinglePath_ShortestPathEquivalence ........................................ Passed 0.00 sec - Start 124: MaxFlow.Clos_3Tier_Topology_Proportional_Shortest -102/170 Test #90: FlowGraph.CoalescesDuplicateEdgeIds ............................................... Passed 0.00 sec - Start 131: MaxFlow.Sensitivity_PartialSaturation -103/170 Test #146: MaskingTests.MaskedSourceReturnsEmptyDAG .......................................... Passed 0.00 sec - Start 110: MaxFlow.TwoCostTiers_ShortestPath_SingleTierOnly -104/170 Test #28: StrictMultiDiGraph.MismatchedArrayLengthsThrow .................................... Passed 0.00 sec - Start 129: MaxFlow.Sensitivity_SinglePath -105/170 Test #124: MaxFlow.Clos_3Tier_Topology_Proportional_Shortest ................................. Passed 0.00 sec - Start 120: MaxFlow.EdgeMask_SelectiveEdgeFiltering -106/170 Test #131: MaxFlow.Sensitivity_PartialSaturation ............................................. Passed 0.00 sec - Start 45: ShortestPaths.PreferHigherResidualTieBreak -107/170 Test #110: MaxFlow.TwoCostTiers_ShortestPath_SingleTierOnly .................................. Passed 0.00 sec - Start 25: StrictMultiDiGraph.MaxCapacityEdges -108/170 Test #129: MaxFlow.Sensitivity_SinglePath .................................................... Passed 0.00 sec - Start 57: FlowState.InitialResidualEqualsCapacity -109/170 Test #120: MaxFlow.EdgeMask_SelectiveEdgeFiltering ........................................... Passed 0.00 sec - Start 123: MaxFlow.SharedBottleneck_MultiplePathsMerge -110/170 Test #45: ShortestPaths.PreferHigherResidualTieBreak ........................................ Passed 0.00 sec - Start 40: ShortestPaths.NodeMaskIsolation -111/170 Test #25: StrictMultiDiGraph.MaxCapacityEdges ............................................... Passed 0.00 sec - Start 125: MaxFlow.Clos_3Tier_Topology_EqualBalanced_Shortest -112/170 Test #57: FlowState.InitialResidualEqualsCapacity ........................................... Passed 0.00 sec - Start 128: MaxFlow.ShortestPath_MustSaturateAllEqualCostPaths -113/170 Test #123: MaxFlow.SharedBottleneck_MultiplePathsMerge ....................................... Passed 0.00 sec - Start 159: MaskingTests.ResidualAndNodeMaskCombined -114/170 Test #40: ShortestPaths.NodeMaskIsolation ................................................... Passed 0.00 sec - Start 74: FlowState.EqualBalanced_ZeroCapacityOnShortestPath_ReturnsZero -115/170 Test #125: MaxFlow.Clos_3Tier_Topology_EqualBalanced_Shortest ................................ Passed 0.00 sec - Start 147: MaskingTests.MaskedSourceWithDestination -116/170 Test #128: MaxFlow.ShortestPath_MustSaturateAllEqualCostPaths ................................ Passed 0.00 sec - Start 70: FlowState.EqualBalancedReconvergentDAGPlacesAll -117/170 Test #159: MaskingTests.ResidualAndNodeMaskCombined .......................................... Passed 0.00 sec - Start 84: FlowGraph.PlacementCreatesEntry -118/170 Test #74: FlowState.EqualBalanced_ZeroCapacityOnShortestPath_ReturnsZero .................... Passed 0.00 sec - Start 62: FlowState.ApplyDeltasAdditive -119/170 Test #147: MaskingTests.MaskedSourceWithDestination .......................................... Passed 0.00 sec - Start 65: FlowState.ResetRestoresCapacity -120/170 Test #70: FlowState.EqualBalancedReconvergentDAGPlacesAll ................................... Passed 0.00 sec - Start 19: StrictMultiDiGraph.SingleEdge -121/170 Test #84: FlowGraph.PlacementCreatesEntry ................................................... Passed 0.00 sec - Start 139: KShortestPaths.MaxCostFactorLimit -122/170 Test #62: FlowState.ApplyDeltasAdditive ..................................................... Passed 0.00 sec - Start 30: StrictMultiDiGraph.OutOfRangeNodeThrows -123/170 Test #65: FlowState.ResetRestoresCapacity ................................................... Passed 0.00 sec - Start 60: FlowState.EqualBalancedDistribution -124/170 Test #19: StrictMultiDiGraph.SingleEdge ..................................................... Passed 0.00 sec - Start 151: MaskingTests.MaskedDestinationUnreachable -125/170 Test #139: KShortestPaths.MaxCostFactorLimit ................................................. Passed 0.00 sec - Start 96: MaxFlow.SinglePath_BottleneckConstraint -126/170 Test #30: StrictMultiDiGraph.OutOfRangeNodeThrows ........................................... Passed 0.00 sec - Start 66: FlowState.CustomInitialResidual -127/170 Test #60: FlowState.EqualBalancedDistribution ............................................... Passed 0.00 sec - Start 101: MaxFlow.ECMP_TwoPaths_EqualBalanced_ShortestPath -128/170 Test #151: MaskingTests.MaskedDestinationUnreachable ......................................... Passed 0.00 sec - Start 115: MaxFlow.FlowConservation_IntermediateNodes -129/170 Test #96: MaxFlow.SinglePath_BottleneckConstraint ........................................... Passed 0.00 sec - Start 102: MaxFlow.ECMP_ThreePaths_Proportional_ShortestPath -130/170 Test #66: FlowState.CustomInitialResidual ................................................... Passed 0.00 sec - Start 26: StrictMultiDiGraph.ZeroCostEdges -131/170 Test #101: MaxFlow.ECMP_TwoPaths_EqualBalanced_ShortestPath .................................. Passed 0.00 sec - Start 95: MaxFlow.SinglePath_BasicLinear -132/170 Test #115: MaxFlow.FlowConservation_IntermediateNodes ........................................ Passed 0.00 sec - Start 72: FlowState.EqualBalanced_MinFlowGating_DoesNotPruneValidParallelEdges -133/170 Test #102: MaxFlow.ECMP_ThreePaths_Proportional_ShortestPath ................................. Passed 0.00 sec - Start 98: MaxFlow.DegenerateCase_SourceEqualsSink -134/170 Test #26: StrictMultiDiGraph.ZeroCostEdges .................................................. Passed 0.00 sec - Start 86: FlowGraph.RemoveByClassFiltersCorrectly -135/170 Test #95: MaxFlow.SinglePath_BasicLinear .................................................... Passed 0.00 sec - Start 73: FlowState.EqualBalanced_DownstreamSplitEqualization -136/170 Test #72: FlowState.EqualBalanced_MinFlowGating_DoesNotPruneValidParallelEdges .............. Passed 0.00 sec - Start 119: MaxFlow.NodeMask_BlocksFlowThroughNode -137/170 Test #98: MaxFlow.DegenerateCase_SourceEqualsSink ........................................... Passed 0.00 sec - Start 85: FlowGraph.RemovalDeletesEntry -138/170 Test #86: FlowGraph.RemoveByClassFiltersCorrectly ........................................... Passed 0.00 sec - Start 87: FlowGraph.GetFlowEdgesReturnsDeltas -139/170 Test #73: FlowState.EqualBalanced_DownstreamSplitEqualization ............................... Passed 0.00 sec - Start 163: MaskingTests.MaxFlowWithEdgeMask -140/170 Test #119: MaxFlow.NodeMask_BlocksFlowThroughNode ............................................ Passed 0.00 sec - Start 164: MaskingTests.MaxFlowAllPathsMasked -141/170 Test #85: FlowGraph.RemovalDeletesEntry ..................................................... Passed 0.00 sec - Start 165: MaskingTests.EmptyNodeMaskAllowsAllNodes -142/170 Test #87: FlowGraph.GetFlowEdgesReturnsDeltas ............................................... Passed 0.00 sec - Start 12: FlowPolicyStatic.PerFlowBundleBinding -143/170 Test #163: MaskingTests.MaxFlowWithEdgeMask .................................................. Passed 0.00 sec - Start 169: MaskingTests.AlgorithmsAPIMaskedSource -144/170 Test #164: MaskingTests.MaxFlowAllPathsMasked ................................................ Passed 0.00 sec - Start 13: FlowPolicyStatic.MaskedBundleIsDown -145/170 Test #165: MaskingTests.EmptyNodeMaskAllowsAllNodes .......................................... Passed 0.00 sec - Start 15: FlowPolicyCostBounds.MaxPathCostFactor_UnreachableDst_KeepsSentinelOutOfTheCast -146/170 Test #12: FlowPolicyStatic.PerFlowBundleBinding ............................................. Passed 0.00 sec - Start 14: FlowPolicyStatic.ValidationRejectsForeignAndDisconnectedBundles -147/170 Test #169: MaskingTests.AlgorithmsAPIMaskedSource ............................................ Passed 0.00 sec - Start 55: ShortestPathsTo.FanoutFromInteriorNodeIsRejected -148/170 Test #13: FlowPolicyStatic.MaskedBundleIsDown ............................................... Passed 0.00 sec - Start 51: ShortestPathsTo.DistancesMatchForwardDistancesToDst -149/170 Test #15: FlowPolicyCostBounds.MaxPathCostFactor_UnreachableDst_KeepsSentinelOutOfTheCast ... Passed 0.00 sec - Start 166: MaskingTests.EmptyEdgeMaskAllowsAllEdges -150/170 Test #14: FlowPolicyStatic.ValidationRejectsForeignAndDisconnectedBundles ................... Passed 0.00 sec - Start 81: FlowState.EqualBalancedLossy_InfiniteRequestFillsWithoutDrops -151/170 Test #55: ShortestPathsTo.FanoutFromInteriorNodeIsRejected .................................. Passed 0.00 sec - Start 79: FlowState.EqualBalancedLossy_FillAndDropReportsDrops -152/170 Test #51: ShortestPathsTo.DistancesMatchForwardDistancesToDst ............................... Passed 0.00 sec - Start 82: FlowGraph.LossyPlacementIsReversibleAndDropsStayOutOfLedger -153/170 Test #166: MaskingTests.EmptyEdgeMaskAllowsAllEdges .......................................... Passed 0.00 sec - Start 16: FlowPolicyCostBounds.MaxPathCostFactor_HugeProduct_DropsBoundInsteadOfWrapping -154/170 Test #81: FlowState.EqualBalancedLossy_InfiniteRequestFillsWithoutDrops ..................... Passed 0.00 sec - Start 52: ShortestPathsTo.ForwardSpfFromPseudoSourceKeepsOnlyNearestSource -155/170 Test #79: FlowState.EqualBalancedLossy_FillAndDropReportsDrops .............................. Passed 0.00 sec - Start 170: MaskingTests.AlgorithmsAPIMaxFlowWithMask -156/170 Test #82: FlowGraph.LossyPlacementIsReversibleAndDropsStayOutOfLedger ....................... Passed 0.00 sec - Start 94: FlowGraphStamp.EqualVersionDivergentCopiesCompareUnequal -157/170 Test #16: FlowPolicyCostBounds.MaxPathCostFactor_HugeProduct_DropsBoundInsteadOfWrapping .... Passed 0.00 sec - Start 167: MaskingTests.AllNodesMaskedNoReachability -158/170 Test #52: ShortestPathsTo.ForwardSpfFromPseudoSourceKeepsOnlyNearestSource .................. Passed 0.00 sec - Start 168: MaskingTests.AllEdgesMaskedNoReachability -159/170 Test #170: MaskingTests.AlgorithmsAPIMaxFlowWithMask ......................................... Passed 0.00 sec - Start 80: FlowState.EqualBalancedLossy_DeficitPropagatesDownstream -160/170 Test #94: FlowGraphStamp.EqualVersionDivergentCopiesCompareUnequal .......................... Passed 0.00 sec - Start 53: ShortestPathsTo.FanoutEdgesForceEverySourceIntoTheDag -161/170 Test #167: MaskingTests.AllNodesMaskedNoReachability ......................................... Passed 0.00 sec - Start 93: FlowGraphStamp.CopiesAndMovesGetFreshUids -162/170 Test #168: MaskingTests.AllEdgesMaskedNoReachability ......................................... Passed 0.00 sec - Start 145: KSP.MaxCostFactorBelowOne_ReturnsEmptyForAllK -163/170 Test #80: FlowState.EqualBalancedLossy_DeficitPropagatesDownstream .......................... Passed 0.00 sec - Start 77: FlowState.EqualBalanced_SaturatedMemberBlocksAdmission -164/170 Test #53: ShortestPathsTo.FanoutEdgesForceEverySourceIntoTheDag ............................. Passed 0.00 sec - Start 54: ShortestPathsTo.FanoutSkipsUnreachableAndMaskedHeads -165/170 Test #93: FlowGraphStamp.CopiesAndMovesGetFreshUids ......................................... Passed 0.00 sec - Start 78: FlowState.EqualBalanced_ProgressesOnlyThroughResidualAwareSpf -166/170 Test #145: KSP.MaxCostFactorBelowOne_ReturnsEmptyForAllK ..................................... Passed 0.00 sec - Start 56: ShortestPathsTo.SinglePathModeKeepsOneSuccessorPerNode -167/170 Test #77: FlowState.EqualBalanced_SaturatedMemberBlocksAdmission ............................ Passed 0.00 sec -168/170 Test #54: ShortestPathsTo.FanoutSkipsUnreachableAndMaskedHeads .............................. Passed 0.00 sec -169/170 Test #78: FlowState.EqualBalanced_ProgressesOnlyThroughResidualAwareSpf ..................... Passed 0.00 sec -170/170 Test #56: ShortestPathsTo.SinglePathModeKeepsOneSuccessorPerNode ............................ Passed 0.00 sec - -100% tests passed out of 170 - -Total Test time (real) = 0.13 sec -........................................................................ [ 15%] -........................................................................ [ 30%] -........................................................................ [ 46%] -........................................................................ [ 61%] -........................................................................ [ 77%] -........................................................................ [ 92%] -................................. [100%] -465 passed in 3.91s diff --git a/dev/research/metal_pathfinding/optimization/run_aba.sh b/dev/research/metal_pathfinding/optimization/run_aba.sh deleted file mode 100644 index 7c041d9..0000000 --- a/dev/research/metal_pathfinding/optimization/run_aba.sh +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -# Run sequentially on the same machine. Do not overlap with builds or tests. -set -euo pipefail -cd "$(dirname "$0")/../../.." -out=${1:-dev/research/metal_pathfinding/optimization/results/aba} -mkdir -p "$out" -unset NGRAPH_CORE_PROFILE -{ - date -u '+%Y-%m-%dT%H:%M:%SZ' - git rev-parse HEAD - sw_vers - sysctl -n machdep.cpu.brand_string hw.memsize hw.ncpu - xcrun clang++ --version - shasum -a 256 dev/research/metal_pathfinding/experiment.mm dev/research/metal_pathfinding/sssp.metal \ - dev/research/metal_pathfinding/optimization/experiment.mm dev/research/metal_pathfinding/optimization/kernels.metal \ - build/metal_pathfinding/optimized - pmset -g therm -} > "$out/environment.txt" -for phase in cpu-a1 base-a1 optimized-b base-a2 cpu-a2; do - top -l 2 -n 8 -o cpu -stats pid,command,cpu,threads > "$out/load-$phase.txt" - ioreg -r -c AGXAccelerator -l | sed -n '/"PerformanceStatistics"/p' > "$out/gpu-load-$phase.txt" - case "$phase" in - cpu-*) mode=cpu ;; - base-*) mode=base ;; - optimized-b) mode=optimized ;; - esac - date -u '+%Y-%m-%dT%H:%M:%SZ' > "$out/time-$phase.txt" - build/metal_pathfinding/optimized "$mode" full 7 > "$out/$phase.csv" 2> "$out/$phase-stderr.txt" - date -u '+%Y-%m-%dT%H:%M:%SZ' >> "$out/time-$phase.txt" -done diff --git a/dev/research/metal_pathfinding/optimization/run_cpu_aba.sh b/dev/research/metal_pathfinding/optimization/run_cpu_aba.sh deleted file mode 100644 index a15f747..0000000 --- a/dev/research/metal_pathfinding/optimization/run_cpu_aba.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -cd "$(dirname "$0")/../../.." -out=dev/research/metal_pathfinding/optimization/results/cpu-algorithms-aba -mkdir -p "$out" -unset NGRAPH_CORE_PROFILE -{ - date -u '+%Y-%m-%dT%H:%M:%SZ' - git rev-parse HEAD - shasum -a 256 dev/research/metal_pathfinding/optimization/cpu_algorithms.mm \ - build/metal_pathfinding/cpu-algorithms build/metal_pathfinding/optimized -} > "$out/environment.txt" -for phase in a1 b a2; do - top -l 2 -n 8 -o cpu -stats pid,command,cpu,threads > "$out/load-$phase.txt" - ioreg -r -c AGXAccelerator -l | sed -n '/"PerformanceStatistics"/p' > "$out/gpu-load-$phase.txt" - if [[ $phase == b ]]; then - build/metal_pathfinding/optimized optimized full 7 > "$out/$phase.csv" 2> "$out/$phase-stderr.txt" - else - build/metal_pathfinding/cpu-algorithms 7 > "$out/$phase.csv" 2> "$out/$phase-stderr.txt" - fi -done diff --git a/dev/research/metal_pathfinding/optimization/summarize.py b/dev/research/metal_pathfinding/optimization/summarize.py deleted file mode 100644 index 2dd648f..0000000 --- a/dev/research/metal_pathfinding/optimization/summarize.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env python3 -"""Produce auditable tables from all five sequential benchmark phases.""" - -import csv -import json -from pathlib import Path - -ROOT = Path(__file__).resolve().parent / "results" / "aba" -PHASES = ("cpu-a1", "base-a1", "optimized-b", "base-a2", "cpu-a2") -rows = {p: list(csv.DictReader((ROOT / f"{p}.csv").open())) for p in PHASES} -assert [len(rows[p]) for p in PHASES] == [48, 36, 216, 36, 48] -assert all(r["correct"] == "pass" for rs in rows.values() for r in rs) - - -def key(r): - return tuple(r[k] for k in ("case", "n", "e", "queries")) - - -def table(phase, case): - return {r["method"]: r for r in rows[phase] if key(r) == case} - - -def elapsed(r): - return float(r["wall_ms"]) - - -keys = list(dict.fromkeys(key(r) for r in rows["optimized-b"])) -lines = [ - "# Exploratory A/B/A measurements", - "", - "Warm full-result latency in ms, including host distances and positive-cost predecessor DAG. " - "Seven measured samples plus two checked warmups per row. CPU batches use 10 reusable workers. " - "Best variant is selected after measurement; this is an oracle comparison, not an implemented dispatcher. " - "Old GPU columns cover the original pull and group variants; the shared-memory variant was not rerun. " - "The machine was not isolated; these are not accepted performance claims.", - "", - "| Case | N / E / Q | CPU full A1 / A2 | Old GPU A1 / A2 | Best tested optimized GPU | ms | CPU BFS full A1 / A2 |", - "|---|---:|---:|---:|---|---:|---:|", -] -summary = [] -for k in keys: - ca, cb, a, b, c = ( - table(p, k) for p in ("cpu-a1", "cpu-a2", "base-a1", "optimized-b", "base-a2") - ) - # Choose one old algorithm by its mean of A1/A2, then show both measurements. - old = min(a, key=lambda name: (elapsed(a[name]) + elapsed(c[name])) / 2) - best = min(b, key=lambda name: elapsed(b[name])) - best_label = best + ( - " (64-bit pull fallback)" if b[best]["fallback64"] == "1" else "" - ) - bfs = ( - f"{elapsed(ca['cpu_bfs_full']):.3f} / {elapsed(cb['cpu_bfs_full']):.3f}" - if "cpu_bfs_full" in ca - else "—" - ) - lines.append( - f"| {k[0]} | {' / '.join(k[1:])} | {elapsed(ca['cpu_full']):.3f} / {elapsed(cb['cpu_full']):.3f} | " - f"{elapsed(a[old]):.3f} / {elapsed(c[old]):.3f} | {best_label} | {elapsed(b[best]):.3f} | {bfs} |" - ) - summary.append( - { - "case": k, - "cpu_a1": elapsed(ca["cpu_full"]), - "cpu_a2": elapsed(cb["cpu_full"]), - "old_method": old, - "old_a1": elapsed(a[old]), - "old_a2": elapsed(c[old]), - "best": b[best], - } - ) - -lines += [ - "", - "## All optimized variants", - "", - "No variants omitted. Columns: full wall / distance wall / device work, ms. " - "Device work includes DAG kernels where selected. Edge visits count SSSP adjacency entries, " - "excluding bucket vertex scans, queue management and DAG extraction.", - "", -] -for k in keys: - lines += [ - f"### {k}", - "", - "| Variant | Full / distance / device ms | Edge visits | Rounds | Host waits | 64-bit fallback |", - "|---|---:|---:|---:|---:|---|", - ] - for r in table("optimized-b", k).values(): - times = " / ".join( - f"{float(r[f]):.3f}" for f in ("wall_ms", "distance_ms", "gpu_ms") - ) - lines.append( - f"| {r['method']} | {times} | {r['edge_visits']} | {r['rounds']} | {r['waits']} | {r['fallback64']} |" - ) - -checks = sum( - (int(r["samples"]) + 2) * int(r["queries"]) for rs in rows.values() for r in rs -) -audit = { - "phase_rows": {p: len(rs) for p, rs in rows.items()}, - "checked_query_results": checks, - "summary": summary, -} -(ROOT / "summary.md").write_text("\n".join(lines) + "\n") -(ROOT / "summary.json").write_text(json.dumps(audit, indent=2) + "\n") -print(json.dumps({k: v for k, v in audit.items() if k != "summary"}, indent=2)) diff --git a/dev/research/metal_pathfinding/optimization/summarize_cpu.py b/dev/research/metal_pathfinding/optimization/summarize_cpu.py deleted file mode 100644 index b815c3d..0000000 --- a/dev/research/metal_pathfinding/optimization/summarize_cpu.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python3 -"""Stronger CPU algorithm comparison; no production-dispatch claim.""" - -import csv -import json -from pathlib import Path - -root = Path(__file__).resolve().parent / "results/cpu-algorithms-aba" -data = {p: list(csv.DictReader((root / f"{p}.csv").open())) for p in ("a1", "b", "a2")} -assert [len(v) for v in data.values()] == [14, 216, 14] -assert all(r["correct"] == "pass" for rs in data.values() for r in rs) - - -def key(row): - return tuple(row[k] for k in ("case", "n", "e", "queries")) - - -lines = [ - "# Stronger CPU algorithm A/B/A", - "", - "All methods include exact distances and canonical-equivalent positive-cost DAG edge sets. " - "CPU batches use 10 workers. Eligibility is cached outside timing for both devices. " - "Seven measured samples and two checked warmups. These remain exploratory, " - "non-isolated desktop measurements. GPU choice is an oracle across 12 tested variants.", - "", - "| Case / N / E / Q | Cached heap + DAG A1 / A2 ms | Dial + DAG A1 / A2 ms | Best GPU B ms | GPU method |", - "|---|---:|---:|---:|---|", -] -summary = [] -for k in dict.fromkeys(key(r) for r in data["a1"]): - phases = [ - {r["method"]: r for r in data[p] if key(r) == k} for p in ("a1", "b", "a2") - ] - a, b, c = phases - best = min(b.values(), key=lambda r: float(r["wall_ms"])) - heap = [float(p["cpu_heap_cached_full"]["wall_ms"]) for p in (a, c)] - dial = [float(p["cpu_dial_full"]["wall_ms"]) for p in (a, c)] - lines.append( - f"| {' / '.join(k)} | {heap[0]:.3f} / {heap[1]:.3f} | {dial[0]:.3f} / {dial[1]:.3f} | " - f"{float(best['wall_ms']):.3f} | {best['method']} |" - ) - summary.append( - {"case": k, "heap_a1_a2": heap, "dial_a1_a2": dial, "best_gpu": best} - ) -audit = { - "phase_rows": {p: len(v) for p, v in data.items()}, - "checked_query_results": sum( - (int(r["samples"]) + 2) * int(r["queries"]) for rs in data.values() for r in rs - ), - "summary": summary, -} -(root / "summary.md").write_text("\n".join(lines) + "\n") -(root / "summary.json").write_text(json.dumps(audit, indent=2) + "\n") -print("\n".join(lines)) diff --git a/dev/research/metal_pathfinding/results/aba-initial/a1-stderr.txt b/dev/research/metal_pathfinding/results/aba-initial/a1-stderr.txt deleted file mode 100644 index e69de29..0000000 diff --git a/dev/research/metal_pathfinding/results/aba-initial/a1.csv b/dev/research/metal_pathfinding/results/aba-initial/a1.csv deleted file mode 100644 index f250b80..0000000 --- a/dev/research/metal_pathfinding/results/aba-initial/a1.csv +++ /dev/null @@ -1,82 +0,0 @@ -case,n,e,queries,method,wall_ms,gpu_ms,distance_wall_ms,upload_ms,rounds,samples,correct,exact_order_queries,wall_samples_ms -random,128,1024,1,cpu_full,0.009625,0.000000,0.009625,0.000000,0,9,pass,1,0.009625;0.010542;0.010041;0.013500;0.010292;0.009458;0.009291;0.009250;0.009208 -random,128,1024,1,cpu_distance,0.004541,0.000000,0.004541,0.000000,0,9,pass,0,0.004583;0.005666;0.004417;0.004625;0.004250;0.006458;0.004541;0.004208;0.004167 -random,128,1024,1,cpu_pool10,0.029750,0.000000,0.029750,0.000000,0,9,pass,1,0.030792;0.043125;0.032708;0.027000;0.029750;0.026875;0.034250;0.027000;0.026625 -random,128,1024,64,cpu_full,0.955250,0.000000,0.955250,0.000000,0,9,pass,64,0.973292;0.945083;0.904875;0.882041;1.075791;0.956416;0.955250;0.977375;0.870959 -random,128,1024,64,cpu_distance,0.351959,0.000000,0.351959,0.000000,0,9,pass,0,0.401375;0.351959;0.340208;0.476250;0.456208;0.379709;0.338625;0.344334;0.308541 -random,128,1024,64,cpu_pool10,0.182417,0.000000,0.182417,0.000000,0,9,pass,64,0.182417;0.176833;0.174542;0.182917;0.191750;0.184291;0.197500;0.167458;0.162833 -random,1024,8192,1,cpu_full,0.103583,0.000000,0.103583,0.000000,0,9,pass,1,0.109833;0.110000;0.099291;0.105625;0.104000;0.102792;0.099458;0.102125;0.103583 -random,1024,8192,1,cpu_distance,0.055541,0.000000,0.055541,0.000000,0,9,pass,0,0.059083;0.051542;0.055333;0.055208;0.055541;0.055375;0.055625;0.055833;0.056209 -random,1024,8192,1,cpu_pool10,0.193125,0.000000,0.193125,0.000000,0,9,pass,1,0.200667;0.229666;0.191083;0.194792;0.193125;0.195459;0.165708;0.161917;0.173542 -random,1024,8192,64,cpu_full,10.685667,0.000000,10.685667,0.000000,0,9,pass,64,10.749791;10.777292;11.154708;10.834875;10.685667;10.563875;10.571209;10.589625;10.627750 -random,1024,8192,64,cpu_distance,5.053459,0.000000,5.053459,0.000000,0,9,pass,0,5.145083;5.053459;5.062084;4.993875;5.026833;5.019416;5.055542;4.969833;5.108583 -random,1024,8192,64,cpu_pool10,1.342375,0.000000,1.342375,0.000000,0,9,pass,64,1.372375;1.342916;1.342375;1.338708;1.318542;1.342916;1.354417;1.335167;1.315708 -fabric,288,16384,1,cpu_full,0.112333,0.000000,0.112333,0.000000,0,9,pass,1,0.115083;0.119833;0.110375;0.112667;0.112333;0.118125;0.109709;0.109792;0.109708 -fabric,288,16384,1,cpu_distance,0.023291,0.000000,0.023291,0.000000,0,9,pass,0,0.023709;0.023667;0.023291;0.023542;0.023291;0.023042;0.023208;0.023166;0.023166 -fabric,288,16384,1,cpu_pool10,0.137583,0.000000,0.137583,0.000000,0,9,pass,1,0.135875;0.135667;0.144417;0.132500;0.137583;0.156500;0.146000;0.136291;0.141125 -fabric,288,16384,64,cpu_full,7.318958,0.000000,7.318958,0.000000,0,9,pass,64,7.419667;7.295250;7.297250;7.489000;7.299334;7.340666;7.318958;7.284542;7.324916 -fabric,288,16384,64,cpu_distance,1.489125,0.000000,1.489125,0.000000,0,9,pass,0,1.489125;1.524542;1.487041;1.499584;1.499292;1.478625;1.510084;1.482750;1.483875 -fabric,288,16384,64,cpu_pool10,0.972167,0.000000,0.972167,0.000000,0,9,pass,64,1.685792;1.140875;0.951709;0.972167;0.977500;0.942208;0.959334;0.916959;0.985417 -grid,1024,3968,1,cpu_full,0.043667,0.000000,0.043667,0.000000,0,9,pass,1,0.045750;0.044542;0.043792;0.043459;0.043583;0.044042;0.043625;0.043541;0.043667 -grid,1024,3968,1,cpu_distance,0.011666,0.000000,0.011666,0.000000,0,9,pass,0,0.012583;0.012042;0.012041;0.011791;0.011666;0.011625;0.011500;0.011542;0.011542 -grid,1024,3968,1,cpu_pool10,0.077875,0.000000,0.077875,0.000000,0,9,pass,1,0.088542;0.068417;0.079375;0.062125;0.084667;0.065500;0.078959;0.077875;0.073916 -grid,1024,3968,64,cpu_full,4.005584,0.000000,4.005584,0.000000,0,9,pass,64,4.005584;3.977000;3.994834;3.970583;4.010875;3.966208;4.044791;4.030375;4.086625 -grid,1024,3968,64,cpu_distance,0.894833,0.000000,0.894833,0.000000,0,9,pass,0,0.925833;0.907000;0.899000;0.894833;0.883292;0.896833;0.878750;0.873041;0.874166 -grid,1024,3968,64,cpu_pool10,0.543500,0.000000,0.543500,0.000000,0,9,pass,64,0.584208;0.566875;0.534833;0.544125;0.531250;0.543500;0.529166;0.552875;0.524417 -chain,256,255,1,cpu_full,0.002542,0.000000,0.002542,0.000000,0,9,pass,1,0.002583;0.002542;0.002583;0.002542;0.003458;0.002667;0.002542;0.002500;0.002541 -chain,256,255,1,cpu_distance,0.000708,0.000000,0.000708,0.000000,0,9,pass,0,0.000708;0.000750;0.000708;0.000709;0.000708;0.000750;0.000708;0.000750;0.000708 -chain,256,255,1,cpu_pool10,0.033958,0.000000,0.033958,0.000000,0,9,pass,1,0.039125;0.039375;0.037292;0.036167;0.031041;0.033958;0.033625;0.027333;0.029000 -random_masked_int64_reverse,1024,8192,8,cpu_full,0.581833,0.000000,0.581833,0.000000,0,9,pass,8,0.692791;0.630583;0.611625;0.585000;0.571125;0.565667;0.559791;0.581833;0.555459 -random_masked_int64_reverse,1024,8192,8,cpu_distance,0.303917,0.000000,0.303917,0.000000,0,9,pass,0,0.377291;0.438458;0.395125;0.336584;0.303917;0.289917;0.274959;0.279542;0.270875 -random_masked_int64_reverse,1024,8192,8,cpu_pool10,0.155667,0.000000,0.155667,0.000000,0,9,pass,8,0.154292;0.167333;0.155667;0.155042;0.157458;0.156000;0.155875;0.147917;0.152875 -random,4096,32768,1,cpu_full,0.656542,0.000000,0.656542,0.000000,0,9,pass,1,0.689083;0.693500;0.656542;0.645666;0.662417;0.637500;0.638250;0.636042;0.790125 -random,4096,32768,1,cpu_distance,0.308542,0.000000,0.308542,0.000000,0,9,pass,0,0.338750;0.325250;0.317625;0.308542;0.304625;0.309916;0.303500;0.303042;0.303000 -random,4096,32768,1,cpu_pool10,0.814208,0.000000,0.814208,0.000000,0,9,pass,1,0.826042;0.869667;0.814208;0.765917;0.765084;0.855791;0.743833;0.829917;0.749625 -random,4096,32768,64,cpu_full,52.554875,0.000000,52.554875,0.000000,0,9,pass,64,52.386917;53.197958;52.959833;52.357667;52.261083;51.995125;59.360875;52.554875;52.678667 -random,4096,32768,64,cpu_distance,26.269750,0.000000,26.269750,0.000000,0,9,pass,0,25.927500;26.269750;26.294709;26.415583;26.370750;26.258458;26.318584;26.239250;26.028667 -random,4096,32768,64,cpu_pool10,5.992416,0.000000,5.992416,0.000000,0,9,pass,64,6.017250;6.053250;5.905542;5.941042;5.959459;5.969500;6.017750;6.285584;5.992416 -random,4096,32768,256,cpu_full,208.676542,0.000000,208.676542,0.000000,0,9,pass,256,208.676542;208.153584;208.156916;208.801833;208.602375;208.752792;208.467208;209.713875;210.652583 -random,4096,32768,256,cpu_distance,106.272833,0.000000,106.272833,0.000000,0,9,pass,0,107.689542;106.997833;106.701375;106.272833;105.206709;106.373917;105.998542;104.856041;104.854167 -random,4096,32768,256,cpu_pool10,23.000917,0.000000,23.000917,0.000000,0,9,pass,256,23.054250;23.179708;23.387250;23.000917;22.925084;23.250125;22.768375;22.927834;22.826625 -random,16384,131072,1,cpu_full,3.887250,0.000000,3.887250,0.000000,0,9,pass,1,3.887250;3.999084;3.780250;4.507709;3.907583;3.710875;3.958666;3.830917;3.877834 -random,16384,131072,1,cpu_distance,1.934458,0.000000,1.934458,0.000000,0,9,pass,0,1.934458;1.881542;1.868917;1.983458;2.134000;1.985167;1.920750;1.881125;1.985209 -random,16384,131072,1,cpu_pool10,3.823917,0.000000,3.823917,0.000000,0,9,pass,1,3.823917;3.839416;3.736166;3.679167;3.695708;3.719167;4.268541;3.980250;3.967292 -random,16384,131072,64,cpu_full,256.127917,0.000000,256.127917,0.000000,0,9,pass,64,259.340500;260.231959;254.190417;252.847166;253.639917;258.855125;261.257792;256.016375;256.127917 -random,16384,131072,64,cpu_distance,134.498459,0.000000,134.498459,0.000000,0,9,pass,0,135.160625;136.307417;137.394250;133.758792;133.098583;134.069500;135.485167;133.665375;134.498459 -random,16384,131072,64,cpu_pool10,28.586375,0.000000,28.586375,0.000000,0,9,pass,64,30.187666;28.698666;28.505708;28.563916;28.892208;28.586375;28.463583;28.565541;28.662417 -random,65536,524288,1,cpu_full,22.864166,0.000000,22.864166,0.000000,0,9,pass,1,21.392875;21.666875;22.864166;23.888750;23.744625;23.006084;24.381000;22.531041;22.057541 -random,65536,524288,1,cpu_distance,12.154417,0.000000,12.154417,0.000000,0,9,pass,0,12.221916;13.038792;11.681125;11.504500;12.697750;12.154417;11.320417;11.733250;12.544542 -random,65536,524288,1,cpu_pool10,22.800125,0.000000,22.800125,0.000000,0,9,pass,1,21.551917;21.493292;23.056834;25.353791;23.876125;22.366334;22.800125;23.572000;22.615292 -random,65536,524288,64,cpu_full,1421.675417,0.000000,1421.675417,0.000000,0,9,pass,64,1543.694875;1501.052792;1482.006208;1432.981166;1320.893792;1328.868667;1355.810834;1421.675417;1337.125625 -random,65536,524288,64,cpu_distance,703.276375,0.000000,703.276375,0.000000,0,9,pass,0,703.276375;719.258750;726.684250;682.657208;697.282959;699.636458;698.637625;704.775709;735.197208 -random,65536,524288,64,cpu_pool10,181.873958,0.000000,181.873958,0.000000,0,9,pass,64,216.777750;186.447958;180.352792;178.576708;181.873958;181.106500;200.742167;186.728833;179.152250 -chain,2048,2047,1,cpu_full,0.019667,0.000000,0.019667,0.000000,0,9,pass,1,0.020125;0.019708;0.019792;0.019459;0.019666;0.019667;0.019542;0.019792;0.019625 -chain,2048,2047,1,cpu_distance,0.004792,0.000000,0.004792,0.000000,0,9,pass,0,0.004958;0.004958;0.004750;0.004792;0.004750;0.004875;0.004792;0.004750;0.004792 -chain,2048,2047,1,cpu_pool10,0.039333,0.000000,0.039333,0.000000,0,9,pass,1,0.041000;0.039125;0.041083;0.039333;0.040666;0.037292;0.039333;0.046417;0.036500 -random,4096,131072,1,cpu_full,1.539625,0.000000,1.539625,0.000000,0,9,pass,1,1.592417;1.600542;1.556292;1.554000;1.539625;1.523500;1.516333;1.535500;1.528833 -random,4096,131072,1,cpu_distance,0.666250,0.000000,0.666250,0.000000,0,9,pass,0,0.706750;0.693500;0.686750;0.677958;0.666250;0.652625;0.647209;0.664459;0.643875 -random,4096,131072,1,cpu_pool10,1.721000,0.000000,1.721000,0.000000,0,9,pass,1,1.796875;1.732167;1.728875;1.721000;1.672791;1.717584;1.651584;1.774958;1.618333 -random,4096,131072,64,cpu_full,112.114083,0.000000,112.114083,0.000000,0,9,pass,64,111.754667;111.104792;111.502500;113.248167;112.114083;112.117750;111.657959;114.661000;112.451209 -random,4096,131072,64,cpu_distance,51.198125,0.000000,51.198125,0.000000,0,9,pass,0,49.981584;50.728417;51.871583;51.198125;51.291833;51.193459;51.875625;51.237041;50.593625 -random,4096,131072,64,cpu_pool10,12.856917,0.000000,12.856917,0.000000,0,9,pass,64,12.974625;12.898958;12.780333;12.824459;12.889000;12.811291;12.853333;12.886750;12.856917 -clos,200,20000,1,cpu_full,0.138708,0.000000,0.138708,0.000000,0,9,pass,1,0.138708;0.149834;0.137584;0.154875;0.147750;0.136250;0.147416;0.135417;0.138166 -clos,200,20000,1,cpu_distance,0.026292,0.000000,0.026292,0.000000,0,9,pass,0,0.026792;0.026375;0.026500;0.026292;0.026500;0.026167;0.026084;0.026042;0.026041 -clos,200,20000,1,cpu_pool10,0.178667,0.000000,0.178667,0.000000,0,9,pass,1,0.177917;0.182833;0.184334;0.175625;0.177000;0.183333;0.175875;0.190459;0.178667 -clos,200,20000,64,cpu_full,9.340000,0.000000,9.340000,0.000000,0,9,pass,64,9.371333;9.260542;9.368500;9.340000;9.502042;9.128083;9.156166;9.015542;9.563584 -clos,200,20000,64,cpu_distance,1.710250,0.000000,1.710250,0.000000,0,9,pass,0,1.794083;1.683042;1.691042;1.641416;1.694916;1.710250;1.843750;1.870708;1.763125 -clos,200,20000,64,cpu_pool10,1.167167,0.000000,1.167167,0.000000,0,9,pass,64,1.182834;1.245250;1.187375;1.099833;1.137916;1.167167;1.148208;1.165625;1.731666 -clos,800,320000,1,cpu_full,2.314167,0.000000,2.314167,0.000000,0,9,pass,1,2.304125;2.335583;2.282750;2.279792;2.288334;2.317416;2.326666;2.346875;2.314167 -clos,800,320000,1,cpu_distance,0.390042,0.000000,0.390042,0.000000,0,9,pass,0,0.390083;0.390042;0.398333;0.388583;0.392167;0.391667;0.388792;0.388875;0.388125 -clos,800,320000,1,cpu_pool10,2.355708,0.000000,2.355708,0.000000,0,9,pass,1,2.373750;2.368833;2.333166;2.329875;2.329166;2.355708;2.335834;2.391833;2.362042 -clos,800,320000,64,cpu_full,148.903625,0.000000,148.903625,0.000000,0,9,pass,64,149.844708;148.332833;150.863708;148.481791;151.271416;148.695625;148.903625;152.917459;148.691292 -clos,800,320000,64,cpu_distance,25.264916,0.000000,25.264916,0.000000,0,9,pass,0,25.148333;25.201625;25.167250;25.264916;25.292417;25.266916;25.295417;25.364708;25.206833 -clos,800,320000,64,cpu_pool10,17.328375,0.000000,17.328375,0.000000,0,9,pass,64,18.354167;17.193458;17.347083;17.145083;17.328375;17.167541;19.327292;17.145500;18.241875 -grid,10000,39600,1,cpu_full,0.631166,0.000000,0.631166,0.000000,0,9,pass,1,0.641042;0.623083;0.630083;0.616125;0.680375;0.640584;0.621459;0.631166;0.754208 -grid,10000,39600,1,cpu_distance,0.182417,0.000000,0.182417,0.000000,0,9,pass,0,0.188542;0.196792;0.183292;0.181959;0.179708;0.177833;0.181458;0.182417;0.195750 -grid,10000,39600,1,cpu_pool10,0.714292,0.000000,0.714292,0.000000,0,9,pass,1,0.722417;0.714292;0.669083;0.710083;0.645125;0.737000;0.663958;0.759166;0.755875 -grid,10000,39600,8,cpu_full,5.696250,0.000000,5.696250,0.000000,0,9,pass,8,5.774709;5.671333;5.712750;5.640375;5.724584;5.615833;5.616584;5.876000;5.696250 -grid,10000,39600,8,cpu_distance,1.624167,0.000000,1.624167,0.000000,0,9,pass,0,1.595875;1.640416;1.586875;1.601542;1.711625;1.624167;1.673875;1.597875;1.698792 -grid,10000,39600,8,cpu_pool10,0.852250,0.000000,0.852250,0.000000,0,9,pass,8,0.852250;1.229500;0.813333;0.819625;1.231458;0.918666;1.240458;0.833500;0.845583 -grid,40000,159200,1,cpu_full,2.919000,0.000000,2.919000,0.000000,0,9,pass,1,3.025375;2.919000;2.934042;2.914209;2.914000;2.943083;2.907625;2.919334;2.887459 -grid,40000,159200,1,cpu_distance,0.868708,0.000000,0.868708,0.000000,0,9,pass,0,0.874583;0.868708;0.870375;0.888666;0.860333;0.874084;0.859917;0.863583;0.862333 -grid,40000,159200,1,cpu_pool10,2.959542,0.000000,2.959542,0.000000,0,9,pass,1,2.970542;2.959542;2.984000;2.926583;2.999500;2.947292;2.928833;2.920292;2.966541 diff --git a/dev/research/metal_pathfinding/results/aba-initial/a2-stderr.txt b/dev/research/metal_pathfinding/results/aba-initial/a2-stderr.txt deleted file mode 100644 index e69de29..0000000 diff --git a/dev/research/metal_pathfinding/results/aba-initial/a2.csv b/dev/research/metal_pathfinding/results/aba-initial/a2.csv deleted file mode 100644 index a32d157..0000000 --- a/dev/research/metal_pathfinding/results/aba-initial/a2.csv +++ /dev/null @@ -1,82 +0,0 @@ -case,n,e,queries,method,wall_ms,gpu_ms,distance_wall_ms,upload_ms,rounds,samples,correct,exact_order_queries,wall_samples_ms -random,128,1024,1,cpu_full,0.010833,0.000000,0.010833,0.000000,0,9,pass,1,0.010250;0.011834;0.009916;0.012334;0.010833;0.011042;0.011292;0.009750;0.009667 -random,128,1024,1,cpu_distance,0.004750,0.000000,0.004750,0.000000,0,9,pass,0,0.004958;0.004750;0.005959;0.004750;0.005458;0.004542;0.005750;0.004500;0.004291 -random,128,1024,1,cpu_pool10,0.028584,0.000000,0.028584,0.000000,0,9,pass,1,0.031333;0.026833;0.030334;0.026333;0.028584;0.026792;0.027500;0.053792;0.048500 -random,128,1024,64,cpu_full,0.821042,0.000000,0.821042,0.000000,0,9,pass,64,0.943291;0.913167;0.870250;0.846167;0.815083;0.821042;0.783792;0.781375;0.776500 -random,128,1024,64,cpu_distance,0.354125,0.000000,0.354125,0.000000,0,9,pass,0,0.433667;0.409125;0.553959;0.420167;0.354125;0.328792;0.306791;0.305459;0.314708 -random,128,1024,64,cpu_pool10,0.156750,0.000000,0.156750,0.000000,0,9,pass,64,0.168125;0.150292;0.156750;0.161125;0.158459;0.150417;0.157667;0.151417;0.146792 -random,1024,8192,1,cpu_full,0.108875,0.000000,0.108875,0.000000,0,9,pass,1,0.111291;0.108875;0.119667;0.111750;0.112375;0.106750;0.103709;0.102334;0.103500 -random,1024,8192,1,cpu_distance,0.057209,0.000000,0.057209,0.000000,0,9,pass,0,0.063500;0.057542;0.057209;0.059500;0.056084;0.059375;0.056875;0.056375;0.055542 -random,1024,8192,1,cpu_pool10,0.179500,0.000000,0.179500,0.000000,0,9,pass,1,0.228584;0.179500;0.216458;0.193375;0.191875;0.167125;0.155417;0.147375;0.160541 -random,1024,8192,64,cpu_full,10.334500,0.000000,10.334500,0.000000,0,9,pass,64,10.415083;10.460125;10.626542;10.342084;10.307166;10.334500;10.297541;10.276292;10.295166 -random,1024,8192,64,cpu_distance,5.089208,0.000000,5.089208,0.000000,0,9,pass,0,5.206541;5.264583;5.280083;5.034667;5.027584;5.015459;5.023583;5.089208;5.108458 -random,1024,8192,64,cpu_pool10,1.276542,0.000000,1.276542,0.000000,0,9,pass,64,1.276542;1.276583;1.278167;1.277375;1.272875;1.242417;1.238208;1.255791;1.294458 -fabric,288,16384,1,cpu_full,0.109542,0.000000,0.109542,0.000000,0,9,pass,1,0.109333;0.108167;0.114042;0.109875;0.109542;0.108625;0.111084;0.108625;0.109834 -fabric,288,16384,1,cpu_distance,0.023459,0.000000,0.023459,0.000000,0,9,pass,0,0.023958;0.024042;0.023708;0.023750;0.023459;0.023125;0.023334;0.023375;0.023083 -fabric,288,16384,1,cpu_pool10,0.140417,0.000000,0.140417,0.000000,0,9,pass,1,0.138917;0.130916;0.142334;0.134833;0.157834;0.160833;0.144708;0.139833;0.140417 -fabric,288,16384,64,cpu_full,7.335417,0.000000,7.335417,0.000000,0,9,pass,64,7.348833;7.351291;7.497708;7.468834;7.281000;7.286833;7.261375;7.335417;7.292000 -fabric,288,16384,64,cpu_distance,1.490792,0.000000,1.490792,0.000000,0,9,pass,0,1.497667;1.484791;1.556166;1.481334;1.489250;1.496291;1.471959;1.490792;1.500042 -fabric,288,16384,64,cpu_pool10,0.994500,0.000000,0.994500,0.000000,0,9,pass,64,1.310167;0.994500;0.885167;1.440208;0.924667;1.369250;0.911375;0.882166;1.262000 -grid,1024,3968,1,cpu_full,0.042708,0.000000,0.042708,0.000000,0,9,pass,1,0.045042;0.043250;0.042667;0.042667;0.042708;0.042834;0.042583;0.042500;0.042750 -grid,1024,3968,1,cpu_distance,0.011792,0.000000,0.011792,0.000000,0,9,pass,0,0.012125;0.011875;0.011917;0.011792;0.011708;0.011709;0.011792;0.011709;0.011750 -grid,1024,3968,1,cpu_pool10,0.078000,0.000000,0.078000,0.000000,0,9,pass,1,0.104375;0.078000;0.062959;0.081000;0.077959;0.069292;0.080084;0.099417;0.067792 -grid,1024,3968,64,cpu_full,3.969708,0.000000,3.969708,0.000000,0,9,pass,64,3.927417;3.982416;4.061667;3.961958;4.054167;3.911708;3.969708;3.937333;3.989291 -grid,1024,3968,64,cpu_distance,0.919125,0.000000,0.919125,0.000000,0,9,pass,0,0.962375;0.942292;0.919125;0.895500;0.881917;1.024958;0.925625;0.898084;0.872708 -grid,1024,3968,64,cpu_pool10,0.545958,0.000000,0.545958,0.000000,0,9,pass,64,0.597417;0.661041;0.519917;0.521167;0.677583;0.545958;0.507708;0.515125;0.678208 -chain,256,255,1,cpu_full,0.002541,0.000000,0.002541,0.000000,0,9,pass,1,0.002542;0.002458;0.002541;0.002583;0.002667;0.002500;0.002708;0.002500;0.002500 -chain,256,255,1,cpu_distance,0.000667,0.000000,0.000667,0.000000,0,9,pass,0,0.000708;0.000708;0.000667;0.000667;0.000667;0.000625;0.000667;0.000667;0.000625 -chain,256,255,1,cpu_pool10,0.030291,0.000000,0.030291,0.000000,0,9,pass,1,0.040666;0.027042;0.030291;0.020500;0.027792;0.025250;0.030375;0.035666;0.036166 -random_masked_int64_reverse,1024,8192,8,cpu_full,0.597541,0.000000,0.597541,0.000000,0,9,pass,8,0.664167;0.636250;0.607958;0.597541;0.581709;0.579417;0.597958;0.566125;0.567250 -random_masked_int64_reverse,1024,8192,8,cpu_distance,0.289750,0.000000,0.289750,0.000000,0,9,pass,0,0.364125;0.346917;0.320291;0.304583;0.289750;0.277959;0.269708;0.270709;0.269083 -random_masked_int64_reverse,1024,8192,8,cpu_pool10,0.203375,0.000000,0.203375,0.000000,0,9,pass,8,0.154459;0.156792;0.230833;0.214083;0.201875;0.203375;0.201375;0.214167;0.273875 -random,4096,32768,1,cpu_full,0.712917,0.000000,0.712917,0.000000,0,9,pass,1,0.681042;0.659792;0.821583;0.779833;0.756666;0.737833;0.712917;0.691500;0.686209 -random,4096,32768,1,cpu_distance,0.309000,0.000000,0.309000,0.000000,0,9,pass,0,0.337875;0.318042;0.305958;0.309000;0.298542;0.341459;0.311000;0.303333;0.301958 -random,4096,32768,1,cpu_pool10,0.785625,0.000000,0.785625,0.000000,0,9,pass,1,0.844916;0.802291;0.791417;0.789125;0.785625;0.780541;0.729375;0.745541;0.746208 -random,4096,32768,64,cpu_full,52.727708,0.000000,52.727708,0.000000,0,9,pass,64,52.664666;52.541958;52.493167;52.884583;53.811209;55.433792;52.673542;53.223542;52.727708 -random,4096,32768,64,cpu_distance,25.830208,0.000000,25.830208,0.000000,0,9,pass,0,26.109875;26.458750;26.554584;25.830208;25.906208;25.789167;25.734000;25.723875;25.792500 -random,4096,32768,64,cpu_pool10,5.860083,0.000000,5.860083,0.000000,0,9,pass,64,5.935000;5.859958;5.821042;5.833000;5.901000;5.850250;5.860083;5.966084;5.929959 -random,4096,32768,256,cpu_full,207.992250,0.000000,207.992250,0.000000,0,9,pass,256,206.724000;208.200708;207.499625;206.872708;210.280083;213.188875;208.143708;206.021542;207.992250 -random,4096,32768,256,cpu_distance,104.051583,0.000000,104.051583,0.000000,0,9,pass,0,104.128959;105.184459;103.591917;103.345583;104.051583;104.073125;103.631084;103.562375;106.041209 -random,4096,32768,256,cpu_pool10,22.650792,0.000000,22.650792,0.000000,0,9,pass,256,23.191209;22.046667;33.122375;23.513166;22.648375;22.679792;22.127083;22.033000;22.650792 -random,16384,131072,1,cpu_full,3.929417,0.000000,3.929417,0.000000,0,9,pass,1,4.274125;3.899833;3.929417;4.422709;3.847792;4.645000;3.848333;4.002333;3.903083 -random,16384,131072,1,cpu_distance,2.171583,0.000000,2.171583,0.000000,0,9,pass,0,2.268166;2.171583;2.203000;2.184500;2.164416;2.150791;2.200750;1.966792;1.933916 -random,16384,131072,1,cpu_pool10,4.152458,0.000000,4.152458,0.000000,0,9,pass,1,4.705584;4.494792;4.191875;4.127666;4.152458;4.026250;3.900041;4.111959;4.444541 -random,16384,131072,64,cpu_full,247.691375,0.000000,247.691375,0.000000,0,9,pass,64,247.384417;248.870041;247.608084;247.486917;246.857833;247.691375;257.440917;250.836500;248.646583 -random,16384,131072,64,cpu_distance,131.827250,0.000000,131.827250,0.000000,0,9,pass,0,131.827250;132.225917;131.814417;131.767875;132.610542;131.409583;130.953708;135.935042;133.253334 -random,16384,131072,64,cpu_pool10,28.359041,0.000000,28.359041,0.000000,0,9,pass,64,28.115292;28.119084;28.112792;28.127334;28.368166;29.834500;29.085750;28.359041;30.483792 -random,65536,524288,1,cpu_full,22.647375,0.000000,22.647375,0.000000,0,9,pass,1,20.932000;23.518000;20.834416;24.593625;22.648875;22.647375;21.936583;22.682208;22.441333 -random,65536,524288,1,cpu_distance,11.794042,0.000000,11.794042,0.000000,0,9,pass,0,11.654459;12.429667;11.794042;11.909250;12.420542;12.983708;11.474417;10.956250;10.923417 -random,65536,524288,1,cpu_pool10,20.695417,0.000000,20.695417,0.000000,0,9,pass,1,20.689834;23.461167;20.695417;24.155625;22.147958;20.718083;20.171959;20.259875;19.568000 -random,65536,524288,64,cpu_full,1320.231250,0.000000,1320.231250,0.000000,0,9,pass,64,1342.003417;1308.543208;1329.705792;1330.231416;1315.563250;1302.226667;1320.231250;1351.369583;1294.068167 -random,65536,524288,64,cpu_distance,703.172916,0.000000,703.172916,0.000000,0,9,pass,0,696.462167;703.172916;729.499542;686.473125;723.540625;727.089417;694.442000;684.076291;730.469000 -random,65536,524288,64,cpu_pool10,190.623000,0.000000,190.623000,0.000000,0,9,pass,64,182.424666;192.549833;182.596875;191.734167;182.435666;193.379583;190.623000;178.277209;193.851375 -chain,2048,2047,1,cpu_full,0.019416,0.000000,0.019416,0.000000,0,9,pass,1,0.019625;0.019500;0.019458;0.019333;0.019375;0.019375;0.019542;0.019375;0.019416 -chain,2048,2047,1,cpu_distance,0.004792,0.000000,0.004792,0.000000,0,9,pass,0,0.004917;0.004750;0.004833;0.004792;0.004750;0.004792;0.004792;0.004708;0.004708 -chain,2048,2047,1,cpu_pool10,0.036834,0.000000,0.036834,0.000000,0,9,pass,1,0.036125;0.037875;0.036834;0.037875;0.037375;0.037583;0.035667;0.034875;0.035000 -random,4096,131072,1,cpu_full,1.679541,0.000000,1.679541,0.000000,0,9,pass,1,1.679541;1.643167;1.592084;1.694542;1.711875;1.677125;1.824875;1.698875;1.639500 -random,4096,131072,1,cpu_distance,0.691209,0.000000,0.691209,0.000000,0,9,pass,0,0.691209;0.674375;0.664250;0.662916;0.655750;0.889125;0.861542;0.732416;0.701209 -random,4096,131072,1,cpu_pool10,1.669292,0.000000,1.669292,0.000000,0,9,pass,1,1.698375;1.669292;1.648959;1.632208;1.717542;1.666333;1.649166;1.786166;1.750334 -random,4096,131072,64,cpu_full,111.530709,0.000000,111.530709,0.000000,0,9,pass,64,111.036208;111.092000;111.208375;111.335500;111.607834;111.530709;111.897333;123.337000;115.064042 -random,4096,131072,64,cpu_distance,49.874000,0.000000,49.874000,0.000000,0,9,pass,0,49.730958;49.667250;50.174584;49.874000;49.732041;49.854875;50.297125;51.777667;50.203834 -random,4096,131072,64,cpu_pool10,12.770500,0.000000,12.770500,0.000000,0,9,pass,64,12.853708;12.836916;12.777666;12.832250;12.754167;12.754208;12.725750;12.692792;12.770500 -clos,200,20000,1,cpu_full,0.144458,0.000000,0.144458,0.000000,0,9,pass,1,0.147125;0.149083;0.159583;0.143958;0.144000;0.144458;0.145500;0.143250;0.143833 -clos,200,20000,1,cpu_distance,0.029417,0.000000,0.029417,0.000000,0,9,pass,0,0.029500;0.029417;0.040209;0.026291;0.026250;0.027125;0.032500;0.032459;0.026417 -clos,200,20000,1,cpu_pool10,0.172209,0.000000,0.172209,0.000000,0,9,pass,1,0.177833;0.169000;0.184208;0.170542;0.170958;0.182333;0.172209;0.176916;0.169833 -clos,200,20000,64,cpu_full,9.344917,0.000000,9.344917,0.000000,0,9,pass,64,9.405083;9.327375;9.344917;9.256375;9.225166;9.460750;9.049167;9.419000;9.451500 -clos,200,20000,64,cpu_distance,1.703708,0.000000,1.703708,0.000000,0,9,pass,0,1.703708;1.771625;1.681083;1.686333;1.807333;1.656834;1.673250;1.711208;1.817500 -clos,200,20000,64,cpu_pool10,1.280458,0.000000,1.280458,0.000000,0,9,pass,64,1.468250;1.114291;1.538709;1.113000;1.129417;1.671500;1.280458;1.133958;1.785375 -clos,800,320000,1,cpu_full,2.456709,0.000000,2.456709,0.000000,0,9,pass,1,2.517875;2.386333;2.496791;2.554583;2.397208;2.427000;2.356875;2.456709;2.576250 -clos,800,320000,1,cpu_distance,0.394584,0.000000,0.394584,0.000000,0,9,pass,0,0.401958;0.389458;0.390875;0.397500;0.394584;0.393417;0.394917;0.397875;0.394500 -clos,800,320000,1,cpu_pool10,2.421333,0.000000,2.421333,0.000000,0,9,pass,1,2.361458;2.418500;2.669208;2.568208;2.395041;2.423167;2.493458;2.421333;2.412875 -clos,800,320000,64,cpu_full,150.211750,0.000000,150.211750,0.000000,0,9,pass,64,150.774000;149.614458;151.205917;149.062583;154.468125;149.091542;150.351875;149.293875;150.211750 -clos,800,320000,64,cpu_distance,25.404750,0.000000,25.404750,0.000000,0,9,pass,0,25.297542;25.404750;25.432334;25.347625;25.474333;25.363375;25.391333;25.542125;25.405834 -clos,800,320000,64,cpu_pool10,17.443042,0.000000,17.443042,0.000000,0,9,pass,64,17.723500;17.432708;18.498708;17.201500;17.314417;18.956917;17.443042;17.232667;18.136750 -grid,10000,39600,1,cpu_full,0.617416,0.000000,0.617416,0.000000,0,9,pass,1,0.637833;0.636542;0.617875;0.618792;0.617416;0.616791;0.612584;0.616750;0.611334 -grid,10000,39600,1,cpu_distance,0.179916,0.000000,0.179916,0.000000,0,9,pass,0,0.182250;0.179167;0.179000;0.179916;0.188250;0.185375;0.177833;0.179375;0.179958 -grid,10000,39600,1,cpu_pool10,0.686959,0.000000,0.686959,0.000000,0,9,pass,1,0.734417;0.711292;0.691458;0.686959;0.706042;0.685584;0.680833;0.677750;0.675167 -grid,10000,39600,8,cpu_full,5.587958,0.000000,5.587958,0.000000,0,9,pass,8,5.609542;5.540458;5.564500;5.599791;5.587958;5.604417;5.595583;5.556542;5.536167 -grid,10000,39600,8,cpu_distance,1.579834,0.000000,1.579834,0.000000,0,9,pass,0,1.598125;1.579834;1.561709;1.566750;1.563667;1.595083;1.588584;1.592917;1.559584 -grid,10000,39600,8,cpu_pool10,0.874125,0.000000,0.874125,0.000000,0,9,pass,8,1.307167;0.874125;0.840250;1.379708;0.871708;1.267542;0.834500;0.831583;1.212583 -grid,40000,159200,1,cpu_full,2.918042,0.000000,2.918042,0.000000,0,9,pass,1,2.908458;2.902584;2.863542;2.918042;2.936250;2.902583;2.953000;3.064666;3.009292 -grid,40000,159200,1,cpu_distance,0.900709,0.000000,0.900709,0.000000,0,9,pass,0,0.889459;0.930333;0.906083;0.909250;0.916958;0.887542;0.876750;0.878583;0.900709 -grid,40000,159200,1,cpu_pool10,3.041500,0.000000,3.041500,0.000000,0,9,pass,1,3.047042;3.043250;3.013167;3.027833;3.012417;3.099708;3.140125;3.041500;3.038209 diff --git a/dev/research/metal_pathfinding/results/aba-initial/b-stderr.txt b/dev/research/metal_pathfinding/results/aba-initial/b-stderr.txt deleted file mode 100644 index ac7eefb..0000000 --- a/dev/research/metal_pathfinding/results/aba-initial/b-stderr.txt +++ /dev/null @@ -1 +0,0 @@ -device=Apple M4 Max setup_ms=29.7193 execution_width=32 diff --git a/dev/research/metal_pathfinding/results/aba-initial/b.csv b/dev/research/metal_pathfinding/results/aba-initial/b.csv deleted file mode 100644 index 3f62dd7..0000000 --- a/dev/research/metal_pathfinding/results/aba-initial/b.csv +++ /dev/null @@ -1,69 +0,0 @@ -case,n,e,queries,method,wall_ms,gpu_ms,distance_wall_ms,upload_ms,rounds,samples,correct,exact_order_queries,wall_samples_ms -random,128,1024,1,metal_pull,0.694333,0.305875,0.688416,0.040542,16,9,pass,0,0.824625;0.880667;0.672541;0.714917;0.599375;0.591625;0.694333;0.808792;0.585333 -random,128,1024,1,metal_group,0.272125,0.130750,0.267833,0.040542,9,9,pass,0,0.753375;0.279375;0.267000;0.271958;0.271667;0.272125;0.500250;0.279209;0.263250 -random,128,1024,1,metal_shared,0.264750,0.123500,0.259625,0.040542,9,9,pass,0,0.267167;0.253583;0.264750;0.258417;0.246916;0.258500;0.287166;0.325875;0.362208 -random,128,1024,64,metal_pull,0.898834,0.346917,0.615667,0.060333,16,9,pass,0,1.087958;0.891333;0.974625;0.898834;0.940333;0.880416;0.842291;0.879875;0.907375 -random,128,1024,64,metal_group,0.597708,0.179375,0.323167,0.060333,13,9,pass,0,0.614250;0.678208;0.597708;0.597000;0.625875;0.600250;0.578042;0.571208;0.577208 -random,128,1024,64,metal_shared,0.598917,0.168542,0.314542,0.060333,13,9,pass,0,0.610708;0.607666;0.602542;0.608167;0.598917;0.583041;0.574083;0.558667;0.559542 -random,1024,8192,1,metal_pull,0.751708,0.398583,0.708791,0.082709,16,9,pass,0,0.788250;0.716333;0.715667;0.831709;0.762541;0.751708;0.716250;1.371375;0.701542 -random,1024,8192,1,metal_group,0.857958,0.678792,0.820333,0.082709,13,9,pass,0,0.861750;0.857458;0.853125;0.854958;0.857958;0.869708;0.900250;0.862750;0.851250 -random,1024,8192,1,metal_shared,0.822459,0.639208,0.780792,0.082709,13,9,pass,0,0.916292;0.802667;0.815750;0.840333;0.820834;0.838583;0.819125;0.822459;0.845916 -random,1024,8192,64,metal_pull,4.666000,1.543917,2.176875,0.114500,24,9,pass,0,4.584042;4.518958;5.016250;5.419500;4.748625;4.529042;4.565292;4.666000;4.892125 -random,1024,8192,64,metal_group,3.842875,0.893625,1.354458,0.114500,17,9,pass,0,3.842875;3.535417;3.878875;3.977333;3.835958;3.829125;3.878708;3.709792;3.910750 -random,1024,8192,64,metal_shared,3.773791,0.828458,1.284917,0.114500,17,9,pass,0,3.734166;3.713583;3.813542;3.809833;3.755083;3.695125;3.798167;3.937292;3.773791 -fabric,288,16384,1,metal_pull,1.859625,1.648625,1.806375,0.116000,8,9,pass,0,1.839917;1.864375;1.995041;1.859625;1.868375;1.937417;1.834584;1.844208;1.766292 -fabric,288,16384,1,metal_group,0.776375,0.576500,0.716917,0.116000,3,9,pass,0,0.786958;0.764250;0.845375;0.818083;0.774375;0.777167;0.776375;0.510167;0.555875 -fabric,288,16384,1,metal_shared,0.733292,0.546875,0.674250,0.116000,3,9,pass,0,0.730417;0.733292;0.786042;0.810708;0.739416;0.695333;0.488541;0.478875;0.892958 -fabric,288,16384,64,metal_pull,5.846791,1.800375,2.335667,0.145292,8,9,pass,0,5.086083;5.870000;5.846791;5.543417;5.846542;6.396292;5.965208;5.897375;5.826000 -fabric,288,16384,64,metal_group,4.629250,0.644375,1.190291,0.145292,3,9,pass,0,4.395458;4.694042;4.700083;4.629250;4.676292;4.376625;4.693042;4.293042;4.586375 -fabric,288,16384,64,metal_shared,4.801458,0.622000,1.157084,0.145292,3,9,pass,0,4.661208;4.952084;4.857542;4.645917;4.805500;4.682250;4.801458;6.446500;4.696791 -grid,1024,3968,1,metal_pull,1.846125,0.651292,1.827084,0.065416,64,9,pass,0,2.019292;1.846125;2.021708;1.716792;1.929959;1.710000;1.883416;1.696250;1.706834 -grid,1024,3968,1,metal_group,1.176292,1.008250,1.158542,0.065416,63,9,pass,0,1.164791;1.182625;1.161584;1.162708;1.213208;1.260084;1.171792;1.179334;1.176292 -grid,1024,3968,1,metal_shared,0.487541,0.348875,0.471292,0.065416,63,9,pass,0,0.505459;0.487541;0.486458;0.504208;0.512792;0.488416;0.461667;0.456291;0.466333 -grid,1024,3968,64,metal_pull,3.045875,0.567583,2.132125,0.080500,64,9,pass,0,2.971500;2.829791;3.048500;2.852875;3.045875;2.905750;3.125083;3.216166;3.372708 -grid,1024,3968,64,metal_group,1.864042,0.405958,0.949125,0.080500,63,9,pass,0,1.852166;1.796541;1.882167;1.836917;1.864042;1.955917;1.558459;1.924375;1.948792 -grid,1024,3968,64,metal_shared,1.958083,0.487208,1.013500,0.080500,63,9,pass,0,1.963458;1.958083;1.495541;2.055667;1.936667;1.975791;1.935166;1.944042;2.318917 -chain,256,255,1,metal_pull,5.824792,1.927375,5.822250,0.052833,256,9,pass,1,6.102125;5.943208;6.338167;5.852500;5.273083;5.824792;5.505208;5.067875;5.153459 -chain,256,255,1,metal_group,0.714667,0.608625,0.713083,0.052833,256,9,pass,1,0.711209;0.720959;0.709417;0.711916;0.716583;0.714667;0.708125;0.717959;0.815000 -chain,256,255,1,metal_shared,0.658709,0.552125,0.657209,0.052833,256,9,pass,1,0.658709;0.654916;0.653791;0.739209;0.801459;0.673667;0.663959;0.653000;0.649500 -random_masked_int64_reverse,1024,8192,8,metal_pull,0.682125,0.133708,0.245708,0.085083,8,9,pass,1,0.740958;0.668292;0.736208;0.661167;0.692542;0.682125;0.667625;0.685750;0.660917 -random_masked_int64_reverse,1024,8192,8,metal_group,0.749041,0.241083,0.338250,0.085083,8,9,pass,1,0.767500;0.754917;0.756750;0.745125;0.749041;0.626875;0.746833;0.751167;0.732333 -random_masked_int64_reverse,1024,8192,8,metal_shared,0.741709,0.224625,0.328917,0.085083,8,9,pass,1,0.723625;0.720375;0.715209;0.741709;0.728000;0.803041;0.774625;0.753041;0.768542 -random,4096,32768,1,metal_pull,0.812833,0.413625,0.660625,0.196375,16,9,pass,0,0.801458;0.806833;0.811333;0.814834;0.816083;0.812833;0.793167;0.860833;0.816417 -random,4096,32768,1,metal_group,1.505875,1.226583,1.358042,0.196375,16,9,pass,0,2.224875;1.505875;1.510208;1.338542;1.329209;1.352791;1.476209;1.584875;1.555541 -random,4096,32768,64,metal_pull,14.598084,3.937250,4.753209,0.208583,24,9,pass,0,14.252500;14.598084;15.751167;17.473250;14.592417;13.591042;17.424500;14.417500;18.520500 -random,4096,32768,64,metal_group,14.066250,4.080833,4.463417,0.208583,19,9,pass,0,14.129583;14.038459;11.394417;14.478125;14.066250;13.967625;14.229542;14.124334;11.212583 -random,4096,32768,256,metal_pull,52.239208,11.306291,13.131667,0.215541,24,9,pass,0,49.794333;49.768167;52.239208;51.144625;52.841292;51.508875;53.434292;53.943542;53.808208 -random,4096,32768,256,metal_group,49.850250,9.712792,10.485541,0.215541,20,9,pass,0,47.301833;48.041792;48.523375;51.754625;52.943792;51.925625;49.809875;49.850250;51.073500 -random,16384,131072,1,metal_pull,1.386041,0.270917,0.696084,0.583583,24,9,pass,0,1.349833;1.344791;1.398750;1.883167;1.773000;1.312167;1.368083;1.459333;1.386041 -random,16384,131072,1,metal_group,5.660333,4.802667,4.988333,0.583583,18,9,pass,0,5.774875;5.682834;5.790833;5.646959;5.583334;5.649250;5.620167;5.790166;5.660333 -random,16384,131072,64,metal_pull,57.259666,11.632292,12.902417,0.623375,24,9,pass,0,57.422542;57.092416;57.618708;57.291917;57.259666;56.624625;56.584416;57.963625;56.911000 -random,16384,131072,64,metal_group,54.493208,9.480500,10.270834,0.623375,22,9,pass,0,53.254667;53.490625;54.070542;54.493208;56.920459;56.394708;54.439875;54.946792;55.627125 -random,65536,524288,1,metal_pull,5.859042,1.942875,2.819833,2.711292,24,9,pass,0,5.889208;5.976292;6.990375;6.082208;5.859042;4.821625;4.951208;4.638042;4.792333 -random,65536,524288,1,metal_group,32.303250,28.469375,29.047916,2.711292,22,9,pass,0,31.766250;31.845500;31.635042;31.586375;32.434666;32.359458;32.305917;32.303250;32.318417 -random,65536,524288,64,metal_pull,269.299583,69.087833,71.201500,2.622333,32,9,pass,0,268.115458;271.970875;269.941292;268.528541;269.299583;269.076750;268.627791;269.599125;272.598000 -random,65536,524288,64,metal_group,274.914833,75.469958,76.650542,2.622333,25,9,pass,0,273.356208;272.365000;274.351291;274.330250;274.914833;277.346917;277.881250;276.976584;276.145209 -chain,2048,2047,1,metal_pull,29.862084,4.310752,29.851875,0.070834,2048,9,pass,1,29.859000;28.595000;29.774000;29.862084;30.292084;29.960708;30.157125;29.597333;30.245000 -chain,2048,2047,1,metal_group,4.815750,4.698792,4.806500,0.070834,2048,9,pass,1,4.798125;4.865583;4.803041;4.799208;4.910500;4.826875;4.881500;4.815625;4.815750 -random,4096,131072,1,metal_pull,0.896667,0.267792,0.483708,0.588333,16,9,pass,0,0.875625;0.890458;0.899750;0.896667;0.916875;0.904833;0.877958;0.906667;0.853708 -random,4096,131072,1,metal_group,2.694958,2.174167,2.280334,0.588333,12,9,pass,0,2.671709;2.646250;2.697916;2.805750;2.669416;2.684459;2.925000;2.738292;2.694958 -random,4096,131072,64,metal_pull,37.135333,8.692458,9.196958,0.675541,16,9,pass,0,35.238334;36.093709;35.691375;35.662584;37.135333;39.312250;39.152625;38.276667;38.420625 -random,4096,131072,64,metal_group,36.557458,7.510292,7.880583,0.675541,15,9,pass,0,34.011792;34.412958;38.154083;41.139291;34.516083;35.256750;36.557458;37.278875;36.584917 -clos,200,20000,1,metal_pull,0.669667,0.443375,0.599625,0.127375,8,9,pass,0,0.991167;0.672042;0.669667;0.674875;0.879750;0.652208;0.656792;0.666916;0.640167 -clos,200,20000,1,metal_group,0.366875,0.151917,0.297084,0.127375,3,9,pass,0,0.366875;0.384750;0.342125;0.357500;0.366042;0.383125;0.485541;0.479500;0.342416 -clos,200,20000,1,metal_shared,0.352542,0.142750,0.281125,0.127375,3,9,pass,0,0.407584;0.346917;0.357959;0.352542;0.342917;0.347167;0.347292;0.403458;0.358833 -clos,200,20000,64,metal_pull,5.845584,1.017542,1.578750,0.162000,8,9,pass,0,5.916459;5.769833;5.835500;5.656750;5.845959;5.728583;6.488792;5.845584;5.853792 -clos,200,20000,64,metal_group,5.049667,0.309625,0.890291,0.162000,3,9,pass,0,5.133958;4.883875;5.132000;5.048208;5.071291;5.049667;5.043708;5.150125;4.778417 -clos,200,20000,64,metal_shared,5.062500,0.293833,0.850083,0.162000,3,9,pass,0,5.062500;5.012500;4.969167;4.868250;5.078416;5.152583;5.033167;5.128291;5.161792 -clos,800,320000,1,metal_pull,4.467084,3.168541,3.390333,1.307417,8,9,pass,0,4.739125;4.467084;4.451583;4.742625;4.750208;4.833000;2.988708;1.954417;2.435167 -clos,800,320000,1,metal_group,2.647917,0.946375,1.618458,1.307417,3,9,pass,0,2.143583;5.294334;3.724166;2.616750;2.647917;5.521750;4.269459;2.507334;2.601250 -clos,800,320000,1,metal_shared,2.639667,0.905000,1.565042,1.307417,3,9,pass,0,2.636542;2.500625;2.630250;2.660500;4.998333;5.397416;4.654375;2.564250;2.639667 -clos,800,320000,64,metal_pull,84.884084,16.348833,16.846000,1.254500,8,9,pass,0,84.918625;83.551292;84.269958;83.793166;85.337916;88.659791;83.283542;88.303542;84.884084 -clos,800,320000,64,metal_group,73.596917,5.233958,5.823625,1.254500,3,9,pass,0,73.978875;73.856000;73.987333;73.942958;73.596917;73.378708;73.150542;73.522083;72.589416 -clos,800,320000,64,metal_shared,73.488333,5.098667,5.715750,1.254500,3,9,pass,0,73.912000;73.938666;72.677167;73.085209;72.850625;75.111209;74.153959;72.633250;73.488333 -grid,10000,39600,1,metal_pull,4.066792,0.588250,3.935042,0.170583,200,9,pass,0,5.971916;4.407667;3.591625;4.066792;4.094125;3.673125;3.720167;4.068667;3.871167 -grid,10000,39600,1,metal_group,7.897958,7.570500,7.738625,0.170583,199,9,pass,0,7.841042;7.899084;7.943167;7.855458;7.873667;7.914667;7.897958;7.891959;8.008416 -grid,10000,39600,8,metal_pull,6.015875,1.397917,5.012500,0.167167,200,9,pass,0,6.379375;5.981750;6.565208;5.743083;6.015875;5.603667;6.220792;5.636583;6.580167 -grid,10000,39600,8,metal_group,9.084958,7.487958,8.109417,0.167167,199,9,pass,0,8.986459;9.386500;9.080083;9.138000;9.178042;9.223667;8.955833;9.082250;9.084958 -grid,40000,159200,1,metal_pull,8.366125,1.756501,7.870792,0.498500,400,9,pass,0,8.677875;9.271667;8.710083;8.543291;8.366125;7.339959;7.248500;7.223375;7.600666 -grid,40000,159200,1,metal_group,65.593041,64.849167,65.113958,0.498500,399,9,pass,0,65.552584;65.564333;65.593041;65.452333;66.461167;66.104959;65.226667;65.833166;65.986167 diff --git a/dev/research/metal_pathfinding/results/aba-initial/environment.txt b/dev/research/metal_pathfinding/results/aba-initial/environment.txt deleted file mode 100644 index a449a5e..0000000 --- a/dev/research/metal_pathfinding/results/aba-initial/environment.txt +++ /dev/null @@ -1,17 +0,0 @@ -2026-09-26T23:02:54Z -fbab119e794c843838f4f3f96d75cb7f32f06845 -ProductName: macOS -ProductVersion: 26.6.1 -BuildVersion: 25G76 -Apple M4 Max -38654705664 -14 -Apple clang version 21.0.0 (clang-2100.1.1.101) -Target: arm64-apple-darwin25.6.0 -Thread model: posix -InstalledDir: /Library/Developer/CommandLineTools/usr/bin -0ea7d865d0d979f1ed79612cff1270bbf2d45425a7548cf7c7fa9595f5f1463b dev/research/metal_pathfinding/experiment.mm -2e36b1623a3940bf1f19f5d81141bb1def14f5873fe2a98aab0101bbe68618a3 dev/research/metal_pathfinding/sssp.metal -Note: No thermal warning level has been recorded -Note: No performance warning level has been recorded -Note: No CPU power status has been recorded diff --git a/dev/research/metal_pathfinding/results/aba-initial/gpu-load-a1.txt b/dev/research/metal_pathfinding/results/aba-initial/gpu-load-a1.txt deleted file mode 100644 index 2a9fdd8..0000000 --- a/dev/research/metal_pathfinding/results/aba-initial/gpu-load-a1.txt +++ /dev/null @@ -1 +0,0 @@ - | "PerformanceStatistics" = {"In use system memory (driver)"=0,"Alloc system memory"=10844422144,"Tiler Utilization %"=5,"recoveryCount"=0,"lastRecoveryTime"=0,"Renderer Utilization %"=8,"TiledSceneBytes"=1441792,"Device Utilization %"=23,"SplitSceneCount"=0,"Allocated PB Size"=187301888,"In use system memory"=1340784640} diff --git a/dev/research/metal_pathfinding/results/aba-initial/gpu-load-a2.txt b/dev/research/metal_pathfinding/results/aba-initial/gpu-load-a2.txt deleted file mode 100644 index fb2f6fe..0000000 --- a/dev/research/metal_pathfinding/results/aba-initial/gpu-load-a2.txt +++ /dev/null @@ -1 +0,0 @@ - | "PerformanceStatistics" = {"In use system memory (driver)"=0,"Alloc system memory"=10848157696,"Tiler Utilization %"=2,"recoveryCount"=0,"lastRecoveryTime"=0,"Renderer Utilization %"=5,"TiledSceneBytes"=1441792,"Device Utilization %"=75,"SplitSceneCount"=0,"Allocated PB Size"=187301888,"In use system memory"=1341440000} diff --git a/dev/research/metal_pathfinding/results/aba-initial/gpu-load-b.txt b/dev/research/metal_pathfinding/results/aba-initial/gpu-load-b.txt deleted file mode 100644 index 67b7835..0000000 --- a/dev/research/metal_pathfinding/results/aba-initial/gpu-load-b.txt +++ /dev/null @@ -1 +0,0 @@ - | "PerformanceStatistics" = {"In use system memory (driver)"=0,"Alloc system memory"=10846322688,"Tiler Utilization %"=6,"recoveryCount"=0,"lastRecoveryTime"=0,"Renderer Utilization %"=10,"TiledSceneBytes"=1441792,"Device Utilization %"=10,"SplitSceneCount"=0,"Allocated PB Size"=187301888,"In use system memory"=1391755264} diff --git a/dev/research/metal_pathfinding/results/aba-initial/load-a1.txt b/dev/research/metal_pathfinding/results/aba-initial/load-a1.txt deleted file mode 100644 index afe6af8..0000000 --- a/dev/research/metal_pathfinding/results/aba-initial/load-a1.txt +++ /dev/null @@ -1,40 +0,0 @@ -Processes: 1029 total, 3 running, 1026 sleeping, 5267 threads -2026/09/27 00:02:55 -Load Avg: 3.44, 4.22, 4.55 -CPU usage: 12.78% user, 9.58% sys, 77.62% idle -SharedLibs: 914M resident, 168M data, 135M linkedit. -MemRegions: 640562 total, 10G resident, 741M private, 4397M shared. -PhysMem: 34G used (3993M wired, 633M compressor), 1623M unused. -VM: 455T vsize, 6144M framework vsize, 2843901(0) swapins, 4729331(0) swapouts. -Networks: packets: 190112204/161G in, 145523736/163G out. -Disks: 97543153/2475G read, 117275070/2097G written. - -PID COMMAND %CPU #TH -99753 mscamerad-xpc 0.0 2 -99752 PlugInLibrarySer 0.0 2 -99750 QuickLookUIServi 0.0 3 -99749 com.apple.appkit 0.0 3 -99710 PlugInLibrarySer 0.0 2 -99709 Preview 0.0 4 -99428 crashpad_handler 0.0 4 -98995 crashpad_handler 0.0 4 -Processes: 1030 total, 4 running, 1026 sleeping, 5290 threads -2026/09/27 00:02:56 -Load Avg: 3.56, 4.24, 4.55 -CPU usage: 16.1% user, 6.20% sys, 77.77% idle -SharedLibs: 914M resident, 168M data, 135M linkedit. -MemRegions: 1440 total, 222M resident, 741M private, 4390M shared. -PhysMem: 34G used (3993M wired, 633M compressor), 1565M unused. -VM: 455T vsize, 6144M framework vsize, 2843901(0) swapins, 4729331(0) swapouts. -Networks: packets: 190112459/161G in, 145523987/163G out. -Disks: 97543153/2475G read, 117275092/2097G written. - -PID COMMAND %CPU #TH -1225 mediaanalysisd 33.3 18/2 -590 WindowServer 30.9 28 -70968 Superset Helper 20.4 32 -0 kernel_task 13.0 850/14 -70942 Superset Helper 7.2 19 -59318 top 5.9 1/1 -65401 codex 3.4 38 -70934 Superset 3.1 50 diff --git a/dev/research/metal_pathfinding/results/aba-initial/load-a2.txt b/dev/research/metal_pathfinding/results/aba-initial/load-a2.txt deleted file mode 100644 index 7d011ae..0000000 --- a/dev/research/metal_pathfinding/results/aba-initial/load-a2.txt +++ /dev/null @@ -1,40 +0,0 @@ -Processes: 1029 total, 3 running, 1026 sleeping, 5318 threads -2026/09/27 00:04:25 -Load Avg: 4.06, 4.30, 4.55 -CPU usage: 11.52% user, 15.65% sys, 72.82% idle -SharedLibs: 914M resident, 168M data, 135M linkedit. -MemRegions: 638423 total, 10G resident, 742M private, 4205M shared. -PhysMem: 33G used (4028M wired, 633M compressor), 1920M unused. -VM: 455T vsize, 6144M framework vsize, 2843901(0) swapins, 4729331(0) swapouts. -Networks: packets: 190145627/161G in, 145557848/163G out. -Disks: 97548223/2475G read, 117280135/2097G written. - -PID COMMAND %CPU #TH -99753 mscamerad-xpc 0.0 2 -99752 PlugInLibrarySer 0.0 2 -99750 QuickLookUIServi 0.0 3 -99749 com.apple.appkit 0.0 3 -99710 PlugInLibrarySer 0.0 2 -99709 Preview 0.0 4 -99428 crashpad_handler 0.0 4 -98995 crashpad_handler 0.0 4 -Processes: 1029 total, 2 running, 1027 sleeping, 5313 threads -2026/09/27 00:04:27 -Load Avg: 4.70, 4.43, 4.59 -CPU usage: 7.48% user, 5.9% sys, 87.41% idle -SharedLibs: 914M resident, 168M data, 135M linkedit. -MemRegions: 0 total, 0B resident, 742M private, 4205M shared. -PhysMem: 33G used (3992M wired, 633M compressor), 1918M unused. -VM: 455T vsize, 6144M framework vsize, 2843901(0) swapins, 4729331(0) swapouts. -Networks: packets: 190145997/161G in, 145558207/163G out. -Disks: 97548250/2475G read, 117280139/2097G written. - -PID COMMAND %CPU #TH -590 WindowServer 36.6 28 -70968 Superset Helper 23.2 33 -594 runningboardd 21.0 7 -0 kernel_task 15.7 850/14 -66135 top 8.9 1/1 -70942 Superset Helper 7.7 18 -21666 2.1.283 6.3 27 -39196 PerfPowerService 5.7 13 diff --git a/dev/research/metal_pathfinding/results/aba-initial/load-b.txt b/dev/research/metal_pathfinding/results/aba-initial/load-b.txt deleted file mode 100644 index a06f45c..0000000 --- a/dev/research/metal_pathfinding/results/aba-initial/load-b.txt +++ /dev/null @@ -1,40 +0,0 @@ -Processes: 1038 total, 4 running, 1034 sleeping, 5297 threads -2026/09/27 00:03:54 -Load Avg: 4.38, 4.36, 4.57 -CPU usage: 6.7% user, 9.19% sys, 84.73% idle -SharedLibs: 914M resident, 168M data, 135M linkedit. -MemRegions: 638679 total, 10G resident, 752M private, 4206M shared. -PhysMem: 33G used (3994M wired, 633M compressor), 1786M unused. -VM: 458T vsize, 6144M framework vsize, 2843901(0) swapins, 4729331(0) swapouts. -Networks: packets: 190133303/161G in, 145544526/163G out. -Disks: 97547513/2475G read, 117278580/2097G written. - -PID COMMAND %CPU #TH -99753 mscamerad-xpc 0.0 2 -99752 PlugInLibrarySer 0.0 2 -99750 QuickLookUIServi 0.0 3 -99749 com.apple.appkit 0.0 3 -99710 PlugInLibrarySer 0.0 2 -99709 Preview 0.0 4 -99428 crashpad_handler 0.0 4 -98995 crashpad_handler 0.0 4 -Processes: 1044 total, 4 running, 1040 sleeping, 5315 threads -2026/09/27 00:03:55 -Load Avg: 4.38, 4.36, 4.57 -CPU usage: 15.9% user, 8.23% sys, 76.66% idle -SharedLibs: 914M resident, 168M data, 135M linkedit. -MemRegions: 1073 total, 191M resident, 752M private, 4208M shared. -PhysMem: 34G used (4060M wired, 633M compressor), 1564M unused. -VM: 461T vsize, 6144M framework vsize, 2843901(0) swapins, 4729331(0) swapouts. -Networks: packets: 190134081/161G in, 145545447/163G out. -Disks: 97547534/2475G read, 117278594/2097G written. - -PID COMMAND %CPU #TH -590 WindowServer 29.1 28 -70968 Superset Helper 24.8 32/1 -1225 mediaanalysisd 24.0 18 -0 kernel_task 14.2 850/14 -517 fseventsd 10.7 16 -81804 2.1.283 9.4 27 -70942 Superset Helper 8.5 18 -63750 top 6.4 1/1 diff --git a/dev/research/metal_pathfinding/results/aba-initial/summary.md b/dev/research/metal_pathfinding/results/aba-initial/summary.md deleted file mode 100644 index 09724ff..0000000 --- a/dev/research/metal_pathfinding/results/aba-initial/summary.md +++ /dev/null @@ -1,38 +0,0 @@ -All times are medians in milliseconds per batch, with nine timed samples. -CPU A columns use serial SPF for Q=1 and the 10-worker pool otherwise. -GPU includes host distance copies and serial CPU ECMP DAG reconstruction. -Upload and pipeline setup are excluded and recorded separately in raw data. -The table picks the fastest measured GPU method for each case. - -| Case | V / E | Q | CPU A1 | GPU B | CPU A2 | GPU method | Distance-only B | CPU drift | -|---|---:|---:|---:|---:|---:|---|---:|---:| -| random | 128 / 1,024 | 1 | 0.010 | 0.265 | 0.011 | metal_shared | 0.260 | +12.6% | -| random | 128 / 1,024 | 64 | 0.182 | 0.598 | 0.157 | metal_group | 0.323 | -14.1% | -| random | 1,024 / 8,192 | 1 | 0.104 | 0.752 | 0.109 | metal_pull | 0.709 | +5.1% | -| random | 1,024 / 8,192 | 64 | 1.342 | 3.774 | 1.277 | metal_shared | 1.285 | -4.9% | -| fabric | 288 / 16,384 | 1 | 0.112 | 0.733 | 0.110 | metal_shared | 0.674 | -2.5% | -| fabric | 288 / 16,384 | 64 | 0.972 | 4.629 | 0.995 | metal_group | 1.190 | +2.3% | -| grid | 1,024 / 3,968 | 1 | 0.044 | 0.488 | 0.043 | metal_shared | 0.471 | -2.2% | -| grid | 1,024 / 3,968 | 64 | 0.543 | 1.864 | 0.546 | metal_group | 0.949 | +0.5% | -| chain | 256 / 255 | 1 | 0.003 | 0.659 | 0.003 | metal_shared | 0.657 | -0.0% | -| random_masked_int64_reverse | 1,024 / 8,192 | 8 | 0.156 | 0.682 | 0.203 | metal_pull | 0.246 | +30.6% | -| random | 4,096 / 32,768 | 1 | 0.657 | 0.813 | 0.713 | metal_pull | 0.661 | +8.6% | -| random | 4,096 / 32,768 | 64 | 5.992 | 14.066 | 5.860 | metal_group | 4.463 | -2.2% | -| random | 4,096 / 32,768 | 256 | 23.001 | 49.850 | 22.651 | metal_group | 10.486 | -1.5% | -| random | 16,384 / 131,072 | 1 | 3.887 | 1.386 | 3.929 | metal_pull | 0.696 | +1.1% | -| random | 16,384 / 131,072 | 64 | 28.586 | 54.493 | 28.359 | metal_group | 10.271 | -0.8% | -| random | 65,536 / 524,288 | 1 | 22.864 | 5.859 | 22.647 | metal_pull | 2.820 | -0.9% | -| random | 65,536 / 524,288 | 64 | 181.874 | 269.300 | 190.623 | metal_pull | 71.201 | +4.8% | -| chain | 2,048 / 2,047 | 1 | 0.020 | 4.816 | 0.019 | metal_group | 4.806 | -1.3% | -| random | 4,096 / 131,072 | 1 | 1.540 | 0.897 | 1.680 | metal_pull | 0.484 | +9.1% | -| random | 4,096 / 131,072 | 64 | 12.857 | 36.557 | 12.771 | metal_group | 7.881 | -0.7% | -| clos | 200 / 20,000 | 1 | 0.139 | 0.353 | 0.144 | metal_shared | 0.281 | +4.1% | -| clos | 200 / 20,000 | 64 | 1.167 | 5.050 | 1.280 | metal_group | 0.890 | +9.7% | -| clos | 800 / 320,000 | 1 | 2.314 | 2.640 | 2.457 | metal_shared | 1.565 | +6.2% | -| clos | 800 / 320,000 | 64 | 17.328 | 73.488 | 17.443 | metal_shared | 5.716 | +0.7% | -| grid | 10,000 / 39,600 | 1 | 0.631 | 4.067 | 0.617 | metal_pull | 3.935 | -2.2% | -| grid | 10,000 / 39,600 | 8 | 0.852 | 6.016 | 0.874 | metal_pull | 5.013 | +2.6% | -| grid | 40,000 / 159,200 | 1 | 2.919 | 8.366 | 2.918 | metal_pull | 7.871 | -0.0% | - -Distance-only timings stop before DAG reconstruction; they are not SPF API timings. -CPU distance-only and serial full-batch controls are also available in a1/a2.csv. diff --git a/dev/research/metal_pathfinding/results/aba/a1-stderr.txt b/dev/research/metal_pathfinding/results/aba/a1-stderr.txt deleted file mode 100644 index e69de29..0000000 diff --git a/dev/research/metal_pathfinding/results/aba/a1.csv b/dev/research/metal_pathfinding/results/aba/a1.csv deleted file mode 100644 index 85a75b7..0000000 --- a/dev/research/metal_pathfinding/results/aba/a1.csv +++ /dev/null @@ -1,109 +0,0 @@ -case,n,e,queries,method,wall_ms,gpu_ms,distance_wall_ms,upload_ms,rounds,samples,correct,exact_order_queries,wall_samples_ms -random,128,1024,1,cpu_full,0.031083,0.000000,0.031083,0.000000,0,9,pass,1,0.025959;0.047458;0.031083;0.025458;0.089375;0.046875;0.027416;0.042458;0.027167 -random,128,1024,1,cpu_distance,0.010625,0.000000,0.010625,0.000000,0,9,pass,0,0.010625;0.010083;0.053000;0.028416;0.012625;0.010750;0.010250;0.009917;0.009917 -random,128,1024,1,cpu_pool10,0.065334,0.000000,0.065334,0.000000,0,9,pass,1,0.072125;0.134959;0.047291;0.041166;0.047625;0.065334;0.108833;0.065334;0.039959 -random,128,1024,1,cpu_pool10_distance,0.031291,0.000000,0.031291,0.000000,0,9,pass,0,0.069500;0.029708;0.031500;0.034125;0.031291;0.036583;0.029125;0.026166;0.027500 -random,128,1024,64,cpu_full,2.259334,0.000000,2.259334,0.000000,0,9,pass,64,2.880125;2.602792;2.582041;2.345583;2.175084;2.259334;2.142166;1.871250;1.849166 -random,128,1024,64,cpu_distance,0.604334,0.000000,0.604334,0.000000,0,9,pass,0,0.777792;0.734917;0.664542;0.613875;0.604334;0.593916;0.557875;0.527708;0.496709 -random,128,1024,64,cpu_pool10,0.234125,0.000000,0.234125,0.000000,0,9,pass,64,0.336750;0.342667;0.316625;0.254000;0.223584;0.234125;0.198583;0.190167;0.206083 -random,128,1024,64,cpu_pool10_distance,0.126417,0.000000,0.126417,0.000000,0,9,pass,0,0.131541;0.137000;0.126417;0.129334;0.111583;0.122541;0.128042;0.108500;0.113791 -random,1024,8192,1,cpu_full,0.145417,0.000000,0.145417,0.000000,0,9,pass,1,0.168083;0.164667;0.152583;0.147458;0.144750;0.145417;0.145333;0.144000;0.144834 -random,1024,8192,1,cpu_distance,0.080125,0.000000,0.080125,0.000000,0,9,pass,0,0.083250;0.080750;0.079959;0.080333;0.079334;0.079584;0.079541;0.081667;0.080125 -random,1024,8192,1,cpu_pool10,0.228792,0.000000,0.228792,0.000000,0,9,pass,1,0.265000;0.228792;0.263084;0.261959;0.227500;0.220083;0.220208;0.195542;0.237208 -random,1024,8192,1,cpu_pool10_distance,0.134708,0.000000,0.134708,0.000000,0,9,pass,0,0.140625;0.138291;0.116250;0.142541;0.134708;0.110167;0.100417;0.150417;0.111000 -random,1024,8192,64,cpu_full,10.256834,0.000000,10.256834,0.000000,0,9,pass,64,10.220708;10.174458;10.082500;10.185167;10.265750;10.277833;10.256834;10.274708;10.452625 -random,1024,8192,64,cpu_distance,5.040292,0.000000,5.040292,0.000000,0,9,pass,0,5.296791;5.411084;5.040292;5.061084;5.073833;4.945042;4.947209;5.020833;4.953291 -random,1024,8192,64,cpu_pool10,1.273334,0.000000,1.273334,0.000000,0,9,pass,64,1.261917;1.254709;1.264208;1.274875;1.273334;1.289083;1.297209;1.288209;1.251541 -random,1024,8192,64,cpu_pool10_distance,0.633542,0.000000,0.633542,0.000000,0,9,pass,0,0.646750;0.634292;0.633542;0.642042;0.639000;0.627834;0.626209;0.630625;0.617000 -fabric,288,16384,1,cpu_full,0.106625,0.000000,0.106625,0.000000,0,9,pass,1,0.103000;0.111958;0.103875;0.109167;0.103208;0.109958;0.103458;0.108709;0.106625 -fabric,288,16384,1,cpu_distance,0.022209,0.000000,0.022209,0.000000,0,9,pass,0,0.022542;0.022375;0.022292;0.022209;0.022042;0.022125;0.022083;0.022041;0.022209 -fabric,288,16384,1,cpu_pool10,0.136416,0.000000,0.136416,0.000000,0,9,pass,1,0.144084;0.130041;0.140500;0.131666;0.137458;0.136416;0.133250;0.129459;0.141334 -fabric,288,16384,1,cpu_pool10_distance,0.051583,0.000000,0.051583,0.000000,0,9,pass,0,0.043250;0.040625;0.052750;0.044667;0.058000;0.053625;0.047084;0.063917;0.051583 -fabric,288,16384,64,cpu_full,7.037208,0.000000,7.037208,0.000000,0,9,pass,64,7.154417;7.377959;7.160000;6.919500;7.004708;7.303625;7.037208;6.997208;7.035792 -fabric,288,16384,64,cpu_distance,1.361708,0.000000,1.361708,0.000000,0,9,pass,0,1.336542;1.394208;1.345500;1.334958;1.364208;1.410250;1.361000;1.369667;1.361708 -fabric,288,16384,64,cpu_pool10,1.010542,0.000000,1.010542,0.000000,0,9,pass,64,0.891250;1.371208;1.054042;0.880958;1.157417;1.010542;0.909041;1.264625;0.965167 -fabric,288,16384,64,cpu_pool10_distance,0.194375,0.000000,0.194375,0.000000,0,9,pass,0,0.209667;0.195750;0.213708;0.207041;0.194250;0.193041;0.188792;0.190834;0.194375 -grid,1024,3968,1,cpu_full,0.037042,0.000000,0.037042,0.000000,0,9,pass,1,0.038875;0.037542;0.037042;0.037250;0.037209;0.036791;0.036917;0.036875;0.036916 -grid,1024,3968,1,cpu_distance,0.010250,0.000000,0.010250,0.000000,0,9,pass,0,0.010792;0.010500;0.010375;0.010375;0.010167;0.010250;0.010125;0.009917;0.010000 -grid,1024,3968,1,cpu_pool10,0.072834,0.000000,0.072834,0.000000,0,9,pass,1,0.082750;0.064583;0.077709;0.063250;0.082875;0.072834;0.070625;0.063208;0.078708 -grid,1024,3968,1,cpu_pool10_distance,0.029667,0.000000,0.029667,0.000000,0,9,pass,0,0.031583;0.028041;0.028125;0.035917;0.029667;0.030750;0.029500;0.026959;0.031833 -grid,1024,3968,64,cpu_full,3.824709,0.000000,3.824709,0.000000,0,9,pass,64,3.806875;3.924791;3.797833;3.863083;3.824709;3.822500;3.807833;3.827250;3.879416 -grid,1024,3968,64,cpu_distance,0.853125,0.000000,0.853125,0.000000,0,9,pass,0,0.887958;0.871792;0.859042;0.873250;0.842917;0.853125;0.849041;0.839958;0.845458 -grid,1024,3968,64,cpu_pool10,0.498458,0.000000,0.498458,0.000000,0,9,pass,64,0.486167;0.522625;0.487375;0.493542;0.521041;0.498458;0.512292;0.498917;0.481625 -grid,1024,3968,64,cpu_pool10_distance,0.146458,0.000000,0.146458,0.000000,0,9,pass,0,0.148833;0.140667;0.145375;0.160292;0.149875;0.139458;0.172917;0.146458;0.141541 -chain,256,255,1,cpu_full,0.002250,0.000000,0.002250,0.000000,0,9,pass,1,0.003500;0.002292;0.002250;0.002291;0.002250;0.002208;0.002250;0.002291;0.002209 -chain,256,255,1,cpu_distance,0.000500,0.000000,0.000500,0.000000,0,9,pass,0,0.000583;0.000459;0.000542;0.000500;0.000458;0.000459;0.000500;0.000459;0.000583 -chain,256,255,1,cpu_pool10,0.033416,0.000000,0.033416,0.000000,0,9,pass,1,0.031625;0.035916;0.026833;0.033416;0.027625;0.024667;0.033708;0.034708;0.036959 -chain,256,255,1,cpu_pool10_distance,0.032375,0.000000,0.032375,0.000000,0,9,pass,0,0.033750;0.033000;0.030500;0.024583;0.043792;0.019459;0.032375;0.032750;0.026333 -random_masked_int64_reverse,1024,8192,8,cpu_full,0.585459,0.000000,0.585459,0.000000,0,9,pass,8,0.634625;0.620333;0.607833;0.610125;0.585459;0.575459;0.583375;0.570917;0.562458 -random_masked_int64_reverse,1024,8192,8,cpu_distance,0.279667,0.000000,0.279667,0.000000,0,9,pass,0,0.336250;0.315000;0.279667;0.277041;0.278250;0.280958;0.286333;0.267625;0.273000 -random_masked_int64_reverse,1024,8192,8,cpu_pool10,0.149667,0.000000,0.149667,0.000000,0,9,pass,8,0.147333;0.155041;0.152666;0.154875;0.149667;0.143208;0.138250;0.151125;0.131666 -random_masked_int64_reverse,1024,8192,8,cpu_pool10_distance,0.092750,0.000000,0.092750,0.000000,0,9,pass,0,0.093708;0.096667;0.091667;0.088125;0.092083;0.090250;0.097916;0.099708;0.092750 -random,4096,32768,1,cpu_full,0.640750,0.000000,0.640750,0.000000,0,9,pass,1,0.654125;0.672584;0.635750;0.640750;0.630541;0.715625;0.663917;0.620958;0.638875 -random,4096,32768,1,cpu_distance,0.305042,0.000000,0.305042,0.000000,0,9,pass,0,0.318834;0.383000;0.356625;0.305042;0.285792;0.279584;0.276209;0.281125;0.308542 -random,4096,32768,1,cpu_pool10,0.750792,0.000000,0.750792,0.000000,0,9,pass,1,0.732959;0.763292;0.765875;0.765083;0.750291;0.750792;0.697250;0.738625;0.866042 -random,4096,32768,1,cpu_pool10_distance,0.396125,0.000000,0.396125,0.000000,0,9,pass,0,0.391083;0.399042;0.400041;0.445875;0.352625;0.396125;0.416417;0.368375;0.364292 -random,4096,32768,64,cpu_full,50.551959,0.000000,50.551959,0.000000,0,9,pass,64,50.982291;50.381667;51.224250;50.245000;49.884125;50.077625;50.921250;50.551959;51.063625 -random,4096,32768,64,cpu_distance,25.068208,0.000000,25.068208,0.000000,0,9,pass,0,24.892083;25.225875;25.305542;24.956250;25.502416;25.652625;24.782041;25.068208;24.635917 -random,4096,32768,64,cpu_pool10,5.984083,0.000000,5.984083,0.000000,0,9,pass,64,6.019500;6.024709;5.952959;5.948666;5.984083;6.007625;5.921583;5.963333;6.381042 -random,4096,32768,64,cpu_pool10_distance,2.967750,0.000000,2.967750,0.000000,0,9,pass,0,2.974166;2.993208;2.954208;2.951584;2.963167;2.988542;2.967750;2.972125;2.958500 -random,4096,32768,256,cpu_full,203.524291,0.000000,203.524291,0.000000,0,9,pass,256,201.889458;203.077667;202.445916;205.140292;207.688792;203.452125;204.337834;203.558833;203.524291 -random,4096,32768,256,cpu_distance,100.870916,0.000000,100.870916,0.000000,0,9,pass,0,102.179417;100.391417;99.621458;99.962083;100.870916;100.120167;102.712666;103.321958;103.868375 -random,4096,32768,256,cpu_pool10,23.461417,0.000000,23.461417,0.000000,0,9,pass,256,23.404958;23.153542;23.566042;22.393541;23.576000;23.669625;28.794875;23.461417;23.088458 -random,4096,32768,256,cpu_pool10_distance,12.019459,0.000000,12.019459,0.000000,0,9,pass,0,11.456875;11.726541;12.966375;12.238042;12.717875;12.019459;11.796125;12.376375;11.948333 -random,16384,131072,1,cpu_full,4.095417,0.000000,4.095417,0.000000,0,9,pass,1,4.290250;3.940416;4.150417;3.954583;3.887333;4.095417;3.985083;4.162667;4.295334 -random,16384,131072,1,cpu_distance,2.076458,0.000000,2.076458,0.000000,0,9,pass,0,2.369584;2.076458;1.996792;2.022250;2.259167;2.148209;2.015833;2.012167;2.160583 -random,16384,131072,1,cpu_pool10,3.907000,0.000000,3.907000,0.000000,0,9,pass,1,3.736125;3.756166;4.311583;4.175125;3.876833;3.907000;4.442625;3.681041;4.181042 -random,16384,131072,1,cpu_pool10_distance,1.988250,0.000000,1.988250,0.000000,0,9,pass,0,1.939375;1.947750;1.892166;1.988250;1.939125;1.997125;2.016583;2.086959;2.044542 -random,16384,131072,64,cpu_full,246.791250,0.000000,246.791250,0.000000,0,9,pass,64,247.174041;257.095833;248.073500;242.532542;244.690333;241.950875;242.319083;246.791250;248.494458 -random,16384,131072,64,cpu_distance,128.259375,0.000000,128.259375,0.000000,0,9,pass,0,128.418500;127.874875;128.259375;128.375083;129.444958;127.964208;128.118000;128.115125;128.568334 -random,16384,131072,64,cpu_pool10,28.289500,0.000000,28.289500,0.000000,0,9,pass,64,28.152417;28.391750;28.205000;28.321167;28.289500;28.210041;28.201417;28.320125;28.388750 -random,16384,131072,64,cpu_pool10_distance,14.897583,0.000000,14.897583,0.000000,0,9,pass,0,14.930292;14.903167;14.878917;14.855125;14.861167;14.889166;14.897583;14.933375;14.931750 -random,65536,524288,1,cpu_full,20.761667,0.000000,20.761667,0.000000,0,9,pass,1,20.893041;20.761667;21.248417;22.771708;19.348959;21.047583;20.342500;19.837625;20.622167 -random,65536,524288,1,cpu_distance,10.450375,0.000000,10.450375,0.000000,0,9,pass,0,10.685375;10.484542;10.293209;10.711500;10.304833;10.450375;10.329542;10.734417;10.264709 -random,65536,524288,1,cpu_pool10,21.484917,0.000000,21.484917,0.000000,0,9,pass,1,18.160875;18.542833;18.610750;19.389750;22.475417;22.478750;21.749084;21.484917;21.888666 -random,65536,524288,1,cpu_pool10_distance,10.246625,0.000000,10.246625,0.000000,0,9,pass,0,11.192042;10.267166;9.973292;10.866416;10.246625;10.542208;10.197125;10.197459;9.896917 -random,65536,524288,64,cpu_full,1257.924209,0.000000,1257.924209,0.000000,0,9,pass,64,1282.247375;1223.090292;1257.924209;1239.818375;1313.894125;1432.189959;1278.728875;1241.412875;1243.033125 -random,65536,524288,64,cpu_distance,665.201250,0.000000,665.201250,0.000000,0,9,pass,0,662.326750;666.337417;657.751459;704.184958;697.644750;659.886125;673.242417;661.982917;665.201250 -random,65536,524288,64,cpu_pool10,174.081708,0.000000,174.081708,0.000000,0,9,pass,64,172.784166;174.081708;191.587583;188.869292;171.705625;172.985750;174.222334;173.790417;176.710000 -random,65536,524288,64,cpu_pool10_distance,85.845625,0.000000,85.845625,0.000000,0,9,pass,0,85.845625;85.446458;85.561875;85.000792;84.927542;86.648500;86.624791;86.942667;86.348917 -chain,2048,2047,1,cpu_full,0.019875,0.000000,0.019875,0.000000,0,9,pass,1,0.020458;0.019959;0.019875;0.019583;0.020208;0.019667;0.019459;0.019625;0.020042 -chain,2048,2047,1,cpu_distance,0.004125,0.000000,0.004125,0.000000,0,9,pass,0,0.004125;0.004084;0.004791;0.006167;0.004167;0.004125;0.004208;0.004125;0.004125 -chain,2048,2047,1,cpu_pool10,0.047125,0.000000,0.047125,0.000000,0,9,pass,1,0.046833;0.050542;0.047125;0.052875;0.048833;0.043500;0.047750;0.046042;0.045958 -chain,2048,2047,1,cpu_pool10_distance,0.038333,0.000000,0.038333,0.000000,0,9,pass,0,0.040375;0.035459;0.067417;0.054459;0.036875;0.033208;0.037333;0.039625;0.038333 -random,4096,131072,1,cpu_full,1.762250,0.000000,1.762250,0.000000,0,9,pass,1,1.806584;1.750208;1.770750;1.810833;1.762250;1.677250;1.621583;1.615750;1.778292 -random,4096,131072,1,cpu_distance,0.664917,0.000000,0.664917,0.000000,0,9,pass,0,0.857084;0.738708;0.715458;0.683333;0.664917;0.663708;0.659416;0.646792;0.643541 -random,4096,131072,1,cpu_pool10,1.889833,0.000000,1.889833,0.000000,0,9,pass,1,2.152625;1.844084;1.923250;1.834959;3.235375;1.889833;1.780667;1.750917;1.954125 -random,4096,131072,1,cpu_pool10_distance,0.804916,0.000000,0.804916,0.000000,0,9,pass,0,0.804916;0.831125;0.772208;0.863541;0.727250;0.842625;0.810208;0.785750;0.799167 -random,4096,131072,64,cpu_full,111.141125,0.000000,111.141125,0.000000,0,9,pass,64,110.630292;111.750125;111.395709;110.963250;112.158667;110.807166;115.478417;111.141125;110.905125 -random,4096,131072,64,cpu_distance,49.129500,0.000000,49.129500,0.000000,0,9,pass,0,49.369208;49.268292;49.148708;48.819417;48.989750;49.011250;49.129500;49.123375;49.282917 -random,4096,131072,64,cpu_pool10,12.773541,0.000000,12.773541,0.000000,0,9,pass,64,12.743708;12.746417;12.784667;12.899042;12.785000;12.787333;12.773541;12.744042;12.745292 -random,4096,131072,64,cpu_pool10_distance,5.610042,0.000000,5.610042,0.000000,0,9,pass,0,5.610042;5.667416;5.604791;5.622333;5.622292;5.609708;5.594958;5.616958;5.591208 -clos,200,20000,1,cpu_full,0.146333,0.000000,0.146333,0.000000,0,9,pass,1,0.155084;0.150416;0.158791;0.145667;0.143875;0.146333;0.147833;0.145000;0.145083 -clos,200,20000,1,cpu_distance,0.024083,0.000000,0.024083,0.000000,0,9,pass,0,0.024459;0.024500;0.024459;0.024292;0.024083;0.023958;0.023916;0.023916;0.023833 -clos,200,20000,1,cpu_pool10,0.170166,0.000000,0.170166,0.000000,0,9,pass,1,0.180250;0.168541;0.179459;0.169875;0.181375;0.166083;0.172709;0.170166;0.163959 -clos,200,20000,1,cpu_pool10_distance,0.049459,0.000000,0.049459,0.000000,0,9,pass,0,0.049458;0.054750;0.042083;0.050958;0.047250;0.049792;0.043166;0.049459;0.050333 -clos,200,20000,64,cpu_full,9.367041,0.000000,9.367041,0.000000,0,9,pass,64,9.367041;9.242792;9.226125;9.416416;9.313959;9.471959;9.546000;9.554458;9.346500 -clos,200,20000,64,cpu_distance,1.571458,0.000000,1.571458,0.000000,0,9,pass,0,1.547375;1.586542;1.571458;1.591250;1.581791;1.544750;1.563000;1.727667;1.547708 -clos,200,20000,64,cpu_pool10,1.180583,0.000000,1.180583,0.000000,0,9,pass,64,1.180583;1.425375;1.678916;1.141583;1.178625;1.772750;1.123250;1.115709;1.238625 -clos,200,20000,64,cpu_pool10_distance,0.210666,0.000000,0.210666,0.000000,0,9,pass,0,0.210666;0.210167;0.210791;0.213917;0.212750;0.209541;0.208833;0.222125;0.209959 -clos,800,320000,1,cpu_full,2.329667,0.000000,2.329667,0.000000,0,9,pass,1,2.286500;2.305791;2.298750;2.346875;2.329667;2.382750;2.380916;2.264417;2.339750 -clos,800,320000,1,cpu_distance,0.356042,0.000000,0.356042,0.000000,0,9,pass,0,0.355584;0.363375;0.355792;0.355042;0.358042;0.354375;0.369792;0.356042;0.356083 -clos,800,320000,1,cpu_pool10,2.282875,0.000000,2.282875,0.000000,0,9,pass,1,2.323791;2.271917;2.281042;2.342584;2.281667;2.282875;2.308708;2.378000;2.273750 -clos,800,320000,1,cpu_pool10_distance,0.353625,0.000000,0.353625,0.000000,0,9,pass,0,0.352834;0.357209;0.353625;0.343166;0.347125;0.376250;0.361042;0.350583;0.377083 -clos,800,320000,64,cpu_full,149.821292,0.000000,149.821292,0.000000,0,9,pass,64,153.024458;148.259167;148.473541;147.598584;150.283417;149.821292;152.691250;151.819917;149.421125 -clos,800,320000,64,cpu_distance,23.226250,0.000000,23.226250,0.000000,0,9,pass,0,23.181583;23.269459;23.226250;23.155542;23.227333;23.171541;23.268875;23.184708;23.296417 -clos,800,320000,64,cpu_pool10,17.461541,0.000000,17.461541,0.000000,0,9,pass,64,17.357208;17.461541;17.265958;18.456042;17.167833;18.505625;18.100083;17.112291;17.978709 -clos,800,320000,64,cpu_pool10_distance,2.672125,0.000000,2.672125,0.000000,0,9,pass,0,2.659791;2.670333;2.681541;2.679541;2.675000;2.728833;2.665083;2.668416;2.672125 -grid,10000,39600,1,cpu_full,0.613750,0.000000,0.613750,0.000000,0,9,pass,1,0.622750;0.618000;0.613750;0.612417;0.615000;0.608000;0.611792;0.607708;0.645042 -grid,10000,39600,1,cpu_distance,0.175083,0.000000,0.175083,0.000000,0,9,pass,0,0.182917;0.172292;0.174584;0.178042;0.175083;0.177042;0.174958;0.176834;0.173000 -grid,10000,39600,1,cpu_pool10,0.669667,0.000000,0.669667,0.000000,0,9,pass,1,0.701208;0.654875;0.704833;0.664958;0.683667;0.685791;0.669667;0.635500;0.627875 -grid,10000,39600,1,cpu_pool10_distance,0.198375,0.000000,0.198375,0.000000,0,9,pass,0,0.198375;0.200542;0.189542;0.197417;0.203625;0.207292;0.197625;0.185166;0.207542 -grid,10000,39600,8,cpu_full,5.544583,0.000000,5.544583,0.000000,0,9,pass,8,5.634791;5.632250;5.544583;5.519792;5.581667;5.498625;5.532291;5.510583;5.655459 -grid,10000,39600,8,cpu_distance,1.551083,0.000000,1.551083,0.000000,0,9,pass,0,1.551083;1.545959;1.550084;1.538041;1.562667;1.553583;1.550583;1.559500;1.551958 -grid,10000,39600,8,cpu_pool10,0.828792,0.000000,0.828792,0.000000,0,9,pass,8,0.829458;0.826417;0.816667;0.841541;0.828792;0.840833;0.818709;0.849666;0.807209 -grid,10000,39600,8,cpu_pool10_distance,0.246709,0.000000,0.246709,0.000000,0,9,pass,0,0.252792;0.247292;0.246709;0.250959;0.243625;0.241375;0.250417;0.245958;0.237708 -grid,40000,159200,1,cpu_full,2.912625,0.000000,2.912625,0.000000,0,9,pass,1,2.956292;2.945167;2.898708;2.920250;2.897166;2.912625;2.900000;2.877417;2.937083 -grid,40000,159200,1,cpu_distance,0.862709,0.000000,0.862709,0.000000,0,9,pass,0,0.866083;0.862709;0.859791;0.868292;0.861792;0.854458;0.858000;0.891958;0.880750 -grid,40000,159200,1,cpu_pool10,2.922000,0.000000,2.922000,0.000000,0,9,pass,1,2.913416;3.005208;2.922000;2.959416;2.880500;2.921209;2.976625;2.863500;2.943000 -grid,40000,159200,1,cpu_pool10_distance,0.859875,0.000000,0.859875,0.000000,0,9,pass,0,0.871416;0.871500;0.890875;0.859875;0.857583;0.859875;0.851917;0.858750;0.884625 diff --git a/dev/research/metal_pathfinding/results/aba/a2-stderr.txt b/dev/research/metal_pathfinding/results/aba/a2-stderr.txt deleted file mode 100644 index e69de29..0000000 diff --git a/dev/research/metal_pathfinding/results/aba/a2.csv b/dev/research/metal_pathfinding/results/aba/a2.csv deleted file mode 100644 index c09febd..0000000 --- a/dev/research/metal_pathfinding/results/aba/a2.csv +++ /dev/null @@ -1,109 +0,0 @@ -case,n,e,queries,method,wall_ms,gpu_ms,distance_wall_ms,upload_ms,rounds,samples,correct,exact_order_queries,wall_samples_ms -random,128,1024,1,cpu_full,0.010125,0.000000,0.010125,0.000000,0,9,pass,1,0.011291;0.010208;0.009417;0.009834;0.010125;0.010667;0.009375;0.010708;0.009500 -random,128,1024,1,cpu_distance,0.004542,0.000000,0.004542,0.000000,0,9,pass,0,0.007583;0.004542;0.004209;0.006084;0.005083;0.004250;0.005041;0.004125;0.004083 -random,128,1024,1,cpu_pool10,0.038500,0.000000,0.038500,0.000000,0,9,pass,1,0.043292;0.032917;0.043125;0.028500;0.041667;0.029708;0.027125;0.045875;0.038500 -random,128,1024,1,cpu_pool10_distance,0.032458,0.000000,0.032458,0.000000,0,9,pass,0,0.028416;0.034125;0.028625;0.040542;0.036459;0.031792;0.032458;0.046292;0.029625 -random,128,1024,64,cpu_full,0.882084,0.000000,0.882084,0.000000,0,9,pass,64,0.943708;0.939166;0.898167;0.865625;0.835250;0.817583;0.808000;0.981042;0.882084 -random,128,1024,64,cpu_distance,0.352750,0.000000,0.352750,0.000000,0,9,pass,0,0.396292;0.383500;0.370208;0.364500;0.352750;0.319208;0.299125;0.288833;0.281458 -random,128,1024,64,cpu_pool10,0.162250,0.000000,0.162250,0.000000,0,9,pass,64,0.186542;0.160833;0.162250;0.166667;0.178041;0.168500;0.157208;0.155042;0.145833 -random,128,1024,64,cpu_pool10_distance,0.101625,0.000000,0.101625,0.000000,0,9,pass,0,0.120959;0.109750;0.105459;0.100958;0.101625;0.098250;0.090542;0.097292;0.111625 -random,1024,8192,1,cpu_full,0.114917,0.000000,0.114917,0.000000,0,9,pass,1,0.115166;0.182417;0.159666;0.129625;0.114917;0.104542;0.107958;0.114625;0.109583 -random,1024,8192,1,cpu_distance,0.056041,0.000000,0.056041,0.000000,0,9,pass,0,0.059250;0.057125;0.056875;0.056292;0.055458;0.055625;0.055584;0.055750;0.056041 -random,1024,8192,1,cpu_pool10,0.176208,0.000000,0.176208,0.000000,0,9,pass,1,0.226166;0.199125;0.176375;0.176208;0.186917;0.144625;0.122041;0.161459;0.117833 -random,1024,8192,1,cpu_pool10_distance,0.081084,0.000000,0.081084,0.000000,0,9,pass,0,0.093458;0.083833;0.079416;0.075917;0.089958;0.077500;0.081084;0.077875;0.102834 -random,1024,8192,64,cpu_full,10.597708,0.000000,10.597708,0.000000,0,9,pass,64,10.453250;10.597708;10.592875;10.643083;10.582625;10.578917;10.603500;10.599333;10.665834 -random,1024,8192,64,cpu_distance,5.116292,0.000000,5.116292,0.000000,0,9,pass,0,5.129709;5.086792;5.078084;5.113291;4.995542;5.247625;5.135333;5.178792;5.116292 -random,1024,8192,64,cpu_pool10,1.330416,0.000000,1.330416,0.000000,0,9,pass,64,1.344375;1.330416;1.368250;1.355459;1.320791;1.322667;1.316250;1.328917;1.334500 -random,1024,8192,64,cpu_pool10_distance,0.657208,0.000000,0.657208,0.000000,0,9,pass,0,0.699125;0.687958;0.645250;0.685584;0.656208;0.638084;0.657792;0.654250;0.657208 -fabric,288,16384,1,cpu_full,0.109875,0.000000,0.109875,0.000000,0,9,pass,1,0.110875;0.110875;0.109834;0.109708;0.109875;0.109500;0.109541;0.113917;0.116625 -fabric,288,16384,1,cpu_distance,0.021542,0.000000,0.021542,0.000000,0,9,pass,0,0.022208;0.021625;0.021750;0.021667;0.021542;0.021500;0.021458;0.021375;0.021375 -fabric,288,16384,1,cpu_pool10,0.135458,0.000000,0.135458,0.000000,0,9,pass,1,0.133584;0.135666;0.133667;0.133542;0.137292;0.141667;0.135458;0.136208;0.130291 -fabric,288,16384,1,cpu_pool10_distance,0.039583,0.000000,0.039583,0.000000,0,9,pass,0,0.036625;0.038750;0.036875;0.041333;0.040791;0.041500;0.039583;0.040666;0.037458 -fabric,288,16384,64,cpu_full,7.310750,0.000000,7.310750,0.000000,0,9,pass,64,7.344708;7.291375;7.259292;7.500875;7.443000;7.251542;7.310750;7.236333;7.320583 -fabric,288,16384,64,cpu_distance,1.393958,0.000000,1.393958,0.000000,0,9,pass,0,1.388542;1.386750;1.402250;1.364583;1.427250;1.416834;1.427416;1.393917;1.393958 -fabric,288,16384,64,cpu_pool10,0.991791,0.000000,0.991791,0.000000,0,9,pass,64,1.373958;0.994750;0.972667;0.947500;1.129792;1.035750;0.958875;0.953333;0.991791 -fabric,288,16384,64,cpu_pool10_distance,0.219167,0.000000,0.219167,0.000000,0,9,pass,0,0.218291;0.221500;0.250875;0.233959;0.234083;0.219167;0.216541;0.212542;0.204333 -grid,1024,3968,1,cpu_full,0.045500,0.000000,0.045500,0.000000,0,9,pass,1,0.047542;0.044041;0.042958;0.044708;0.045500;0.045750;0.045042;0.045583;0.045833 -grid,1024,3968,1,cpu_distance,0.011750,0.000000,0.011750,0.000000,0,9,pass,0,0.012250;0.012083;0.012000;0.011834;0.011750;0.011708;0.011750;0.011750;0.011750 -grid,1024,3968,1,cpu_pool10,0.084417,0.000000,0.084417,0.000000,0,9,pass,1,0.094708;0.087625;0.076625;0.061000;0.084417;0.067833;0.096875;0.117792;0.078792 -grid,1024,3968,1,cpu_pool10_distance,0.045500,0.000000,0.045500,0.000000,0,9,pass,0,0.064333;0.045667;0.031125;0.033708;0.035250;0.053875;0.045500;0.041792;0.057500 -grid,1024,3968,64,cpu_full,4.000542,0.000000,4.000542,0.000000,0,9,pass,64,3.861917;4.025167;3.945917;3.903083;3.850167;4.000542;4.054667;4.033458;4.074458 -grid,1024,3968,64,cpu_distance,0.911416,0.000000,0.911416,0.000000,0,9,pass,0,0.911416;0.948042;1.001584;0.923792;0.934333;0.881667;0.869666;0.863375;0.878458 -grid,1024,3968,64,cpu_pool10,0.556209,0.000000,0.556209,0.000000,0,9,pass,64,0.582292;0.556209;0.572125;0.576792;0.514250;0.520333;0.565041;0.535541;0.523625 -grid,1024,3968,64,cpu_pool10_distance,0.157333,0.000000,0.157333,0.000000,0,9,pass,0,0.159250;0.156500;0.148250;0.157333;0.149000;0.169417;0.172209;0.159416;0.156792 -chain,256,255,1,cpu_full,0.002542,0.000000,0.002542,0.000000,0,9,pass,1,0.002667;0.002542;0.002584;0.002542;0.002542;0.002541;0.002583;0.002500;0.002500 -chain,256,255,1,cpu_distance,0.000958,0.000000,0.000958,0.000000,0,9,pass,0,0.000875;0.000958;0.000917;0.000958;0.001000;0.001000;0.000917;0.000959;0.000667 -chain,256,255,1,cpu_pool10,0.038084,0.000000,0.038084,0.000000,0,9,pass,1,0.049917;0.022958;0.059167;0.026500;0.041958;0.038084;0.036959;0.032916;0.039875 -chain,256,255,1,cpu_pool10_distance,0.032084,0.000000,0.032084,0.000000,0,9,pass,0,0.034417;0.027541;0.024833;0.059083;0.032084;0.036416;0.031375;0.035792;0.031709 -random_masked_int64_reverse,1024,8192,8,cpu_full,0.622542,0.000000,0.622542,0.000000,0,9,pass,8,0.656083;0.637375;0.623500;0.618166;0.612250;0.638208;0.621334;0.622542;0.615333 -random_masked_int64_reverse,1024,8192,8,cpu_distance,0.313375,0.000000,0.313375,0.000000,0,9,pass,0,0.455750;0.416334;0.353042;0.336416;0.311333;0.313375;0.298417;0.294709;0.274708 -random_masked_int64_reverse,1024,8192,8,cpu_pool10,0.149333,0.000000,0.149333,0.000000,0,9,pass,8,0.161500;0.170125;0.149333;0.148958;0.164959;0.147667;0.152334;0.145125;0.147209 -random_masked_int64_reverse,1024,8192,8,cpu_pool10_distance,0.094875,0.000000,0.094875,0.000000,0,9,pass,0,0.098500;0.094875;0.090916;0.093625;0.098708;0.092167;0.097417;0.095833;0.086709 -random,4096,32768,1,cpu_full,0.665250,0.000000,0.665250,0.000000,0,9,pass,1,0.705917;0.685375;0.671708;0.665250;0.653375;0.651750;0.648125;0.643875;0.835666 -random,4096,32768,1,cpu_distance,0.321708,0.000000,0.321708,0.000000,0,9,pass,0,0.410750;0.405125;0.388500;0.354875;0.321708;0.306916;0.311333;0.304709;0.306125 -random,4096,32768,1,cpu_pool10,0.846916,0.000000,0.846916,0.000000,0,9,pass,1,0.846916;0.862333;0.751875;0.909917;0.887167;0.744667;0.925958;0.837833;0.797042 -random,4096,32768,1,cpu_pool10_distance,0.419167,0.000000,0.419167,0.000000,0,9,pass,0,0.497750;0.422541;0.426500;0.419167;0.427625;0.391417;0.366791;0.368958;0.339334 -random,4096,32768,64,cpu_full,52.102750,0.000000,52.102750,0.000000,0,9,pass,64,54.203292;52.887375;53.023041;52.102750;52.049958;52.098542;51.865416;52.029500;52.275167 -random,4096,32768,64,cpu_distance,25.658708,0.000000,25.658708,0.000000,0,9,pass,0,25.682333;25.592583;25.472083;25.676084;25.588542;25.605833;25.926083;25.931125;25.658708 -random,4096,32768,64,cpu_pool10,5.952500,0.000000,5.952500,0.000000,0,9,pass,64,5.960083;5.958084;5.952500;5.969125;5.916334;5.945459;5.953625;5.943708;5.924416 -random,4096,32768,64,cpu_pool10_distance,2.946417,0.000000,2.946417,0.000000,0,9,pass,0,2.963708;2.961416;2.946417;2.949209;2.940333;2.938209;2.953708;2.929417;2.940166 -random,4096,32768,256,cpu_full,207.033042,0.000000,207.033042,0.000000,0,9,pass,256,208.565333;207.732417;206.951125;206.977250;206.746375;206.994000;207.033042;207.599666;207.870500 -random,4096,32768,256,cpu_distance,104.569458,0.000000,104.569458,0.000000,0,9,pass,0,105.489166;105.644292;104.210667;102.589875;104.569458;105.916375;103.105292;102.977125;107.510125 -random,4096,32768,256,cpu_pool10,23.386500,0.000000,23.386500,0.000000,0,9,pass,256,40.046458;23.359125;23.306292;23.386500;24.774417;23.782250;22.939459;22.685208;23.506417 -random,4096,32768,256,cpu_pool10_distance,11.367917,0.000000,11.367917,0.000000,0,9,pass,0,11.047292;10.975666;11.068375;11.096125;11.459875;11.367917;11.371333;11.403250;11.463708 -random,16384,131072,1,cpu_full,3.915167,0.000000,3.915167,0.000000,0,9,pass,1,3.871459;4.099250;4.174833;3.915167;4.067542;3.926667;3.831250;3.879667;3.870000 -random,16384,131072,1,cpu_distance,1.960417,0.000000,1.960417,0.000000,0,9,pass,0,2.021875;1.960417;1.889875;1.857792;2.170125;2.063458;2.063166;1.951458;1.904583 -random,16384,131072,1,cpu_pool10,3.940500,0.000000,3.940500,0.000000,0,9,pass,1,3.966375;3.874167;3.981292;4.094000;3.940500;4.023542;3.924041;3.918916;3.782834 -random,16384,131072,1,cpu_pool10_distance,2.005583,0.000000,2.005583,0.000000,0,9,pass,0,2.005583;2.140833;2.080500;2.099291;1.932666;2.019334;1.991167;1.966417;1.891875 -random,16384,131072,64,cpu_full,249.146417,0.000000,249.146417,0.000000,0,9,pass,64,252.116875;249.126041;248.933209;249.146417;248.897333;248.611000;260.195000;253.889292;251.157125 -random,16384,131072,64,cpu_distance,132.024792,0.000000,132.024792,0.000000,0,9,pass,0,131.589042;132.093459;131.786375;132.343417;132.024792;131.772916;131.826667;132.098333;135.958042 -random,16384,131072,64,cpu_pool10,28.429458,0.000000,28.429458,0.000000,0,9,pass,64,28.800875;29.234250;28.369500;28.398458;28.653125;28.380084;28.296458;29.236500;28.429458 -random,16384,131072,64,cpu_pool10_distance,15.058709,0.000000,15.058709,0.000000,0,9,pass,0,15.146458;15.097000;15.004334;15.058709;15.114167;15.033833;15.072750;14.995417;14.993083 -random,65536,524288,1,cpu_full,22.344958,0.000000,22.344958,0.000000,0,9,pass,1,20.957625;24.492167;21.762292;22.567791;23.338708;23.546042;22.344958;20.185500;20.875542 -random,65536,524288,1,cpu_distance,11.777084,0.000000,11.777084,0.000000,0,9,pass,0,10.724750;10.590250;10.794833;11.777084;12.048833;13.794083;19.637375;12.146958;11.220417 -random,65536,524288,1,cpu_pool10,21.763875,0.000000,21.763875,0.000000,0,9,pass,1,20.818541;20.408583;21.249875;21.763875;22.344583;21.183208;22.291542;22.923167;22.071500 -random,65536,524288,1,cpu_pool10_distance,10.599042,0.000000,10.599042,0.000000,0,9,pass,0,11.239875;11.393583;10.554125;10.913917;10.701125;10.246333;10.511791;10.435750;10.599042 -random,65536,524288,64,cpu_full,1286.349583,0.000000,1286.349583,0.000000,0,9,pass,64,1436.236750;1242.607958;1309.405000;1284.448917;1317.436833;1286.349583;1280.854542;1304.641916;1262.528792 -random,65536,524288,64,cpu_distance,687.072041,0.000000,687.072041,0.000000,0,9,pass,0,684.747959;695.653708;779.019209;761.691542;753.222334;687.072041;685.139583;682.279667;676.369750 -random,65536,524288,64,cpu_pool10,189.990250,0.000000,189.990250,0.000000,0,9,pass,64,185.822458;179.728458;185.915209;182.268708;189.990250;192.153125;192.239500;198.405625;190.860042 -random,65536,524288,64,cpu_pool10_distance,93.937500,0.000000,93.937500,0.000000,0,9,pass,0,99.322250;93.374583;93.937500;94.460250;87.558167;89.512333;88.269208;104.313125;95.677000 -chain,2048,2047,1,cpu_full,0.019666,0.000000,0.019666,0.000000,0,9,pass,1,0.019541;0.019542;0.019666;0.019791;0.019708;0.019791;0.019625;0.019625;0.019666 -chain,2048,2047,1,cpu_distance,0.004042,0.000000,0.004042,0.000000,0,9,pass,0,0.004209;0.004083;0.004042;0.004083;0.004042;0.004000;0.004042;0.004041;0.004209 -chain,2048,2047,1,cpu_pool10,0.048500,0.000000,0.048500,0.000000,0,9,pass,1,0.050000;0.044166;0.051875;0.049250;0.048500;0.044291;0.041708;0.047042;0.072375 -chain,2048,2047,1,cpu_pool10_distance,0.042333,0.000000,0.042333,0.000000,0,9,pass,0,0.062625;0.052375;0.035417;0.041667;0.042333;0.039750;0.045375;0.040334;0.063042 -random,4096,131072,1,cpu_full,1.762667,0.000000,1.762667,0.000000,0,9,pass,1,1.783083;1.739791;1.677667;1.630625;1.836959;1.791583;1.798959;1.762667;1.697500 -random,4096,131072,1,cpu_distance,0.680958,0.000000,0.680958,0.000000,0,9,pass,0,0.821875;0.748750;0.696833;0.677666;0.680958;0.664792;0.675959;0.692125;0.670458 -random,4096,131072,1,cpu_pool10,1.771417,0.000000,1.771417,0.000000,0,9,pass,1,1.784500;1.758750;1.775333;1.712750;1.894250;1.771417;1.747083;1.638625;1.985833 -random,4096,131072,1,cpu_pool10_distance,0.768625,0.000000,0.768625,0.000000,0,9,pass,0,0.760541;0.750709;0.868917;0.747375;0.830375;0.832375;0.811041;0.748417;0.768625 -random,4096,131072,64,cpu_full,114.166208,0.000000,114.166208,0.000000,0,9,pass,64,114.166208;116.409167;117.530542;116.397459;113.341167;114.552042;112.884375;113.846000;112.957667 -random,4096,131072,64,cpu_distance,50.983000,0.000000,50.983000,0.000000,0,9,pass,0,49.911458;52.691959;51.868542;51.245250;50.838875;50.500250;51.579291;50.983000;49.495584 -random,4096,131072,64,cpu_pool10,12.922583,0.000000,12.922583,0.000000,0,9,pass,64,12.922583;13.513000;12.838000;12.768708;12.765625;12.907458;12.986166;13.330500;12.955583 -random,4096,131072,64,cpu_pool10_distance,5.637750,0.000000,5.637750,0.000000,0,9,pass,0,5.667166;5.637750;5.624833;5.653125;5.619750;5.603500;5.658417;5.609250;5.802792 -clos,200,20000,1,cpu_full,0.144333,0.000000,0.144333,0.000000,0,9,pass,1,0.148333;0.146667;0.143875;0.144333;0.143083;0.142916;0.145292;0.143083;0.144583 -clos,200,20000,1,cpu_distance,0.023875,0.000000,0.023875,0.000000,0,9,pass,0,0.024500;0.024583;0.025375;0.023916;0.023875;0.023625;0.023709;0.023833;0.023708 -clos,200,20000,1,cpu_pool10,0.181708,0.000000,0.181708,0.000000,0,9,pass,1,0.195084;0.181834;0.174416;0.181708;0.185958;0.175625;0.172917;0.182959;0.177958 -clos,200,20000,1,cpu_pool10_distance,0.045791,0.000000,0.045791,0.000000,0,9,pass,0,0.045125;0.044042;0.045959;0.044208;0.044125;0.045791;0.051208;0.053792;0.047958 -clos,200,20000,64,cpu_full,9.353375,0.000000,9.353375,0.000000,0,9,pass,64,9.553375;9.510625;9.461208;9.351667;9.505875;9.271583;9.353375;9.296625;9.265625 -clos,200,20000,64,cpu_distance,1.548583,0.000000,1.548583,0.000000,0,9,pass,0,1.557625;1.542666;1.548583;1.566084;1.550334;1.554500;1.535959;1.540042;1.542584 -clos,200,20000,64,cpu_pool10,1.698750,0.000000,1.698750,0.000000,0,9,pass,64,1.173084;2.027625;1.917583;1.160792;1.453625;1.748917;1.155959;1.698750;1.717292 -clos,200,20000,64,cpu_pool10_distance,0.208292,0.000000,0.208292,0.000000,0,9,pass,0,0.202125;0.205000;0.210625;0.208292;0.209000;0.211875;0.209833;0.204000;0.205084 -clos,800,320000,1,cpu_full,2.367500,0.000000,2.367500,0.000000,0,9,pass,1,2.290667;2.292958;2.367500;2.330167;2.377042;2.379000;2.437042;2.441333;2.338166 -clos,800,320000,1,cpu_distance,0.354709,0.000000,0.354709,0.000000,0,9,pass,0,0.354416;0.354709;0.368084;0.354375;0.354916;0.354459;0.355250;0.354125;0.354833 -clos,800,320000,1,cpu_pool10,2.355459,0.000000,2.355459,0.000000,0,9,pass,1,2.333708;2.340416;2.323208;2.343625;2.355459;2.450958;2.476583;2.390417;2.366000 -clos,800,320000,1,cpu_pool10_distance,0.384625,0.000000,0.384625,0.000000,0,9,pass,0,0.390750;0.377542;0.374417;0.393666;0.384625;0.371542;0.365750;0.433833;0.465958 -clos,800,320000,64,cpu_full,150.499083,0.000000,150.499083,0.000000,0,9,pass,64,150.015333;149.947166;150.499083;156.703375;152.934333;149.782625;148.341584;150.854583;152.985292 -clos,800,320000,64,cpu_distance,23.215333,0.000000,23.215333,0.000000,0,9,pass,0,23.143708;23.199416;23.217792;23.347208;23.196417;23.255250;23.228292;23.215333;23.190166 -clos,800,320000,64,cpu_pool10,17.633500,0.000000,17.633500,0.000000,0,9,pass,64,18.064708;17.153916;17.209292;17.633500;18.146959;17.173000;17.843000;17.199125;18.153375 -clos,800,320000,64,cpu_pool10_distance,2.661125,0.000000,2.661125,0.000000,0,9,pass,0,2.661792;2.661125;2.652542;2.656709;2.671125;2.672625;2.656959;2.663500;2.651500 -grid,10000,39600,1,cpu_full,0.616459,0.000000,0.616459,0.000000,0,9,pass,1,0.626625;0.616459;0.613167;0.613625;0.625416;0.613000;0.608542;0.639750;0.714666 -grid,10000,39600,1,cpu_distance,0.183167,0.000000,0.183167,0.000000,0,9,pass,0,0.196583;0.182042;0.193041;0.183167;0.183584;0.199417;0.177208;0.178875;0.180833 -grid,10000,39600,1,cpu_pool10,0.698583,0.000000,0.698583,0.000000,0,9,pass,1,0.721334;0.700833;0.734208;0.698333;0.704459;0.686333;0.682375;0.698583;0.695333 -grid,10000,39600,1,cpu_pool10_distance,0.203416,0.000000,0.203416,0.000000,0,9,pass,0,0.205958;0.199667;0.214250;0.198750;0.205666;0.203416;0.192709;0.208667;0.193792 -grid,10000,39600,8,cpu_full,5.581208,0.000000,5.581208,0.000000,0,9,pass,8,5.578792;5.599916;5.618333;5.582417;5.581208;5.543958;5.535083;5.537709;5.587084 -grid,10000,39600,8,cpu_distance,1.567875,0.000000,1.567875,0.000000,0,9,pass,0,1.580000;1.576542;1.555958;1.555125;1.567875;1.579667;1.546834;1.552584;1.587292 -grid,10000,39600,8,cpu_pool10,0.817917,0.000000,0.817917,0.000000,0,9,pass,8,0.801417;0.817917;0.823250;0.824500;0.808833;0.838833;0.810916;0.840542;0.804375 -grid,10000,39600,8,cpu_pool10_distance,0.246125,0.000000,0.246125,0.000000,0,9,pass,0,0.253875;0.246125;0.244458;0.240583;0.246709;0.250500;0.241417;0.254875;0.241833 -grid,40000,159200,1,cpu_full,2.937459,0.000000,2.937459,0.000000,0,9,pass,1,2.965208;2.922333;2.901166;2.931000;2.937459;2.923958;2.956375;2.958417;3.028584 -grid,40000,159200,1,cpu_distance,0.877667,0.000000,0.877667,0.000000,0,9,pass,0,0.877667;0.871375;0.900625;0.874333;0.858875;0.877792;0.873958;0.895041;0.924417 -grid,40000,159200,1,cpu_pool10,3.069875,0.000000,3.069875,0.000000,0,9,pass,1,3.066875;3.098083;3.105208;3.176875;3.069875;3.004583;3.026041;3.023792;3.222750 -grid,40000,159200,1,cpu_pool10_distance,0.924708,0.000000,0.924708,0.000000,0,9,pass,0,0.936292;0.924708;0.929125;0.915334;0.884375;0.945375;0.927125;0.899667;0.890125 diff --git a/dev/research/metal_pathfinding/results/aba/b-stderr.txt b/dev/research/metal_pathfinding/results/aba/b-stderr.txt deleted file mode 100644 index 5985671..0000000 --- a/dev/research/metal_pathfinding/results/aba/b-stderr.txt +++ /dev/null @@ -1 +0,0 @@ -device=Apple M4 Max setup_ms=24.1354 execution_width=32 diff --git a/dev/research/metal_pathfinding/results/aba/b.csv b/dev/research/metal_pathfinding/results/aba/b.csv deleted file mode 100644 index 9be0375..0000000 --- a/dev/research/metal_pathfinding/results/aba/b.csv +++ /dev/null @@ -1,69 +0,0 @@ -case,n,e,queries,method,wall_ms,gpu_ms,distance_wall_ms,upload_ms,rounds,samples,correct,exact_order_queries,wall_samples_ms -random,128,1024,1,metal_pull,0.567417,0.322166,0.563459,0.019916,16,9,pass,0,0.602750;0.587458;0.567417;0.556208;0.548792;0.569833;0.601416;0.558500;0.566750 -random,128,1024,1,metal_group,0.251042,0.130875,0.247458,0.019916,9,9,pass,0,0.252625;0.251042;0.251041;0.251083;0.250791;0.250458;0.267250;0.280500;0.243125 -random,128,1024,1,metal_shared,0.242250,0.123542,0.238708,0.019916,9,9,pass,0,0.244125;0.240792;0.243250;0.242209;0.247542;0.370625;0.242250;0.240250;0.242042 -random,128,1024,64,metal_pull,0.857625,0.380292,0.638500,0.031459,16,9,pass,0,0.866750;0.833917;0.866208;0.857625;0.854625;0.869375;0.850458;0.844500;0.870542 -random,128,1024,64,metal_group,0.585250,0.179333,0.323750,0.031459,13,9,pass,0,0.516250;0.630916;0.534417;0.511167;0.585250;0.585291;0.586250;0.577042;1.320875 -random,128,1024,64,metal_shared,0.526625,0.168792,0.290916,0.031459,13,9,pass,0,0.526625;0.530375;0.533208;0.516334;0.532167;0.540000;0.520250;0.520958;0.524625 -random,1024,8192,1,metal_pull,0.706541,0.431458,0.672833,0.063792,16,9,pass,0,0.708958;0.706541;0.683500;0.696333;0.716708;0.703208;0.702042;0.706542;0.709250 -random,1024,8192,1,metal_group,0.898959,0.679542,0.828542,0.063792,13,9,pass,0,0.874041;0.952625;0.835417;0.958875;0.956334;0.957792;0.836917;0.834667;0.898959 -random,1024,8192,1,metal_shared,0.855917,0.640000,0.787083,0.063792,13,9,pass,0,0.795708;0.898333;0.792708;0.855917;0.934083;0.924583;0.848958;0.892709;0.798875 -random,1024,8192,64,metal_pull,4.632416,1.600792,2.329459,0.066041,24,9,pass,0,4.332500;4.367000;4.657500;4.592333;4.674958;4.290750;4.916542;4.632416;4.770708 -random,1024,8192,64,metal_group,3.383708,0.822625,1.053875,0.066041,17,9,pass,0,3.680667;3.598500;3.752959;3.733166;3.379375;3.383708;3.346375;3.052459;3.109458 -random,1024,8192,64,metal_shared,3.647125,0.827375,1.293500,0.066041,17,9,pass,0,2.843375;3.248042;3.677583;3.657959;3.652542;3.647125;3.590833;3.698167;3.128125 -fabric,288,16384,1,metal_pull,0.584500,0.408625,0.523791,0.100625,8,9,pass,0,0.611375;0.592875;0.576625;0.585417;0.584500;0.579167;0.918042;0.567042;0.565459 -fabric,288,16384,1,metal_group,0.773084,0.583125,0.709625,0.100625,3,9,pass,0,0.327667;0.503125;0.767875;0.773084;0.791375;0.774750;0.777583;0.780084;0.761833 -fabric,288,16384,1,metal_shared,0.737875,0.555167,0.679958,0.100625,3,9,pass,0,0.739417;0.739875;0.736542;0.761292;0.918042;0.733042;0.737875;0.731083;0.726666 -fabric,288,16384,64,metal_pull,5.875333,1.801583,2.368750,0.132416,8,9,pass,0,6.109209;5.987041;5.790292;5.954583;5.768875;5.917583;5.761416;5.875333;5.816042 -fabric,288,16384,64,metal_group,4.538083,0.652000,1.189125,0.132416,3,9,pass,0,4.560458;4.799042;4.693917;4.400916;4.460208;4.538083;4.538041;4.492500;4.595333 -fabric,288,16384,64,metal_shared,4.551917,0.631000,1.168166,0.132416,3,9,pass,0,4.407250;4.394208;4.624959;4.553750;4.580375;4.241083;4.558125;4.551917;4.439542 -grid,1024,3968,1,metal_pull,1.681000,0.651333,1.668041,0.069500,64,9,pass,0,3.145792;1.681000;1.832667;1.848833;1.864125;1.546333;1.373083;1.407792;1.178375 -grid,1024,3968,1,metal_group,0.388208,0.227000,0.373083,0.069500,63,9,pass,0,0.387292;0.388208;0.388583;0.388750;0.385500;0.440667;0.387792;0.388583;0.387209 -grid,1024,3968,1,metal_shared,0.374459,0.213375,0.359417,0.069500,63,9,pass,0,0.365709;0.375708;0.376375;0.374459;0.374375;0.369500;0.375667;0.372625;0.378083 -grid,1024,3968,64,metal_pull,2.805208,0.405750,1.965292,0.049083,64,9,pass,0,3.028000;3.075959;2.952958;2.663000;2.846458;2.737833;2.370708;2.250000;2.805208 -grid,1024,3968,64,metal_group,1.723750,0.206625,0.854125,0.049083,63,9,pass,0,1.723750;1.217917;1.237792;1.618083;1.954292;1.625958;1.766209;1.758125;1.827375 -grid,1024,3968,64,metal_shared,1.775500,0.195250,0.870750,0.049083,63,9,pass,0,1.726416;1.202875;1.847292;1.667541;1.777375;1.775500;1.795250;1.679000;1.792250 -chain,256,255,1,metal_pull,4.188292,0.508375,4.186625,0.062958,256,9,pass,1,4.498417;4.636458;4.241791;4.533875;4.188292;3.823209;3.755125;3.775666;3.508791 -chain,256,255,1,metal_group,0.225292,0.132750,0.224000,0.062958,256,9,pass,1,0.224542;0.229375;0.228458;0.227375;0.225292;0.223292;0.222709;0.224208;0.324750 -chain,256,255,1,metal_shared,0.229792,0.122250,0.228458,0.062958,256,9,pass,1,0.230500;0.227917;0.228000;0.218333;0.235750;0.229792;0.231167;0.230375;0.222041 -random_masked_int64_reverse,1024,8192,8,metal_pull,0.522875,0.032125,0.135209,0.090500,8,9,pass,1,0.511834;0.544083;0.563459;0.534917;0.562042;0.507209;0.522875;0.520583;0.507917 -random_masked_int64_reverse,1024,8192,8,metal_group,0.531875,0.053625,0.158542,0.090500,8,9,pass,1,0.527416;0.562709;0.514000;0.537875;0.531875;0.519292;0.523708;0.546541;0.538459 -random_masked_int64_reverse,1024,8192,8,metal_shared,0.526041,0.049917,0.159333,0.090500,8,9,pass,1,0.514334;0.539375;0.533833;0.518833;0.512709;0.557000;0.524750;0.526041;0.547541 -random,4096,32768,1,metal_pull,0.458167,0.106292,0.325875,0.184875,16,9,pass,0,0.458042;0.453625;0.463291;0.451416;0.457791;0.462958;0.458167;0.465000;0.529708 -random,4096,32768,1,metal_group,1.031083,0.737750,0.873125,0.184875,16,9,pass,0,1.038458;1.004667;0.969208;0.976291;1.031083;1.036333;1.075125;0.979417;1.085459 -random,4096,32768,64,metal_pull,13.708083,2.607750,3.892709,0.228209,24,9,pass,0,19.328916;12.469084;12.552083;18.668167;13.211458;18.690625;13.948333;13.708083;12.846250 -random,4096,32768,64,metal_group,12.983292,2.521375,3.123333,0.228209,19,9,pass,0,11.323000;11.705708;12.151083;12.648958;12.983292;13.544500;13.467916;14.265500;14.300583 -random,4096,32768,256,metal_pull,49.690584,9.834250,11.281292,0.220667,24,9,pass,0,49.880458;49.319875;49.208292;49.690584;50.530375;49.900583;49.381000;49.333916;50.406125 -random,4096,32768,256,metal_group,47.235458,8.198500,8.931625,0.220667,20,9,pass,0,48.244833;48.005541;46.817625;46.219417;46.147375;46.081250;47.235458;56.480959;51.777709 -random,16384,131072,1,metal_pull,1.529042,0.427750,0.892000,0.595375,24,9,pass,0,2.015375;1.495250;1.529042;1.527875;1.600917;1.563125;1.599542;1.528292;1.519417 -random,16384,131072,1,metal_group,5.643083,4.789500,4.978750,0.595375,18,9,pass,0,5.749875;5.666792;5.650041;5.599667;5.643083;5.604333;5.611708;5.601166;5.683917 -random,16384,131072,64,metal_pull,58.410458,11.548458,13.326750,0.663209,24,9,pass,0,67.121166;57.667833;58.470167;57.913459;58.156250;58.410458;59.990625;61.279500;58.239084 -random,16384,131072,64,metal_group,54.004166,8.331625,9.176375,0.663209,22,9,pass,0,54.397292;54.004166;53.502291;53.214125;52.934375;66.739042;54.280541;54.790833;53.420042 -random,65536,524288,1,metal_pull,4.921875,0.846833,1.920917,2.726125,24,9,pass,0,6.001291;5.252834;4.935458;4.593417;4.787125;4.773958;4.849458;4.921875;5.000417 -random,65536,524288,1,metal_group,31.772208,28.237500,28.881750,2.726125,22,9,pass,0,31.775041;31.772208;31.570917;31.932708;31.588333;31.742625;32.067125;31.611333;32.124917 -random,65536,524288,64,metal_pull,278.466208,78.681083,80.713958,2.758750,32,9,pass,0,281.715833;280.435583;278.466208;281.948125;277.973000;277.116458;281.096833;266.919750;276.584625 -random,65536,524288,64,metal_group,277.240041,77.399292,78.233042,2.758750,25,9,pass,0,278.460875;277.240041;273.348500;274.964750;277.577750;281.394417;273.737042;273.665000;280.961666 -chain,2048,2047,1,metal_pull,28.527166,4.077999,28.517125,0.065792,2048,9,pass,1,29.465125;29.033125;28.804292;28.516417;28.424041;28.197667;28.328708;28.647750;28.527166 -chain,2048,2047,1,metal_group,4.803792,4.693375,4.793333,0.065792,2048,9,pass,1,4.799833;4.803792;4.848750;4.903459;4.850958;4.797625;4.800458;4.854666;4.799416 -random,4096,131072,1,metal_pull,0.842792,0.254458,0.451708,0.581791,16,9,pass,0,0.838875;0.832625;0.823042;0.842792;0.843417;0.834083;0.854458;0.886917;0.861834 -random,4096,131072,1,metal_group,2.677375,2.169000,2.272500,0.581791,12,9,pass,0,2.698875;2.772292;2.643875;2.677375;2.720708;2.809459;2.660750;2.642417;2.643792 -random,4096,131072,64,metal_pull,35.841000,6.486458,8.086625,0.641875,16,9,pass,0,35.841000;35.529625;35.501708;35.851209;34.786084;35.420083;37.314042;38.495375;38.841625 -random,4096,131072,64,metal_group,35.625625,7.266500,7.957042,0.641875,15,9,pass,0,32.706541;33.508083;33.932792;37.469041;35.970166;35.835375;35.625625;35.057250;35.728792 -clos,200,20000,1,metal_pull,0.928583,0.457833,0.859500,0.122584,8,9,pass,0,1.622417;1.091250;0.843625;1.078542;1.037583;0.765958;0.783041;0.928583;0.390333 -clos,200,20000,1,metal_group,0.260833,0.063333,0.197625,0.122584,3,9,pass,0,0.234208;0.237334;0.251208;0.250708;0.282042;0.268625;0.260833;0.291875;0.268208 -clos,200,20000,1,metal_shared,0.253875,0.059500,0.190000,0.122584,3,9,pass,0,0.254834;0.253083;0.253125;0.252833;0.255250;0.253875;0.257084;0.253667;0.256458 -clos,200,20000,64,metal_pull,5.757625,1.018167,1.556333,0.129209,8,9,pass,0,5.767959;5.609834;5.724250;5.673250;5.757625;5.656250;5.840625;5.759333;5.932584 -clos,200,20000,64,metal_group,5.121500,0.300292,0.831667,0.129209,3,9,pass,0,5.121500;5.192666;5.247041;5.146750;5.005208;4.946208;5.251791;5.036250;4.973417 -clos,200,20000,64,metal_shared,5.014750,0.294792,0.884542,0.129209,3,9,pass,0,5.155125;5.007708;5.014750;5.007417;5.045417;5.119459;5.008125;5.027000;4.946958 -clos,800,320000,1,metal_pull,2.688750,1.320875,1.690291,1.198250,8,9,pass,0,5.262625;4.836667;2.426042;1.908625;2.955417;4.635917;2.688750;1.943125;2.464041 -clos,800,320000,1,metal_group,3.609042,2.091917,2.558959,1.198250,3,9,pass,0,2.589541;5.534292;5.195666;3.609042;2.121750;2.612750;2.684584;5.583500;4.444583 -clos,800,320000,1,metal_shared,2.589500,0.886333,1.607250,1.198250,3,9,pass,0,2.055708;2.589500;5.415708;5.412917;2.020792;2.759542;2.584042;2.544167;3.905959 -clos,800,320000,64,metal_pull,84.243334,17.155875,17.769125,1.222459,8,9,pass,0,85.693292;82.820583;84.810666;85.224834;85.088417;84.171083;83.663167;82.692000;84.243334 -clos,800,320000,64,metal_group,72.947292,5.246625,5.806292,1.222459,3,9,pass,0,73.739000;72.666209;72.213333;73.495000;73.113167;72.947292;72.646917;72.817208;73.371959 -clos,800,320000,64,metal_shared,72.451792,5.047375,5.681500,1.222459,3,9,pass,0,72.509583;73.012750;72.451792;71.838583;72.226000;71.746875;72.920042;71.717500;73.992291 -grid,10000,39600,1,metal_pull,5.348750,2.243750,5.217083,0.188000,200,9,pass,0,5.705000;5.633250;5.483041;5.417042;5.337041;5.326500;5.348750;5.246000;5.313625 -grid,10000,39600,1,metal_group,7.826000,7.551000,7.692791,0.188000,199,9,pass,0,7.837625;7.825416;7.803542;7.800167;7.832833;7.832208;7.836458;7.812709;7.826000 -grid,10000,39600,8,metal_pull,6.016250,1.352667,4.932792,0.180625,200,9,pass,0,6.059625;5.488791;5.902791;6.103208;5.938250;6.325250;6.016250;5.983042;6.140208 -grid,10000,39600,8,metal_group,9.135500,7.427292,8.073792,0.180625,199,9,pass,0,8.722000;8.678750;9.158917;9.133459;9.125792;9.135500;9.266750;9.217875;9.343875 -grid,40000,159200,1,metal_pull,8.408041,1.743417,7.867500,0.554416,400,9,pass,0,9.057583;9.127500;8.940416;8.546416;8.408041;7.482041;7.682875;7.581166;7.693792 -grid,40000,159200,1,metal_group,65.239125,64.260458,64.756208,0.554416,399,9,pass,0,64.734541;65.239125;65.375083;64.894917;65.609625;66.268041;65.783375;64.778250;64.975083 diff --git a/dev/research/metal_pathfinding/results/aba/environment.txt b/dev/research/metal_pathfinding/results/aba/environment.txt deleted file mode 100644 index b039362..0000000 --- a/dev/research/metal_pathfinding/results/aba/environment.txt +++ /dev/null @@ -1,17 +0,0 @@ -2026-09-26T23:07:34Z -fbab119e794c843838f4f3f96d75cb7f32f06845 -ProductName: macOS -ProductVersion: 26.6.1 -BuildVersion: 25G76 -Apple M4 Max -38654705664 -14 -Apple clang version 21.0.0 (clang-2100.1.1.101) -Target: arm64-apple-darwin25.6.0 -Thread model: posix -InstalledDir: /Library/Developer/CommandLineTools/usr/bin -1da9ff77bd2ad97e95e951454821b25005859c6f75c27a3c56d925e5fc99f6fc dev/research/metal_pathfinding/experiment.mm -2e36b1623a3940bf1f19f5d81141bb1def14f5873fe2a98aab0101bbe68618a3 dev/research/metal_pathfinding/sssp.metal -Note: No thermal warning level has been recorded -Note: No performance warning level has been recorded -Note: No CPU power status has been recorded diff --git a/dev/research/metal_pathfinding/results/aba/gpu-load-a1.txt b/dev/research/metal_pathfinding/results/aba/gpu-load-a1.txt deleted file mode 100644 index 7b19971..0000000 --- a/dev/research/metal_pathfinding/results/aba/gpu-load-a1.txt +++ /dev/null @@ -1 +0,0 @@ - | "PerformanceStatistics" = {"In use system memory (driver)"=0,"Alloc system memory"=11028365312,"Tiler Utilization %"=5,"recoveryCount"=0,"lastRecoveryTime"=0,"Renderer Utilization %"=8,"TiledSceneBytes"=2326528,"Device Utilization %"=8,"SplitSceneCount"=0,"Allocated PB Size"=190316544,"In use system memory"=1562312704} diff --git a/dev/research/metal_pathfinding/results/aba/gpu-load-a2.txt b/dev/research/metal_pathfinding/results/aba/gpu-load-a2.txt deleted file mode 100644 index 63385cb..0000000 --- a/dev/research/metal_pathfinding/results/aba/gpu-load-a2.txt +++ /dev/null @@ -1 +0,0 @@ - | "PerformanceStatistics" = {"In use system memory (driver)"=0,"Alloc system memory"=11064967168,"Tiler Utilization %"=1,"recoveryCount"=0,"lastRecoveryTime"=0,"Renderer Utilization %"=3,"TiledSceneBytes"=1736704,"Device Utilization %"=75,"SplitSceneCount"=0,"Allocated PB Size"=192806912,"In use system memory"=1753808896} diff --git a/dev/research/metal_pathfinding/results/aba/gpu-load-b.txt b/dev/research/metal_pathfinding/results/aba/gpu-load-b.txt deleted file mode 100644 index 7edec31..0000000 --- a/dev/research/metal_pathfinding/results/aba/gpu-load-b.txt +++ /dev/null @@ -1 +0,0 @@ - | "PerformanceStatistics" = {"In use system memory (driver)"=0,"Alloc system memory"=11040063488,"Tiler Utilization %"=3,"recoveryCount"=0,"lastRecoveryTime"=0,"Renderer Utilization %"=5,"TiledSceneBytes"=2326528,"Device Utilization %"=5,"SplitSceneCount"=0,"Allocated PB Size"=191889408,"In use system memory"=1626095616} diff --git a/dev/research/metal_pathfinding/results/aba/load-a1.txt b/dev/research/metal_pathfinding/results/aba/load-a1.txt deleted file mode 100644 index dad8c67..0000000 --- a/dev/research/metal_pathfinding/results/aba/load-a1.txt +++ /dev/null @@ -1,40 +0,0 @@ -Processes: 1026 total, 3 running, 1023 sleeping, 5227 threads -2026/09/27 00:07:35 -Load Avg: 2.50, 3.63, 4.23 -CPU usage: 3.63% user, 8.76% sys, 87.60% idle -SharedLibs: 914M resident, 168M data, 135M linkedit. -MemRegions: 625179 total, 9927M resident, 739M private, 4203M shared. -PhysMem: 33G used (4233M wired, 633M compressor), 1868M unused. -VM: 452T vsize, 6144M framework vsize, 2843905(0) swapins, 4729331(0) swapouts. -Networks: packets: 190194634/161G in, 145611011/163G out. -Disks: 97549297/2475G read, 117292342/2098G written. - -PID COMMAND %CPU #TH -99753 mscamerad-xpc 0.0 2 -99752 PlugInLibrarySer 0.0 2 -99750 QuickLookUIServi 0.0 3 -99749 com.apple.appkit 0.0 3 -99710 PlugInLibrarySer 0.0 2 -99709 Preview 0.0 4 -99428 crashpad_handler 0.0 4 -98995 crashpad_handler 0.0 4 -Processes: 1026 total, 2 running, 1024 sleeping, 5225 threads -2026/09/27 00:07:36 -Load Avg: 2.54, 3.62, 4.22 -CPU usage: 5.65% user, 4.27% sys, 90.7% idle -SharedLibs: 914M resident, 168M data, 135M linkedit. -MemRegions: 0 total, 0B resident, 739M private, 4203M shared. -PhysMem: 33G used (4198M wired, 633M compressor), 1875M unused. -VM: 452T vsize, 6144M framework vsize, 2843905(0) swapins, 4729331(0) swapouts. -Networks: packets: 190194809/161G in, 145611193/163G out. -Disks: 97549297/2475G read, 117292347/2098G written. - -PID COMMAND %CPU #TH -70968 Superset Helper 22.2 32 -590 WindowServer 16.0 30 -0 kernel_task 10.7 850/14 -80307 top 8.9 1/1 -65401 codex 6.0 38 -70942 Superset Helper 5.4 18 -21666 2.1.283 4.7 28 -70934 Superset 4.3 51 diff --git a/dev/research/metal_pathfinding/results/aba/load-a2.txt b/dev/research/metal_pathfinding/results/aba/load-a2.txt deleted file mode 100644 index 153a0a0..0000000 --- a/dev/research/metal_pathfinding/results/aba/load-a2.txt +++ /dev/null @@ -1,40 +0,0 @@ -Processes: 1028 total, 2 running, 1026 sleeping, 5254 threads -2026/09/27 00:09:05 -Load Avg: 2.84, 3.47, 4.10 -CPU usage: 11.34% user, 14.82% sys, 73.82% idle -SharedLibs: 914M resident, 168M data, 135M linkedit. -MemRegions: 638481 total, 10G resident, 741M private, 4225M shared. -PhysMem: 34G used (4167M wired, 632M compressor), 1555M unused. -VM: 454T vsize, 6144M framework vsize, 2843921(0) swapins, 4729331(0) swapouts. -Networks: packets: 190245179/161G in, 145633207/163G out. -Disks: 97561084/2475G read, 117298099/2098G written. - -PID COMMAND %CPU #TH -99753 mscamerad-xpc 0.0 2 -99752 PlugInLibrarySer 0.0 2 -99750 QuickLookUIServi 0.0 3 -99749 com.apple.appkit 0.0 3 -99710 PlugInLibrarySer 0.0 2 -99709 Preview 0.0 4 -99428 crashpad_handler 0.0 4 -98995 crashpad_handler 0.0 4 -Processes: 1029 total, 9 running, 1020 sleeping, 5311 threads -2026/09/27 00:09:06 -Load Avg: 2.84, 3.47, 4.10 -CPU usage: 15.54% user, 11.8% sys, 73.37% idle -SharedLibs: 914M resident, 168M data, 135M linkedit. -MemRegions: 33 total, 80K resident, 741M private, 4226M shared. -PhysMem: 34G used (4371M wired, 632M compressor), 1526M unused. -VM: 455T vsize, 6144M framework vsize, 2843921(0) swapins, 4729331(0) swapouts. -Networks: packets: 190245522/161G in, 145633548/163G out. -Disks: 97561090/2475G read, 117298104/2098G written. - -PID COMMAND %CPU #TH -70968 Superset Helper 33.8 32 -590 WindowServer 31.2 29/1 -0 kernel_task 15.1 850/14 -70969 Superset 14.9 23 -70942 Superset Helper 9.4 18 -70934 Superset 7.9 51 -89777 top 6.0 1/1 -554 opendirectoryd 4.5 9/1 diff --git a/dev/research/metal_pathfinding/results/aba/load-b.txt b/dev/research/metal_pathfinding/results/aba/load-b.txt deleted file mode 100644 index c761959..0000000 --- a/dev/research/metal_pathfinding/results/aba/load-b.txt +++ /dev/null @@ -1,40 +0,0 @@ -Processes: 1028 total, 2 running, 1026 sleeping, 5258 threads -2026/09/27 00:08:33 -Load Avg: 2.69, 3.51, 4.14 -CPU usage: 4.4% user, 9.48% sys, 86.46% idle -SharedLibs: 914M resident, 168M data, 135M linkedit. -MemRegions: 638285 total, 10G resident, 741M private, 4228M shared. -PhysMem: 34G used (4218M wired, 632M compressor), 1622M unused. -VM: 454T vsize, 6144M framework vsize, 2843921(0) swapins, 4729331(0) swapouts. -Networks: packets: 190237774/161G in, 145624667/163G out. -Disks: 97549658/2475G read, 117296556/2098G written. - -PID COMMAND %CPU #TH -99753 mscamerad-xpc 0.0 2 -99752 PlugInLibrarySer 0.0 2 -99750 QuickLookUIServi 0.0 3 -99749 com.apple.appkit 0.0 3 -99710 PlugInLibrarySer 0.0 2 -99709 Preview 0.0 4 -99428 crashpad_handler 0.0 4 -98995 crashpad_handler 0.0 4 -Processes: 1027 total, 2 running, 1025 sleeping, 5243 threads -2026/09/27 00:08:34 -Load Avg: 2.69, 3.51, 4.14 -CPU usage: 5.44% user, 3.79% sys, 90.75% idle -SharedLibs: 914M resident, 168M data, 135M linkedit. -MemRegions: 0 total, 0B resident, 741M private, 4228M shared. -PhysMem: 33G used (4259M wired, 632M compressor), 1761M unused. -VM: 452T vsize, 6144M framework vsize, 2843921(0) swapins, 4729331(0) swapouts. -Networks: packets: 190237907/161G in, 145624794/163G out. -Disks: 97549658/2475G read, 117296557/2098G written. - -PID COMMAND %CPU #TH -70968 Superset Helper 18.0 32 -590 WindowServer 17.7 29 -0 kernel_task 11.5 850/14 -81804 2.1.283 8.8 27 -84786 top 8.5 1/1 -70942 Superset Helper 7.0 19 -39757 codex 4.0 36 -21666 2.1.283 3.5 28 diff --git a/dev/research/metal_pathfinding/results/aba/summary.md b/dev/research/metal_pathfinding/results/aba/summary.md deleted file mode 100644 index 3f5a20f..0000000 --- a/dev/research/metal_pathfinding/results/aba/summary.md +++ /dev/null @@ -1,74 +0,0 @@ -Exploratory observations, not a production performance acceptance result. -Inspect load-*.txt and gpu-load-*.txt; speedup claims require a quiet machine. -The prototype matches supported DAG edge sets, not CPU predecessor order. - -All times are medians in milliseconds per batch, with nine timed samples. -CPU A columns use serial SPF for Q=1 and the 10-worker pool otherwise. -GPU includes host distance copies and serial CPU ECMP DAG reconstruction. -Upload and pipeline setup are excluded and recorded separately in raw data. -The table picks the fastest measured GPU method for each case. - -| Case | V / E | Q | CPU A1 | GPU B | CPU A2 | GPU method | Distance-only B | CPU drift | -|---|---:|---:|---:|---:|---:|---|---:|---:| -| random | 128 / 1,024 | 1 | 0.031 | 0.242 | 0.010 | metal_shared | 0.239 | -67.4% | -| random | 128 / 1,024 | 64 | 0.234 | 0.527 | 0.162 | metal_shared | 0.291 | -30.7% | -| random | 1,024 / 8,192 | 1 | 0.145 | 0.707 | 0.115 | metal_pull | 0.673 | -21.0% | -| random | 1,024 / 8,192 | 64 | 1.273 | 3.384 | 1.330 | metal_group | 1.054 | +4.5% | -| fabric | 288 / 16,384 | 1 | 0.107 | 0.585 | 0.110 | metal_pull | 0.524 | +3.0% | -| fabric | 288 / 16,384 | 64 | 1.011 | 4.538 | 0.992 | metal_group | 1.189 | -1.9% | -| grid | 1,024 / 3,968 | 1 | 0.037 | 0.374 | 0.045 | metal_shared | 0.359 | +22.8% | -| grid | 1,024 / 3,968 | 64 | 0.498 | 1.724 | 0.556 | metal_group | 0.854 | +11.6% | -| chain | 256 / 255 | 1 | 0.002 | 0.225 | 0.003 | metal_group | 0.224 | +13.0% | -| random_masked_int64_reverse | 1,024 / 8,192 | 8 | 0.150 | 0.523 | 0.149 | metal_pull | 0.135 | -0.2% | -| random | 4,096 / 32,768 | 1 | 0.641 | 0.458 | 0.665 | metal_pull | 0.326 | +3.8% | -| random | 4,096 / 32,768 | 64 | 5.984 | 12.983 | 5.952 | metal_group | 3.123 | -0.5% | -| random | 4,096 / 32,768 | 256 | 23.461 | 47.235 | 23.387 | metal_group | 8.932 | -0.3% | -| random | 16,384 / 131,072 | 1 | 4.095 | 1.529 | 3.915 | metal_pull | 0.892 | -4.4% | -| random | 16,384 / 131,072 | 64 | 28.290 | 54.004 | 28.429 | metal_group | 9.176 | +0.5% | -| random | 65,536 / 524,288 | 1 | 20.762 | 4.922 | 22.345 | metal_pull | 1.921 | +7.6% | -| random | 65,536 / 524,288 | 64 | 174.082 | 277.240 | 189.990 | metal_group | 78.233 | +9.1% | -| chain | 2,048 / 2,047 | 1 | 0.020 | 4.804 | 0.020 | metal_group | 4.793 | -1.1% | -| random | 4,096 / 131,072 | 1 | 1.762 | 0.843 | 1.763 | metal_pull | 0.452 | +0.0% | -| random | 4,096 / 131,072 | 64 | 12.774 | 35.626 | 12.923 | metal_group | 7.957 | +1.2% | -| clos | 200 / 20,000 | 1 | 0.146 | 0.254 | 0.144 | metal_shared | 0.190 | -1.4% | -| clos | 200 / 20,000 | 64 | 1.181 | 5.015 | 1.699 | metal_shared | 0.885 | +43.9% | -| clos | 800 / 320,000 | 1 | 2.330 | 2.590 | 2.368 | metal_shared | 1.607 | +1.6% | -| clos | 800 / 320,000 | 64 | 17.462 | 72.452 | 17.634 | metal_shared | 5.681 | +1.0% | -| grid | 10,000 / 39,600 | 1 | 0.614 | 5.349 | 0.616 | metal_pull | 5.217 | +0.4% | -| grid | 10,000 / 39,600 | 8 | 0.829 | 6.016 | 0.818 | metal_pull | 4.933 | -1.3% | -| grid | 40,000 / 159,200 | 1 | 2.913 | 8.408 | 2.937 | metal_pull | 7.867 | +0.9% | - -Distance-only timings stop before DAG reconstruction; they are not SPF API timings. -CPU distance-only and serial full-batch controls are also available in a1/a2.csv. - -Distances-only comparison (both sides omit DAG construction): - -| Case | V / E | Q | CPU A1 | GPU B | CPU A2 | GPU method | -|---|---:|---:|---:|---:|---:|---| -| random | 128 / 1,024 | 1 | 0.011 | 0.239 | 0.005 | metal_shared | -| random | 128 / 1,024 | 64 | 0.126 | 0.291 | 0.102 | metal_shared | -| random | 1,024 / 8,192 | 1 | 0.080 | 0.673 | 0.056 | metal_pull | -| random | 1,024 / 8,192 | 64 | 0.634 | 1.054 | 0.657 | metal_group | -| fabric | 288 / 16,384 | 1 | 0.022 | 0.524 | 0.022 | metal_pull | -| fabric | 288 / 16,384 | 64 | 0.194 | 1.168 | 0.219 | metal_shared | -| grid | 1,024 / 3,968 | 1 | 0.010 | 0.359 | 0.012 | metal_shared | -| grid | 1,024 / 3,968 | 64 | 0.146 | 0.854 | 0.157 | metal_group | -| chain | 256 / 255 | 1 | 0.001 | 0.224 | 0.001 | metal_group | -| random_masked_int64_reverse | 1,024 / 8,192 | 8 | 0.093 | 0.135 | 0.095 | metal_pull | -| random | 4,096 / 32,768 | 1 | 0.305 | 0.326 | 0.322 | metal_pull | -| random | 4,096 / 32,768 | 64 | 2.968 | 3.123 | 2.946 | metal_group | -| random | 4,096 / 32,768 | 256 | 12.019 | 8.932 | 11.368 | metal_group | -| random | 16,384 / 131,072 | 1 | 2.076 | 0.892 | 1.960 | metal_pull | -| random | 16,384 / 131,072 | 64 | 14.898 | 9.176 | 15.059 | metal_group | -| random | 65,536 / 524,288 | 1 | 10.450 | 1.921 | 11.777 | metal_pull | -| random | 65,536 / 524,288 | 64 | 85.846 | 78.233 | 93.938 | metal_group | -| chain | 2,048 / 2,047 | 1 | 0.004 | 4.793 | 0.004 | metal_group | -| random | 4,096 / 131,072 | 1 | 0.665 | 0.452 | 0.681 | metal_pull | -| random | 4,096 / 131,072 | 64 | 5.610 | 7.957 | 5.638 | metal_group | -| clos | 200 / 20,000 | 1 | 0.024 | 0.190 | 0.024 | metal_shared | -| clos | 200 / 20,000 | 64 | 0.211 | 0.832 | 0.208 | metal_group | -| clos | 800 / 320,000 | 1 | 0.356 | 1.607 | 0.355 | metal_shared | -| clos | 800 / 320,000 | 64 | 2.672 | 5.681 | 2.661 | metal_shared | -| grid | 10,000 / 39,600 | 1 | 0.175 | 5.217 | 0.183 | metal_pull | -| grid | 10,000 / 39,600 | 8 | 0.247 | 4.933 | 0.246 | metal_pull | -| grid | 40,000 / 159,200 | 1 | 0.863 | 7.867 | 0.878 | metal_pull | diff --git a/dev/research/metal_pathfinding/results/audit.json b/dev/research/metal_pathfinding/results/audit.json deleted file mode 100644 index a218315..0000000 --- a/dev/research/metal_pathfinding/results/audit.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "a1": { - "rows": 108, - "queries_checked_including_warmups": 40744 - }, - "b": { - "rows": 68, - "queries_checked_including_warmups": 24761 - }, - "a2": { - "rows": 108, - "queries_checked_including_warmups": 40744 - }, - "correctness.csv": { - "rows": 36, - "queries_checked_including_warmups": 1152 - }, - "correctness-sanitized.csv": { - "rows": 36, - "queries_checked_including_warmups": 1152 - }, - "stress.csv": { - "rows": 6, - "queries_checked_including_warmups": 39168 - }, - "stress-sanitized.csv": { - "rows": 6, - "queries_checked_including_warmups": 8448 - }, - "cpu-sanitized.csv": { - "rows": 40, - "queries_checked_including_warmups": 3228 - }, - "experiment.mm_sha256": "1da9ff77bd2ad97e95e951454821b25005859c6f75c27a3c56d925e5fc99f6fc", - "sssp.metal_sha256": "2e36b1623a3940bf1f19f5d81141bb1def14f5873fe2a98aab0101bbe68618a3", - "core_commit": "fbab119e794c843838f4f3f96d75cb7f32f06845", - "quiet_machine_gate_passed": false, - "performance_claim_approved": false, - "ci_run": false, - "production_backend_added": false, - "core_cpp_tests": 170, - "core_python_tests": 465, - "core_sanitizer_tests": 170 -} diff --git a/dev/research/metal_pathfinding/results/capabilities.txt b/dev/research/metal_pathfinding/results/capabilities.txt deleted file mode 100644 index 95b9ad1..0000000 --- a/dev/research/metal_pathfinding/results/capabilities.txt +++ /dev/null @@ -1,31 +0,0 @@ -device=Apple M4 Max unified=1 max_buffer=22613000192 -probe=0 compiled=1 -probe=1 compiled=0 -program_source:3:25: error: 'double' is not supported in Metal -kernel void test(device double* x [[buffer(0)]], uint i [[thread_position_in_grid]]) { x[i] += 1.0; } - ^ - -probe=2 compiled=0 -program_source:3:58: error: no matching function for call to 'atomic_fetch_min_explicit' -kernel void test(device atomic_ulong* x [[buffer(0)]]) { atomic_fetch_min_explicit(x, 1ul, memory_order_relaxed); } - ^~~~~~~~~~~~~~~~~~~~~~~~~ -/System/Library/PrivateFrameworks/GPUCompiler.framework/Versions/32023/Libraries/lib/clang/32023.886/include/metal/metal_atomic:552:14: note: candidate template ignored: requirement '_valid_fetch_min_type::value' was not satisfied [with T = unsigned long, U = unsigned long] -METAL_FUNC T atomic_fetch_min_explicit( - ^ -/System/Library/PrivateFrameworks/GPUCompiler.framework/Versions/32023/Libraries/lib/clang/32023.886/include/metal/metal_atomic:560:14: note: candidate template ignored: requirement '_valid_fetch_min_type::value' was not satisfied [with T = unsigned long, U = unsigned long] -METAL_FUNC T atomic_fetch_min_explicit( - ^ - -probe=3 compiled=1 -probe=4 compiled=1 -probe=5 compiled=0 -program_source:3:96: error: no matching function for call to 'atomic_load_explicit' -kernel void test(device atomic_ulong* x [[buffer(0)]], device ulong* y [[buffer(1)]]) { y[0] = atomic_load_explicit(x, memory_order_relaxed); } - ^~~~~~~~~~~~~~~~~~~~ -/System/Library/PrivateFrameworks/GPUCompiler.framework/Versions/32023/Libraries/lib/clang/32023.886/include/metal/metal_atomic:240:14: note: candidate template ignored: requirement '_valid_load_type::value' was not satisfied [with T = unsigned long] -METAL_FUNC T atomic_load_explicit( - ^ -/System/Library/PrivateFrameworks/GPUCompiler.framework/Versions/32023/Libraries/lib/clang/32023.886/include/metal/metal_atomic:247:14: note: candidate template ignored: requirement '_valid_load_type::value' was not satisfied [with T = unsigned long] -METAL_FUNC T atomic_load_explicit( - ^ - diff --git a/dev/research/metal_pathfinding/results/correctness-sanitized.csv b/dev/research/metal_pathfinding/results/correctness-sanitized.csv deleted file mode 100644 index a77e150..0000000 --- a/dev/research/metal_pathfinding/results/correctness-sanitized.csv +++ /dev/null @@ -1,37 +0,0 @@ -case,n,e,queries,method,wall_ms,gpu_ms,distance_wall_ms,upload_ms,rounds,samples,correct,exact_order_queries,wall_samples_ms -random_masked_int64,40,240,8,metal_pull,1.263750,0.395000,1.205458,0.053958,8,2,pass,7,1.263750;0.901542 -random_masked_int64,40,240,8,metal_group,0.652583,0.283916,0.607084,0.053958,6,2,pass,7,0.617708;0.652583 -random_masked_int64,40,240,8,metal_shared,0.951500,0.198250,0.906541,0.053958,6,2,pass,7,0.860042;0.951500 -random_masked,43,258,8,metal_pull,1.944458,0.886917,1.892250,0.033375,16,2,pass,0,1.755792;1.944458 -random_masked,43,258,8,metal_group,0.756125,0.419417,0.707625,0.033375,9,2,pass,0,0.756125;0.754584 -random_masked,43,258,8,metal_shared,0.661750,0.313667,0.615750,0.033375,9,2,pass,0,0.661750;0.640542 -random_masked_int64_reverse,46,276,8,metal_pull,0.567792,0.068875,0.519583,0.023333,8,2,pass,1,0.567792;0.486209 -random_masked_int64_reverse,46,276,8,metal_group,0.468083,0.033042,0.422375,0.023333,5,2,pass,1,0.393167;0.468083 -random_masked_int64_reverse,46,276,8,metal_shared,0.370541,0.027708,0.323542,0.023333,5,2,pass,1,0.363958;0.370541 -random_masked,49,294,8,metal_pull,0.528250,0.096292,0.480834,0.022084,8,2,pass,2,0.528250;0.518416 -random_masked,49,294,8,metal_group,0.450542,0.078042,0.397875,0.022084,8,2,pass,2,0.430209;0.450542 -random_masked,49,294,8,metal_shared,0.382750,0.060834,0.333750,0.022084,8,2,pass,2,0.382750;0.373667 -random_masked_int64,52,312,8,metal_pull,0.583250,0.108417,0.531208,0.024708,8,2,pass,7,0.583250;0.549291 -random_masked_int64,52,312,8,metal_group,0.568667,0.062125,0.516500,0.024708,6,2,pass,7,0.568667;0.434666 -random_masked_int64,52,312,8,metal_shared,0.390209,0.050000,0.336334,0.024708,6,2,pass,7,0.390209;0.381666 -random_masked_reverse,55,330,8,metal_pull,0.955500,0.137167,0.904750,0.024250,16,2,pass,0,0.932875;0.955500 -random_masked_reverse,55,330,8,metal_group,0.400375,0.056750,0.342417,0.024250,10,2,pass,0,0.388125;0.400375 -random_masked_reverse,55,330,8,metal_shared,0.516750,0.046250,0.459417,0.024250,10,2,pass,0,0.498875;0.516750 -random_masked_int64,58,348,8,metal_pull,0.521208,0.088875,0.463542,0.028666,8,2,pass,7,0.521208;0.514292 -random_masked_int64,58,348,8,metal_group,0.375292,0.045500,0.314708,0.028666,5,2,pass,7,0.375292;0.371792 -random_masked_int64,58,348,8,metal_shared,0.372625,0.039250,0.314083,0.028666,5,2,pass,7,0.369333;0.372625 -random_masked,61,366,8,metal_pull,1.079708,0.210875,1.013334,0.021459,16,2,pass,1,1.034542;1.079708 -random_masked,61,366,8,metal_group,0.427291,0.089750,0.367750,0.021459,9,2,pass,1,0.427291;0.422917 -random_masked,61,366,8,metal_shared,0.399166,0.071916,0.342667,0.021459,9,2,pass,1,0.399166;0.389709 -random_masked_int64_reverse,64,384,8,metal_pull,0.487667,0.070375,0.426917,0.023333,8,2,pass,1,0.483333;0.487667 -random_masked_int64_reverse,64,384,8,metal_group,0.373917,0.033750,0.314708,0.023333,5,2,pass,1,0.364750;0.373917 -random_masked_int64_reverse,64,384,8,metal_shared,0.374791,0.027750,0.315333,0.023333,5,2,pass,1,0.374791;0.344833 -random_masked,67,402,8,metal_pull,0.648583,0.089542,0.517833,0.027542,8,2,pass,0,0.523500;0.648583 -random_masked,67,402,8,metal_group,0.459166,0.066708,0.391292,0.027542,8,2,pass,0,0.459166;0.427208 -random_masked,67,402,8,metal_shared,0.461292,0.123750,0.397750,0.027542,8,2,pass,0,0.441083;0.461292 -random_masked_int64,70,420,8,metal_pull,0.568167,0.100208,0.484084,0.023667,8,2,pass,3,0.568167;0.519291 -random_masked_int64,70,420,8,metal_group,0.412083,0.060250,0.350416,0.023667,6,2,pass,3,0.400500;0.412083 -random_masked_int64,70,420,8,metal_shared,0.675834,0.206875,0.605291,0.023667,6,2,pass,3,0.675834;0.557083 -random_masked_reverse,73,438,8,metal_pull,1.424125,0.585833,1.358291,0.028083,16,2,pass,0,1.424125;1.365500 -random_masked_reverse,73,438,8,metal_group,0.718042,0.249750,0.652875,0.028083,10,2,pass,0,0.623667;0.718042 -random_masked_reverse,73,438,8,metal_shared,0.596708,0.202250,0.526333,0.028083,10,2,pass,0,0.596708;0.561417 diff --git a/dev/research/metal_pathfinding/results/correctness-stderr.txt b/dev/research/metal_pathfinding/results/correctness-stderr.txt deleted file mode 100644 index 0c58358..0000000 --- a/dev/research/metal_pathfinding/results/correctness-stderr.txt +++ /dev/null @@ -1 +0,0 @@ -device=Apple M4 Max setup_ms=23.9988 execution_width=32 diff --git a/dev/research/metal_pathfinding/results/correctness.csv b/dev/research/metal_pathfinding/results/correctness.csv deleted file mode 100644 index ab8100c..0000000 --- a/dev/research/metal_pathfinding/results/correctness.csv +++ /dev/null @@ -1,37 +0,0 @@ -case,n,e,queries,method,wall_ms,gpu_ms,distance_wall_ms,upload_ms,rounds,samples,correct,exact_order_queries,wall_samples_ms -random_masked_int64,40,240,8,metal_pull,0.295708,0.129833,0.277459,0.016125,8,2,pass,7,0.295708;0.284042 -random_masked_int64,40,240,8,metal_group,0.210458,0.068708,0.203291,0.016125,6,2,pass,7,0.191750;0.210458 -random_masked_int64,40,240,8,metal_shared,0.587333,0.074583,0.580834,0.016125,6,2,pass,7,0.495125;0.587333 -random_masked,43,258,8,metal_pull,0.841500,0.289833,0.833625,0.023083,16,2,pass,0,0.841500;0.760584 -random_masked,43,258,8,metal_group,0.232041,0.105375,0.223667,0.023083,9,2,pass,0,0.232041;0.230958 -random_masked,43,258,8,metal_shared,0.225208,0.099375,0.218333,0.023083,9,2,pass,0,0.223292;0.225208 -random_masked_int64_reverse,46,276,8,metal_pull,0.547208,0.104125,0.527583,0.027750,8,2,pass,1,0.232750;0.547208 -random_masked_int64_reverse,46,276,8,metal_group,0.175709,0.044708,0.168125,0.027750,5,2,pass,1,0.172292;0.175709 -random_masked_int64_reverse,46,276,8,metal_shared,0.180708,0.042125,0.173916,0.027750,5,2,pass,1,0.180708;0.180334 -random_masked,49,294,8,metal_pull,0.273208,0.125083,0.253625,0.018125,8,2,pass,2,0.261041;0.273208 -random_masked,49,294,8,metal_group,0.236250,0.093750,0.227083,0.018125,8,2,pass,2,0.226666;0.236250 -random_masked,49,294,8,metal_shared,0.222125,0.088000,0.213500,0.018125,8,2,pass,2,0.222125;0.222125 -random_masked_int64,52,312,8,metal_pull,0.289833,0.150542,0.280541,0.017708,8,2,pass,7,0.276834;0.289833 -random_masked_int64,52,312,8,metal_group,0.212750,0.077125,0.194041,0.017708,6,2,pass,7,0.212750;0.205125 -random_masked_int64,52,312,8,metal_shared,0.204459,0.072667,0.196167,0.017708,6,2,pass,7,0.203125;0.204459 -random_masked_reverse,55,330,8,metal_pull,0.457667,0.212375,0.447292,0.017084,16,2,pass,0,0.457667;0.437583 -random_masked_reverse,55,330,8,metal_group,0.200458,0.074208,0.192000,0.017084,10,2,pass,0,0.200458;0.193541 -random_masked_reverse,55,330,8,metal_shared,0.184667,0.069417,0.176000,0.017084,10,2,pass,0,0.184667;0.183125 -random_masked_int64,58,348,8,metal_pull,0.266375,0.117667,0.256625,0.013333,8,2,pass,7,0.250167;0.266375 -random_masked_int64,58,348,8,metal_group,0.180209,0.057667,0.170667,0.013333,5,2,pass,7,0.180209;0.177625 -random_masked_int64,58,348,8,metal_shared,0.248583,0.142375,0.239083,0.013333,5,2,pass,7,0.248583;0.183667 -random_masked,61,366,8,metal_pull,0.525791,0.273333,0.515166,0.015292,16,2,pass,1,0.525791;0.515458 -random_masked,61,366,8,metal_group,0.236833,0.110000,0.226958,0.015292,9,2,pass,1,0.233750;0.236833 -random_masked,61,366,8,metal_shared,0.227042,0.103208,0.217375,0.015292,9,2,pass,1,0.227042;0.225792 -random_masked_int64_reverse,64,384,8,metal_pull,0.374125,0.102208,0.359166,0.012708,8,2,pass,1,0.374125;0.239125 -random_masked_int64_reverse,64,384,8,metal_group,0.222541,0.045000,0.167750,0.012708,5,2,pass,1,0.222541;0.190041 -random_masked_int64_reverse,64,384,8,metal_shared,0.187667,0.042625,0.173541,0.012708,5,2,pass,1,0.183959;0.187667 -random_masked,67,402,8,metal_pull,0.260875,0.118875,0.248459,0.019167,8,2,pass,0,0.248834;0.260875 -random_masked,67,402,8,metal_group,0.250791,0.084792,0.227666,0.019167,8,2,pass,0,0.223333;0.250791 -random_masked,67,402,8,metal_shared,0.267833,0.079750,0.256333,0.019167,8,2,pass,0,0.267833;0.215541 -random_masked_int64,70,420,8,metal_pull,0.397708,0.232000,0.379083,0.020375,8,2,pass,3,0.390583;0.397708 -random_masked_int64,70,420,8,metal_group,0.253125,0.075250,0.220625,0.020375,6,2,pass,3,0.242750;0.253125 -random_masked_int64,70,420,8,metal_shared,0.242166,0.071083,0.213625,0.020375,6,2,pass,3,0.221834;0.242166 -random_masked_reverse,73,438,8,metal_pull,1.053542,0.734042,1.034042,0.031625,16,2,pass,0,1.053542;0.504542 -random_masked_reverse,73,438,8,metal_group,0.240750,0.073791,0.227708,0.031625,10,2,pass,0,0.240750;0.221834 -random_masked_reverse,73,438,8,metal_shared,0.269792,0.069167,0.226000,0.031625,10,2,pass,0,0.269792;0.250833 diff --git a/dev/research/metal_pathfinding/results/cpu-sanitized.csv b/dev/research/metal_pathfinding/results/cpu-sanitized.csv deleted file mode 100644 index 84f0bcf..0000000 --- a/dev/research/metal_pathfinding/results/cpu-sanitized.csv +++ /dev/null @@ -1,41 +0,0 @@ -case,n,e,queries,method,wall_ms,gpu_ms,distance_wall_ms,upload_ms,rounds,samples,correct,exact_order_queries,wall_samples_ms -random,128,1024,1,cpu_full,0.097375,0.000000,0.097375,0.000000,0,1,pass,1,0.097375 -random,128,1024,1,cpu_distance,0.030375,0.000000,0.030375,0.000000,0,1,pass,0,0.030375 -random,128,1024,1,cpu_pool10,0.166000,0.000000,0.166000,0.000000,0,1,pass,1,0.166000 -random,128,1024,1,cpu_pool10_distance,0.077291,0.000000,0.077291,0.000000,0,1,pass,0,0.077291 -random,128,1024,64,cpu_full,4.778583,0.000000,4.778583,0.000000,0,1,pass,64,4.778583 -random,128,1024,64,cpu_distance,1.507292,0.000000,1.507292,0.000000,0,1,pass,0,1.507292 -random,128,1024,64,cpu_pool10,0.686542,0.000000,0.686542,0.000000,0,1,pass,64,0.686542 -random,128,1024,64,cpu_pool10_distance,0.330625,0.000000,0.330625,0.000000,0,1,pass,0,0.330625 -random,1024,8192,1,cpu_full,0.539375,0.000000,0.539375,0.000000,0,1,pass,1,0.539375 -random,1024,8192,1,cpu_distance,0.188375,0.000000,0.188375,0.000000,0,1,pass,0,0.188375 -random,1024,8192,1,cpu_pool10,0.586917,0.000000,0.586917,0.000000,0,1,pass,1,0.586917 -random,1024,8192,1,cpu_pool10_distance,0.240834,0.000000,0.240834,0.000000,0,1,pass,0,0.240834 -random,1024,8192,64,cpu_full,34.944792,0.000000,34.944792,0.000000,0,1,pass,64,34.944792 -random,1024,8192,64,cpu_distance,13.138375,0.000000,13.138375,0.000000,0,1,pass,0,13.138375 -random,1024,8192,64,cpu_pool10,4.126666,0.000000,4.126666,0.000000,0,1,pass,64,4.126666 -random,1024,8192,64,cpu_pool10_distance,1.569333,0.000000,1.569333,0.000000,0,1,pass,0,1.569333 -fabric,288,16384,1,cpu_full,0.859542,0.000000,0.859542,0.000000,0,1,pass,1,0.859542 -fabric,288,16384,1,cpu_distance,0.116792,0.000000,0.116792,0.000000,0,1,pass,0,0.116792 -fabric,288,16384,1,cpu_pool10,0.838292,0.000000,0.838292,0.000000,0,1,pass,1,0.838292 -fabric,288,16384,1,cpu_pool10_distance,0.132208,0.000000,0.132208,0.000000,0,1,pass,0,0.132208 -fabric,288,16384,64,cpu_full,55.711541,0.000000,55.711541,0.000000,0,1,pass,64,55.711541 -fabric,288,16384,64,cpu_distance,8.051041,0.000000,8.051041,0.000000,0,1,pass,0,8.051041 -fabric,288,16384,64,cpu_pool10,6.186542,0.000000,6.186542,0.000000,0,1,pass,64,6.186542 -fabric,288,16384,64,cpu_pool10_distance,0.728208,0.000000,0.728208,0.000000,0,1,pass,0,0.728208 -grid,1024,3968,1,cpu_full,0.292625,0.000000,0.292625,0.000000,0,1,pass,1,0.292625 -grid,1024,3968,1,cpu_distance,0.067333,0.000000,0.067333,0.000000,0,1,pass,0,0.067333 -grid,1024,3968,1,cpu_pool10,0.291583,0.000000,0.291583,0.000000,0,1,pass,1,0.291583 -grid,1024,3968,1,cpu_pool10_distance,0.096125,0.000000,0.096125,0.000000,0,1,pass,0,0.096125 -grid,1024,3968,64,cpu_full,18.959167,0.000000,18.959167,0.000000,0,1,pass,64,18.959167 -grid,1024,3968,64,cpu_distance,4.468167,0.000000,4.468167,0.000000,0,1,pass,0,4.468167 -grid,1024,3968,64,cpu_pool10,2.241042,0.000000,2.241042,0.000000,0,1,pass,64,2.241042 -grid,1024,3968,64,cpu_pool10_distance,0.553875,0.000000,0.553875,0.000000,0,1,pass,0,0.553875 -chain,256,255,1,cpu_full,0.026584,0.000000,0.026584,0.000000,0,1,pass,1,0.026584 -chain,256,255,1,cpu_distance,0.007542,0.000000,0.007542,0.000000,0,1,pass,0,0.007542 -chain,256,255,1,cpu_pool10,0.055416,0.000000,0.055416,0.000000,0,1,pass,1,0.055416 -chain,256,255,1,cpu_pool10_distance,0.031166,0.000000,0.031166,0.000000,0,1,pass,0,0.031166 -random_masked_int64_reverse,1024,8192,8,cpu_full,2.706625,0.000000,2.706625,0.000000,0,1,pass,8,2.706625 -random_masked_int64_reverse,1024,8192,8,cpu_distance,0.929791,0.000000,0.929791,0.000000,0,1,pass,0,0.929791 -random_masked_int64_reverse,1024,8192,8,cpu_pool10,0.434375,0.000000,0.434375,0.000000,0,1,pass,8,0.434375 -random_masked_int64_reverse,1024,8192,8,cpu_pool10_distance,0.197500,0.000000,0.197500,0.000000,0,1,pass,0,0.197500 diff --git a/dev/research/metal_pathfinding/results/cpu-validation.txt b/dev/research/metal_pathfinding/results/cpu-validation.txt deleted file mode 100644 index e69de29..0000000 diff --git a/dev/research/metal_pathfinding/results/rejected-race/a1-stderr.txt b/dev/research/metal_pathfinding/results/rejected-race/a1-stderr.txt deleted file mode 100644 index e69de29..0000000 diff --git a/dev/research/metal_pathfinding/results/rejected-race/a1.csv b/dev/research/metal_pathfinding/results/rejected-race/a1.csv deleted file mode 100644 index 40a48a5..0000000 --- a/dev/research/metal_pathfinding/results/rejected-race/a1.csv +++ /dev/null @@ -1,82 +0,0 @@ -case,n,e,queries,method,wall_ms,gpu_ms,distance_wall_ms,upload_ms,rounds,samples,correct,exact_order_queries,wall_samples_ms -random,128,1024,1,cpu_full,0.009666,0.000000,0.009666,0.000000,0,9,pass,1,0.010500;0.011750;0.010709;0.009666;0.009375;0.010292;0.009458;0.009292;0.009375 -random,128,1024,1,cpu_distance,0.004750,0.000000,0.004750,0.000000,0,9,pass,0,0.004750;0.004875;0.006167;0.004583;0.004333;0.006292;0.005542;0.004583;0.004458 -random,128,1024,1,cpu_pool10,0.038167,0.000000,0.038167,0.000000,0,9,pass,1,0.062375;0.040667;0.050667;0.038167;0.043250;0.036875;0.035292;0.028667;0.034958 -random,128,1024,64,cpu_full,0.800000,0.000000,0.800000,0.000000,0,9,pass,64,0.907541;0.878042;0.845291;0.835625;0.800000;0.788042;0.781792;0.784208;0.773458 -random,128,1024,64,cpu_distance,0.396125,0.000000,0.396125,0.000000,0,9,pass,0,0.442208;0.424833;0.404792;0.394959;0.390000;0.399083;0.396125;0.387125;0.386542 -random,128,1024,64,cpu_pool10,0.170208,0.000000,0.170208,0.000000,0,9,pass,64,0.172209;0.167500;0.174958;0.158834;0.170208;0.166500;0.161167;0.198625;0.181000 -random,1024,8192,1,cpu_full,0.099084,0.000000,0.099084,0.000000,0,9,pass,1,0.103500;0.101375;0.098542;0.099209;0.097667;0.099417;0.099041;0.097583;0.099084 -random,1024,8192,1,cpu_distance,0.058041,0.000000,0.058041,0.000000,0,9,pass,0,0.060750;0.058041;0.056750;0.057167;0.056333;0.059542;0.062833;0.059875;0.056875 -random,1024,8192,1,cpu_pool10,0.165833,0.000000,0.165833,0.000000,0,9,pass,1,0.172125;0.203542;0.148416;0.124917;0.191000;0.166625;0.143292;0.165833;0.137583 -random,1024,8192,64,cpu_full,10.543584,0.000000,10.543584,0.000000,0,9,pass,64,10.571083;10.583583;10.586917;10.594250;10.498959;10.348333;10.482750;10.392041;10.543584 -random,1024,8192,64,cpu_distance,5.130416,0.000000,5.130416,0.000000,0,9,pass,0,5.130416;5.089708;5.094791;5.078417;5.263458;5.194500;5.156167;5.122708;5.251958 -random,1024,8192,64,cpu_pool10,1.302667,0.000000,1.302667,0.000000,0,9,pass,64,1.297875;1.289334;1.334458;1.313875;1.291750;1.302667;1.320209;1.273375;1.380250 -fabric,288,16384,1,cpu_full,0.111042,0.000000,0.111042,0.000000,0,9,pass,1,0.116416;0.114041;0.111042;0.115458;0.110375;0.110000;0.110709;0.111708;0.109916 -fabric,288,16384,1,cpu_distance,0.023375,0.000000,0.023375,0.000000,0,9,pass,0,0.023833;0.023792;0.023375;0.023459;0.023416;0.023167;0.023333;0.023250;0.023250 -fabric,288,16384,1,cpu_pool10,0.136584,0.000000,0.136584,0.000000,0,9,pass,1,0.139375;0.149417;0.133875;0.134542;0.136584;0.132542;0.150708;0.135167;0.144917 -fabric,288,16384,64,cpu_full,7.296542,0.000000,7.296542,0.000000,0,9,pass,64,7.296542;7.249084;7.306250;7.259250;7.382000;7.404750;7.204834;7.289084;7.365292 -fabric,288,16384,64,cpu_distance,1.513458,0.000000,1.513458,0.000000,0,9,pass,0,1.482000;1.522833;1.501708;1.522166;1.513458;1.513333;1.503958;1.553209;1.569583 -fabric,288,16384,64,cpu_pool10,0.958250,0.000000,0.958250,0.000000,0,9,pass,64,1.064625;0.943042;0.958250;0.963417;0.960708;0.981125;0.951500;0.927750;0.946458 -grid,1024,3968,1,cpu_full,0.042166,0.000000,0.042166,0.000000,0,9,pass,1,0.047375;0.045667;0.044083;0.042166;0.041458;0.043333;0.041167;0.041292;0.040875 -grid,1024,3968,1,cpu_distance,0.011958,0.000000,0.011958,0.000000,0,9,pass,0,0.012584;0.012167;0.012125;0.012167;0.011792;0.011792;0.011958;0.011750;0.011709 -grid,1024,3968,1,cpu_pool10,0.073917,0.000000,0.073917,0.000000,0,9,pass,1,0.086125;0.073917;0.087500;0.077750;0.067458;0.074667;0.070042;0.068750;0.066125 -grid,1024,3968,64,cpu_full,3.898167,0.000000,3.898167,0.000000,0,9,pass,64,3.895625;3.931583;3.898167;3.892791;3.934375;3.910291;3.882542;3.895417;3.907417 -grid,1024,3968,64,cpu_distance,0.937667,0.000000,0.937667,0.000000,0,9,pass,0,0.953584;0.920083;0.906583;0.896791;1.014416;0.992750;0.958250;0.937667;0.928916 -grid,1024,3968,64,cpu_pool10,0.528625,0.000000,0.528625,0.000000,0,9,pass,64,0.520500;0.554000;0.528625;0.520209;0.517667;0.490542;0.536000;0.590291;0.531209 -chain,256,255,1,cpu_full,0.002750,0.000000,0.002750,0.000000,0,9,pass,1,0.004250;0.002792;0.002708;0.002708;0.002750;0.002792;0.002709;0.002750;0.002667 -chain,256,255,1,cpu_distance,0.000666,0.000000,0.000666,0.000000,0,9,pass,0,0.000625;0.000583;0.000583;0.000542;0.000666;0.000708;0.000709;0.000709;0.000667 -chain,256,255,1,cpu_pool10,0.036333,0.000000,0.036333,0.000000,0,9,pass,1,0.022791;0.036333;0.039916;0.033958;0.034875;0.039209;0.040292;0.026375;0.037625 -random_masked_int64_reverse,1024,8192,8,cpu_full,0.637292,0.000000,0.637292,0.000000,0,9,pass,8,0.667833;0.637292;0.610000;0.586250;0.574125;0.568834;0.684500;0.703167;0.666542 -random_masked_int64_reverse,1024,8192,8,cpu_distance,0.300208,0.000000,0.300208,0.000000,0,9,pass,0,0.459750;0.389292;0.344250;0.303167;0.300208;0.288250;0.285292;0.279416;0.282959 -random_masked_int64_reverse,1024,8192,8,cpu_pool10,0.156000,0.000000,0.156000,0.000000,0,9,pass,8,0.159459;0.161625;0.149458;0.158792;0.152625;0.156000;0.159417;0.151959;0.150167 -random,4096,32768,1,cpu_full,0.631667,0.000000,0.631667,0.000000,0,9,pass,1,0.706459;0.669375;0.644166;0.631667;0.620542;0.617000;0.617833;0.601292;0.727000 -random,4096,32768,1,cpu_distance,0.329750,0.000000,0.329750,0.000000,0,9,pass,0,0.329750;0.340834;0.336292;0.338250;0.327792;0.317375;0.330875;0.304542;0.304708 -random,4096,32768,1,cpu_pool10,0.817667,0.000000,0.817667,0.000000,0,9,pass,1,0.845333;0.823416;0.817667;0.842500;0.807292;0.770000;0.722583;0.818916;0.761541 -random,4096,32768,64,cpu_full,52.210125,0.000000,52.210125,0.000000,0,9,pass,64,52.438458;52.412625;52.033666;52.333375;52.210125;51.993958;52.123292;52.159083;52.419041 -random,4096,32768,64,cpu_distance,26.296916,0.000000,26.296916,0.000000,0,9,pass,0,26.418875;26.417792;26.296916;26.482583;26.288958;26.609667;26.009667;26.031875;26.104125 -random,4096,32768,64,cpu_pool10,6.491792,0.000000,6.491792,0.000000,0,9,pass,64,6.491792;6.342875;6.799208;6.762459;6.705875;6.170292;6.229750;6.722625;6.462917 -random,4096,32768,256,cpu_full,209.806250,0.000000,209.806250,0.000000,0,9,pass,256,209.554292;211.988208;210.116500;209.288583;209.680917;209.828375;209.806250;209.382167;217.385875 -random,4096,32768,256,cpu_distance,105.298583,0.000000,105.298583,0.000000,0,9,pass,0,105.078250;105.298583;105.704542;105.054000;105.176084;105.098709;105.568833;105.914917;106.202334 -random,4096,32768,256,cpu_pool10,22.902917,0.000000,22.902917,0.000000,0,9,pass,256,22.643541;22.909667;22.902917;22.651667;23.214542;22.817000;23.036292;22.723583;22.937291 -random,16384,131072,1,cpu_full,3.829959,0.000000,3.829959,0.000000,0,9,pass,1,3.758083;4.336333;3.799417;3.741708;3.829959;3.751625;3.870791;3.908833;3.859792 -random,16384,131072,1,cpu_distance,1.911000,0.000000,1.911000,0.000000,0,9,pass,0,2.010083;1.909125;1.899250;1.850917;2.113667;2.056375;1.960000;1.911000;1.884875 -random,16384,131072,1,cpu_pool10,3.920375,0.000000,3.920375,0.000000,0,9,pass,1,3.819708;3.773125;4.272791;3.939208;3.692208;4.034500;3.920375;4.013833;3.866792 -random,16384,131072,64,cpu_full,251.553583,0.000000,251.553583,0.000000,0,9,pass,64,252.180500;251.192875;246.819666;251.553583;248.245708;246.188208;252.333084;252.777417;257.578000 -random,16384,131072,64,cpu_distance,132.990000,0.000000,132.990000,0.000000,0,9,pass,0,132.707583;134.917958;133.056834;132.915500;132.990000;132.365792;133.342708;133.048167;132.554875 -random,16384,131072,64,cpu_pool10,28.447375,0.000000,28.447375,0.000000,0,9,pass,64,28.610000;28.458042;28.357791;28.447375;28.609708;28.349416;28.265208;28.702541;28.418541 -random,65536,524288,1,cpu_full,20.940041,0.000000,20.940041,0.000000,0,9,pass,1,21.304334;20.860834;21.335416;20.838250;21.789875;20.292334;21.301250;20.940041;20.917500 -random,65536,524288,1,cpu_distance,11.158625,0.000000,11.158625,0.000000,0,9,pass,0,11.319166;10.996000;10.906125;11.023625;11.190708;10.960709;11.158625;11.208667;11.261834 -random,65536,524288,1,cpu_pool10,23.570334,0.000000,23.570334,0.000000,0,9,pass,1,21.283542;20.398708;22.447709;24.142625;24.654791;24.214250;23.570334;24.476750;22.815583 -random,65536,524288,64,cpu_full,1475.197459,0.000000,1475.197459,0.000000,0,9,pass,64,1384.978917;1475.197459;1464.451459;1485.729083;1612.075042;1686.816042;1673.231417;1429.034000;1386.365083 -random,65536,524288,64,cpu_distance,758.485125,0.000000,758.485125,0.000000,0,9,pass,0,762.178042;763.873042;766.125500;780.269292;758.485125;737.577708;741.359667;744.495709;755.063458 -random,65536,524288,64,cpu_pool10,199.161041,0.000000,199.161041,0.000000,0,9,pass,64,203.127916;199.161041;200.671042;200.809458;198.845500;198.417584;195.237833;199.019000;200.340792 -chain,2048,2047,1,cpu_full,0.019667,0.000000,0.019667,0.000000,0,9,pass,1,0.019459;0.019750;0.020000;0.019584;0.019542;0.019584;0.019708;0.019791;0.019667 -chain,2048,2047,1,cpu_distance,0.004042,0.000000,0.004042,0.000000,0,9,pass,0,0.004958;0.004917;0.004042;0.004416;0.003917;0.004000;0.003958;0.004875;0.004000 -chain,2048,2047,1,cpu_pool10,0.039084,0.000000,0.039084,0.000000,0,9,pass,1,0.043542;0.043291;0.037541;0.039084;0.037917;0.041167;0.033708;0.040000;0.036666 -random,4096,131072,1,cpu_full,1.723084,0.000000,1.723084,0.000000,0,9,pass,1,1.723084;1.816458;1.822000;1.715708;2.010917;1.749500;1.692459;1.691292;1.674250 -random,4096,131072,1,cpu_distance,0.712000,0.000000,0.712000,0.000000,0,9,pass,0,0.738875;0.716959;0.712000;0.700250;0.676166;0.678875;0.682958;0.762375;0.823833 -random,4096,131072,1,cpu_pool10,1.769500,0.000000,1.769500,0.000000,0,9,pass,1,1.757375;1.769500;1.803625;1.661250;1.808917;1.693666;1.652417;1.815417;2.108958 -random,4096,131072,64,cpu_full,114.542416,0.000000,114.542416,0.000000,0,9,pass,64,113.662709;114.371875;113.470208;113.180125;117.370209;114.542416;118.531125;118.463875;121.422042 -random,4096,131072,64,cpu_distance,52.960208,0.000000,52.960208,0.000000,0,9,pass,0,54.235500;54.562542;52.976500;52.960208;51.147458;50.654209;51.172625;52.637000;53.555875 -random,4096,131072,64,cpu_pool10,16.696333,0.000000,16.696333,0.000000,0,9,pass,64,17.632417;16.696333;17.935500;17.066958;16.540042;14.694666;23.514875;14.485291;13.440750 -clos,200,20000,1,cpu_full,0.147125,0.000000,0.147125,0.000000,0,9,pass,1,0.147375;0.146416;0.147000;0.146959;0.153333;0.147583;0.151167;0.147125;0.147000 -clos,200,20000,1,cpu_distance,0.026167,0.000000,0.026167,0.000000,0,9,pass,0,0.026750;0.026708;0.029041;0.026167;0.026167;0.029041;0.025834;0.026000;0.026125 -clos,200,20000,1,cpu_pool10,0.176417,0.000000,0.176417,0.000000,0,9,pass,1,0.202625;0.176417;0.178792;0.191833;0.176125;0.169833;0.169042;0.173541;0.188042 -clos,200,20000,64,cpu_full,9.558125,0.000000,9.558125,0.000000,0,9,pass,64,9.327083;9.558125;9.654875;9.680333;9.408292;9.393375;9.611375;9.525917;9.592459 -clos,200,20000,64,cpu_distance,1.738709,0.000000,1.738709,0.000000,0,9,pass,0,1.729000;1.729417;1.792625;1.745458;1.730167;1.869125;2.065958;1.738709;1.725625 -clos,200,20000,64,cpu_pool10,1.869167,0.000000,1.869167,0.000000,0,9,pass,64,2.808834;2.692000;2.343250;2.577292;1.869167;1.174959;1.203708;1.285250;1.219667 -clos,800,320000,1,cpu_full,2.567583,0.000000,2.567583,0.000000,0,9,pass,1,2.635000;2.553959;2.598042;2.565666;2.537542;2.567583;2.579542;2.595208;2.566083 -clos,800,320000,1,cpu_distance,0.407708,0.000000,0.407708,0.000000,0,9,pass,0,0.407042;0.407583;0.408167;0.407125;0.407708;0.410375;0.424583;0.415833;0.407334 -clos,800,320000,1,cpu_pool10,2.464458,0.000000,2.464458,0.000000,0,9,pass,1,2.607250;2.708625;2.449125;2.464458;2.431584;2.487958;2.421458;2.426583;2.499208 -clos,800,320000,64,cpu_full,155.384917,0.000000,155.384917,0.000000,0,9,pass,64,163.952083;155.384917;155.413125;151.858083;156.446167;155.488000;151.541084;151.281583;151.429750 -clos,800,320000,64,cpu_distance,25.811833,0.000000,25.811833,0.000000,0,9,pass,0,25.656209;25.709083;25.892292;25.811833;25.767625;25.899542;25.672500;26.257709;26.174416 -clos,800,320000,64,cpu_pool10,18.125041,0.000000,18.125041,0.000000,0,9,pass,64,19.433500;18.233833;17.844333;17.857167;17.345709;18.285458;17.183667;19.231542;18.125041 -grid,10000,39600,1,cpu_full,0.622375,0.000000,0.622375,0.000000,0,9,pass,1,0.625916;0.622375;0.615208;0.611750;0.614209;0.612917;0.682500;0.666667;0.656125 -grid,10000,39600,1,cpu_distance,0.181792,0.000000,0.181792,0.000000,0,9,pass,0,0.184250;0.183375;0.182917;0.181792;0.183083;0.180834;0.179042;0.179750;0.180209 -grid,10000,39600,1,cpu_pool10,0.709500,0.000000,0.709500,0.000000,0,9,pass,1,0.648792;0.709500;0.644084;0.637708;0.703416;0.730292;0.742167;0.750208;0.759833 -grid,10000,39600,8,cpu_full,5.845667,0.000000,5.845667,0.000000,0,9,pass,8,5.580833;5.744125;6.192083;5.945000;6.019250;6.081042;5.845667;5.556583;5.503542 -grid,10000,39600,8,cpu_distance,1.600833,0.000000,1.600833,0.000000,0,9,pass,0,1.679667;1.590959;1.575791;1.578458;1.564791;1.768875;1.621541;1.605458;1.600833 -grid,10000,39600,8,cpu_pool10,1.225333,0.000000,1.225333,0.000000,0,9,pass,8,0.858459;0.821167;0.901125;1.336292;1.225333;1.265792;1.339875;1.428417;1.167250 -grid,40000,159200,1,cpu_full,3.005208,0.000000,3.005208,0.000000,0,9,pass,1,3.052750;3.039291;3.014625;2.943125;2.901375;2.935542;3.052791;2.982208;3.005208 -grid,40000,159200,1,cpu_distance,0.861333,0.000000,0.861333,0.000000,0,9,pass,0,0.865708;0.860334;0.861333;0.857000;0.863750;0.860375;0.859708;0.953250;0.933792 -grid,40000,159200,1,cpu_pool10,3.089417,0.000000,3.089417,0.000000,0,9,pass,1,3.089417;2.962667;2.994000;3.129125;3.048042;3.150875;3.102834;2.986583;3.166417 diff --git a/dev/research/metal_pathfinding/results/rejected-race/b-stderr.txt b/dev/research/metal_pathfinding/results/rejected-race/b-stderr.txt deleted file mode 100644 index 7806da3..0000000 --- a/dev/research/metal_pathfinding/results/rejected-race/b-stderr.txt +++ /dev/null @@ -1,2 +0,0 @@ -device=Apple M4 Max setup_ms=29.6173 execution_width=32 -FAIL: distance mismatch at query 59 diff --git a/dev/research/metal_pathfinding/results/rejected-race/b.csv b/dev/research/metal_pathfinding/results/rejected-race/b.csv deleted file mode 100644 index 9126f3c..0000000 --- a/dev/research/metal_pathfinding/results/rejected-race/b.csv +++ /dev/null @@ -1,23 +0,0 @@ -case,n,e,queries,method,wall_ms,gpu_ms,distance_wall_ms,upload_ms,rounds,samples,correct,exact_order_queries,wall_samples_ms -random,128,1024,1,metal_pull,0.816458,0.307250,0.812917,0.019833,16,9,pass,0,0.592208;0.819542;0.819458;0.816458;0.901334;0.811084;0.817583;0.560375;0.555250 -random,128,1024,1,metal_group,0.249250,0.129500,0.245459,0.019833,9,9,pass,0,0.269541;0.249250;0.257125;0.247125;0.242083;0.247875;0.248792;0.251792;0.253833 -random,128,1024,1,metal_shared,0.271792,0.122250,0.267583,0.019833,9,9,pass,0,0.268500;0.485333;0.264667;0.495959;0.361625;0.271792;0.494625;0.270625;0.244917 -random,128,1024,64,metal_pull,0.878042,0.346833,0.616208,0.061167,16,9,pass,0,0.898375;0.871459;0.857708;0.866667;0.878208;0.863583;0.878042;0.909083;0.938750 -random,128,1024,64,metal_group,0.569875,0.178625,0.308000,0.061167,13,9,pass,0,0.555916;0.559583;0.596000;0.651334;0.581583;0.575791;0.569875;0.567708;0.563708 -random,128,1024,64,metal_shared,0.537708,0.168042,0.295042,0.061167,13,9,pass,0,0.548917;0.549375;0.567167;0.531750;0.537708;0.601708;0.531583;0.516333;0.532208 -random,1024,8192,1,metal_pull,0.676250,0.397875,0.641792,0.083584,16,9,pass,0,0.703584;0.701583;0.679083;0.676250;0.663709;0.722417;0.666791;0.667666;0.656167 -random,1024,8192,1,metal_group,0.842583,0.678083,0.809167,0.083584,13,9,pass,0,1.298250;0.830208;0.842583;0.845167;0.841125;0.837417;0.840041;0.845750;0.852083 -random,1024,8192,1,metal_shared,0.811125,0.637792,0.775833,0.083584,13,9,pass,0,0.819959;0.808292;0.812000;0.811125;0.798291;0.811250;0.809458;0.813041;0.801084 -random,1024,8192,64,metal_pull,4.685833,1.546416,2.311167,0.097167,24,9,pass,0,4.668167;4.401916;5.264625;4.813000;4.743792;4.685833;4.779792;4.610958;4.660625 -random,1024,8192,64,metal_group,3.703708,0.886125,1.335375,0.097167,17,9,pass,0,3.703708;3.667417;3.495459;3.551833;3.825667;3.801500;3.883542;3.540250;3.747000 -random,1024,8192,64,metal_shared,3.640583,0.831125,1.256917,0.097167,17,9,pass,0,3.393333;3.665417;3.607333;3.547959;3.653458;3.624833;3.640583;3.653792;3.644208 -fabric,288,16384,1,metal_pull,1.831416,1.649458,1.774709,0.121750,8,9,pass,0,1.831416;1.898416;1.873500;1.825333;1.838958;1.826833;1.808167;2.394584;1.186584 -fabric,288,16384,1,metal_group,0.754041,0.575708,0.697125,0.121750,3,9,pass,0,0.759167;0.756791;0.754041;0.866458;0.763750;0.600625;0.514000;0.504291;0.568208 -fabric,288,16384,1,metal_shared,0.700750,0.507750,0.643667,0.121750,3,9,pass,0,0.749250;0.750541;0.729708;0.735500;0.700750;0.500625;0.499959;0.504042;0.564667 -fabric,288,16384,64,metal_pull,5.854333,1.801583,2.349125,0.128458,8,9,pass,0,5.230666;5.573292;6.165708;5.854333;5.898959;5.926500;5.789875;6.005459;5.788750 -fabric,288,16384,64,metal_group,4.626000,0.643250,1.182875,0.128458,3,9,pass,0,4.626000;4.593708;4.322292;4.596375;4.634209;4.741584;4.599625;4.722167;4.693709 -fabric,288,16384,64,metal_shared,4.613333,0.621375,1.159000,0.128458,3,9,pass,0,4.405417;4.666458;4.603375;4.603334;4.613333;4.720250;4.403458;4.680416;4.707125 -grid,1024,3968,1,metal_pull,1.759875,0.653875,1.743750,0.060083,64,9,pass,0,2.030541;1.759875;1.747459;2.255083;1.600958;2.206875;1.709500;1.800334;1.600042 -grid,1024,3968,1,metal_group,1.156208,1.000000,1.139875,0.060083,63,9,pass,0,1.154667;1.164959;1.157417;1.147209;1.134917;1.148125;1.156208;1.665167;1.197417 -grid,1024,3968,1,metal_shared,0.486500,0.345750,0.469042,0.060083,63,9,pass,0,0.498750;0.507834;0.482208;0.486500;0.488375;0.488166;0.465291;0.485917;0.467125 -grid,1024,3968,64,metal_pull,3.018000,0.569042,2.047500,0.095667,64,9,pass,0,3.018000;2.953667;3.431208;2.733709;2.995208;2.668375;3.199958;3.169250;3.109583 diff --git a/dev/research/metal_pathfinding/results/rejected-race/environment.txt b/dev/research/metal_pathfinding/results/rejected-race/environment.txt deleted file mode 100644 index 6fc5908..0000000 --- a/dev/research/metal_pathfinding/results/rejected-race/environment.txt +++ /dev/null @@ -1,17 +0,0 @@ -2026-09-26T23:00:02Z -fbab119e794c843838f4f3f96d75cb7f32f06845 -ProductName: macOS -ProductVersion: 26.6.1 -BuildVersion: 25G76 -Apple M4 Max -38654705664 -14 -Apple clang version 21.0.0 (clang-2100.1.1.101) -Target: arm64-apple-darwin25.6.0 -Thread model: posix -InstalledDir: /Library/Developer/CommandLineTools/usr/bin -c7d33ce878a8f0f1a6822efff641c8fdf0514edabcb521cfe7ae3044e56170d7 dev/research/metal_pathfinding/experiment.mm -bd5f14bdb079c40a4e0ef26ab4d48e16b8c63b830858a53a18445a2addc2b480 dev/research/metal_pathfinding/sssp.metal -Note: No thermal warning level has been recorded -Note: No performance warning level has been recorded -Note: No CPU power status has been recorded diff --git a/dev/research/metal_pathfinding/results/rejected-race/load-a1.txt b/dev/research/metal_pathfinding/results/rejected-race/load-a1.txt deleted file mode 100644 index 9e27631..0000000 --- a/dev/research/metal_pathfinding/results/rejected-race/load-a1.txt +++ /dev/null @@ -1,40 +0,0 @@ -Processes: 1025 total, 4 running, 1021 sleeping, 5327 threads -2026/09/27 00:00:02 -Load Avg: 3.96, 4.55, 4.69 -CPU usage: 16.11% user, 13.13% sys, 70.74% idle -SharedLibs: 914M resident, 168M data, 135M linkedit. -MemRegions: 639741 total, 10G resident, 737M private, 4343M shared. -PhysMem: 35G used (4508M wired, 633M compressor), 577M unused. -VM: 453T vsize, 6144M framework vsize, 2843881(0) swapins, 4729331(0) swapouts. -Networks: packets: 190075863/161G in, 145485402/163G out. -Disks: 97533882/2474G read, 117262731/2097G written. - -PID COMMAND %CPU #TH -99753 mscamerad-xpc 0.0 2 -99752 PlugInLibrarySer 0.0 2 -99750 QuickLookUIServi 0.0 3 -99749 com.apple.appkit 0.0 3 -99710 PlugInLibrarySer 0.0 2 -99709 Preview 0.0 4 -99428 crashpad_handler 0.0 4 -98995 crashpad_handler 0.0 4 -Processes: 1027 total, 3 running, 1024 sleeping, 5343 threads -2026/09/27 00:00:03 -Load Avg: 3.96, 4.55, 4.69 -CPU usage: 11.46% user, 5.96% sys, 82.56% idle -SharedLibs: 914M resident, 168M data, 135M linkedit. -MemRegions: 204 total, 26M resident, 737M private, 4344M shared. -PhysMem: 34G used (4463M wired, 633M compressor), 634M unused. -VM: 454T vsize, 6144M framework vsize, 2843881(0) swapins, 4729331(0) swapouts. -Networks: packets: 190075966/161G in, 145485507/163G out. -Disks: 97534168/2474G read, 117262748/2097G written. - -PID COMMAND %CPU #TH -590 WindowServer 30.6 28 -1331 NotificationCent 17.7 17 -70968 Superset Helper 16.5 32 -0 kernel_task 15.9 850/14 -70942 Superset Helper 6.2 19 -45949 top 5.9 1/1 -39196 PerfPowerService 5.3 8 -70934 Superset 3.4 51 diff --git a/dev/research/metal_pathfinding/results/rejected-race/load-b.txt b/dev/research/metal_pathfinding/results/rejected-race/load-b.txt deleted file mode 100644 index 592e523..0000000 --- a/dev/research/metal_pathfinding/results/rejected-race/load-b.txt +++ /dev/null @@ -1,40 +0,0 @@ -Processes: 1026 total, 4 running, 1022 sleeping, 5280 threads -2026/09/27 00:01:03 -Load Avg: 4.62, 4.67, 4.73 -CPU usage: 14.6% user, 9.18% sys, 76.75% idle -SharedLibs: 914M resident, 167M data, 135M linkedit. -MemRegions: 676023 total, 10G resident, 736M private, 4305M shared. -PhysMem: 34G used (4015M wired, 633M compressor), 891M unused. -VM: 458T vsize, 6144M framework vsize, 2843881(0) swapins, 4729331(0) swapouts. -Networks: packets: 190086917/161G in, 145497019/163G out. -Disks: 97535297/2474G read, 117266119/2097G written. - -PID COMMAND %CPU #TH -99753 mscamerad-xpc 0.0 2 -99752 PlugInLibrarySer 0.0 2 -99750 QuickLookUIServi 0.0 3 -99749 com.apple.appkit 0.0 3 -99710 PlugInLibrarySer 0.0 2 -99709 Preview 0.0 4 -99428 crashpad_handler 0.0 4 -98995 crashpad_handler 0.0 4 -Processes: 1026 total, 3 running, 1023 sleeping, 5287 threads -2026/09/27 00:01:04 -Load Avg: 4.62, 4.67, 4.73 -CPU usage: 12.45% user, 3.0% sys, 84.54% idle -SharedLibs: 914M resident, 167M data, 135M linkedit. -MemRegions: 0 total, 0B resident, 736M private, 4305M shared. -PhysMem: 34G used (4054M wired, 633M compressor), 911M unused. -VM: 458T vsize, 6144M framework vsize, 2843881(0) swapins, 4729331(0) swapouts. -Networks: packets: 190087017/161G in, 145497121/163G out. -Disks: 97535298/2474G read, 117266121/2097G written. - -PID COMMAND %CPU #TH -48825 Python 73.0 1/1 -590 WindowServer 25.5 28 -70968 Superset Helper 20.9 32 -0 kernel_task 10.0 850/15 -70942 Superset Helper 7.6 18 -21666 2.1.283 6.5 28 -48902 top 6.3 1/1 -70934 Superset 3.8 51 diff --git a/dev/research/metal_pathfinding/results/rejected-race/pre-fix-smoke.csv b/dev/research/metal_pathfinding/results/rejected-race/pre-fix-smoke.csv deleted file mode 100644 index bbcfc40..0000000 --- a/dev/research/metal_pathfinding/results/rejected-race/pre-fix-smoke.csv +++ /dev/null @@ -1,21 +0,0 @@ -case,n,e,queries,method,wall_ms,gpu_ms,distance_wall_ms,upload_ms,rounds,samples,correct -random,128,1024,1,metal_pull,0.614208,0.304625,0.606959,0.053541,16,3,pass -random,128,1024,1,metal_group,0.310458,0.129667,0.307333,0.053541,9,3,pass -random,128,1024,64,metal_pull,0.886292,0.347083,0.624083,0.055542,16,3,pass -random,128,1024,64,metal_group,0.570625,0.177500,0.315042,0.055542,13,3,pass -random,1024,8192,1,metal_pull,0.704750,0.398333,0.662834,0.078125,16,3,pass -random,1024,8192,1,metal_group,0.862250,0.722333,0.827125,0.078125,13,3,pass -random,1024,8192,64,metal_pull,4.660125,1.654750,2.257041,0.088125,24,3,pass -random,1024,8192,64,metal_group,3.771334,0.891792,1.350084,0.088125,17,3,pass -fabric,288,16384,1,metal_pull,1.844583,1.652417,1.794125,0.118167,8,3,pass -fabric,288,16384,1,metal_group,0.790083,0.583125,0.732959,0.118167,3,3,pass -fabric,288,16384,64,metal_pull,5.691166,1.800708,2.326167,0.114166,8,3,pass -fabric,288,16384,64,metal_group,4.476125,0.652125,1.174042,0.114166,3,3,pass -grid,1024,3968,1,metal_pull,1.953792,0.654417,1.936833,0.073500,64,3,pass -grid,1024,3968,1,metal_group,1.172875,1.000375,1.159500,0.073500,63,3,pass -grid,1024,3968,64,metal_pull,3.767584,1.438375,2.877834,0.084959,64,3,pass -grid,1024,3968,64,metal_group,2.245667,0.944250,1.384000,0.084959,63,3,pass -chain,256,255,1,metal_pull,6.148250,1.919250,6.146208,0.065667,256,3,pass -chain,256,255,1,metal_group,0.701417,0.575583,0.699959,0.065667,256,3,pass -random_masked_int64_reverse,1024,8192,8,metal_pull,0.689167,0.133750,0.271792,0.088000,8,3,pass -random_masked_int64_reverse,1024,8192,8,metal_group,0.792458,0.241500,0.385917,0.088000,8,3,pass diff --git a/dev/research/metal_pathfinding/results/sanitize-test.txt b/dev/research/metal_pathfinding/results/sanitize-test.txt deleted file mode 100644 index 20e81e8..0000000 --- a/dev/research/metal_pathfinding/results/sanitize-test.txt +++ /dev/null @@ -1,372 +0,0 @@ --- pybind11 not found via config; using FetchContent --- pybind11 v3.0.0 --- Enabling sanitizers (disables optimizations) --- Configuring done (0.6s) --- Generating done (0.0s) --- Build files have been written to: /Users/networmix/ws/NetGraph-Core/build/cpp-sanitize -[1/21] Building CXX object CMakeFiles/netgraph_core.dir/src/strict_multidigraph.cpp.o -[2/21] Building CXX object CMakeFiles/netgraph_core.dir/src/k_shortest_paths.cpp.o -[3/21] Building CXX object CMakeFiles/netgraph_core.dir/src/shortest_paths.cpp.o -[4/21] Building CXX object CMakeFiles/netgraph_core.dir/src/max_flow.cpp.o -[5/21] Building CXX object CMakeFiles/netgraph_core.dir/src/cpu_backend.cpp.o -[6/21] Building CXX object CMakeFiles/netgraph_core.dir/src/flow_state.cpp.o -[7/21] Building CXX object CMakeFiles/netgraph_core.dir/src/flow_graph.cpp.o -[8/21] Building CXX object CMakeFiles/netgraph_core.dir/src/flow_policy.cpp.o -[9/21] Building CXX object CMakeFiles/netgraph_core_tests.dir/tests/cpp/smoke_test.cpp.o -[10/21] Building CXX object CMakeFiles/netgraph_core_tests.dir/tests/cpp/strict_multidigraph_tests.cpp.o -[11/21] Building CXX object CMakeFiles/netgraph_core_tests.dir/tests/cpp/flow_policy_tests.cpp.o -[12/21] Building CXX object CMakeFiles/netgraph_core_tests.dir/tests/cpp/shortest_paths_tests.cpp.o -[13/21] Building CXX object CMakeFiles/netgraph_core_tests.dir/tests/cpp/flow_state_tests.cpp.o -[14/21] Building CXX object CMakeFiles/netgraph_core_tests.dir/tests/cpp/flow_graph_tests.cpp.o -[15/21] Linking CXX static library libnetgraph_core.a -[16/21] Building CXX object CMakeFiles/netgraph_core_tests.dir/tests/cpp/max_flow_tests.cpp.o -[17/21] Building CXX object CMakeFiles/netgraph_core_tests.dir/tests/cpp/k_shortest_paths_tests.cpp.o -[18/21] Building CXX object CMakeFiles/netgraph_core_tests.dir/tests/cpp/masking_tests.cpp.o -[19/21] Linking CXX executable netgraph_core_tests -[20/21] Building CXX object CMakeFiles/_netgraph_core.dir/bindings/python/module.cpp.o -[21/21] Linking CXX shared module _netgraph_core.cpython-314-darwin.so -Test project /Users/networmix/ws/NetGraph-Core/build/cpp-sanitize - Start 93: FlowGraphStamp.CopiesAndMovesGetFreshUids - Start 94: FlowGraphStamp.EqualVersionDivergentCopiesCompareUnequal - Start 16: FlowPolicyCostBounds.MaxPathCostFactor_HugeProduct_DropsBoundInsteadOfWrapping - Start 15: FlowPolicyCostBounds.MaxPathCostFactor_UnreachableDst_KeepsSentinelOutOfTheCast - 1/170 Test #93: FlowGraphStamp.CopiesAndMovesGetFreshUids ......................................... Passed 0.06 sec - 2/170 Test #94: FlowGraphStamp.EqualVersionDivergentCopiesCompareUnequal .......................... Passed 0.06 sec - 3/170 Test #16: FlowPolicyCostBounds.MaxPathCostFactor_HugeProduct_DropsBoundInsteadOfWrapping .... Passed 0.06 sec - Start 47: ShortestPaths.ResolveToPathsWithMaxPathsLimit - Start 46: ShortestPaths.ResolveToPathsSplittingParallelEdges - Start 11: FlowPolicyCore.EqualBalanced_ShortestPath_IgnoresHigherCostTier - 4/170 Test #15: FlowPolicyCostBounds.MaxPathCostFactor_UnreachableDst_KeepsSentinelOutOfTheCast ... Passed 0.06 sec - Start 43: ShortestPaths.PredDAGIntegrity - 5/170 Test #11: FlowPolicyCore.EqualBalanced_ShortestPath_IgnoresHigherCostTier ................... Passed 0.06 sec - Start 18: StrictMultiDiGraph.SingleNode - 6/170 Test #46: ShortestPaths.ResolveToPathsSplittingParallelEdges ................................ Passed 0.06 sec - 7/170 Test #43: ShortestPaths.PredDAGIntegrity .................................................... Passed 0.06 sec - Start 45: ShortestPaths.PreferHigherResidualTieBreak - Start 30: StrictMultiDiGraph.OutOfRangeNodeThrows - 8/170 Test #47: ShortestPaths.ResolveToPathsWithMaxPathsLimit ..................................... Passed 0.06 sec - Start 38: ShortestPaths.MultipleEqualCostPaths - 9/170 Test #45: ShortestPaths.PreferHigherResidualTieBreak ........................................ Passed 0.06 sec - Start 41: ShortestPaths.EdgeMaskFiltering - 10/170 Test #18: StrictMultiDiGraph.SingleNode ..................................................... Passed 0.06 sec - 11/170 Test #30: StrictMultiDiGraph.OutOfRangeNodeThrows ........................................... Passed 0.06 sec - 12/170 Test #38: ShortestPaths.MultipleEqualCostPaths .............................................. Passed 0.06 sec - Start 130: MaxFlow.Sensitivity_TwoParallelPaths - Start 103: MaxFlow.ECMP_FourPaths_ClosFabric_Simulation - Start 31: StrictMultiDiGraph.NegativeCapacityThrows - 13/170 Test #41: ShortestPaths.EdgeMaskFiltering ................................................... Passed 0.06 sec - Start 28: StrictMultiDiGraph.MismatchedArrayLengthsThrow - 14/170 Test #130: MaxFlow.Sensitivity_TwoParallelPaths .............................................. Passed 0.06 sec - Start 71: FlowState.EqualBalanced_ECMP3_FirstHopEqualization - 15/170 Test #103: MaxFlow.ECMP_FourPaths_ClosFabric_Simulation ...................................... Passed 0.06 sec - 16/170 Test #31: StrictMultiDiGraph.NegativeCapacityThrows ......................................... Passed 0.06 sec - Start 109: MaxFlow.ZeroCapacityPath_CorrectFiltering - Start 147: MaskingTests.MaskedSourceWithDestination - 17/170 Test #28: StrictMultiDiGraph.MismatchedArrayLengthsThrow .................................... Passed 0.06 sec - Start 152: MaskingTests.MaskedIntermediateNode - 18/170 Test #71: FlowState.EqualBalanced_ECMP3_FirstHopEqualization ................................ Passed 0.06 sec - 19/170 Test #109: MaxFlow.ZeroCapacityPath_CorrectFiltering ......................................... Passed 0.06 sec - 20/170 Test #147: MaskingTests.MaskedSourceWithDestination .......................................... Passed 0.06 sec - Start 12: FlowPolicyStatic.PerFlowBundleBinding - Start 112: MaxFlow.EqualCost_ShortestVsFull_Equivalent - Start 5: FlowPolicyCore.Proportional_SingleDemand_PartialPlacement_InsufficientCapacity - 21/170 Test #152: MaskingTests.MaskedIntermediateNode ............................................... Passed 0.06 sec - Start 122: MaxFlow.GridGraph_3x3_MultipleEqualCostPaths - 22/170 Test #12: FlowPolicyStatic.PerFlowBundleBinding ............................................. Passed 0.06 sec - Start 68: FlowState.ResetWithInvalidResidualThrows - 23/170 Test #112: MaxFlow.EqualCost_ShortestVsFull_Equivalent ....................................... Passed 0.06 sec - 24/170 Test #5: FlowPolicyCore.Proportional_SingleDemand_PartialPlacement_InsufficientCapacity .... Passed 0.06 sec - Start 44: ShortestPaths.TieBreakingDeterminism - Start 39: ShortestPaths.ResidualAwareFiltering - 25/170 Test #122: MaxFlow.GridGraph_3x3_MultipleEqualCostPaths ...................................... Passed 0.07 sec - Start 4: FlowPolicyCore.Proportional_SingleDemand_MaxFlowCount1_UsesSinglePath - 26/170 Test #44: ShortestPaths.TieBreakingDeterminism .............................................. Passed 0.07 sec - Start 108: MaxFlow.EqualBalanced_NodeMask_ReducesECMPWidth - 27/170 Test #39: ShortestPaths.ResidualAwareFiltering .............................................. Passed 0.07 sec - Start 73: FlowState.EqualBalanced_DownstreamSplitEqualization - 28/170 Test #68: FlowState.ResetWithInvalidResidualThrows .......................................... Passed 0.07 sec - Start 137: KShortestPaths.NodeMaskBlocksPaths - 29/170 Test #4: FlowPolicyCore.Proportional_SingleDemand_MaxFlowCount1_UsesSinglePath ............. Passed 0.07 sec - Start 72: FlowState.EqualBalanced_MinFlowGating_DoesNotPruneValidParallelEdges - 30/170 Test #73: FlowState.EqualBalanced_DownstreamSplitEqualization ............................... Passed 0.06 sec - Start 107: MaxFlow.AsymmetricCapacity_1to100_EqualBalanced - 31/170 Test #108: MaxFlow.EqualBalanced_NodeMask_ReducesECMPWidth ................................... Passed 0.06 sec - 32/170 Test #137: KShortestPaths.NodeMaskBlocksPaths ................................................ Passed 0.06 sec - Start 140: KShortestPaths.PathsSortedByCost - Start 36: ShortestPaths.SingleSourceAllNodes - 33/170 Test #72: FlowState.EqualBalanced_MinFlowGating_DoesNotPruneValidParallelEdges .............. Passed 0.06 sec - Start 158: MaskingTests.MaskWithResidualCapacity - 34/170 Test #107: MaxFlow.AsymmetricCapacity_1to100_EqualBalanced ................................... Passed 0.06 sec - 35/170 Test #140: KShortestPaths.PathsSortedByCost .................................................. Passed 0.06 sec - 36/170 Test #36: ShortestPaths.SingleSourceAllNodes ................................................ Passed 0.06 sec - Start 148: MaskingTests.MaskedSourceInMaxFlow - Start 113: MaxFlow.ThreeCostTiers_ShortestPath_LowestTierOnly - Start 157: MaskingTests.NodeMaskOverridesEdgeMask - 37/170 Test #158: MaskingTests.MaskWithResidualCapacity ............................................. Passed 0.06 sec - Start 146: MaskingTests.MaskedSourceReturnsEmptyDAG - 38/170 Test #148: MaskingTests.MaskedSourceInMaxFlow ................................................ Passed 0.06 sec - 39/170 Test #113: MaxFlow.ThreeCostTiers_ShortestPath_LowestTierOnly ................................ Passed 0.06 sec - 40/170 Test #157: MaskingTests.NodeMaskOverridesEdgeMask ............................................ Passed 0.06 sec - Start 1: GraphSmoke.ConstructAndRunSPF - Start 24: StrictMultiDiGraph.CSRReverseAdjacency - Start 67: FlowState.InvalidResidualInitThrows - 41/170 Test #146: MaskingTests.MaskedSourceReturnsEmptyDAG .......................................... Passed 0.07 sec - 42/170 Test #1: GraphSmoke.ConstructAndRunSPF ..................................................... Passed 0.06 sec - 43/170 Test #24: StrictMultiDiGraph.CSRReverseAdjacency ............................................ Passed 0.06 sec - Start 92: FlowGraph.LedgerMicroFlows_RemovalRestoresResidual - Start 10: FlowPolicyCore.EqualBalanced_ShortestPath_DownstreamSplit_BalancesBottleneck - Start 40: ShortestPaths.NodeMaskIsolation - 44/170 Test #67: FlowState.InvalidResidualInitThrows ............................................... Passed 0.07 sec - Start 123: MaxFlow.SharedBottleneck_MultiplePathsMerge - 45/170 Test #40: ShortestPaths.NodeMaskIsolation ................................................... Passed 0.06 sec - Start 22: StrictMultiDiGraph.ExternalEdgeIdsPreserved - 46/170 Test #10: FlowPolicyCore.EqualBalanced_ShortestPath_DownstreamSplit_BalancesBottleneck ...... Passed 0.06 sec - 47/170 Test #123: MaxFlow.SharedBottleneck_MultiplePathsMerge ....................................... Passed 0.06 sec - Start 74: FlowState.EqualBalanced_ZeroCapacityOnShortestPath_ReturnsZero - Start 20: StrictMultiDiGraph.ParallelEdges - 48/170 Test #92: FlowGraph.LedgerMicroFlows_RemovalRestoresResidual ................................ Passed 0.07 sec - Start 115: MaxFlow.FlowConservation_IntermediateNodes - 49/170 Test #74: FlowState.EqualBalanced_ZeroCapacityOnShortestPath_ReturnsZero .................... Passed 0.07 sec - Start 91: FlowGraph.LedgerHistory_RemovalRestoresResidual - 50/170 Test #22: StrictMultiDiGraph.ExternalEdgeIdsPreserved ....................................... Passed 0.07 sec - 51/170 Test #20: StrictMultiDiGraph.ParallelEdges .................................................. Passed 0.07 sec - Start 35: StrictMultiDiGraph.MultipleCostTiers - Start 33: StrictMultiDiGraph.SelfLoopAllowed - 52/170 Test #115: MaxFlow.FlowConservation_IntermediateNodes ........................................ Passed 0.07 sec - Start 9: FlowPolicyCore.EqualBalanced_ShortestPath_ECMP3_SaturatesAllEqualCostPaths - 53/170 Test #33: StrictMultiDiGraph.SelfLoopAllowed ................................................ Passed 0.07 sec - Start 29: StrictMultiDiGraph.ExtEdgeIdsWrongLengthThrows - 54/170 Test #91: FlowGraph.LedgerHistory_RemovalRestoresResidual ................................... Passed 0.07 sec - 55/170 Test #35: StrictMultiDiGraph.MultipleCostTiers .............................................. Passed 0.07 sec - Start 2: FlowPolicyCore.Proportional_SingleDemand_UsesShortestPath - Start 75: FlowState.Proportional_ZeroCapacityOnShortestPath_ReturnsZero - 56/170 Test #9: FlowPolicyCore.EqualBalanced_ShortestPath_ECMP3_SaturatesAllEqualCostPaths ........ Passed 0.06 sec - Start 101: MaxFlow.ECMP_TwoPaths_EqualBalanced_ShortestPath - 57/170 Test #2: FlowPolicyCore.Proportional_SingleDemand_UsesShortestPath ......................... Passed 0.07 sec - Start 145: KSP.MaxCostFactorBelowOne_ReturnsEmptyForAllK - 58/170 Test #29: StrictMultiDiGraph.ExtEdgeIdsWrongLengthThrows .................................... Passed 0.07 sec - 59/170 Test #75: FlowState.Proportional_ZeroCapacityOnShortestPath_ReturnsZero ..................... Passed 0.07 sec - Start 119: MaxFlow.NodeMask_BlocksFlowThroughNode - Start 134: MaxFlow.CompletionPhase_RespectsNodeMask - 60/170 Test #101: MaxFlow.ECMP_TwoPaths_EqualBalanced_ShortestPath .................................. Passed 0.07 sec - Start 126: MaxFlow.Triangle_Topology_Combine_Mode - 61/170 Test #145: KSP.MaxCostFactorBelowOne_ReturnsEmptyForAllK ..................................... Passed 0.07 sec - Start 167: MaskingTests.AllNodesMaskedNoReachability - 62/170 Test #119: MaxFlow.NodeMask_BlocksFlowThroughNode ............................................ Passed 0.07 sec - 63/170 Test #134: MaxFlow.CompletionPhase_RespectsNodeMask .......................................... Passed 0.07 sec - Start 21: StrictMultiDiGraph.EdgeSortingByCost - Start 19: StrictMultiDiGraph.SingleEdge - 64/170 Test #126: MaxFlow.Triangle_Topology_Combine_Mode ............................................ Passed 0.07 sec - Start 17: StrictMultiDiGraph.EmptyGraph - 65/170 Test #21: StrictMultiDiGraph.EdgeSortingByCost .............................................. Passed 0.07 sec - Start 14: FlowPolicyStatic.ValidationRejectsForeignAndDisconnectedBundles - 66/170 Test #167: MaskingTests.AllNodesMaskedNoReachability ......................................... Passed 0.07 sec - 67/170 Test #19: StrictMultiDiGraph.SingleEdge ..................................................... Passed 0.07 sec - Start 151: MaskingTests.MaskedDestinationUnreachable - Start 37: ShortestPaths.DisconnectedComponents - 68/170 Test #17: StrictMultiDiGraph.EmptyGraph ..................................................... Passed 0.07 sec - Start 100: MaxFlow.ECMP_TwoPaths_Proportional_FullMaxFlow - 69/170 Test #14: FlowPolicyStatic.ValidationRejectsForeignAndDisconnectedBundles ................... Passed 0.07 sec - Start 106: MaxFlow.AsymmetricCapacity_1to1M_NumericalStability - 70/170 Test #151: MaskingTests.MaskedDestinationUnreachable ......................................... Passed 0.07 sec - 71/170 Test #37: ShortestPaths.DisconnectedComponents .............................................. Passed 0.07 sec - Start 99: MaxFlow.ECMP_TwoPaths_Proportional_ShortestPath - Start 85: FlowGraph.RemovalDeletesEntry - 72/170 Test #100: MaxFlow.ECMP_TwoPaths_Proportional_FullMaxFlow .................................... Passed 0.07 sec - Start 114: MaxFlow.PlacementModes_EqualCapacity_IdenticalResult - 73/170 Test #99: MaxFlow.ECMP_TwoPaths_Proportional_ShortestPath ................................... Passed 0.07 sec - Start 89: FlowGraph.GetFlowPathForDAGReturnsEmpty - 74/170 Test #106: MaxFlow.AsymmetricCapacity_1to1M_NumericalStability ............................... Passed 0.07 sec - 75/170 Test #85: FlowGraph.RemovalDeletesEntry ..................................................... Passed 0.07 sec - Start 13: FlowPolicyStatic.MaskedBundleIsDown - Start 139: KShortestPaths.MaxCostFactorLimit - 76/170 Test #114: MaxFlow.PlacementModes_EqualCapacity_IdenticalResult .............................. Passed 0.07 sec - Start 135: KShortestPaths.FindsKPaths - 77/170 Test #13: FlowPolicyStatic.MaskedBundleIsDown ............................................... Passed 0.07 sec - Start 149: MaskingTests.MaskedSourceInComputeMinCut - 78/170 Test #89: FlowGraph.GetFlowPathForDAGReturnsEmpty ........................................... Passed 0.07 sec - 79/170 Test #139: KShortestPaths.MaxCostFactorLimit ................................................. Passed 0.07 sec - 80/170 Test #135: KShortestPaths.FindsKPaths ........................................................ Passed 0.07 sec - Start 96: MaxFlow.SinglePath_BottleneckConstraint - Start 27: StrictMultiDiGraph.InvalidNumNodesThrows - Start 63: FlowState.ApplyDeltasSubtractive - 81/170 Test #27: StrictMultiDiGraph.InvalidNumNodesThrows .......................................... Passed 0.07 sec - Start 170: MaskingTests.AlgorithmsAPIMaxFlowWithMask - 82/170 Test #149: MaskingTests.MaskedSourceInComputeMinCut .......................................... Passed 0.07 sec - 83/170 Test #96: MaxFlow.SinglePath_BottleneckConstraint ........................................... Passed 0.07 sec - 84/170 Test #63: FlowState.ApplyDeltasSubtractive .................................................. Passed 0.07 sec - Start 110: MaxFlow.TwoCostTiers_ShortestPath_SingleTierOnly - Start 160: MaskingTests.KSPWithNodeMask - Start 6: FlowPolicyCore.EqualBalanced_SingleDemand_FlowCount2_BalancedDistribution - 85/170 Test #170: MaskingTests.AlgorithmsAPIMaxFlowWithMask ......................................... Passed 0.07 sec - 86/170 Test #160: MaskingTests.KSPWithNodeMask ...................................................... Passed 0.07 sec - 87/170 Test #6: FlowPolicyCore.EqualBalanced_SingleDemand_FlowCount2_BalancedDistribution ......... Passed 0.07 sec - Start 144: KShortestPaths.LargerOutOfOrderTopology - Start 23: StrictMultiDiGraph.CSROffsetsMonotonicity - Start 8: FlowPolicyCore.DiminishingReturnsCutoff - 88/170 Test #110: MaxFlow.TwoCostTiers_ShortestPath_SingleTierOnly .................................. Passed 0.07 sec - Start 97: MaxFlow.SinglePath_ShortestPathEquivalence - 89/170 Test #144: KShortestPaths.LargerOutOfOrderTopology ........................................... Passed 0.07 sec - 90/170 Test #8: FlowPolicyCore.DiminishingReturnsCutoff ........................................... Passed 0.07 sec - Start 156: MaskingTests.CombinedNodeAndEdgeMasks - Start 169: MaskingTests.AlgorithmsAPIMaskedSource - 91/170 Test #23: StrictMultiDiGraph.CSROffsetsMonotonicity ......................................... Passed 0.07 sec - 92/170 Test #97: MaxFlow.SinglePath_ShortestPathEquivalence ........................................ Passed 0.07 sec - Start 117: MaxFlow.CostDistribution_AccountsForAllFlow - Start 86: FlowGraph.RemoveByClassFiltersCorrectly - 93/170 Test #117: MaxFlow.CostDistribution_AccountsForAllFlow ....................................... Passed 0.07 sec - Start 136: KShortestPaths.KEqualsZeroReturnsEmpty - 94/170 Test #156: MaskingTests.CombinedNodeAndEdgeMasks ............................................. Passed 0.08 sec - 95/170 Test #169: MaskingTests.AlgorithmsAPIMaskedSource ............................................ Passed 0.08 sec - 96/170 Test #86: FlowGraph.RemoveByClassFiltersCorrectly ........................................... Passed 0.08 sec - Start 104: MaxFlow.ECMP_TenPaths_ScalabilityValidation - Start 132: MaxFlow.Sensitivity_ShortestPathVsMaxFlow - Start 120: MaxFlow.EdgeMask_SelectiveEdgeFiltering - 97/170 Test #136: KShortestPaths.KEqualsZeroReturnsEmpty ............................................ Passed 0.07 sec - Start 124: MaxFlow.Clos_3Tier_Topology_Proportional_Shortest - 98/170 Test #104: MaxFlow.ECMP_TenPaths_ScalabilityValidation ....................................... Passed 0.07 sec - 99/170 Test #132: MaxFlow.Sensitivity_ShortestPathVsMaxFlow ......................................... Passed 0.07 sec -100/170 Test #120: MaxFlow.EdgeMask_SelectiveEdgeFiltering ........................................... Passed 0.07 sec - Start 65: FlowState.ResetRestoresCapacity - Start 49: ShortestPaths.RejectsMaskLengthMismatch - Start 133: MaxFlow.CompletionPhase_RecoversCancellation -101/170 Test #65: FlowState.ResetRestoresCapacity ................................................... Passed 0.07 sec - Start 165: MaskingTests.EmptyNodeMaskAllowsAllNodes -102/170 Test #124: MaxFlow.Clos_3Tier_Topology_Proportional_Shortest ................................. Passed 0.07 sec -103/170 Test #49: ShortestPaths.RejectsMaskLengthMismatch ........................................... Passed 0.07 sec - Start 111: MaxFlow.TwoCostTiers_FullMaxFlow_AllTiers - Start 129: MaxFlow.Sensitivity_SinglePath -104/170 Test #133: MaxFlow.CompletionPhase_RecoversCancellation ...................................... Passed 0.07 sec - Start 161: MaskingTests.KSPWithEdgeMask -105/170 Test #165: MaskingTests.EmptyNodeMaskAllowsAllNodes .......................................... Passed 0.07 sec - Start 32: StrictMultiDiGraph.NegativeCostThrows -106/170 Test #111: MaxFlow.TwoCostTiers_FullMaxFlow_AllTiers ......................................... Passed 0.07 sec -107/170 Test #129: MaxFlow.Sensitivity_SinglePath .................................................... Passed 0.07 sec -108/170 Test #161: MaskingTests.KSPWithEdgeMask ...................................................... Passed 0.07 sec - Start 66: FlowState.CustomInitialResidual - Start 42: ShortestPaths.EarlyExitOptimization - Start 88: FlowGraph.GetFlowPathForLinearFlow -109/170 Test #66: FlowState.CustomInitialResidual ................................................... Passed 0.07 sec - Start 25: StrictMultiDiGraph.MaxCapacityEdges -110/170 Test #32: StrictMultiDiGraph.NegativeCostThrows ............................................. Passed 0.07 sec -111/170 Test #42: ShortestPaths.EarlyExitOptimization ............................................... Passed 0.07 sec -112/170 Test #88: FlowGraph.GetFlowPathForLinearFlow ................................................ Passed 0.07 sec - Start 168: MaskingTests.AllEdgesMaskedNoReachability - Start 142: KShortestPaths.LooplessPaths - Start 138: KShortestPaths.UniqueModeFiltering -113/170 Test #25: StrictMultiDiGraph.MaxCapacityEdges ............................................... Passed 0.07 sec -114/170 Test #138: KShortestPaths.UniqueModeFiltering ................................................ Passed 0.07 sec - Start 7: FlowPolicyCore.EqualBalanced_SingleDemand_FlowCount3_SaturatesBothPaths - Start 3: FlowPolicyCore.Proportional_SingleDemand_SplitsAcrossTwoEqualCostPaths -115/170 Test #168: MaskingTests.AllEdgesMaskedNoReachability ......................................... Passed 0.07 sec -116/170 Test #142: KShortestPaths.LooplessPaths ...................................................... Passed 0.07 sec - Start 159: MaskingTests.ResidualAndNodeMaskCombined - Start 143: KShortestPaths.OutOfOrderNodePredDAGSemantics -117/170 Test #7: FlowPolicyCore.EqualBalanced_SingleDemand_FlowCount3_SaturatesBothPaths ........... Passed 0.07 sec -118/170 Test #159: MaskingTests.ResidualAndNodeMaskCombined .......................................... Passed 0.07 sec - Start 62: FlowState.ApplyDeltasAdditive - Start 131: MaxFlow.Sensitivity_PartialSaturation -119/170 Test #3: FlowPolicyCore.Proportional_SingleDemand_SplitsAcrossTwoEqualCostPaths ............ Passed 0.07 sec -120/170 Test #143: KShortestPaths.OutOfOrderNodePredDAGSemantics ..................................... Passed 0.07 sec - Start 83: FlowGraph.EmptyLedgerInitially - Start 84: FlowGraph.PlacementCreatesEntry -121/170 Test #83: FlowGraph.EmptyLedgerInitially .................................................... Passed 0.07 sec - Start 76: FlowState.RepeatedPlacementAfterResetAndIndependentInstancesStayStable -122/170 Test #62: FlowState.ApplyDeltasAdditive ..................................................... Passed 0.07 sec -123/170 Test #131: MaxFlow.Sensitivity_PartialSaturation ............................................. Passed 0.07 sec -124/170 Test #84: FlowGraph.PlacementCreatesEntry ................................................... Passed 0.07 sec - Start 153: MaskingTests.MaskedEdgeBlocksPath - Start 102: MaxFlow.ECMP_ThreePaths_Proportional_ShortestPath - Start 64: FlowState.MinCutComputation -125/170 Test #76: FlowState.RepeatedPlacementAfterResetAndIndependentInstancesStayStable ............ Passed 0.07 sec - Start 69: FlowState.ApplyDeltasClampsWithinCapacityBounds -126/170 Test #153: MaskingTests.MaskedEdgeBlocksPath ................................................. Passed 0.07 sec -127/170 Test #102: MaxFlow.ECMP_ThreePaths_Proportional_ShortestPath ................................. Passed 0.07 sec -128/170 Test #64: FlowState.MinCutComputation ....................................................... Passed 0.07 sec - Start 95: MaxFlow.SinglePath_BasicLinear - Start 121: MaxFlow.BatchMaxFlow_IndependentMaskApplication - Start 116: MaxFlow.MinCut_ValidEdgeIdentification -129/170 Test #95: MaxFlow.SinglePath_BasicLinear .................................................... Passed 0.07 sec -130/170 Test #121: MaxFlow.BatchMaxFlow_IndependentMaskApplication ................................... Passed 0.07 sec - Start 34: StrictMultiDiGraph.NegativeNodeIdThrows - Start 87: FlowGraph.GetFlowEdgesReturnsDeltas -131/170 Test #69: FlowState.ApplyDeltasClampsWithinCapacityBounds ................................... Passed 0.08 sec -132/170 Test #116: MaxFlow.MinCut_ValidEdgeIdentification ............................................ Passed 0.07 sec - Start 50: ShortestPaths.ZeroCostEdges_PredDAGIsAcyclic - Start 105: MaxFlow.AsymmetricCapacity_1to100_Proportional -133/170 Test #34: StrictMultiDiGraph.NegativeNodeIdThrows ........................................... Passed 0.07 sec -134/170 Test #87: FlowGraph.GetFlowEdgesReturnsDeltas ............................................... Passed 0.07 sec -135/170 Test #50: ShortestPaths.ZeroCostEdges_PredDAGIsAcyclic ...................................... Passed 0.07 sec -136/170 Test #105: MaxFlow.AsymmetricCapacity_1to100_Proportional .................................... Passed 0.07 sec - Start 128: MaxFlow.ShortestPath_MustSaturateAllEqualCostPaths - Start 125: MaxFlow.Clos_3Tier_Topology_EqualBalanced_Shortest - Start 98: MaxFlow.DegenerateCase_SourceEqualsSink - Start 118: MaxFlow.OptionalOutputs_ResidualAndReachable -137/170 Test #128: MaxFlow.ShortestPath_MustSaturateAllEqualCostPaths ................................ Passed 0.08 sec - Start 90: FlowGraph.CoalescesDuplicateEdgeIds -138/170 Test #125: MaxFlow.Clos_3Tier_Topology_EqualBalanced_Shortest ................................ Passed 0.08 sec -139/170 Test #98: MaxFlow.DegenerateCase_SourceEqualsSink ........................................... Passed 0.08 sec -140/170 Test #118: MaxFlow.OptionalOutputs_ResidualAndReachable ...................................... Passed 0.08 sec - Start 48: ShortestPaths.LargeGraphStressTest - Start 163: MaskingTests.MaxFlowWithEdgeMask - Start 164: MaskingTests.MaxFlowAllPathsMasked -141/170 Test #90: FlowGraph.CoalescesDuplicateEdgeIds ............................................... Passed 0.08 sec - Start 26: StrictMultiDiGraph.ZeroCostEdges -142/170 Test #163: MaskingTests.MaxFlowWithEdgeMask .................................................. Passed 0.08 sec -143/170 Test #164: MaskingTests.MaxFlowAllPathsMasked ................................................ Passed 0.08 sec - Start 60: FlowState.EqualBalancedDistribution - Start 141: KShortestPaths.DisconnectedReturnsEmpty -144/170 Test #48: ShortestPaths.LargeGraphStressTest ................................................ Passed 0.08 sec - Start 154: MaskingTests.MaskedEdgeSelectsAlternativePath -145/170 Test #60: FlowState.EqualBalancedDistribution ............................................... Passed 0.08 sec - Start 150: MaskingTests.OutOfRangeSourceInComputeMinCut -146/170 Test #26: StrictMultiDiGraph.ZeroCostEdges .................................................. Passed 0.08 sec -147/170 Test #141: KShortestPaths.DisconnectedReturnsEmpty ........................................... Passed 0.08 sec -148/170 Test #154: MaskingTests.MaskedEdgeSelectsAlternativePath ..................................... Passed 0.08 sec - Start 166: MaskingTests.EmptyEdgeMaskAllowsAllEdges - Start 70: FlowState.EqualBalancedReconvergentDAGPlacesAll - Start 162: MaskingTests.MaxFlowWithNodeMask -149/170 Test #150: MaskingTests.OutOfRangeSourceInComputeMinCut ...................................... Passed 0.07 sec -150/170 Test #166: MaskingTests.EmptyEdgeMaskAllowsAllEdges .......................................... Passed 0.07 sec - Start 61: FlowState.ZeroCapacityHandling - Start 59: FlowState.ProportionalDistribution -151/170 Test #70: FlowState.EqualBalancedReconvergentDAGPlacesAll ................................... Passed 0.07 sec -152/170 Test #162: MaskingTests.MaxFlowWithNodeMask .................................................. Passed 0.07 sec - Start 155: MaskingTests.MultipleEdgesMasked - Start 57: FlowState.InitialResidualEqualsCapacity -153/170 Test #59: FlowState.ProportionalDistribution ................................................ Passed 0.07 sec - Start 127: MaxFlow.SimpleParallelPaths_Topology -154/170 Test #61: FlowState.ZeroCapacityHandling .................................................... Passed 0.07 sec -155/170 Test #155: MaskingTests.MultipleEdgesMasked .................................................. Passed 0.07 sec -156/170 Test #57: FlowState.InitialResidualEqualsCapacity ........................................... Passed 0.07 sec - Start 58: FlowState.PlacementReducesResidual - Start 51: ShortestPathsTo.DistancesMatchForwardDistancesToDst - Start 52: ShortestPathsTo.ForwardSpfFromPseudoSourceKeepsOnlyNearestSource -157/170 Test #58: FlowState.PlacementReducesResidual ................................................ Passed 0.07 sec - Start 53: ShortestPathsTo.FanoutEdgesForceEverySourceIntoTheDag -158/170 Test #127: MaxFlow.SimpleParallelPaths_Topology .............................................. Passed 0.07 sec -159/170 Test #51: ShortestPathsTo.DistancesMatchForwardDistancesToDst ............................... Passed 0.07 sec -160/170 Test #52: ShortestPathsTo.ForwardSpfFromPseudoSourceKeepsOnlyNearestSource .................. Passed 0.07 sec - Start 54: ShortestPathsTo.FanoutSkipsUnreachableAndMaskedHeads - Start 55: ShortestPathsTo.FanoutFromInteriorNodeIsRejected - Start 56: ShortestPathsTo.SinglePathModeKeepsOneSuccessorPerNode -161/170 Test #53: ShortestPathsTo.FanoutEdgesForceEverySourceIntoTheDag ............................. Passed 0.07 sec - Start 77: FlowState.EqualBalanced_SaturatedMemberBlocksAdmission -162/170 Test #54: ShortestPathsTo.FanoutSkipsUnreachableAndMaskedHeads .............................. Passed 0.07 sec -163/170 Test #55: ShortestPathsTo.FanoutFromInteriorNodeIsRejected .................................. Passed 0.07 sec -164/170 Test #56: ShortestPathsTo.SinglePathModeKeepsOneSuccessorPerNode ............................ Passed 0.07 sec - Start 78: FlowState.EqualBalanced_ProgressesOnlyThroughResidualAwareSpf - Start 79: FlowState.EqualBalancedLossy_FillAndDropReportsDrops - Start 80: FlowState.EqualBalancedLossy_DeficitPropagatesDownstream -165/170 Test #79: FlowState.EqualBalancedLossy_FillAndDropReportsDrops .............................. Passed 0.07 sec - Start 81: FlowState.EqualBalancedLossy_InfiniteRequestFillsWithoutDrops -166/170 Test #77: FlowState.EqualBalanced_SaturatedMemberBlocksAdmission ............................ Passed 0.07 sec -167/170 Test #78: FlowState.EqualBalanced_ProgressesOnlyThroughResidualAwareSpf ..................... Passed 0.07 sec -168/170 Test #80: FlowState.EqualBalancedLossy_DeficitPropagatesDownstream .......................... Passed 0.07 sec - Start 82: FlowGraph.LossyPlacementIsReversibleAndDropsStayOutOfLedger -169/170 Test #81: FlowState.EqualBalancedLossy_InfiniteRequestFillsWithoutDrops ..................... Passed 0.07 sec -170/170 Test #82: FlowGraph.LossyPlacementIsReversibleAndDropsStayOutOfLedger ....................... Passed 0.07 sec - -100% tests passed out of 170 - -Total Test time (real) = 3.00 sec diff --git a/dev/research/metal_pathfinding/results/stress-sanitized.csv b/dev/research/metal_pathfinding/results/stress-sanitized.csv deleted file mode 100644 index c9d9fb1..0000000 --- a/dev/research/metal_pathfinding/results/stress-sanitized.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,method,wall_ms,gpu_ms,distance_wall_ms,upload_ms,rounds,samples,correct,exact_order_queries,wall_samples_ms -grid,1024,3968,64,metal_pull,15.530167,4.484500,9.108750,0.188375,64,20,pass,0,16.700250;15.530167;15.417208;14.727875;14.057000;15.392333;16.472042;15.792750;15.891250;11.988333;15.802375;15.560042;16.590166;15.518750;11.375958;16.363709;17.054667;15.159750;15.207500;15.467084 -grid,1024,3968,64,metal_group,11.060208,3.933417,4.879000,0.188375,63,20,pass,0,11.942000;7.964375;11.096834;10.917458;11.153459;8.798208;11.295417;11.240333;10.839417;11.959583;11.143958;11.022000;10.818417;11.017167;8.111667;11.060208;11.184917;10.825750;11.167166;10.949792 -grid,1024,3968,64,metal_shared,10.228625,3.022375,3.916833,0.188375,63,20,pass,0,9.921208;10.004083;9.822125;9.661958;10.228625;11.134167;10.203042;10.252459;10.255542;10.094833;10.453375;10.557041;10.717250;9.984875;10.313458;10.648125;10.205625;10.073000;10.538500;10.068917 -random_masked_int64_reverse,1024,8192,64,metal_pull,13.439750,0.905375,1.931417,0.195667,8,20,pass,6,13.597583;13.481542;13.092833;13.398708;13.384584;13.448375;14.449125;15.040625;13.340542;13.603958;13.039250;13.253625;13.401208;13.473250;13.170750;13.203708;13.391417;13.586667;15.356334;13.439750 -random_masked_int64_reverse,1024,8192,64,metal_group,13.348292,0.879917,1.800458,0.195667,8,20,pass,6,13.703917;13.791375;13.181167;13.526292;13.570083;13.179875;13.348292;13.530625;13.278459;13.327250;13.056458;13.275542;14.277209;13.365333;13.212542;13.166417;12.992666;12.752334;13.547125;14.419541 -random_masked_int64_reverse,1024,8192,64,metal_shared,13.334542,0.695792,1.711416,0.195667,8,20,pass,6,13.044125;13.625292;14.034959;13.278042;14.283083;14.222250;13.571042;13.812625;13.348125;14.462250;13.157583;12.979958;13.194167;13.043500;13.855750;13.129500;13.334542;12.628834;12.541375;13.082375 diff --git a/dev/research/metal_pathfinding/results/stress-stderr.txt b/dev/research/metal_pathfinding/results/stress-stderr.txt deleted file mode 100644 index 8052255..0000000 --- a/dev/research/metal_pathfinding/results/stress-stderr.txt +++ /dev/null @@ -1 +0,0 @@ -device=Apple M4 Max setup_ms=98.9218 execution_width=32 diff --git a/dev/research/metal_pathfinding/results/stress-validation.txt b/dev/research/metal_pathfinding/results/stress-validation.txt deleted file mode 100644 index c87a5f1..0000000 --- a/dev/research/metal_pathfinding/results/stress-validation.txt +++ /dev/null @@ -1,3 +0,0 @@ -2026-09-27 00:02:38.195 experiment-sanitize[57642:38285138] Metal API Validation Enabled -2026-09-27 00:02:38.195 experiment-sanitize[57642:38285138] Metal GPU Validation Enabled -device=Apple M4 Max setup_ms=223.541 execution_width=32 diff --git a/dev/research/metal_pathfinding/results/stress.csv b/dev/research/metal_pathfinding/results/stress.csv deleted file mode 100644 index 59e8274..0000000 --- a/dev/research/metal_pathfinding/results/stress.csv +++ /dev/null @@ -1,7 +0,0 @@ -case,n,e,queries,method,wall_ms,gpu_ms,distance_wall_ms,upload_ms,rounds,samples,correct,exact_order_queries,wall_samples_ms -grid,1024,3968,64,metal_pull,2.893875,0.425792,2.019709,0.078417,64,100,pass,0,4.494667;4.062667;4.002000;3.562250;3.658292;3.957500;3.817125;3.879208;4.127208;3.733583;3.796667;3.714583;3.924333;4.171167;3.697167;4.234458;3.981083;4.905542;3.749833;3.651375;3.933417;3.802250;4.545875;3.511667;3.961000;3.879709;3.730875;3.300042;3.143708;3.516167;3.528167;3.266792;3.355542;2.673291;3.135584;3.154791;3.013542;2.940125;2.839459;2.471667;3.009083;2.916875;2.351375;3.027959;2.859958;2.249584;2.713750;2.853708;2.942916;2.976084;2.883708;2.293750;2.909208;2.350291;2.887291;2.889542;2.879709;2.956083;2.806000;2.873542;2.270250;2.895084;2.823958;2.835458;2.635125;2.893375;2.209375;2.224666;3.001459;2.193958;2.865291;2.849334;2.713083;2.704917;2.349458;2.787459;2.347500;2.919917;2.832500;2.252667;2.293708;2.299542;2.930000;2.809125;2.607875;2.154125;2.661750;2.835375;2.214000;2.893875;2.839666;2.221583;2.192500;2.323375;2.456792;2.335750;2.697167;2.229375;2.864916;3.039958 -grid,1024,3968,64,metal_group,2.067916,0.943375,1.156792,0.078417,63,100,pass,0,1.222208;1.703584;1.525334;1.843750;1.220291;1.866208;1.850000;1.772125;1.237916;1.877125;1.660875;1.669625;1.827416;1.852958;1.781875;1.837208;1.869250;1.172917;1.594416;2.365458;2.554708;2.298959;2.377958;2.293208;1.330625;1.203458;1.702000;1.770416;1.217500;1.702458;1.827042;2.313958;1.974291;2.381541;2.276708;2.289041;1.233959;1.722250;1.691166;1.880875;1.246125;1.601250;1.355666;2.275250;2.019166;2.305542;2.237250;2.303208;2.269292;2.261667;2.271166;2.288416;2.285459;2.386250;2.067916;2.284542;2.339292;2.054292;2.208500;2.065125;2.321167;2.278708;2.346000;2.342958;2.289416;2.306541;2.283458;2.056000;2.273750;1.973334;2.322375;2.281417;2.016667;2.242208;2.006250;2.290208;2.322584;2.174125;2.274917;2.294291;2.330667;2.292000;2.628000;2.086458;2.317583;2.328709;1.567959;1.733708;1.753625;1.792584;1.828083;1.726375;1.306916;2.292541;2.073167;2.431583;2.308167;2.337917;1.889792;1.825292 -grid,1024,3968,64,metal_shared,1.748708,0.194750,0.873834,0.078417,63,100,pass,0,1.833000;1.229542;1.731500;1.226667;1.749833;1.360959;1.781084;1.281334;1.243667;2.318000;1.971333;2.212125;2.285458;2.207833;1.792750;1.802042;1.796000;1.777417;1.533959;1.195459;1.818208;1.806666;1.810291;1.678708;1.785708;1.711917;1.671625;1.780625;1.780208;1.677208;1.704709;1.198375;1.743000;1.746542;1.808875;1.270416;2.439500;2.051083;2.253208;2.199917;2.006209;2.074958;2.267041;2.015084;2.284958;2.247500;2.188542;1.660917;1.655208;1.228000;1.673292;1.323083;1.648625;1.705459;1.497542;1.635709;1.679042;1.681250;1.265291;1.663875;2.260708;2.035417;1.929333;2.587208;1.982041;1.679209;1.204375;1.643542;1.670334;1.625000;1.618125;1.735833;1.605792;1.632875;1.602917;2.221916;2.270500;1.966791;2.231417;2.152958;1.751167;1.204500;1.677833;1.891417;1.779500;1.690167;1.708208;1.707584;1.644125;1.264458;1.793792;1.272667;1.186375;2.311500;2.077917;2.057791;2.246125;2.278958;1.748708;1.746708 -random_masked_int64_reverse,1024,8192,64,metal_pull,4.320500,0.411000,0.942125,0.098667,8,100,pass,6,3.915584;3.984333;4.290417;4.201541;4.721584;4.304333;3.936375;3.948584;4.342250;4.290167;4.296041;4.371125;4.213667;4.409000;4.367542;4.160125;4.006542;4.411709;4.427792;4.432417;4.385458;4.346709;4.013458;4.421708;4.260875;4.200708;4.396167;4.359709;4.305542;4.258334;4.253750;4.311791;3.961292;4.247125;3.931042;3.962000;4.304584;3.920083;4.041000;4.367583;4.228875;4.334459;4.284000;4.374584;4.071542;4.312375;4.656291;4.328000;3.946334;4.320500;4.079333;4.190416;4.930666;3.979875;4.378458;4.446834;4.385833;4.225792;4.216667;4.382917;4.363083;4.369875;4.332917;4.398625;3.892083;4.374167;4.355875;3.985459;4.380292;4.356917;4.203583;4.345500;4.419958;3.965208;4.269792;4.254334;4.234917;4.338792;3.941584;4.135791;4.342584;4.314334;4.395084;4.393708;4.362541;4.325458;4.271375;4.331833;4.662667;4.429875;4.387000;4.061250;4.395959;4.346167;4.068583;4.460083;4.419750;4.284042;4.372291;4.326500 -random_masked_int64_reverse,1024,8192,64,metal_group,4.130833,0.240250,0.757125,0.098667,8,100,pass,6,4.159792;4.163750;4.188959;4.129250;3.907334;3.927416;4.088083;3.847333;4.244208;4.168333;4.101375;4.115000;4.250917;4.249542;4.077417;4.133125;3.820667;4.157292;3.801416;4.237917;4.132083;3.737250;4.185458;4.106667;4.181875;4.294000;4.160042;4.162167;4.141166;4.067666;4.287959;4.048417;4.080208;4.074666;4.122000;3.814500;4.130166;3.799666;3.773208;4.202458;4.118959;4.077041;4.130833;3.951708;4.453959;3.985458;3.756459;4.193500;4.245542;4.392292;4.224667;4.173541;4.192459;3.817833;3.784459;4.228708;4.218500;3.784750;4.124833;4.093875;4.086666;4.084334;4.235750;3.813291;4.238541;4.105792;3.827084;4.104416;4.121208;4.295375;4.366209;4.095417;4.200125;3.931375;4.102583;4.208416;4.233666;4.141541;4.237000;4.259541;4.127334;4.149750;3.713833;3.788292;4.195959;4.113458;4.112209;3.820500;3.793334;4.329583;4.180000;4.204083;4.143041;4.254959;4.079416;4.259917;4.167875;4.093000;4.297709;4.166625 -random_masked_int64_reverse,1024,8192,64,metal_shared,4.154125,0.221417,0.721583,0.098667,8,100,pass,6,3.756917;3.782208;4.299417;4.174375;4.168166;4.101584;4.228833;3.782625;4.102625;4.134833;4.242875;4.193500;4.115209;4.191000;4.190875;4.146167;4.107166;4.026375;4.243250;4.117167;4.027459;3.831667;4.176167;4.154125;4.148583;4.222792;4.025041;4.497167;4.314084;4.296291;3.930791;4.316917;4.131417;4.165375;3.806875;3.835833;4.255666;4.048333;4.141417;4.261666;3.805750;3.877667;4.506167;4.162500;4.181750;4.146666;3.890250;3.849875;4.116166;4.118083;4.208167;3.950000;4.283625;4.089875;4.224333;4.217750;4.234625;4.166709;4.066666;4.212584;4.336375;4.250292;4.180875;4.222458;4.219125;4.060125;4.200833;3.887084;4.454250;4.179708;4.046416;4.045667;4.104625;3.936666;4.128667;4.157583;4.193084;3.881291;4.401875;3.909542;4.126500;4.319250;4.151291;4.130417;4.142500;4.205666;4.290834;4.397625;4.086833;4.155250;4.330625;4.169792;4.146333;3.774916;4.187500;3.773834;3.897834;4.243042;3.813334;4.321792 diff --git a/dev/research/metal_pathfinding/results/validation.txt b/dev/research/metal_pathfinding/results/validation.txt deleted file mode 100644 index a0d4adc..0000000 --- a/dev/research/metal_pathfinding/results/validation.txt +++ /dev/null @@ -1,3 +0,0 @@ -2026-09-27 00:02:40.947 experiment-sanitize[57855:38285628] Metal API Validation Enabled -2026-09-27 00:02:40.947 experiment-sanitize[57855:38285628] Metal GPU Validation Enabled -device=Apple M4 Max setup_ms=28.2634 execution_width=32 diff --git a/dev/research/metal_pathfinding/results/witnesses-sanitized.txt b/dev/research/metal_pathfinding/results/witnesses-sanitized.txt deleted file mode 100644 index 6159e5d..0000000 --- a/dev/research/metal_pathfinding/results/witnesses-sanitized.txt +++ /dev/null @@ -1,9 +0,0 @@ -PASS diamond: all ECMP alternatives, parallel edges, positive self-loop excluded, isolated node -PASS reverse diamond: hand-derived distances and forward DAG -PASS masked source and transit node -PASS double capacity threshold: below rejected, exact threshold admitted -PASS edge mask and residual gate combined -PASS exact int64 costs above 2^53 and one-unit path difference -PASS expected gap: zero-cost distances agree, tight-edge GPU DAG has 1->2->1 cycle; CPU DAG has 2 edges -PASS precision gaps: float32 collapses 16777216/16777217 and admits nextafter(kMinCap,0) -PASS capacity tie gap: CPU chooses edge 1 at 1+1e-8; float32 collapses both capacities diff --git a/dev/research/metal_pathfinding/results/witnesses-stderr.txt b/dev/research/metal_pathfinding/results/witnesses-stderr.txt deleted file mode 100644 index 6958d46..0000000 --- a/dev/research/metal_pathfinding/results/witnesses-stderr.txt +++ /dev/null @@ -1 +0,0 @@ -device=Apple M4 Max setup_ms=25.3313 execution_width=32 diff --git a/dev/research/metal_pathfinding/results/witnesses-validation.txt b/dev/research/metal_pathfinding/results/witnesses-validation.txt deleted file mode 100644 index eb1391c..0000000 --- a/dev/research/metal_pathfinding/results/witnesses-validation.txt +++ /dev/null @@ -1,3 +0,0 @@ -2026-09-27 00:02:41.134 experiment-sanitize[57187:38285642] Metal API Validation Enabled -2026-09-27 00:02:41.134 experiment-sanitize[57187:38285642] Metal GPU Validation Enabled -device=Apple M4 Max setup_ms=33.3338 execution_width=32 diff --git a/dev/research/metal_pathfinding/results/witnesses.txt b/dev/research/metal_pathfinding/results/witnesses.txt deleted file mode 100644 index 6159e5d..0000000 --- a/dev/research/metal_pathfinding/results/witnesses.txt +++ /dev/null @@ -1,9 +0,0 @@ -PASS diamond: all ECMP alternatives, parallel edges, positive self-loop excluded, isolated node -PASS reverse diamond: hand-derived distances and forward DAG -PASS masked source and transit node -PASS double capacity threshold: below rejected, exact threshold admitted -PASS edge mask and residual gate combined -PASS exact int64 costs above 2^53 and one-unit path difference -PASS expected gap: zero-cost distances agree, tight-edge GPU DAG has 1->2->1 cycle; CPU DAG has 2 edges -PASS precision gaps: float32 collapses 16777216/16777217 and admits nextafter(kMinCap,0) -PASS capacity tie gap: CPU chooses edge 1 at 1+1e-8; float32 collapses both capacities diff --git a/dev/research/metal_pathfinding/results/workspace-check.txt b/dev/research/metal_pathfinding/results/workspace-check.txt deleted file mode 100644 index 7f3aec6..0000000 --- a/dev/research/metal_pathfinding/results/workspace-check.txt +++ /dev/null @@ -1,411 +0,0 @@ -📦 Installing package (editable, default optimizations) -Obtaining file:///Users/networmix/ws/NetGraph-Core - Installing build dependencies: started - Installing build dependencies: finished with status 'done' - Checking if build backend supports build_editable: started - Checking if build backend supports build_editable: finished with status 'done' - Getting requirements to build editable: started - Getting requirements to build editable: finished with status 'done' - Installing backend dependencies: started - Installing backend dependencies: finished with status 'done' - Preparing editable metadata (pyproject.toml): started - Preparing editable metadata (pyproject.toml): finished with status 'done' -Requirement already satisfied: numpy>=1.23.2 in ./venv/lib/python3.14/site-packages (from netgraph-core==0.10.0) (2.5.2) -Building wheels for collected packages: netgraph-core - Building editable for netgraph-core (pyproject.toml): started - Building editable for netgraph-core (pyproject.toml): finished with status 'done' - Created wheel for netgraph-core: filename=netgraph_core-0.10.0-cp314-cp314-macosx_15_0_arm64.whl size=314893 sha256=4dbd81d69e154acb7b436937ec9e484f0a6e31c56787e8ac028619002e4fa1f9 - Stored in directory: /private/var/folders/8q/438f_q5s0ys8nlnvjqkf953m0000gn/T/pip-ephem-wheel-cache-lpf_6ry7/wheels/18/8c/1a/f701a43f4b69f7c794e11f1f5dd0c17e819d2c98bfd1285718 -Successfully built netgraph-core -Installing collected packages: netgraph-core - Attempting uninstall: netgraph-core - Found existing installation: netgraph-core 0.10.0 - Uninstalling netgraph-core-0.10.0: - Successfully uninstalled netgraph-core-0.10.0 -Successfully installed netgraph-core-0.10.0 -44 files already formatted -All checks passed! -0 errors, 0 warnings, 0 informations -WARNING: there is a new pyright version available (v1.1.401 -> v1.1.414). -Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` - --- pybind11 not found via config; using FetchContent --- pybind11 v3.0.0 --- LTO not supported: CMake doesn't support IPO for current C compiler --- Configuring done (0.6s) --- Generating done (0.0s) --- Build files have been written to: /Users/networmix/ws/NetGraph-Core/build/cpp-tests -[1/21] Building CXX object CMakeFiles/netgraph_core.dir/src/strict_multidigraph.cpp.o -[2/21] Building CXX object CMakeFiles/netgraph_core.dir/src/k_shortest_paths.cpp.o -[3/21] Building CXX object CMakeFiles/netgraph_core.dir/src/shortest_paths.cpp.o -[4/21] Building CXX object CMakeFiles/netgraph_core.dir/src/max_flow.cpp.o -[5/21] Building CXX object CMakeFiles/netgraph_core.dir/src/cpu_backend.cpp.o -[6/21] Building CXX object CMakeFiles/netgraph_core.dir/src/flow_state.cpp.o -[7/21] Building CXX object CMakeFiles/netgraph_core.dir/src/flow_graph.cpp.o -[8/21] Building CXX object CMakeFiles/netgraph_core_tests.dir/tests/cpp/smoke_test.cpp.o -[9/21] Building CXX object CMakeFiles/netgraph_core.dir/src/flow_policy.cpp.o -[10/21] Building CXX object CMakeFiles/netgraph_core_tests.dir/tests/cpp/strict_multidigraph_tests.cpp.o -[11/21] Building CXX object CMakeFiles/netgraph_core_tests.dir/tests/cpp/flow_policy_tests.cpp.o -[12/21] Building CXX object CMakeFiles/netgraph_core_tests.dir/tests/cpp/shortest_paths_tests.cpp.o -[13/21] Building CXX object CMakeFiles/netgraph_core_tests.dir/tests/cpp/flow_state_tests.cpp.o -[14/21] Building CXX object CMakeFiles/netgraph_core_tests.dir/tests/cpp/flow_graph_tests.cpp.o -[15/21] Linking CXX static library libnetgraph_core.a -[16/21] Building CXX object CMakeFiles/netgraph_core_tests.dir/tests/cpp/max_flow_tests.cpp.o -[17/21] Building CXX object CMakeFiles/netgraph_core_tests.dir/tests/cpp/k_shortest_paths_tests.cpp.o -[18/21] Building CXX object CMakeFiles/netgraph_core_tests.dir/tests/cpp/masking_tests.cpp.o -[19/21] Linking CXX executable netgraph_core_tests -[20/21] Building CXX object CMakeFiles/_netgraph_core.dir/bindings/python/module.cpp.o -[21/21] Linking CXX shared module _netgraph_core.cpython-314-darwin.so -Test project /Users/networmix/ws/NetGraph-Core/build/cpp-tests - Start 141: KShortestPaths.DisconnectedReturnsEmpty - Start 132: MaxFlow.Sensitivity_ShortestPathVsMaxFlow - Start 34: StrictMultiDiGraph.NegativeNodeIdThrows - Start 11: FlowPolicyCore.EqualBalanced_ShortestPath_IgnoresHigherCostTier - 1/170 Test #141: KShortestPaths.DisconnectedReturnsEmpty ........................................... Passed 0.00 sec - Start 152: MaskingTests.MaskedIntermediateNode - 2/170 Test #132: MaxFlow.Sensitivity_ShortestPathVsMaxFlow ......................................... Passed 0.00 sec - Start 9: FlowPolicyCore.EqualBalanced_ShortestPath_ECMP3_SaturatesAllEqualCostPaths - 3/170 Test #34: StrictMultiDiGraph.NegativeNodeIdThrows ........................................... Passed 0.00 sec - 4/170 Test #11: FlowPolicyCore.EqualBalanced_ShortestPath_IgnoresHigherCostTier ................... Passed 0.00 sec - Start 105: MaxFlow.AsymmetricCapacity_1to100_Proportional - Start 10: FlowPolicyCore.EqualBalanced_ShortestPath_DownstreamSplit_BalancesBottleneck - 5/170 Test #152: MaskingTests.MaskedIntermediateNode ............................................... Passed 0.00 sec - Start 154: MaskingTests.MaskedEdgeSelectsAlternativePath - 6/170 Test #9: FlowPolicyCore.EqualBalanced_ShortestPath_ECMP3_SaturatesAllEqualCostPaths ........ Passed 0.00 sec - Start 156: MaskingTests.CombinedNodeAndEdgeMasks - 7/170 Test #105: MaxFlow.AsymmetricCapacity_1to100_Proportional .................................... Passed 0.00 sec - Start 144: KShortestPaths.LargerOutOfOrderTopology - 8/170 Test #10: FlowPolicyCore.EqualBalanced_ShortestPath_DownstreamSplit_BalancesBottleneck ...... Passed 0.00 sec - Start 43: ShortestPaths.PredDAGIntegrity - 9/170 Test #154: MaskingTests.MaskedEdgeSelectsAlternativePath ..................................... Passed 0.00 sec - Start 148: MaskingTests.MaskedSourceInMaxFlow - 10/170 Test #156: MaskingTests.CombinedNodeAndEdgeMasks ............................................. Passed 0.00 sec - Start 118: MaxFlow.OptionalOutputs_ResidualAndReachable - 11/170 Test #144: KShortestPaths.LargerOutOfOrderTopology ........................................... Passed 0.00 sec - Start 33: StrictMultiDiGraph.SelfLoopAllowed - 12/170 Test #43: ShortestPaths.PredDAGIntegrity .................................................... Passed 0.00 sec - Start 8: FlowPolicyCore.DiminishingReturnsCutoff - 13/170 Test #148: MaskingTests.MaskedSourceInMaxFlow ................................................ Passed 0.00 sec - Start 138: KShortestPaths.UniqueModeFiltering - 14/170 Test #118: MaxFlow.OptionalOutputs_ResidualAndReachable ...................................... Passed 0.00 sec - Start 92: FlowGraph.LedgerMicroFlows_RemovalRestoresResidual - 15/170 Test #33: StrictMultiDiGraph.SelfLoopAllowed ................................................ Passed 0.00 sec - Start 47: ShortestPaths.ResolveToPathsWithMaxPathsLimit - 16/170 Test #8: FlowPolicyCore.DiminishingReturnsCutoff ........................................... Passed 0.00 sec - Start 83: FlowGraph.EmptyLedgerInitially - 17/170 Test #138: KShortestPaths.UniqueModeFiltering ................................................ Passed 0.00 sec - Start 67: FlowState.InvalidResidualInitThrows - 18/170 Test #47: ShortestPaths.ResolveToPathsWithMaxPathsLimit ..................................... Passed 0.00 sec - Start 17: StrictMultiDiGraph.EmptyGraph - 19/170 Test #92: FlowGraph.LedgerMicroFlows_RemovalRestoresResidual ................................ Passed 0.00 sec - Start 106: MaxFlow.AsymmetricCapacity_1to1M_NumericalStability - 20/170 Test #83: FlowGraph.EmptyLedgerInitially .................................................... Passed 0.00 sec - Start 1: GraphSmoke.ConstructAndRunSPF - 21/170 Test #67: FlowState.InvalidResidualInitThrows ............................................... Passed 0.00 sec - Start 41: ShortestPaths.EdgeMaskFiltering - 22/170 Test #17: StrictMultiDiGraph.EmptyGraph ..................................................... Passed 0.00 sec - Start 89: FlowGraph.GetFlowPathForDAGReturnsEmpty - 23/170 Test #106: MaxFlow.AsymmetricCapacity_1to1M_NumericalStability ............................... Passed 0.00 sec - Start 104: MaxFlow.ECMP_TenPaths_ScalabilityValidation - 24/170 Test #1: GraphSmoke.ConstructAndRunSPF ..................................................... Passed 0.00 sec - Start 61: FlowState.ZeroCapacityHandling - 25/170 Test #41: ShortestPaths.EdgeMaskFiltering ................................................... Passed 0.00 sec - Start 22: StrictMultiDiGraph.ExternalEdgeIdsPreserved - 26/170 Test #89: FlowGraph.GetFlowPathForDAGReturnsEmpty ........................................... Passed 0.00 sec - Start 149: MaskingTests.MaskedSourceInComputeMinCut - 27/170 Test #104: MaxFlow.ECMP_TenPaths_ScalabilityValidation ....................................... Passed 0.00 sec - Start 103: MaxFlow.ECMP_FourPaths_ClosFabric_Simulation - 28/170 Test #61: FlowState.ZeroCapacityHandling .................................................... Passed 0.00 sec - Start 155: MaskingTests.MultipleEdgesMasked - 29/170 Test #22: StrictMultiDiGraph.ExternalEdgeIdsPreserved ....................................... Passed 0.00 sec - Start 109: MaxFlow.ZeroCapacityPath_CorrectFiltering - 30/170 Test #149: MaskingTests.MaskedSourceInComputeMinCut .......................................... Passed 0.00 sec - Start 126: MaxFlow.Triangle_Topology_Combine_Mode - 31/170 Test #103: MaxFlow.ECMP_FourPaths_ClosFabric_Simulation ...................................... Passed 0.00 sec - Start 107: MaxFlow.AsymmetricCapacity_1to100_EqualBalanced - 32/170 Test #155: MaskingTests.MultipleEdgesMasked .................................................. Passed 0.00 sec - Start 122: MaxFlow.GridGraph_3x3_MultipleEqualCostPaths - 33/170 Test #109: MaxFlow.ZeroCapacityPath_CorrectFiltering ......................................... Passed 0.00 sec - Start 42: ShortestPaths.EarlyExitOptimization - 34/170 Test #126: MaxFlow.Triangle_Topology_Combine_Mode ............................................ Passed 0.00 sec - Start 64: FlowState.MinCutComputation - 35/170 Test #107: MaxFlow.AsymmetricCapacity_1to100_EqualBalanced ................................... Passed 0.00 sec - Start 75: FlowState.Proportional_ZeroCapacityOnShortestPath_ReturnsZero - 36/170 Test #122: MaxFlow.GridGraph_3x3_MultipleEqualCostPaths ...................................... Passed 0.00 sec - Start 29: StrictMultiDiGraph.ExtEdgeIdsWrongLengthThrows - 37/170 Test #42: ShortestPaths.EarlyExitOptimization ............................................... Passed 0.00 sec - Start 32: StrictMultiDiGraph.NegativeCostThrows - 38/170 Test #64: FlowState.MinCutComputation ....................................................... Passed 0.00 sec - Start 135: KShortestPaths.FindsKPaths - 39/170 Test #75: FlowState.Proportional_ZeroCapacityOnShortestPath_ReturnsZero ..................... Passed 0.00 sec - Start 99: MaxFlow.ECMP_TwoPaths_Proportional_ShortestPath - 40/170 Test #29: StrictMultiDiGraph.ExtEdgeIdsWrongLengthThrows .................................... Passed 0.00 sec - Start 21: StrictMultiDiGraph.EdgeSortingByCost - 41/170 Test #32: StrictMultiDiGraph.NegativeCostThrows ............................................. Passed 0.00 sec - Start 143: KShortestPaths.OutOfOrderNodePredDAGSemantics - 42/170 Test #135: KShortestPaths.FindsKPaths ........................................................ Passed 0.00 sec - Start 37: ShortestPaths.DisconnectedComponents - 43/170 Test #99: MaxFlow.ECMP_TwoPaths_Proportional_ShortestPath ................................... Passed 0.00 sec - Start 7: FlowPolicyCore.EqualBalanced_SingleDemand_FlowCount3_SaturatesBothPaths - 44/170 Test #21: StrictMultiDiGraph.EdgeSortingByCost .............................................. Passed 0.00 sec - Start 69: FlowState.ApplyDeltasClampsWithinCapacityBounds - 45/170 Test #143: KShortestPaths.OutOfOrderNodePredDAGSemantics ..................................... Passed 0.00 sec - Start 134: MaxFlow.CompletionPhase_RespectsNodeMask - 46/170 Test #37: ShortestPaths.DisconnectedComponents .............................................. Passed 0.00 sec - Start 68: FlowState.ResetWithInvalidResidualThrows - 47/170 Test #7: FlowPolicyCore.EqualBalanced_SingleDemand_FlowCount3_SaturatesBothPaths ........... Passed 0.00 sec - Start 112: MaxFlow.EqualCost_ShortestVsFull_Equivalent - 48/170 Test #69: FlowState.ApplyDeltasClampsWithinCapacityBounds ................................... Passed 0.00 sec - Start 157: MaskingTests.NodeMaskOverridesEdgeMask - 49/170 Test #134: MaxFlow.CompletionPhase_RespectsNodeMask .......................................... Passed 0.00 sec - Start 46: ShortestPaths.ResolveToPathsSplittingParallelEdges - 50/170 Test #68: FlowState.ResetWithInvalidResidualThrows .......................................... Passed 0.00 sec - Start 35: StrictMultiDiGraph.MultipleCostTiers - 51/170 Test #112: MaxFlow.EqualCost_ShortestVsFull_Equivalent ....................................... Passed 0.00 sec - Start 133: MaxFlow.CompletionPhase_RecoversCancellation - 52/170 Test #157: MaskingTests.NodeMaskOverridesEdgeMask ............................................ Passed 0.00 sec - Start 153: MaskingTests.MaskedEdgeBlocksPath - 53/170 Test #46: ShortestPaths.ResolveToPathsSplittingParallelEdges ................................ Passed 0.00 sec - Start 114: MaxFlow.PlacementModes_EqualCapacity_IdenticalResult - 54/170 Test #35: StrictMultiDiGraph.MultipleCostTiers .............................................. Passed 0.00 sec - Start 27: StrictMultiDiGraph.InvalidNumNodesThrows - 55/170 Test #133: MaxFlow.CompletionPhase_RecoversCancellation ...................................... Passed 0.00 sec - Start 91: FlowGraph.LedgerHistory_RemovalRestoresResidual - 56/170 Test #153: MaskingTests.MaskedEdgeBlocksPath ................................................. Passed 0.00 sec - Start 20: StrictMultiDiGraph.ParallelEdges - 57/170 Test #114: MaxFlow.PlacementModes_EqualCapacity_IdenticalResult .............................. Passed 0.00 sec - Start 71: FlowState.EqualBalanced_ECMP3_FirstHopEqualization - 58/170 Test #27: StrictMultiDiGraph.InvalidNumNodesThrows .......................................... Passed 0.00 sec - Start 63: FlowState.ApplyDeltasSubtractive - 59/170 Test #91: FlowGraph.LedgerHistory_RemovalRestoresResidual ................................... Passed 0.00 sec - Start 31: StrictMultiDiGraph.NegativeCapacityThrows - 60/170 Test #20: StrictMultiDiGraph.ParallelEdges .................................................. Passed 0.00 sec - Start 2: FlowPolicyCore.Proportional_SingleDemand_UsesShortestPath - 61/170 Test #71: FlowState.EqualBalanced_ECMP3_FirstHopEqualization ................................ Passed 0.00 sec - Start 100: MaxFlow.ECMP_TwoPaths_Proportional_FullMaxFlow - 62/170 Test #63: FlowState.ApplyDeltasSubtractive .................................................. Passed 0.00 sec - Start 5: FlowPolicyCore.Proportional_SingleDemand_PartialPlacement_InsufficientCapacity - 63/170 Test #31: StrictMultiDiGraph.NegativeCapacityThrows ......................................... Passed 0.00 sec - Start 150: MaskingTests.OutOfRangeSourceInComputeMinCut - 64/170 Test #2: FlowPolicyCore.Proportional_SingleDemand_UsesShortestPath ......................... Passed 0.00 sec - Start 76: FlowState.RepeatedPlacementAfterResetAndIndependentInstancesStayStable - 65/170 Test #100: MaxFlow.ECMP_TwoPaths_Proportional_FullMaxFlow .................................... Passed 0.00 sec - Start 121: MaxFlow.BatchMaxFlow_IndependentMaskApplication - 66/170 Test #5: FlowPolicyCore.Proportional_SingleDemand_PartialPlacement_InsufficientCapacity .... Passed 0.00 sec - Start 117: MaxFlow.CostDistribution_AccountsForAllFlow - 67/170 Test #150: MaskingTests.OutOfRangeSourceInComputeMinCut ...................................... Passed 0.00 sec - Start 113: MaxFlow.ThreeCostTiers_ShortestPath_LowestTierOnly - 68/170 Test #76: FlowState.RepeatedPlacementAfterResetAndIndependentInstancesStayStable ............ Passed 0.00 sec - Start 23: StrictMultiDiGraph.CSROffsetsMonotonicity - 69/170 Test #121: MaxFlow.BatchMaxFlow_IndependentMaskApplication ................................... Passed 0.00 sec - Start 48: ShortestPaths.LargeGraphStressTest - 70/170 Test #117: MaxFlow.CostDistribution_AccountsForAllFlow ....................................... Passed 0.00 sec - Start 59: FlowState.ProportionalDistribution - 71/170 Test #113: MaxFlow.ThreeCostTiers_ShortestPath_LowestTierOnly ................................ Passed 0.00 sec - Start 6: FlowPolicyCore.EqualBalanced_SingleDemand_FlowCount2_BalancedDistribution - 72/170 Test #23: StrictMultiDiGraph.CSROffsetsMonotonicity ......................................... Passed 0.00 sec - Start 58: FlowState.PlacementReducesResidual - 73/170 Test #48: ShortestPaths.LargeGraphStressTest ................................................ Passed 0.00 sec - Start 4: FlowPolicyCore.Proportional_SingleDemand_MaxFlowCount1_UsesSinglePath - 74/170 Test #59: FlowState.ProportionalDistribution ................................................ Passed 0.00 sec - Start 130: MaxFlow.Sensitivity_TwoParallelPaths - 75/170 Test #6: FlowPolicyCore.EqualBalanced_SingleDemand_FlowCount2_BalancedDistribution ......... Passed 0.00 sec - Start 44: ShortestPaths.TieBreakingDeterminism - 76/170 Test #58: FlowState.PlacementReducesResidual ................................................ Passed 0.00 sec - Start 140: KShortestPaths.PathsSortedByCost - 77/170 Test #4: FlowPolicyCore.Proportional_SingleDemand_MaxFlowCount1_UsesSinglePath ............. Passed 0.00 sec - Start 158: MaskingTests.MaskWithResidualCapacity - 78/170 Test #130: MaxFlow.Sensitivity_TwoParallelPaths .............................................. Passed 0.00 sec - Start 161: MaskingTests.KSPWithEdgeMask - 79/170 Test #44: ShortestPaths.TieBreakingDeterminism .............................................. Passed 0.00 sec - Start 18: StrictMultiDiGraph.SingleNode - 80/170 Test #140: KShortestPaths.PathsSortedByCost .................................................. Passed 0.00 sec - Start 127: MaxFlow.SimpleParallelPaths_Topology - 81/170 Test #158: MaskingTests.MaskWithResidualCapacity ............................................. Passed 0.00 sec - Start 39: ShortestPaths.ResidualAwareFiltering - 82/170 Test #161: MaskingTests.KSPWithEdgeMask ...................................................... Passed 0.00 sec - Start 162: MaskingTests.MaxFlowWithNodeMask - 83/170 Test #18: StrictMultiDiGraph.SingleNode ..................................................... Passed 0.00 sec - Start 49: ShortestPaths.RejectsMaskLengthMismatch - 84/170 Test #127: MaxFlow.SimpleParallelPaths_Topology .............................................. Passed 0.00 sec - Start 137: KShortestPaths.NodeMaskBlocksPaths - 85/170 Test #39: ShortestPaths.ResidualAwareFiltering .............................................. Passed 0.00 sec - Start 3: FlowPolicyCore.Proportional_SingleDemand_SplitsAcrossTwoEqualCostPaths - 86/170 Test #162: MaskingTests.MaxFlowWithNodeMask .................................................. Passed 0.00 sec - Start 160: MaskingTests.KSPWithNodeMask - 87/170 Test #49: ShortestPaths.RejectsMaskLengthMismatch ........................................... Passed 0.00 sec - Start 116: MaxFlow.MinCut_ValidEdgeIdentification - 88/170 Test #137: KShortestPaths.NodeMaskBlocksPaths ................................................ Passed 0.00 sec - Start 142: KShortestPaths.LooplessPaths - 89/170 Test #3: FlowPolicyCore.Proportional_SingleDemand_SplitsAcrossTwoEqualCostPaths ............ Passed 0.00 sec - Start 88: FlowGraph.GetFlowPathForLinearFlow - 90/170 Test #160: MaskingTests.KSPWithNodeMask ...................................................... Passed 0.00 sec - Start 108: MaxFlow.EqualBalanced_NodeMask_ReducesECMPWidth - 91/170 Test #116: MaxFlow.MinCut_ValidEdgeIdentification ............................................ Passed 0.00 sec - Start 50: ShortestPaths.ZeroCostEdges_PredDAGIsAcyclic - 92/170 Test #142: KShortestPaths.LooplessPaths ...................................................... Passed 0.00 sec - Start 36: ShortestPaths.SingleSourceAllNodes - 93/170 Test #88: FlowGraph.GetFlowPathForLinearFlow ................................................ Passed 0.00 sec - Start 136: KShortestPaths.KEqualsZeroReturnsEmpty - 94/170 Test #108: MaxFlow.EqualBalanced_NodeMask_ReducesECMPWidth ................................... Passed 0.00 sec - Start 38: ShortestPaths.MultipleEqualCostPaths - 95/170 Test #50: ShortestPaths.ZeroCostEdges_PredDAGIsAcyclic ...................................... Passed 0.00 sec - Start 97: MaxFlow.SinglePath_ShortestPathEquivalence - 96/170 Test #36: ShortestPaths.SingleSourceAllNodes ................................................ Passed 0.00 sec - Start 90: FlowGraph.CoalescesDuplicateEdgeIds - 97/170 Test #136: KShortestPaths.KEqualsZeroReturnsEmpty ............................................ Passed 0.00 sec - Start 131: MaxFlow.Sensitivity_PartialSaturation - 98/170 Test #38: ShortestPaths.MultipleEqualCostPaths .............................................. Passed 0.00 sec - Start 45: ShortestPaths.PreferHigherResidualTieBreak - 99/170 Test #97: MaxFlow.SinglePath_ShortestPathEquivalence ........................................ Passed 0.00 sec - Start 129: MaxFlow.Sensitivity_SinglePath -100/170 Test #90: FlowGraph.CoalescesDuplicateEdgeIds ............................................... Passed 0.00 sec - Start 110: MaxFlow.TwoCostTiers_ShortestPath_SingleTierOnly -101/170 Test #131: MaxFlow.Sensitivity_PartialSaturation ............................................. Passed 0.00 sec - Start 28: StrictMultiDiGraph.MismatchedArrayLengthsThrow -102/170 Test #45: ShortestPaths.PreferHigherResidualTieBreak ........................................ Passed 0.00 sec - Start 111: MaxFlow.TwoCostTiers_FullMaxFlow_AllTiers -103/170 Test #129: MaxFlow.Sensitivity_SinglePath .................................................... Passed 0.00 sec - Start 146: MaskingTests.MaskedSourceReturnsEmptyDAG -104/170 Test #110: MaxFlow.TwoCostTiers_ShortestPath_SingleTierOnly .................................. Passed 0.00 sec - Start 24: StrictMultiDiGraph.CSRReverseAdjacency -105/170 Test #28: StrictMultiDiGraph.MismatchedArrayLengthsThrow .................................... Passed 0.00 sec - Start 124: MaxFlow.Clos_3Tier_Topology_Proportional_Shortest -106/170 Test #111: MaxFlow.TwoCostTiers_FullMaxFlow_AllTiers ......................................... Passed 0.00 sec - Start 120: MaxFlow.EdgeMask_SelectiveEdgeFiltering -107/170 Test #146: MaskingTests.MaskedSourceReturnsEmptyDAG .......................................... Passed 0.00 sec - Start 25: StrictMultiDiGraph.MaxCapacityEdges -108/170 Test #24: StrictMultiDiGraph.CSRReverseAdjacency ............................................ Passed 0.00 sec - Start 57: FlowState.InitialResidualEqualsCapacity -109/170 Test #124: MaxFlow.Clos_3Tier_Topology_Proportional_Shortest ................................. Passed 0.00 sec - Start 123: MaxFlow.SharedBottleneck_MultiplePathsMerge -110/170 Test #120: MaxFlow.EdgeMask_SelectiveEdgeFiltering ........................................... Passed 0.00 sec - Start 40: ShortestPaths.NodeMaskIsolation -111/170 Test #25: StrictMultiDiGraph.MaxCapacityEdges ............................................... Passed 0.00 sec - Start 125: MaxFlow.Clos_3Tier_Topology_EqualBalanced_Shortest -112/170 Test #57: FlowState.InitialResidualEqualsCapacity ........................................... Passed 0.00 sec - Start 128: MaxFlow.ShortestPath_MustSaturateAllEqualCostPaths -113/170 Test #123: MaxFlow.SharedBottleneck_MultiplePathsMerge ....................................... Passed 0.00 sec - Start 74: FlowState.EqualBalanced_ZeroCapacityOnShortestPath_ReturnsZero -114/170 Test #40: ShortestPaths.NodeMaskIsolation ................................................... Passed 0.00 sec - Start 70: FlowState.EqualBalancedReconvergentDAGPlacesAll -115/170 Test #125: MaxFlow.Clos_3Tier_Topology_EqualBalanced_Shortest ................................ Passed 0.00 sec - Start 147: MaskingTests.MaskedSourceWithDestination -116/170 Test #128: MaxFlow.ShortestPath_MustSaturateAllEqualCostPaths ................................ Passed 0.00 sec - Start 159: MaskingTests.ResidualAndNodeMaskCombined -117/170 Test #74: FlowState.EqualBalanced_ZeroCapacityOnShortestPath_ReturnsZero .................... Passed 0.00 sec - Start 84: FlowGraph.PlacementCreatesEntry -118/170 Test #70: FlowState.EqualBalancedReconvergentDAGPlacesAll ................................... Passed 0.00 sec - Start 62: FlowState.ApplyDeltasAdditive -119/170 Test #147: MaskingTests.MaskedSourceWithDestination .......................................... Passed 0.00 sec - Start 30: StrictMultiDiGraph.OutOfRangeNodeThrows -120/170 Test #159: MaskingTests.ResidualAndNodeMaskCombined .......................................... Passed 0.00 sec - Start 139: KShortestPaths.MaxCostFactorLimit -121/170 Test #84: FlowGraph.PlacementCreatesEntry ................................................... Passed 0.00 sec - Start 65: FlowState.ResetRestoresCapacity -122/170 Test #62: FlowState.ApplyDeltasAdditive ..................................................... Passed 0.00 sec - Start 19: StrictMultiDiGraph.SingleEdge -123/170 Test #30: StrictMultiDiGraph.OutOfRangeNodeThrows ........................................... Passed 0.00 sec - Start 151: MaskingTests.MaskedDestinationUnreachable -124/170 Test #139: KShortestPaths.MaxCostFactorLimit ................................................. Passed 0.00 sec - Start 60: FlowState.EqualBalancedDistribution -125/170 Test #65: FlowState.ResetRestoresCapacity ................................................... Passed 0.00 sec - Start 96: MaxFlow.SinglePath_BottleneckConstraint -126/170 Test #19: StrictMultiDiGraph.SingleEdge ..................................................... Passed 0.00 sec - Start 26: StrictMultiDiGraph.ZeroCostEdges -127/170 Test #151: MaskingTests.MaskedDestinationUnreachable ......................................... Passed 0.00 sec - Start 115: MaxFlow.FlowConservation_IntermediateNodes -128/170 Test #60: FlowState.EqualBalancedDistribution ............................................... Passed 0.00 sec - Start 72: FlowState.EqualBalanced_MinFlowGating_DoesNotPruneValidParallelEdges -129/170 Test #96: MaxFlow.SinglePath_BottleneckConstraint ........................................... Passed 0.00 sec - Start 66: FlowState.CustomInitialResidual -130/170 Test #26: StrictMultiDiGraph.ZeroCostEdges .................................................. Passed 0.00 sec - Start 102: MaxFlow.ECMP_ThreePaths_Proportional_ShortestPath -131/170 Test #115: MaxFlow.FlowConservation_IntermediateNodes ........................................ Passed 0.00 sec - Start 101: MaxFlow.ECMP_TwoPaths_EqualBalanced_ShortestPath -132/170 Test #72: FlowState.EqualBalanced_MinFlowGating_DoesNotPruneValidParallelEdges .............. Passed 0.00 sec - Start 95: MaxFlow.SinglePath_BasicLinear -133/170 Test #66: FlowState.CustomInitialResidual ................................................... Passed 0.00 sec - Start 98: MaxFlow.DegenerateCase_SourceEqualsSink -134/170 Test #102: MaxFlow.ECMP_ThreePaths_Proportional_ShortestPath ................................. Passed 0.00 sec - Start 86: FlowGraph.RemoveByClassFiltersCorrectly -135/170 Test #101: MaxFlow.ECMP_TwoPaths_EqualBalanced_ShortestPath .................................. Passed 0.00 sec - Start 73: FlowState.EqualBalanced_DownstreamSplitEqualization -136/170 Test #95: MaxFlow.SinglePath_BasicLinear .................................................... Passed 0.00 sec - Start 119: MaxFlow.NodeMask_BlocksFlowThroughNode -137/170 Test #98: MaxFlow.DegenerateCase_SourceEqualsSink ........................................... Passed 0.00 sec - Start 85: FlowGraph.RemovalDeletesEntry -138/170 Test #86: FlowGraph.RemoveByClassFiltersCorrectly ........................................... Passed 0.00 sec - Start 87: FlowGraph.GetFlowEdgesReturnsDeltas -139/170 Test #73: FlowState.EqualBalanced_DownstreamSplitEqualization ............................... Passed 0.00 sec - Start 163: MaskingTests.MaxFlowWithEdgeMask -140/170 Test #119: MaxFlow.NodeMask_BlocksFlowThroughNode ............................................ Passed 0.00 sec - Start 164: MaskingTests.MaxFlowAllPathsMasked -141/170 Test #85: FlowGraph.RemovalDeletesEntry ..................................................... Passed 0.00 sec - Start 165: MaskingTests.EmptyNodeMaskAllowsAllNodes -142/170 Test #87: FlowGraph.GetFlowEdgesReturnsDeltas ............................................... Passed 0.00 sec - Start 12: FlowPolicyStatic.PerFlowBundleBinding -143/170 Test #163: MaskingTests.MaxFlowWithEdgeMask .................................................. Passed 0.00 sec - Start 169: MaskingTests.AlgorithmsAPIMaskedSource -144/170 Test #164: MaskingTests.MaxFlowAllPathsMasked ................................................ Passed 0.00 sec - Start 13: FlowPolicyStatic.MaskedBundleIsDown -145/170 Test #165: MaskingTests.EmptyNodeMaskAllowsAllNodes .......................................... Passed 0.00 sec - Start 15: FlowPolicyCostBounds.MaxPathCostFactor_UnreachableDst_KeepsSentinelOutOfTheCast -146/170 Test #12: FlowPolicyStatic.PerFlowBundleBinding ............................................. Passed 0.00 sec - Start 14: FlowPolicyStatic.ValidationRejectsForeignAndDisconnectedBundles -147/170 Test #169: MaskingTests.AlgorithmsAPIMaskedSource ............................................ Passed 0.00 sec - Start 55: ShortestPathsTo.FanoutFromInteriorNodeIsRejected -148/170 Test #13: FlowPolicyStatic.MaskedBundleIsDown ............................................... Passed 0.00 sec - Start 166: MaskingTests.EmptyEdgeMaskAllowsAllEdges -149/170 Test #15: FlowPolicyCostBounds.MaxPathCostFactor_UnreachableDst_KeepsSentinelOutOfTheCast ... Passed 0.00 sec - Start 81: FlowState.EqualBalancedLossy_InfiniteRequestFillsWithoutDrops -150/170 Test #14: FlowPolicyStatic.ValidationRejectsForeignAndDisconnectedBundles ................... Passed 0.00 sec - Start 16: FlowPolicyCostBounds.MaxPathCostFactor_HugeProduct_DropsBoundInsteadOfWrapping -151/170 Test #55: ShortestPathsTo.FanoutFromInteriorNodeIsRejected .................................. Passed 0.00 sec - Start 52: ShortestPathsTo.ForwardSpfFromPseudoSourceKeepsOnlyNearestSource -152/170 Test #166: MaskingTests.EmptyEdgeMaskAllowsAllEdges .......................................... Passed 0.00 sec - Start 51: ShortestPathsTo.DistancesMatchForwardDistancesToDst -153/170 Test #81: FlowState.EqualBalancedLossy_InfiniteRequestFillsWithoutDrops ..................... Passed 0.00 sec - Start 170: MaskingTests.AlgorithmsAPIMaxFlowWithMask -154/170 Test #16: FlowPolicyCostBounds.MaxPathCostFactor_HugeProduct_DropsBoundInsteadOfWrapping .... Passed 0.00 sec - Start 82: FlowGraph.LossyPlacementIsReversibleAndDropsStayOutOfLedger -155/170 Test #52: ShortestPathsTo.ForwardSpfFromPseudoSourceKeepsOnlyNearestSource .................. Passed 0.00 sec - Start 167: MaskingTests.AllNodesMaskedNoReachability -156/170 Test #51: ShortestPathsTo.DistancesMatchForwardDistancesToDst ............................... Passed 0.00 sec - Start 168: MaskingTests.AllEdgesMaskedNoReachability -157/170 Test #170: MaskingTests.AlgorithmsAPIMaxFlowWithMask ......................................... Passed 0.00 sec - Start 94: FlowGraphStamp.EqualVersionDivergentCopiesCompareUnequal -158/170 Test #82: FlowGraph.LossyPlacementIsReversibleAndDropsStayOutOfLedger ....................... Passed 0.00 sec - Start 79: FlowState.EqualBalancedLossy_FillAndDropReportsDrops -159/170 Test #167: MaskingTests.AllNodesMaskedNoReachability ......................................... Passed 0.00 sec - Start 53: ShortestPathsTo.FanoutEdgesForceEverySourceIntoTheDag -160/170 Test #168: MaskingTests.AllEdgesMaskedNoReachability ......................................... Passed 0.00 sec - Start 80: FlowState.EqualBalancedLossy_DeficitPropagatesDownstream -161/170 Test #94: FlowGraphStamp.EqualVersionDivergentCopiesCompareUnequal .......................... Passed 0.00 sec - Start 93: FlowGraphStamp.CopiesAndMovesGetFreshUids -162/170 Test #79: FlowState.EqualBalancedLossy_FillAndDropReportsDrops .............................. Passed 0.00 sec -163/170 Test #53: ShortestPathsTo.FanoutEdgesForceEverySourceIntoTheDag ............................. Passed 0.00 sec - Start 145: KSP.MaxCostFactorBelowOne_ReturnsEmptyForAllK - Start 54: ShortestPathsTo.FanoutSkipsUnreachableAndMaskedHeads -164/170 Test #80: FlowState.EqualBalancedLossy_DeficitPropagatesDownstream .......................... Passed 0.00 sec - Start 77: FlowState.EqualBalanced_SaturatedMemberBlocksAdmission -165/170 Test #93: FlowGraphStamp.CopiesAndMovesGetFreshUids ......................................... Passed 0.00 sec - Start 56: ShortestPathsTo.SinglePathModeKeepsOneSuccessorPerNode -166/170 Test #145: KSP.MaxCostFactorBelowOne_ReturnsEmptyForAllK ..................................... Passed 0.00 sec - Start 78: FlowState.EqualBalanced_ProgressesOnlyThroughResidualAwareSpf -167/170 Test #54: ShortestPathsTo.FanoutSkipsUnreachableAndMaskedHeads .............................. Passed 0.00 sec -168/170 Test #77: FlowState.EqualBalanced_SaturatedMemberBlocksAdmission ............................ Passed 0.00 sec -169/170 Test #56: ShortestPathsTo.SinglePathModeKeepsOneSuccessorPerNode ............................ Passed 0.00 sec -170/170 Test #78: FlowState.EqualBalanced_ProgressesOnlyThroughResidualAwareSpf ..................... Passed 0.00 sec - -100% tests passed out of 170 - -Total Test time (real) = 0.10 sec -........................................................................ [ 15%] -........................................................................ [ 30%] -........................................................................ [ 46%] -........................................................................ [ 61%] -........................................................................ [ 77%] -........................................................................ [ 92%] -................................. [100%] -465 passed in 4.07s diff --git a/dev/research/metal_pathfinding/run_aba.sh b/dev/research/metal_pathfinding/run_aba.sh deleted file mode 100644 index 70f8f25..0000000 --- a/dev/research/metal_pathfinding/run_aba.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# Serial fresh-process CPU / Metal / CPU runs. Do not overlap with builds/tests. -set -euo pipefail -cd "$(dirname "$0")/../.." -out=${1:-dev/research/metal_pathfinding/results} -mkdir -p "$out" -unset NGRAPH_CORE_PROFILE -{ - date -u '+%Y-%m-%dT%H:%M:%SZ' - git rev-parse HEAD - sw_vers - sysctl -n machdep.cpu.brand_string hw.memsize hw.ncpu - xcrun clang++ --version - shasum -a 256 dev/research/metal_pathfinding/experiment.mm dev/research/metal_pathfinding/sssp.metal - pmset -g therm -} > "$out/environment.txt" -for phase in a1 b a2; do - top -l 2 -n 8 -o cpu -stats pid,command,cpu,threads > "$out/load-$phase.txt" - ioreg -r -c AGXAccelerator -l | sed -n '/"PerformanceStatistics"/p' > "$out/gpu-load-$phase.txt" - mode=cpu - [[ $phase != b ]] || mode=gpu - build/metal_pathfinding/experiment "$mode" dev/research/metal_pathfinding/sssp.metal full 9 \ - > "$out/$phase.csv" 2> "$out/$phase-stderr.txt" -done diff --git a/dev/research/metal_pathfinding/sssp.metal b/dev/research/metal_pathfinding/sssp.metal deleted file mode 100644 index c8e486c..0000000 --- a/dev/research/metal_pathfinding/sssp.metal +++ /dev/null @@ -1,122 +0,0 @@ -#include -using namespace metal; - -// Research kernels: exact int64, nonnegative costs, one writer per distance. -// Incoming CSR includes a host-computed edge eligibility byte, so double -// capacity comparisons keep their CPU semantics. -constant long INF = 0x7fffffffffffffffL; -struct Params { uint n; uint queries; uint round; uint unused; }; - -kernel void relax_pull(device const int* row [[buffer(0)]], - device const int* col [[buffer(1)]], - device const long* cost [[buffer(2)]], - device const uchar* allowed [[buffer(3)]], - device const long* before [[buffer(4)]], - device long* after [[buffer(5)]], - device atomic_uint* changed [[buffer(6)]], - constant Params& p [[buffer(7)]], - uint tid [[thread_position_in_grid]]) { - if (tid >= p.n * p.queries) return; - uint v = tid % p.n; - uint base = tid - v; - long best = before[tid]; - for (int j = row[v]; j < row[v + 1]; ++j) { - long d = before[base + col[j]]; - if (allowed[j] && d != INF) best = min(best, d + cost[j]); - } - after[tid] = best; - if (best != before[tid]) - atomic_store_explicit(changed + p.round, 1u, memory_order_relaxed); -} - -// A whole SSSP in one threadgroup, with independent queries across groups. -// Each group owns both of its N-element distance arrays. Device-memory barriers -// are local to that group; no unsafe grid-wide barrier or in-place data race. -kernel void solve_group(device const int* row [[buffer(0)]], - device const int* col [[buffer(1)]], - device const long* cost [[buffer(2)]], - device const uchar* allowed [[buffer(3)]], - device long* a [[buffer(4)]], - device long* b [[buffer(5)]], - device uint* rounds [[buffer(6)]], - constant Params& p [[buffer(7)]], - device const int* sources [[buffer(8)]], - uint lane [[thread_index_in_threadgroup]], - uint width [[threads_per_threadgroup]], - uint query [[threadgroup_position_in_grid]]) { - uint base = query * p.n; - for (uint v = lane; v < p.n; v += width) - a[base + v] = (int(v) == sources[query]) ? 0L : INF; - threadgroup atomic_uint changed; - threadgroup_barrier(mem_flags::mem_device); - uint step = 0; - for (; step < p.n; ++step) { - if (lane == 0) atomic_store_explicit(&changed, 0u, memory_order_relaxed); - threadgroup_barrier(mem_flags::mem_threadgroup); - for (uint v = lane; v < p.n; v += width) { - long best = a[base + v]; - for (int j = row[v]; j < row[v + 1]; ++j) { - long d = a[base + col[j]]; - if (allowed[j] && d != INF) best = min(best, d + cost[j]); - } - b[base + v] = best; - if (best != a[base + v]) - atomic_store_explicit(&changed, 1u, memory_order_relaxed); - } - threadgroup_barrier(mem_flags::mem_device | mem_flags::mem_threadgroup); - uint any_changed = atomic_load_explicit(&changed, memory_order_relaxed); - // Every lane must observe this round before lane 0 resets the flag for the - // next one. A barrier *before* the loads alone does not prevent that race. - threadgroup_barrier(mem_flags::mem_threadgroup); - if (any_changed == 0) break; - device long* tmp = a; a = b; b = tmp; - } - // Host selects the output buffer by round parity; convergence leaves a == b. - if (lane == 0) rounds[query] = min(step + 1, p.n); -} - -// Small-graph variant: 2 * N * 8 bytes of threadgroup memory, N <= 2048 on -// this device. Removes repeated device-memory accesses and barriers per round. -kernel void solve_shared(device const int* row [[buffer(0)]], - device const int* col [[buffer(1)]], - device const long* cost [[buffer(2)]], - device const uchar* allowed [[buffer(3)]], - device long* output [[buffer(4)]], - device uint* rounds [[buffer(6)]], - constant Params& p [[buffer(7)]], - device const int* sources [[buffer(8)]], - threadgroup long* scratch [[threadgroup(0)]], - uint lane [[thread_index_in_threadgroup]], - uint width [[threads_per_threadgroup]], - uint query [[threadgroup_position_in_grid]]) { - threadgroup long* a = scratch; - threadgroup long* b = scratch + p.n; - for (uint v = lane; v < p.n; v += width) - a[v] = (int(v) == sources[query]) ? 0L : INF; - threadgroup atomic_uint changed; - threadgroup_barrier(mem_flags::mem_threadgroup); - uint step = 0; - for (; step < p.n; ++step) { - if (lane == 0) atomic_store_explicit(&changed, 0u, memory_order_relaxed); - threadgroup_barrier(mem_flags::mem_threadgroup); - bool lane_changed = false; - for (uint v = lane; v < p.n; v += width) { - long best = a[v]; - for (int j = row[v]; j < row[v + 1]; ++j) { - long d = a[col[j]]; - if (allowed[j] && d != INF) best = min(best, d + cost[j]); - } - b[v] = best; - lane_changed |= best != a[v]; - } - if (simd_any(lane_changed) && (lane % 32 == 0)) - atomic_store_explicit(&changed, 1u, memory_order_relaxed); - threadgroup_barrier(mem_flags::mem_threadgroup); - threadgroup long* tmp = a; a = b; b = tmp; - uint any_changed = atomic_load_explicit(&changed, memory_order_relaxed); - threadgroup_barrier(mem_flags::mem_threadgroup); - if (any_changed == 0) break; - } - for (uint v = lane; v < p.n; v += width) output[query * p.n + v] = a[v]; - if (lane == 0) rounds[query] = min(step + 1, p.n); -} diff --git a/dev/research/metal_pathfinding/summarize.py b/dev/research/metal_pathfinding/summarize.py deleted file mode 100644 index 217d582..0000000 --- a/dev/research/metal_pathfinding/summarize.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Summarize fresh-process A/B/A CSVs; timings are milliseconds per whole batch.""" - -from __future__ import annotations - -import csv -import sys -from pathlib import Path - - -def read(path: Path) -> dict[tuple[str, int, int, int], dict[str, dict[str, str]]]: - cases: dict[tuple[str, int, int, int], dict[str, dict[str, str]]] = {} - with path.open() as file: - for row in csv.DictReader(file): - assert row["correct"] == "pass", row - key = (row["case"], int(row["n"]), int(row["e"]), int(row["queries"])) - cases.setdefault(key, {})[row["method"]] = row - return cases - - -def main() -> None: - root = Path(sys.argv[1]) - a1, b, a2 = (read(root / name) for name in ("a1.csv", "b.csv", "a2.csv")) - assert a1.keys() == b.keys() == a2.keys() - print("Exploratory observations, not a production performance acceptance result.") - print( - "Inspect load-*.txt and gpu-load-*.txt; speedup claims require a quiet machine." - ) - print("The prototype matches supported DAG edge sets, not CPU predecessor order.\n") - print("All times are medians in milliseconds per batch, with nine timed samples.") - print("CPU A columns use serial SPF for Q=1 and the 10-worker pool otherwise.") - print("GPU includes host distance copies and serial CPU ECMP DAG reconstruction.") - print("Upload and pipeline setup are excluded and recorded separately in raw data.") - print("The table picks the fastest measured GPU method for each case.\n") - print( - "| Case | V / E | Q | CPU A1 | GPU B | CPU A2 | GPU method | " - "Distance-only B | CPU drift |" - ) - print("|---|---:|---:|---:|---:|---:|---|---:|---:|") - for key, runs in b.items(): - name, n, e, q = key - baseline = "cpu_full" if q == 1 else "cpu_pool10" - left = float(a1[key][baseline]["wall_ms"]) - right = float(a2[key][baseline]["wall_ms"]) - best = min(runs.values(), key=lambda row: float(row["wall_ms"])) - dist = float(best["distance_wall_ms"]) - drift = 100 * (right / left - 1) - print( - f"| {name} | {n:,} / {e:,} | {q} | {left:.3f} | " - f"{float(best['wall_ms']):.3f} | {right:.3f} | {best['method']} | " - f"{dist:.3f} | {drift:+.1f}% |" - ) - print( - "\nDistance-only timings stop before DAG reconstruction; they are not SPF API timings." - ) - print( - "CPU distance-only and serial full-batch controls are also available in a1/a2.csv." - ) - print("\nDistances-only comparison (both sides omit DAG construction):\n") - print("| Case | V / E | Q | CPU A1 | GPU B | CPU A2 | GPU method |") - print("|---|---:|---:|---:|---:|---:|---|") - for key, runs in b.items(): - name, n, e, q = key - baseline = "cpu_distance" if q == 1 else "cpu_pool10_distance" - left = float(a1[key][baseline]["wall_ms"]) - right = float(a2[key][baseline]["wall_ms"]) - best = min(runs.values(), key=lambda row: float(row["distance_wall_ms"])) - print( - f"| {name} | {n:,} / {e:,} | {q} | {left:.3f} | " - f"{float(best['distance_wall_ms']):.3f} | {right:.3f} | {best['method']} |" - ) - - -if __name__ == "__main__": - main() diff --git a/dev/run-checks.sh b/dev/run-checks.sh index b3f50d4..d3687ee 100755 --- a/dev/run-checks.sh +++ b/dev/run-checks.sh @@ -1,138 +1,228 @@ -#!/bin/bash -# NetGraph-Core: Comprehensive checks and tests -# - Pre-commit (auto-fix, then verify) -# - C++ tests via ctest (optional, if cmake is available) -# - Python tests (with coverage if pytest-cov is available) - -set -e -set -u -set -o pipefail - -# Prefer provided PYTHON env (passed from Makefile), fallback to python3 +#!/usr/bin/env bash +# The Python-side check entrypoints. Each one only reads the tree: formatting and fixes are +# `make format` and `make hooks`. +# +# bash dev/run-checks.sh pytest on [PYTEST_ARGS...] +# The Python suite against the netgraph_core that $PYTHON imports, which must carry the +# private hooks (the editable development build): no test may skip. (make check-ci) +# bash dev/run-checks.sh pytest off [PYTEST_ARGS...] +# The Python suite against an installed, non-editable netgraph_core without the hooks (a +# wheel, or a build from the sdist): exactly the tests that need a hook skip. (release CI) +# bash dev/run-checks.sh cov +# C++ and Python coverage (make cov): a coverage venv of its own (build/coverage-venv, pinned +# by .github/ci-constraints.txt; the development venv is not touched), the extension and the +# C++ tests built with coverage in their own build directories, every build, test and +# report failure fails the run, and so does coverage that was not measured: no profile +# data written in either build directory, no file of the C++ report under one of its +# filters, or a report that counts no line. Reports in build/coverage/. +# +# PYTHON names the interpreter (default python3); CMAKE_BUILD_PARALLEL_LEVEL bounds the builds, +# ctest and gcovr (default 4; exported, so it also reaches the extension's build under pip); GCOV +# names the gcov command gcovr runs (default: xcrun llvm-cov gcov on macOS, llvm-cov gcov when CXX +# is clang, gcov otherwise). +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." +ROOT=$PWD PYTHON=${PYTHON:-python3} -# Prepend the directory of the configured Python (usually venv/bin) to PATH -PY_BIN_DIR=$(dirname "$PYTHON") -export PATH="$PY_BIN_DIR:$PATH" -# If running within project venv, also prepend its bin to PATH so cmake/ninja resolve from venv -if [ -n "${VIRTUAL_ENV:-}" ] && [ -x "$VIRTUAL_ENV/bin/python" ]; then - export PATH="$VIRTUAL_ENV/bin:$PATH" -fi +JOBS=${CMAKE_BUILD_PARALLEL_LEVEL:-4} +case "$JOBS" in ''|*[!0-9]*|0) echo 'CMAKE_BUILD_PARALLEL_LEVEL must be positive.' >&2; exit 2 ;; esac +export CMAKE_BUILD_PARALLEL_LEVEL=$JOBS -# Prefer Apple Command Line Tools compilers to avoid Homebrew libc++ ABI mismatches -APPLE_CLANG=$(xcrun --find clang 2>/dev/null || true) -APPLE_CLANGXX=$(xcrun --find clang++ 2>/dev/null || true) -DEFAULT_MACOSX=15.0 -if [ -n "$APPLE_CLANG" ] && [ -n "$APPLE_CLANGXX" ]; then - export CC="$APPLE_CLANG" - export CXX="$APPLE_CLANGXX" - export CMAKE_ARGS="${CMAKE_ARGS:-} -DCMAKE_C_COMPILER=$CC -DCMAKE_CXX_COMPILER=$CXX" -fi -export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-$DEFAULT_MACOSX}" +# The Python tests that need the private hooks and skip without them, by pytest node id, one per +# line (an id can contain spaces); every replay of the fixtures produced by the reference under +# tests/reference/policy/ (the parametrizations of test_fixture) skips as well. +EXPECTED_OFF_SKIPS=" +tests/py/test_apportionment.py::test_apportionment_entries +tests/py/test_dag_cache.py::test_frozen_statistics_survive_python_allocation_failures +tests/py/test_dag_cache.py::test_the_frozen_sweep_rejects_a_wrong_call +tests/py/test_flow_ledger.py::test_a_flow_id_read_passes_an_allocation_failure_through +tests/py/test_flow_policy.py::test_evaluator_results_survive_python_allocation_failures +tests/py/test_flow_policy.py::test_owner_hook_is_the_owner_of_the_records +tests/py/test_flow_policy.py::test_python_publication_is_all_or_nothing[False-costonly] +tests/py/test_flow_policy.py::test_python_publication_is_all_or_nothing[False-global] +tests/py/test_flow_policy.py::test_python_publication_is_all_or_nothing[True-costonly] +tests/py/test_flow_policy.py::test_python_publication_is_all_or_nothing[True-global] +tests/py/test_flow_policy_fixtures.py::test_a_corrupted_expectation_fails[ledger] +tests/py/test_flow_policy_fixtures.py::test_a_corrupted_expectation_fails[policy] +tests/py/test_flow_policy_fixtures.py::test_a_corrupted_expectation_fails[result] +tests/py/test_flow_policy_fixtures.py::test_a_corrupted_expectation_fails[trace] +tests/py/test_flow_policy_fixtures.py::test_every_other_replay_clause_reports_its_operation +tests/py/test_flow_policy_fixtures.py::test_an_expected_value_of_another_type_fails[bool as integer] +tests/py/test_flow_policy_fixtures.py::test_an_expected_value_of_another_type_fails[integer as float] +tests/py/test_ksp.py::test_python_result_lists_are_all_or_nothing +tests/py/test_native_allocation.py::test_native_allocation_failure_raises_and_changes_nothing[FlowLedger.reset] +tests/py/test_native_allocation.py::test_native_allocation_failure_raises_and_changes_nothing[batch_capacity.global] +tests/py/test_native_allocation.py::test_native_allocation_failure_raises_and_changes_nothing[capacity.frozen_cache] +tests/py/test_native_allocation.py::test_native_allocation_failure_raises_and_changes_nothing[capacity.global] +tests/py/test_native_allocation.py::test_native_allocation_failure_raises_and_changes_nothing[ksp] +tests/py/test_native_allocation.py::test_native_allocation_failure_raises_and_changes_nothing[place.costonly] +tests/py/test_native_allocation.py::test_native_allocation_failure_raises_and_changes_nothing[place.global] +tests/py/test_native_allocation.py::test_native_allocation_failure_raises_and_changes_nothing[place_traced.global] +tests/py/test_native_allocation.py::test_native_allocation_failure_raises_and_changes_nothing[resolve_to_paths] +tests/py/test_native_allocation.py::test_native_allocation_failure_raises_and_changes_nothing[sensitivity.global] +tests/py/test_native_allocation.py::test_native_allocation_failure_raises_and_changes_nothing[spf.full] +tests/py/test_native_allocation.py::test_native_allocation_failure_raises_and_changes_nothing[spf.single_path] +tests/py/test_native_allocation.py::test_native_allocation_failure_raises_and_changes_nothing[spf.targets_residual] +tests/py/test_native_allocation.py::test_the_row_checks_reject_a_wrong_call +tests/py/test_search.py::test_python_result_construction_is_all_or_nothing +tests/py/test_search.py::test_the_python_allocation_sweep_rejects_a_wrong_call +" +EXPECTED_OFF_SKIPPED_PREFIX='tests/py/test_flow_policy_fixtures.py::test_fixture[' -# Ensure required tools are present -if ! "$PYTHON" -m pre_commit --version &> /dev/null; then - echo "❌ pre-commit is not installed. Please run 'make dev' first." - exit 1 -fi -if ! "$PYTHON" -m pytest --version &> /dev/null; then - echo "❌ pytest is not installed. Please run 'make dev' first." - exit 1 -fi +die() { echo "dev/run-checks.sh: $*" >&2; exit 1; } +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT -# Ensure pre-commit hooks are installed (if in a git repo) -if [ -d .git ] && [ ! -f .git/hooks/pre-commit ]; then - echo "⚠️ Pre-commit hooks not installed. Installing now..." - "$PYTHON" -m pre_commit install - echo "" +if [[ "$(uname -s)" == Darwin ]]; then + CC=$(xcrun --find clang) + CXX=$(xcrun --find clang++) + export CC CXX + export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-15.0}" fi -# First pass: allow auto-fixes -echo "🏃 Running pre-commit (first pass: apply auto-fixes if needed)..." -set +e -"$PYTHON" -m pre_commit run --all-files -first_pass_status=$? -set -e - -if [ $first_pass_status -ne 0 ]; then - echo "ℹ️ Some hooks modified files or reported issues. Re-running checks..." -fi +# run_pytest on|off [PYTEST_ARGS...]: the suite, then the check of what was imported and skipped. +# A small plugin records the skipped node ids and the imported modules (PYTEST_ADDOPTS and its +# reports stay the caller's). +run_pytest() { + local hooks=$1 tmp=$TMP/pytest; shift + rm -rf "$tmp"; mkdir -p "$tmp" + cat > "$tmp/ngc_skip_record.py" <<'PY' +import json +import os +import sys -# Second pass: verify all checks -echo "🏃 Running pre-commit (second pass: verify all checks)..." -if ! "$PYTHON" -m pre_commit run --all-files; then - echo "" - echo "❌ Pre-commit checks failed after applying fixes. Please address the issues above." - exit 1 -fi +_seen, _skipped = set(), {} -autofixed=0 -if [ $first_pass_status -ne 0 ]; then - autofixed=1 -fi -echo "" -echo "✅ Pre-commit checks passed!" -echo "" +def pytest_runtest_logreport(report): + _seen.add(report.nodeid) + if report.skipped: + _skipped[report.nodeid] = str(report.longrepr[-1] if isinstance(report.longrepr, tuple) else report.longrepr) -# Optional: Run C++ tests via ctest if cmake is available (and not skipped) -if [ "${SKIP_CPP_TESTS:-0}" = "1" ]; then - echo "⏭️ SKIP_CPP_TESTS=1 set. Skipping C++ tests." -else - echo "🔧 Checking for CMake to run C++ tests..." - if command -v cmake >/dev/null 2>&1; then - if [ "${SKIP_CPP_TESTS:-0}" != "1" ]; then - echo "🧪 Running C++ tests (ctest)..." - BUILD_DIR="build/cpp-tests" - mkdir -p "$BUILD_DIR" - GEN_ARGS="" - if command -v ninja >/dev/null 2>&1; then - GEN_ARGS="-G Ninja" - fi - cmake -S . -B "$BUILD_DIR" -DNETGRAPH_CORE_BUILD_TESTS=ON -DCMAKE_BUILD_TYPE=Release $GEN_ARGS - cmake --build "$BUILD_DIR" --config Release -j - # Determine parallelism - if command -v sysctl >/dev/null 2>&1; then - NPROC=$(sysctl -n hw.ncpu 2>/dev/null || echo 2) - elif command -v nproc >/dev/null 2>&1; then - NPROC=$(nproc) - else - NPROC=2 - fi - # Add per-test timeout to avoid hangs - ctest --test-dir "$BUILD_DIR" --output-on-failure -j "$NPROC" --timeout 120 - fi - else - echo "⚠️ CMake not found. Skipping C++ tests." - fi -fi -echo "" +def pytest_sessionfinish(session, exitstatus): + ext = sys.modules.get("_netgraph_core") + pkg = sys.modules.get("netgraph_core") + record = { + "seen": sorted(_seen), + "skipped": _skipped, + "hooks": hasattr(ext, "_max_flows_per_call"), + "modules": [getattr(ext, "__file__", None), getattr(pkg, "__file__", None)], + } + with open(os.environ["NGC_SKIP_RECORD"], "w") as f: + json.dump(record, f) +PY + NGC_SKIP_RECORD="$tmp/record.json" PYTHONPATH="$tmp${PYTHONPATH:+:$PYTHONPATH}" \ + "$PYTHON" -m pytest -p ngc_skip_record "$@" + [[ -s "$tmp/record.json" ]] || die "pytest wrote no record of its run" + echo "$EXPECTED_OFF_SKIPS" | sed '/^$/d' > "$tmp/expected.txt" + "$PYTHON" - "$hooks" "$tmp/record.json" "$tmp/expected.txt" "$EXPECTED_OFF_SKIPPED_PREFIX" "$ROOT" <<'PY' +import json +import os +import sys -# Run Python tests (with coverage if available) -echo "🧪 Running Python tests..." -# Ensure the extension is built for the active Python and importable -echo "🔧 Installing project in editable mode for current Python..." -"$PYTHON" -m pip install -e . >/dev/null -if "$PYTHON" -c "import pytest_cov" >/dev/null 2>&1; then - "$PYTHON" -m pytest --cov=netgraph_core --cov-report=term-missing -else - "$PYTHON" -m pytest -fi +hooks, record_path, expected_path, prefix, root = sys.argv[1:] +record = json.load(open(record_path)) +# One node id per line: an id can hold spaces (a parametrization id such as "[integer as +# float]"), so the list is never split on other whitespace. +with open(expected_path) as f: + expected = {line.strip() for line in f if line.strip()} +skipped = record["skipped"] +problems = [] +if None in record["modules"]: + problems.append(f"netgraph_core or _netgraph_core was not imported: {record['modules']}") +if hooks == "on": + if not record["hooks"]: + problems.append("the extension lacks the private hooks (expected the editable build)") + if skipped: + problems.append(f"{len(skipped)} tests skipped in a hooks-on run: {sorted(skipped)}") +else: + if record["hooks"]: + problems.append("the extension carries the private hooks (expected a wheel or sdist build)") + inside = [m for m in record["modules"] if m and os.path.realpath(m).startswith(os.path.realpath(root) + os.sep)] + if inside: + problems.append(f"imported from the source tree, not an installation: {inside}") + replays = {n for n in record["seen"] if n.startswith(prefix)} + if not replays: + problems.append(f"no test {prefix}...] ran") + want = expected | replays + got = set(skipped) + for name in sorted(want - got): + problems.append(f"expected to skip, did not: {name}") + for name in sorted(got - want): + problems.append(f"skipped, not a known hook-only test: {name}") + for name, reason in sorted(skipped.items()): + if "needs the private hooks" not in reason: + problems.append(f"skipped for another reason: {name}: {reason}") +for p in problems: + print(f"dev/run-checks.sh: {p}", file=sys.stderr) +if problems: + sys.exit(1) +if hooks == "off": + print(f"hooks off: exactly the {len(skipped)} expected hook-only tests skipped; imported {record['modules'][0]}") +else: + print(f"hooks on: no test skipped; imported {record['modules'][0]}") +PY +} -if [ $? -eq 0 ]; then - echo "" - if [ $autofixed -eq 1 ]; then - echo "🎉 All checks and tests passed. Auto-fixes were applied by pre-commit." +coverage() { + local cvenv=build/coverage-venv ext=build/coverage-ext cpp=build/cpp-tests-cov out=build/coverage + local gcov dir root filters=(include/ src/ bindings/) + rm -rf "$cvenv" "$out" + mkdir -p "$out" + echo "== coverage venv $cvenv (pins: .github/ci-constraints.txt)" + "$PYTHON" -m venv "$cvenv" + local cpy=$ROOT/$cvenv/bin/python + "$cpy" -m pip install --disable-pip-version-check -q -U -c .github/ci-constraints.txt pip + "$cpy" -m pip install --disable-pip-version-check -q -c .github/ci-constraints.txt \ + scikit-build-core pybind11 cmake ninja numpy pytest pytest-cov gcovr + export PATH="$ROOT/$cvenv/bin:$PATH" + echo "== the extension with coverage (editable, hooks on, Debug) in $ext" + "$cpy" -m pip install --disable-pip-version-check --no-build-isolation --no-deps -e . \ + -C build-dir="$ext" -C cmake.build-type=Debug \ + -C cmake.define.NETGRAPH_CORE_COVERAGE=ON -C cmake.define.NETGRAPH_CORE_PRIVATE_HOOKS=ON + # gcovr reads every profile (.gcda file) it finds and reports no data, or old data, without + # failing: the profiles of earlier runs go first, and each build directory must then hold + # profiles that this run's tests wrote. + find "$ext" -name '*.gcda' -delete + [[ ! -d "$cpp" ]] || find "$cpp" -name '*.gcda' -delete + echo "== Python tests with coverage" + PYTHON=$cpy run_pytest on --cov=netgraph_core --cov-report=term-missing \ + --cov-report=xml:"$out/coverage-python.xml" + echo "== C++ tests with coverage" + bash dev/cpp-tests.sh coverage + for dir in "$ext" "$cpp"; do + [[ -n "$(find "$dir" -name '*.gcda' -print -quit)" ]] || + die "no C++ profile data in $dir: the tests that ran wrote no coverage there" + done + echo "== C++ coverage report (gcovr: the library, the bindings and the headers)" + if [[ "$(uname -s)" == Darwin ]]; then + gcov="xcrun llvm-cov gcov" + elif [[ "${CXX:-}" == *clang* ]]; then + gcov="llvm-cov gcov" else - echo "🎉 All checks and tests passed." + gcov=gcov fi -else - echo "" - if [ $autofixed -eq 1 ]; then - echo "❌ Some tests failed. Note: auto-fixes were applied earlier by pre-commit." - else - echo "❌ Some tests failed." - fi - exit 1 -fi + gcov=${GCOV:-$gcov} + "$cpy" -m gcovr --root . --gcov-executable "$gcov" -j "$JOBS" \ + "${filters[@]/#/--filter=}" --xml-pretty -o "$out/coverage-cpp.xml" "$ext" "$cpp" + "$cpy" dev/coverage_summary.py "$out/coverage-python.xml" "$out/coverage-cpp.xml" \ + --html="$out/coverage-combined.html" + # A filter that matches no file (a moved directory) would leave that part out silently. + for root in "${filters[@]}"; do + grep -q "filename=\"$root" "$out/coverage-cpp.xml" || + die "the C++ coverage report has no file under $root" + done + echo "Coverage ready in $out/: coverage-python.xml, coverage-cpp.xml, coverage-combined.html" +} + +case "${1:-}" in + pytest) + case "${2:-}" in on|off) ;; *) die "usage: dev/run-checks.sh pytest on|off [PYTEST_ARGS...]" ;; esac + hooks=$2; shift 2 + run_pytest "$hooks" "$@" + ;; + cov) coverage ;; + *) echo 'Usage: bash dev/run-checks.sh pytest on|off [PYTEST_ARGS...] | cov' >&2; exit 2 ;; +esac diff --git a/dev/stubs/check_clients.py b/dev/stubs/check_clients.py new file mode 100644 index 0000000..dd2c787 --- /dev/null +++ b/dev/stubs/check_clients.py @@ -0,0 +1,234 @@ +"""Type-check the client programs of tests/typing against the installed netgraph_core. + +Run by ``bash dev/stubs/run.sh check`` with the interpreter of an environment holding an +installed netgraph_core (a wheel, or an installation built from the sdist) and pinned Pyright: + +* the installation is the one checked: netgraph_core is imported from this interpreter's + site-packages, its __init__.pyi equals the committed stub byte for byte, py.typed is there and + _docs.py is not; Pyright's search paths (its verbose log) hold that site-packages and not the + source tree's python/ directory, and the clients run from a copy outside the source tree; +* tests/typing/valid_client.py: zero Pyright diagnostics of any severity, and it runs; +* tests/typing/invalid_client.py: the diagnostics are exactly the lines marked + ``# expect: RULE[, RULE...]`` (one diagnostic of each rule listed), at least one per typing + case of REQUIRED below; +* a control: a copy of the valid client with one wrong line appended gets exactly that one + diagnostic (the valid client is analysed, its zero is not vacuous); +* every Pyright run ends normally (``pyright`` below); one that does not stops the check. + +Usage: python -I dev/stubs/check_clients.py LABEL WORK_DIR +""" + +from __future__ import annotations + +import collections +import importlib +import json +import os +import re +import shutil +import subprocess +import sys +import sysconfig +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +CLIENTS = ROOT / "tests/typing" +STUB = ROOT / "python/netgraph_core/__init__.pyi" +EXPECT = re.compile(r"#\s*expect:\s*(\w+(?:\s*,\s*\w+)*)\s*$") +# The typing cases the invalid client must keep, one per marked line (the result classes the +# module refuses to construct, wrong dtypes, wrong argument types, missing Optional checks; a +# line containing the text, marked): a marked line deleted with its mark is caught. +REQUIRED = [ + "ng.Eligibility()", + "ng.PredDAG()", + "ng.FlowRecord()", + "ng.PlacementResult()", + "ng.DagCacheStats()", + "ng.SensitivityEntry()", + "ng.SensitivityResult()", + "ng.SpfResult()", + "npt.NDArray[np.float64] = g.capacity_units()", + "text: str = g.capacity_units().item(0)", + 'ng.Graph("wrong"', + "ng.Graph(2, [0], [1], [1.5]", + "ng.FlowLedger(g, residual=np.zeros(1, dtype=np.float64))", + "ng.spf(g, 0, ng.KspOptions(1, sel))", + "ng.capacity(g, 0, 1, ng.SensitivityOptions(cfg))", + "ng.CapacityAware(max_path_cost=guard)", + 'ng.to_units("1.5", 1.0)', + "ng.Path(g, np.zeros((1, 1), dtype=np.float64))", + "ng.batch_capacity(g, float_pairs,", + "cost: int = result.target_cost", + "first = result.costs[0]", + "total = flows.sum()", +] + + +def fail(label: str, problems: list[str]) -> int: + for p in problems: + print(f"dev/stubs/check_clients.py [{label}]: {p}", file=sys.stderr) + return 1 + + +def installation(problems: list[str]) -> Path: + pkg = importlib.import_module("netgraph_core") + where = Path(os.path.realpath(pkg.__file__ or "")).parent + roots = {os.path.realpath(sysconfig.get_paths()[k]) for k in ("purelib", "platlib")} + if not any(str(where).startswith(r + os.sep) for r in roots): + problems.append(f"netgraph_core imported from {where}, not this site-packages") + stub = where / "__init__.pyi" + if not stub.is_file(): + problems.append(f"the installation has no {stub.name}") + elif stub.read_bytes() != STUB.read_bytes(): + problems.append(f"{stub} differs from the committed {STUB.relative_to(ROOT)}") + if not (where / "py.typed").is_file(): + problems.append("the installation has no py.typed") + if (where / "_docs.py").exists(): + problems.append("the installation still carries _docs.py") + return where + + +def pyright(work: Path, verbose: bool = False) -> dict: + """Pyright on the project in ``work``: its verbose log, or its JSON report. Exits with the + reason unless Pyright ended normally: exit status 0 (no error) or 1 (errors reported), not + a signal or another status (a fatal error, a bad configuration or command line), and for + the report, standard output that is one JSON report whose error count agrees with the + status.""" + cmd = [sys.executable, "-m", "pyright", "--pythonpath", sys.executable] + cmd += ["--project", str(work), "--verbose" if verbose else "--outputjson"] + proc = subprocess.run(cmd, capture_output=True, text=True, cwd=work) + out = proc.stdout + proc.stderr + (work / ("pyright-verbose.log" if verbose else "pyright.json")).write_text(out) + if proc.returncode < 0: + sys.exit(f"pyright was killed by signal {-proc.returncode} on {work}:\n{out}") + if proc.returncode not in (0, 1): + sys.exit(f"pyright failed (exit {proc.returncode}) on {work}:\n{out}") + if verbose: + return {"log": out} + try: + report = json.loads(proc.stdout) + except json.JSONDecodeError: + sys.exit(f"pyright gave no JSON (exit {proc.returncode}):\n{out}") + if not isinstance(report, dict): + report = {} + summary = report.get("summary") + errors = summary.get("errorCount") if isinstance(summary, dict) else None + if ( + not isinstance(report.get("generalDiagnostics"), list) + or type(errors) is not int + or proc.returncode != (errors > 0) + ): + sys.exit( + f"pyright's report is not one of a complete run (exit {proc.returncode}):\n{out}" + ) + return report + + +def diagnostics(report: dict) -> dict[str, collections.Counter]: + got: dict[str, collections.Counter] = collections.defaultdict(collections.Counter) + for d in report.get("generalDiagnostics", []): + line = d["range"]["start"]["line"] + 1 + got[Path(d["file"]).name][(line, d.get("rule", d["severity"]))] += 1 + return got + + +def main() -> int: + if len(sys.argv) != 3: + sys.exit("usage: check_clients.py LABEL WORK_DIR") + label, work = sys.argv[1], Path(sys.argv[2]).resolve() + problems: list[str] = [] + site = installation(problems) + shutil.rmtree(work, ignore_errors=True) + work.mkdir(parents=True) + names = ["valid_client.py", "invalid_client.py"] + for name in names: + shutil.copyfile(CLIENTS / name, work / name) + version = f"{sys.version_info.major}.{sys.version_info.minor}" + (work / "pyrightconfig.json").write_text( + json.dumps( + { + "include": names, + "typeCheckingMode": "standard", + "pythonVersion": version, + }, + indent=2, + ) + ) + + log = pyright(work, verbose=True)["log"] + paths = [ln.strip() for ln in log.splitlines() if ln.startswith(" ")] + if not any( + os.path.realpath(p) == str(site.parent) for p in paths if os.path.isdir(p) + ): + problems.append( + f"Pyright's search paths do not hold {site.parent} (pyright-verbose.log)" + ) + source = os.path.realpath(ROOT / "python") + if any(os.path.realpath(p) == source for p in paths if os.path.isdir(p)): + problems.append("Pyright searches the source tree's python/ directory") + + report = pyright(work) + got = diagnostics(report) + if got["valid_client.py"]: + for (line, rule), n in sorted(got["valid_client.py"].items()): + problems.append( + f"valid_client.py:{line}: {n} unexpected diagnostic(s) {rule}" + ) + + lines = (work / "invalid_client.py").read_text().splitlines() + want: collections.Counter = collections.Counter() + for i, text in enumerate(lines, 1): + m = EXPECT.search(text) + if m: + for rule in re.split(r"\s*,\s*", m.group(1)): + want[(i, rule)] += 1 + for case in REQUIRED: + if not any(case in t and EXPECT.search(t) for t in lines): + problems.append(f"invalid_client.py lost the marked case {case!r}") + for key in sorted(set(want) | set(got["invalid_client.py"])): + w, g = want[key], got["invalid_client.py"][key] + if w != g: + problems.append( + f"invalid_client.py:{key[0]}: {key[1]} expected {w}, got {g}" + ) + details = { + (d["range"]["start"]["line"] + 1, d.get("rule")): d["message"].splitlines()[0] + for d in report.get("generalDiagnostics", []) + if Path(d["file"]).name == "invalid_client.py" + } + + # The control: one wrong line appended to a copy of the valid client. + control = work / "control" + control.mkdir() + planted = (CLIENTS / "valid_client.py").read_text() + "oops: str = g.num_edges()\n" + (control / "valid_client.py").write_text(planted) + (control / "pyrightconfig.json").write_text( + json.dumps({"include": ["valid_client.py"], "typeCheckingMode": "standard"}) + ) + seen = diagnostics(pyright(control))["valid_client.py"] + last = len(planted.splitlines()) + if seen != collections.Counter({(last, "reportAssignmentType"): 1}): + problems.append(f"control: the planted line {last} gave {dict(seen)}") + + run = subprocess.run( + [sys.executable, "-I", str(work / "valid_client.py")], + capture_output=True, + text=True, + ) + if run.returncode != 0: + problems.append(f"valid_client.py does not run:\n{run.stderr}") + + if problems: + return fail(label, problems) + print( + f"[{label}] {site}: stub identical to the committed one; valid client: 0 " + f"diagnostics and it runs (control: a planted line caught); invalid client: " + f"{sum(want.values())} expected diagnostics on {len({k[0] for k in want})} lines, no other" + ) + for (line, rule), message in sorted(details.items()): + print(f" invalid_client.py:{line}: {rule}: {message}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev/stubs/check_contents.py b/dev/stubs/check_contents.py new file mode 100644 index 0000000..285da49 --- /dev/null +++ b/dev/stubs/check_contents.py @@ -0,0 +1,168 @@ +"""Check the files of the wheel and the sdist against the files intended for them. + +Run by ``bash dev/stubs/run.sh check`` with the wheel and the sdist it built from this checkout: + +* the sdist holds exactly the intended source files, plus the PKG-INFO the backend writes. The + intended files are the checkout's files that git tracks or would track (new files git does + not ignore) under the top-level entries of SDIST_TOP, without tests/reference/. That rule is + the one of pyproject.toml's sdist.include and sdist.exclude, repeated here so that a change + to that list is caught. A file git ignores (a cache, a compiled module, a local tool's + output) or a file outside those entries is refused, and so is an intended file left out; +* the wheel holds exactly the package files of the sdist's python/netgraph_core/ under + netgraph_core/, one compiled extension module, and its .dist-info directory with the + metadata files and both licence notices (the project's and pybind11's); +* both carry the stub, py.typed and the licence notices, and neither carries a _docs.py. + +The one file git cannot tell apart from an intended one is a new file it does not ignore inside +a shipped directory: ``git status`` lists it, and CI builds from a clean checkout. + +Usage: python -I dev/stubs/check_contents.py WHEEL SDIST WORK_DIR +""" + +from __future__ import annotations + +import re +import subprocess +import sys +import tarfile +import zipfile +from collections.abc import Iterable +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +# The top-level files and directories the sdist ships (a directory ends with "/") and the +# directories under them it leaves out. +SDIST_TOP = ( + ".coveragerc", + ".gitignore", + "CHANGELOG.md", + "CMakeLists.txt", + "CONTRIBUTING.md", + "LICENSE", + "README.md", + "pyproject.toml", + "bindings/", + "docs/", + "include/", + "python/", + "src/", + "tests/", + "third_party/", +) +SDIST_LEFT_OUT = ("tests/reference/",) +PACKAGE = "python/netgraph_core/" +NEED = { + "wheel": [ + "netgraph_core/__init__.py", + "netgraph_core/__init__.pyi", + "netgraph_core/py.typed", + ], + "sdist": [ + "python/netgraph_core/__init__.py", + "python/netgraph_core/__init__.pyi", + "python/netgraph_core/py.typed", + "LICENSE", + "third_party/pybind11/LICENSE", + ], +} +EXTENSION = re.compile(r"_netgraph_core\.[^/]+\.(so|pyd)") +DIST_INFO = re.compile(r"netgraph_core-[^/]+\.dist-info/") +DIST_INFO_FILES = { + "METADATA", + "WHEEL", + "RECORD", + "licenses/LICENSE", + "licenses/third_party/pybind11/LICENSE", +} + + +def shipped(name: str) -> bool: + """Whether the sdist rule selects the checkout path ``name``.""" + if name.startswith(SDIST_LEFT_OUT): + return False + return any( + name.startswith(top) if top.endswith("/") else name == top for top in SDIST_TOP + ) + + +def checkout_files(root: Path) -> list[str]: + """The checkout's files that git tracks or would track (a new file it does not ignore), + as they are on disk now: a tracked file deleted from the work tree is not one.""" + cmd = ["git", "-C", str(root), "ls-files", "-z", "--cached", "--others"] + try: + out = subprocess.run( + [*cmd, "--exclude-standard"], capture_output=True, check=True + ).stdout + except (OSError, subprocess.CalledProcessError) as exc: + sys.exit( + f"dev/stubs/check_contents.py: cannot list the checkout's files ({exc})" + ) + names = {n for n in out.decode("utf-8").split("\0") if n} + return sorted(n for n in names if (root / n).is_file()) + + +def problems( + wheel: Iterable[str], sdist: Iterable[str], intended: Iterable[str] +) -> list[str]: + """What is wrong with the member names of the wheel and of the sdist (the sdist's without + their top directory), given the checkout files ``intended`` for the sdist.""" + wheel, sdist = set(wheel), set(sdist) + want_sdist = {n for n in intended if shipped(n)} | {"PKG-INFO"} + package = {n[len(PACKAGE) :] for n in want_sdist if n.startswith(PACKAGE)} + out = [] + for kind, names in (("wheel", wheel), ("sdist", sdist)): + out += [f"the {kind} lacks {n}" for n in NEED[kind] if n not in names] + out += [ + f"the {kind} carries {n}" for n in sorted(names) if n.endswith("_docs.py") + ] + out += [ + f"the sdist carries {n}, not an intended file" + for n in sorted(sdist - want_sdist) + ] + out += [f"the sdist lacks {n}" for n in sorted(want_sdist - sdist)] + + extensions = sorted(n for n in wheel if EXTENSION.fullmatch(n)) + if len(extensions) != 1: + out.append(f"the wheel holds {len(extensions)} extension modules: {extensions}") + infos = {m.group(0) for n in wheel if (m := DIST_INFO.match(n))} + if len(infos) != 1: + out.append( + f"the wheel holds {len(infos)} .dist-info directories: {sorted(infos)}" + ) + want_wheel = {f"netgraph_core/{n}" for n in package} | set(extensions) + want_wheel |= {i + n for i in infos for n in DIST_INFO_FILES} + out += [ + f"the wheel carries {n}, not an intended file" + for n in sorted(wheel - want_wheel) + ] + out += [f"the wheel lacks {n}" for n in sorted(want_wheel - wheel)] + return out + + +def main() -> int: + if len(sys.argv) != 4: + sys.exit("usage: check_contents.py WHEEL SDIST WORK_DIR") + wheel, sdist, work = sys.argv[1], sys.argv[2], Path(sys.argv[3]) + with zipfile.ZipFile(wheel) as z: + wheel_names = sorted(n for n in z.namelist() if not n.endswith("/")) + with tarfile.open(sdist) as t: + members = [m for m in t if not m.isdir()] + tops = {m.name.split("/", 1)[0] for m in members} + sdist_names = sorted(m.name.split("/", 1)[1] for m in members if "/" in m.name) + found = list(problems(wheel_names, sdist_names, checkout_files(ROOT))) + if len(tops) != 1 or len(sdist_names) != len(members): + found.append( + f"the sdist's files are not all under one top directory: {sorted(tops)}" + ) + for kind, names in (("wheel", wheel_names), ("sdist", sdist_names)): + (work / f"contents-{kind}.txt").write_text("\n".join(names) + "\n") + print(f"{kind}: {len(names)} files ({work}/contents-{kind}.txt)") + if found: + for p in found: + print(f"dev/stubs/check_contents.py: {p}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev/stubs/generate.py b/dev/stubs/generate.py new file mode 100644 index 0000000..dfa502b --- /dev/null +++ b/dev/stubs/generate.py @@ -0,0 +1,603 @@ +"""Generate the package's type stub, python/netgraph_core/__init__.pyi, or check it for drift. + +Run by ``bash dev/stubs/run.sh`` with the interpreter of an environment holding an installed, +non-editable netgraph_core (a wheel or an sdist build: the shipped configuration, without the +private hooks) and the pinned pybind11-stubgen and ruff of pyproject.toml's ``dev`` extra. + +The steps, each failing loudly: + +1. pybind11-stubgen writes the stub of the compiled module ``_netgraph_core``. +2. The package stub keeps the declarations of ``netgraph_core.__all__`` (the private names of the + compiled module and private members are not part of the package), under the package's own + docstring, ``__all__`` and ``__version__``. +3. The reviewed overrides of dev/stubs/overrides.pyi replace generated declarations. An override + may change annotations only: its parameter names, kinds and defaults must equal the generated + ones, the exact generated line it replaces is quoted above it (``# generated:``) with its + reason (``# reason:``), and a changed generated line fails until the override is reviewed + again. Overloads replace one generated declaration. The one addition allowed is the marker + of a class the module refuses to construct (``def __init__(self, _no_public_constructor: + typing.Never, /)``): a type checker then rejects ``Cls()``; the marker must match the runtime + both ways. +4. No ``typing.Any`` remains, except where the module accepts any object: the operand of + ``__eq__``/``__ne__`` and the memo of ``__deepcopy__``. +5. The module's positional-only parameters (``/`` in its signatures, which pybind11-stubgen + 3.0.0 drops) are positional-only in the stub too, wherever a parameter other than the + receiver is affected (the enums' comparison operand and ``__setstate__``'s state). +6. ruff (pinned) formats the result; ``--write`` writes it, ``--check`` compares it with the + committed stub and prints the difference. + +Usage: python -I dev/stubs/generate.py (--write | --check) [--raw DIR] +""" + +from __future__ import annotations + +import argparse +import ast +import copy +import difflib +import importlib +import inspect +import os +import subprocess +import sys +import sysconfig +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +STUB = ROOT / "python/netgraph_core/__init__.pyi" +OVERRIDES = ROOT / "dev/stubs/overrides.pyi" +HEADER = "# Generated from the compiled module; do not edit.\n" +# The imports, grouped as ruff's isort rules want them (ast.unparse keeps no blank lines). +IMPORTS = """from __future__ import annotations + +import collections.abc +import typing + +import numpy +import numpy.typing +""" +MARKER_PARAM = "_no_public_constructor" +# Where the module accepts any object, typing.Any is the exact domain: the comparison operand +# (pybind11 names it "other" on enums, "arg0" on the value classes, which define __eq__ only) +# and copy.deepcopy's memo. +ANY_ALLOWED = { + ("__eq__", "other"), + ("__eq__", "arg0"), + ("__ne__", "other"), + ("__deepcopy__", "memo"), +} + + +class Failure(Exception): + pass + + +def installed_package(): + """netgraph_core and _netgraph_core, imported from this interpreter's site-packages (an + installation, not the source tree or an editable build's redirect).""" + pkg = importlib.import_module("netgraph_core") + ext = importlib.import_module("_netgraph_core") + roots = {os.path.realpath(sysconfig.get_paths()[k]) for k in ("purelib", "platlib")} + for mod in (pkg, ext): + where = os.path.realpath(mod.__file__ or "") + if not any(where.startswith(r + os.sep) for r in roots): + raise Failure( + f"{mod.__name__} is imported from {where}, not from an installation in " + "this interpreter's site-packages (build a wheel: bash dev/stubs/run.sh)" + ) + if hasattr(ext, "_max_flows_per_call"): + raise Failure( + "the installed extension carries the private hooks; generate from the " + "shipped configuration (a wheel or an sdist build)" + ) + return pkg, ext + + +def run_stubgen(raw_dir: Path) -> str: + cmd = [ + sys.executable, + "-I", + "-m", + "pybind11_stubgen", + "--exit-code", + "-o", + str(raw_dir), + "_netgraph_core", + ] + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0 or proc.stderr.strip(): + raise Failure( + f"pybind11-stubgen failed ({proc.returncode}):\n{proc.stderr}{proc.stdout}" + ) + return (raw_dir / "_netgraph_core.pyi").read_text(encoding="utf-8") + + +def is_private(name: str) -> bool: + return name.startswith("_") and not (name.startswith("__") and name.endswith("__")) + + +def params(fn: ast.FunctionDef) -> list[tuple[str, str, str | None]]: + """(name, kind, default) of every parameter, annotations left out.""" + a = fn.args + pos = [*a.posonlyargs, *a.args] + defaults: list[ast.expr | None] = [None] * (len(pos) - len(a.defaults)) + defaults += list(a.defaults) + out = [] + for i, (arg, d) in enumerate(zip(pos, defaults, strict=True)): + kind = "positional-only" if i < len(a.posonlyargs) else "positional" + out.append((arg.arg, kind, None if d is None else ast.unparse(d))) + if a.vararg: + out.append((a.vararg.arg, "*args", None)) + for arg, d in zip(a.kwonlyargs, a.kw_defaults, strict=True): + out.append((arg.arg, "keyword-only", None if d is None else ast.unparse(d))) + if a.kwarg: + out.append((a.kwarg.arg, "**kwargs", None)) + return out + + +def is_marker(fn: ast.FunctionDef) -> bool: + a = fn.args + return ( + fn.name == "__init__" + and not fn.decorator_list + and [x.arg for x in a.posonlyargs] == ["self", MARKER_PARAM] + and not (a.args or a.vararg or a.kwonlyargs or a.kwarg or a.defaults) + and ast.unparse(a.posonlyargs[1].annotation or ast.Constant(None)) + == "typing.Never" + and ast.unparse(fn.returns or ast.Constant(None)) == "None" + ) + + +def comment_block(lines: list[str], first_line: int) -> list[str]: + """The comment lines directly above line ``first_line`` (1-based).""" + out: list[str] = [] + i = first_line - 2 + while i >= 0 and lines[i].strip().startswith("#"): + out.insert(0, lines[i].strip()) + i -= 1 + return out + + +class Override: + def __init__(self, qual: str, defs: list[ast.FunctionDef], lines: list[str]): + self.qual = qual + self.defs = defs + first = defs[0] + start = min([first.lineno, *(d.lineno for d in first.decorator_list)]) + comments = comment_block(lines, start) + self.reasons = [ + c.removeprefix("# reason:").strip() + for c in comments + if c.startswith("# reason:") + ] + gen = [ + c.removeprefix("# generated:").strip() + for c in comments + if c.startswith("# generated:") + ] + where = f"{OVERRIDES.relative_to(ROOT)}:{first.lineno} {qual}" + if not self.reasons or not all(self.reasons): + raise Failure(f"{where}: an override needs a '# reason:' line above it") + if len(gen) != 1: + raise Failure( + f"{where}: an override needs exactly one '# generated:' line above it " + "(the generated line it replaces, or '(none)' for the constructor marker)" + ) + self.generated = gen[0] + for d in defs[1:]: + stray = comment_block( + lines, min([d.lineno, *(x.lineno for x in d.decorator_list)]) + ) + if any(c.startswith(("# reason:", "# generated:")) for c in stray): + raise Failure( + f"{where}: '# reason:'/'# generated:' belong above the first overload only" + ) + if len(defs) > 1 and not all( + any(ast.unparse(x) == "typing.overload" for x in d.decorator_list) + for d in defs + ): + raise Failure( + f"{where}: repeated declarations must all be @typing.overload" + ) + + +def load_overrides() -> dict[str, Override]: + text = OVERRIDES.read_text(encoding="utf-8") + lines = text.splitlines() + tree = ast.parse(text) + groups: dict[str, list[ast.FunctionDef]] = {} + order: list[str] = [] + + def add(qual: str, fn: ast.FunctionDef) -> None: + if qual in groups and order[-1] != qual: + raise Failure( + f"{OVERRIDES.name}:{fn.lineno} {qual}: overloads must be adjacent" + ) + if qual not in groups: + order.append(qual) + groups.setdefault(qual, []).append(fn) + + for node in tree.body: + if isinstance(node, ast.FunctionDef): + add(node.name, node) + elif isinstance(node, ast.ClassDef): + for item in node.body: + if isinstance(item, ast.FunctionDef): + add(f"{node.name}.{item.name}", item) + elif not ( + isinstance(item, ast.Expr) and isinstance(item.value, ast.Constant) + ): + raise Failure( + f"{OVERRIDES.name}:{item.lineno}: only method overrides inside a class" + ) + elif not isinstance(node, (ast.Import, ast.ImportFrom)) and not ( + isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant) + ): + raise Failure(f"{OVERRIDES.name}:{node.lineno}: unexpected statement") + return {q: Override(q, groups[q], lines) for q in order} + + +def generated_line(raw_lines: list[str], fn: ast.FunctionDef) -> str: + return raw_lines[fn.lineno - 1].strip() + + +def body_of(fn: ast.FunctionDef) -> list[ast.stmt]: + """The generated body (the docstring or ``...``).""" + return copy.deepcopy(fn.body) + + +def with_body(fn: ast.FunctionDef, body: list[ast.stmt]) -> ast.FunctionDef: + new = copy.deepcopy(fn) + new.body = body + return new + + +def apply_overrides( + container: list[ast.stmt], + owner: str, + raw_lines: list[str], + overrides: dict[str, Override], + used: set[str], +) -> list[ast.stmt]: + out: list[ast.stmt] = [] + funcs = [n for n in container if isinstance(n, ast.FunctionDef)] + for node in container: + if not isinstance(node, ast.FunctionDef): + out.append(node) + continue + qual = f"{owner}.{node.name}" if owner else node.name + ov = overrides.get(qual) + if ov is None: + out.append(node) + continue + if sum(n.name == node.name for n in funcs) > 1: + raise Failure( + f"{qual}: generated as overloads; an override replaces one declaration" + ) + used.add(qual) + if ov.generated == "(none)": + raise Failure( + f"{qual}: the override says '(none)', but {qual} is generated" + ) + got = generated_line(raw_lines, node) + if got != ov.generated: + raise Failure( + f"{qual}: the generated declaration changed; review the override again\n" + f" override quotes: {ov.generated}\n generated now: {got}" + ) + want = params(node) + for d in ov.defs: + if params(d) != want: + raise Failure( + f"{qual}: an override changes only annotations; parameters " + f"{params(d)} differ from the generated {want}" + ) + decos = {ast.unparse(x) for x in node.decorator_list} + for d in ov.defs: + have = {ast.unparse(x) for x in d.decorator_list} - {"typing.overload"} + if have != decos: + raise Failure( + f"{qual}: decorators {sorted(have)} differ from the generated {sorted(decos)}" + ) + for i, d in enumerate(ov.defs): + body = body_of(node) if i == 0 else [ast.Expr(ast.Constant(Ellipsis))] + out.append(with_body(d, body)) + return out + + +def add_markers( + cls: ast.ClassDef, overrides: dict[str, Override], used: set[str] +) -> None: + qual = f"{cls.name}.__init__" + ov = overrides.get(qual) + if ov is None or any( + isinstance(n, ast.FunctionDef) and n.name == "__init__" for n in cls.body + ): + return + used.add(qual) + if ov.generated != "(none)" or len(ov.defs) != 1 or not is_marker(ov.defs[0]): + raise Failure( + f"{qual}: not generated; the only addition allowed is the constructor marker " + f"'def __init__(self, {MARKER_PARAM}: typing.Never, /) -> None' " + "with '# generated: (none)'" + ) + marker = with_body(ov.defs[0], [ast.Expr(ast.Constant(Ellipsis))]) + # After the class docstring, before the first member (the generated order is kept). + at = ( + 1 + if cls.body + and isinstance(cls.body[0], ast.Expr) + and isinstance(cls.body[0].value, ast.Constant) + else 0 + ) + cls.body.insert(at, marker) + + +def refuses_construction(cls: type) -> bool: + try: + cls() + except TypeError as exc: + return "No constructor defined" in str(exc) + return False + + +def positional_only(doc: str | None) -> list[list[str]]: + """Per pybind11 signature of a docstring, the names its ``/`` makes positional-only.""" + lines = (doc or "").strip("\n").split("\n") + if len(lines) > 1 and lines[1].strip() == "Overloaded function.": + sigs = [ln.split(". ", 1)[1] for ln in lines if ln[:1].isdigit() and ". " in ln] + else: + sigs = lines[:1] + out = [] + for sig in sigs: + if "(" not in sig or ")" not in sig: + out.append([]) + continue + inner = sig[sig.index("(") + 1 : sig.rindex(")")] + parts, depth, cur = [], 0, "" + for ch in inner: + depth += ch in "([{" + depth -= ch in ")]}" + if ch == "," and depth == 0: + parts.append(cur.strip()) + cur = "" + else: + cur += ch + parts.append(cur.strip()) + names = [p.split(":")[0].split("=")[0].strip() for p in parts] + out.append(names[: names.index("/")] if "/" in names else []) + return out + + +def restore_positional_only(defs: list[ast.FunctionDef], obj, qual: str) -> None: + """pybind11-stubgen 3.0.0 drops the ``/`` of the module's signatures. Where the module makes + a parameter other than the receiver positional-only, the stub declares the same leading + parameters positional-only (the enums' ``__eq__``/``__ne__`` operand, ``__setstate__``'s + state); a lone positional-only receiver is left as generated.""" + sigs = positional_only(getattr(obj, "__doc__", None)) + if not any(set(names) - {"self"} for names in sigs): + return + if len(sigs) == 1: + sigs = sigs * len(defs) + if len(sigs) != len(defs): + raise Failure( + f"{qual}: {len(sigs)} module signatures, {len(defs)} stub declarations" + ) + for names, fn in zip(sigs, defs, strict=True): + a = fn.args + pos = [*a.posonlyargs, *a.args] + if [x.arg for x in pos[: len(names)]] != names: + raise Failure( + f"{qual}: the module makes {names} positional-only; the stub starts with " + f"{[x.arg for x in pos[: len(names)]]}" + ) + a.posonlyargs, a.args = pos[: len(names)], pos[len(names) :] + + +def apply_positional_only(decls: list[ast.stmt], pkg) -> None: + def groups(body: list[ast.stmt]) -> dict[str, list[ast.FunctionDef]]: + out: dict[str, list[ast.FunctionDef]] = {} + for n in body: + if isinstance(n, ast.FunctionDef) and "property" not in { + ast.unparse(d) for d in n.decorator_list + }: + out.setdefault(n.name, []).append(n) + return out + + for name, defs in groups(decls).items(): + restore_positional_only(defs, getattr(pkg, name), name) + for node in decls: + if isinstance(node, ast.ClassDef): + cls = getattr(pkg, node.name) + for name, defs in groups(node.body).items(): + if name in vars(cls) and not any(is_marker(d) for d in defs): + restore_positional_only( + defs, getattr(cls, name), f"{node.name}.{name}" + ) + + +def check_markers(classes: dict[str, ast.ClassDef], pkg) -> None: + for name, node in classes.items(): + runtime = getattr(pkg, name) + if issubclass(runtime, BaseException): + continue + marked = any(isinstance(n, ast.FunctionDef) and is_marker(n) for n in node.body) + refuses = refuses_construction(runtime) + if marked and not refuses: + raise Failure( + f"{name}: marked not constructible, but the module constructs it" + ) + if refuses and not marked: + raise Failure( + f"{name}: the module refuses to construct it (No constructor defined); " + "add the constructor marker to dev/stubs/overrides.pyi" + ) + + +def check_no_any(tree: ast.Module) -> None: + problems = [] + + def visit(fn: ast.FunctionDef, owner: str) -> None: + a = fn.args + # Every place a parameter can be annotated: positional-only, positional, *args, + # keyword-only and **kwargs. + every = [*a.posonlyargs, *a.args, a.vararg, *a.kwonlyargs, a.kwarg] + for arg in (x for x in every if x is not None): + if arg.annotation is not None and "typing.Any" in ast.unparse( + arg.annotation + ): + if (fn.name, arg.arg) not in ANY_ALLOWED: + problems.append(f"{owner}{fn.name}({arg.arg})") + if fn.returns is not None and "typing.Any" in ast.unparse(fn.returns): + problems.append(f"{owner}{fn.name} -> ...") + + def scan(body: list[ast.stmt], owner: str) -> None: + for node in body: + if isinstance(node, ast.FunctionDef): + visit(node, owner) + elif isinstance(node, ast.ClassDef): + scan(node.body, f"{owner}{node.name}.") + elif isinstance(node, ast.AnnAssign) and "typing.Any" in ast.unparse( + node.annotation + ): + problems.append(f"{owner}{ast.unparse(node.target)}") + + scan(tree.body, "") + if problems: + raise Failure( + "typing.Any left in the stub (add a reviewed override): " + + ", ".join(problems) + ) + + +def build_stub(raw: str, pkg) -> str: + raw_lines = raw.splitlines() + tree = ast.parse(raw) + exported = list(pkg.__all__) + overrides = load_overrides() + used: set[str] = set() + decls: list[ast.stmt] = [] + seen: set[str] = set() + classes: dict[str, ast.ClassDef] = {} + for node in tree.body: + if isinstance(node, (ast.ClassDef, ast.FunctionDef)): + name = node.name + elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + name = node.target.id + else: + continue # the module docstring, imports and the module's own __all__ + if name == "__all__": + continue + if name not in exported: + if not is_private(name): + raise Failure( + f"_netgraph_core.{name} is public but not exported by netgraph_core" + ) + continue + seen.add(name) + if isinstance(node, ast.ClassDef): + node.body = [ + n + for n in node.body + if not (isinstance(n, (ast.FunctionDef,)) and is_private(n.name)) + and not ( + isinstance(n, ast.AnnAssign) + and isinstance(n.target, ast.Name) + and is_private(n.target.id) + ) + ] + node.body = apply_overrides( + node.body, node.name, raw_lines, overrides, used + ) + add_markers(node, overrides, used) + classes[name] = node + decls.append(node) + decls = apply_overrides(decls, "", raw_lines, overrides, used) + missing = [n for n in exported if n != "__version__" and n not in seen] + if missing: + raise Failure(f"exported by netgraph_core, not generated: {missing}") + unused = [q for q in overrides if q not in used] + if unused: + raise Failure(f"overrides without a generated declaration: {unused}") + check_markers(classes, pkg) + apply_positional_only(decls, pkg) + + doc = ast.Expr(ast.Constant(inspect.cleandoc(pkg.__doc__ or ""))) + body: list[ast.stmt] = ast.parse(f"__all__: list[str] = {exported!r}").body + body += ast.parse("__version__: str").body + body += decls + module = ast.Module(body=body, type_ignores=[]) + check_no_any(module) + return f"{HEADER}{ast.unparse(ast.Module([doc], []))}\n\n{IMPORTS}\n{ast.unparse(module)}\n" + + +def ruff_format(text: str) -> str: + proc = subprocess.run( + [ + sys.executable, + "-m", + "ruff", + "format", + "--stdin-filename", + str(STUB.relative_to(ROOT)), + "-", + ], + input=text, + capture_output=True, + text=True, + cwd=ROOT, + ) + if proc.returncode != 0: + raise Failure(f"ruff format failed:\n{proc.stderr}") + return proc.stdout + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + mode = ap.add_mutually_exclusive_group(required=True) + mode.add_argument("--write", action="store_true", help="write the stub") + mode.add_argument("--check", action="store_true", help="fail on drift") + ap.add_argument( + "--raw", type=Path, help="keep the raw pybind11-stubgen output here" + ) + args = ap.parse_args() + try: + pkg, ext = installed_package() + print(f"generating from {ext.__file__}") + with tempfile.TemporaryDirectory() as tmp: + raw_dir = args.raw or Path(tmp) + raw = run_stubgen(raw_dir) + stub = ruff_format(build_stub(raw, pkg)) + except Failure as exc: + print(f"dev/stubs/generate.py: {exc}", file=sys.stderr) + return 1 + if args.write: + STUB.write_text(stub, encoding="utf-8") + print(f"wrote {STUB.relative_to(ROOT)} ({len(stub.splitlines())} lines)") + return 0 + committed = STUB.read_text(encoding="utf-8") if STUB.exists() else "" + if committed == stub: + print( + f"{STUB.relative_to(ROOT)} is up to date ({len(stub.splitlines())} lines)" + ) + return 0 + rel = str(STUB.relative_to(ROOT)) + sys.stdout.writelines( + difflib.unified_diff( + committed.splitlines(keepends=True), + stub.splitlines(keepends=True), + f"{rel} (committed)", + f"{rel} (generated)", + ) + ) + print( + f"dev/stubs/generate.py: {rel} differs from a fresh generation " + "(regenerate: bash dev/stubs/run.sh generate)", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev/stubs/overrides.pyi b/dev/stubs/overrides.pyi new file mode 100644 index 0000000..598a183 --- /dev/null +++ b/dev/stubs/overrides.pyi @@ -0,0 +1,560 @@ +# Reviewed overrides of the generated stub python/netgraph_core/__init__.pyi (applied by +# dev/stubs/generate.py; regenerate with bash dev/stubs/run.sh generate). +# +# pybind11-stubgen can only write what the compiled signatures show. The bindings take most +# integer arguments as Python objects (to validate exact, unnarrowed values themselves), return +# NumPy arrays without a declared dtype and optional values as plain objects, so the raw stub +# says typing.Any, numpy.ndarray or a bare list there. Each override below restores the domain +# the binding code accepts and the type it returns, read from bindings/python/*.cpp and checked +# on the built module. +# +# Rules (enforced by the generator): an override changes annotations only (its parameter names, +# kinds and defaults are the generated ones); "# reason:" says why; "# generated:" quotes the +# exact generated line it replaces, and the generation fails when that line changes, so a +# changed binding is reviewed again. Overloads replace one generated declaration. The one +# addition is the marker of a class the module refuses to construct ("No constructor defined"): +# __init__(self, _no_public_constructor: typing.Never, /), so a type checker rejects Cls(). +# +# The domains, written out in full (a stub has no private aliases): +# - an integer: typing.SupportsIndex, a Python int or a NumPy integer scalar (any object with +# __index__; a bool is rejected at run time, which a type cannot express); +# - integers: collections.abc.Sequence[typing.SupportsIndex] | numpy.typing.NDArray[numpy.integer], +# one-dimensional (the rank and the values are checked at run time); +# - a node set: one integer or integers; +# - a number: float | numpy.floating | typing.SupportsIndex. +# Arrays that the module returns carry their exact dtype; their shape (one-dimensional) and +# whether they are read-only views are in the docstrings. +# +# The overrides name the classes of the generated stub, which this file does not declare. +# ruff: noqa: F821 + +import collections.abc +import typing + +import numpy +import numpy.typing + +class Eligibility: + # reason: no constructor: built by Graph.eligibility and Eligibility.intersect + # generated: (none) + def __init__(self, _no_public_constructor: typing.Never, /) -> None: ... + # reason: a read-only view of the bool flags, one per edge + # generated: def edge_allowed(self) -> numpy.ndarray: + def edge_allowed(self) -> numpy.typing.NDArray[numpy.bool_]: ... + # reason: a read-only view of the bool flags, one per node + # generated: def node_allowed(self) -> numpy.ndarray: + def node_allowed(self) -> numpy.typing.NDArray[numpy.bool_]: ... + +class Graph: + # reason: num_nodes is an integer, the four edge arrays are integers (exact values checked by the binding) + # generated: def __init__(self, num_nodes: typing.Any, src: typing.Any, dst: typing.Any, capacity_units: typing.Any, cost: typing.Any, resolution: typing.SupportsFloat | typing.SupportsIndex) -> None: + def __init__( + self, + num_nodes: typing.SupportsIndex, + src: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + dst: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + capacity_units: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + cost: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + resolution: typing.SupportsFloat | typing.SupportsIndex, + ) -> None: ... + # reason: a read-only int64 view + # generated: def capacity_units(self) -> numpy.ndarray: + def capacity_units(self) -> numpy.typing.NDArray[numpy.int64]: ... + # reason: a read-only int64 view + # generated: def cost(self) -> numpy.ndarray: + def cost(self) -> numpy.typing.NDArray[numpy.int64]: ... + # reason: a read-only int32 view + # generated: def edge_dst(self) -> numpy.ndarray: + def edge_dst(self) -> numpy.typing.NDArray[numpy.int32]: ... + # reason: a read-only int32 view + # generated: def edge_src(self) -> numpy.ndarray: + def edge_src(self) -> numpy.typing.NDArray[numpy.int32]: ... + # reason: the excluded node ids and EdgeIds are integers + # generated: def eligibility(self, excluded_nodes: typing.Any = (), excluded_edges: typing.Any = ()) -> Eligibility: + def eligibility( + self, + excluded_nodes: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] = (), + excluded_edges: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] = (), + ) -> Eligibility: ... + # reason: a read-only int32 view + # generated: def in_adj_edge_index(self) -> numpy.ndarray: + def in_adj_edge_index(self) -> numpy.typing.NDArray[numpy.int32]: ... + # reason: a read-only int32 view + # generated: def in_row_offsets(self) -> numpy.ndarray: + def in_row_offsets(self) -> numpy.typing.NDArray[numpy.int32]: ... + # reason: a read-only int32 view + # generated: def input_index(self) -> numpy.ndarray: + def input_index(self) -> numpy.typing.NDArray[numpy.int32]: ... + # reason: a read-only int32 view + # generated: def row_offsets(self) -> numpy.ndarray: + def row_offsets(self) -> numpy.typing.NDArray[numpy.int32]: ... + +class PredDAG: + # reason: no constructor: built by PredDAG.from_csr, Path.to_dag and the searches + # generated: (none) + def __init__(self, _no_public_constructor: typing.Never, /) -> None: ... + # reason: offsets, parents and via are integers + # generated: def from_csr(graph: Graph, offsets: typing.Any, parents: typing.Any, via: typing.Any) -> PredDAG: + @staticmethod + def from_csr( + graph: Graph, + offsets: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + parents: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + via: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + ) -> PredDAG: ... + # reason: a read-only int32 view + # generated: def parents(self) -> numpy.ndarray: + def parents(self) -> numpy.typing.NDArray[numpy.int32]: ... + # reason: a read-only int32 view + # generated: def via_edges(self) -> numpy.ndarray: + def via_edges(self) -> numpy.typing.NDArray[numpy.int32]: ... + +class Path: + # reason: hops is a sequence of hops, each one integers (EdgeIds); a two-dimensional integer + # array is such a sequence too (one row per hop) + # generated: def __init__(self, graph: Graph, hops: typing.Any) -> None: + def __init__( + self, + graph: Graph, + hops: collections.abc.Sequence[ + collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] + ] + | numpy.typing.NDArray[numpy.integer], + ) -> None: ... + +class FlowRecord: + # reason: no constructor: snapshots come from FlowLedger.records/record and FlowPolicy.records/record + # generated: (none) + def __init__(self, _no_public_constructor: typing.Never, /) -> None: ... + # reason: a list of (EdgeId, units) pairs; units are exact Python ints + # generated: def drops(self) -> list: + @property + def drops(self) -> list[tuple[int, int]]: ... + # reason: a list of (EdgeId, units) pairs + # generated: def edges(self) -> list: + @property + def edges(self) -> list[tuple[int, int]]: ... + # reason: an exact Python int for a Lossy flow, None otherwise + # generated: def offered(self) -> typing.Any: + @property + def offered(self) -> int | None: ... + # reason: an int, None when the route is empty + # generated: def route_cost(self) -> typing.Any: + @property + def route_cost(self) -> int | None: ... + +class FlowLedger: + # reason: the baseline residual is integers, or None for the capacities + # generated: def __init__(self, graph: Graph, residual: typing.Any = None) -> None: + def __init__( + self, + graph: Graph, + residual: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] + | None = None, + ) -> None: ... + # reason: sources is a node set; elig may be None (all allowed) + # generated: def bottleneck(self, sources: typing.Any, elig: Eligibility = None) -> list[int]: + def bottleneck( + self, + sources: typing.SupportsIndex + | collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + elig: Eligibility | None = None, + ) -> list[int]: ... + # reason: a read-only int64 view + # generated: def capacity_units(self) -> numpy.ndarray: + def capacity_units(self) -> numpy.typing.NDArray[numpy.int64]: ... + # reason: a FlowId is a tuple (ledger uid, id) of integers + # generated: def record(self, flow_id: typing.Any) -> FlowRecord: + def record( + self, flow_id: tuple[typing.SupportsIndex, typing.SupportsIndex] + ) -> FlowRecord: ... + # reason: the new baseline is integers, or None for the capacities + # generated: def reset(self, residual: typing.Any = None) -> None: + def reset( + self, + residual: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] + | None = None, + ) -> None: ... + +class Absolute: + # reason: cost is an integer + # generated: def __init__(self, cost: typing.Any) -> None: + def __init__(self, cost: typing.SupportsIndex) -> None: ... + +class Stretch: + # reason: factor is a number (a float, a NumPy floating scalar or an integer) + # generated: def __init__(self, factor: typing.Any) -> None: + def __init__( + self, factor: float | numpy.floating | typing.SupportsIndex + ) -> None: ... + +class DiminishingReturns: + # reason: window is an integer, fraction a number + # generated: def __init__(self, window: typing.Any, fraction: typing.Any) -> None: + def __init__( + self, + window: typing.SupportsIndex, + fraction: float | numpy.floating | typing.SupportsIndex, + ) -> None: ... + +class CapacityAware: + # reason: the counts and the bandwidth are integers (max_flow_count may be None); the ceiling is Absolute, Stretch or None; diminishing is DiminishingReturns or None + # generated: def __init__(self, *, flow_count: typing.Any = 1, max_flow_count: typing.Any = None, max_path_cost: typing.Any = None, min_flow_bandwidth: typing.Any = 0, reoptimize: bool = False, diminishing: typing.Any = None) -> None: + def __init__( + self, + *, + flow_count: typing.SupportsIndex = 1, + max_flow_count: typing.SupportsIndex | None = None, + max_path_cost: Absolute | Stretch | None = None, + min_flow_bandwidth: typing.SupportsIndex = 0, + reoptimize: bool = False, + diminishing: DiminishingReturns | None = None, + ) -> None: ... + # reason: the ceiling given at construction: Absolute, Stretch or None + # generated: def max_path_cost(self) -> typing.Any: + @property + def max_path_cost(self) -> Absolute | Stretch | None: ... + +class Pinned: + # reason: bundles is a sequence of PredDAG (a Path converts with to_dag()) + # generated: def __init__(self, bundles: typing.Any) -> None: + def __init__(self, bundles: collections.abc.Sequence[PredDAG]) -> None: ... + # reason: a new list of the PredDAG bundles + # generated: def bundles(self) -> list: + @property + def bundles(self) -> list[PredDAG]: ... + +class FlowPolicyConfig: + # reason: routing is one of the four routing modes (TypeError otherwise) + # generated: def __init__(self, *, routing: typing.Any, placement: FlowPlacement, admission: Admission, selection: PathSelection) -> None: + def __init__( + self, + *, + routing: CostOnly | CapacityAware | Global | Pinned, + placement: FlowPlacement, + admission: Admission, + selection: PathSelection, + ) -> None: ... + # reason: the routing mode given at construction + # generated: def routing(self) -> typing.Any: + @property + def routing(self) -> CostOnly | CapacityAware | Global | Pinned: ... + +class PlacementResult: + # reason: no constructor: returned by FlowPolicy.place and the evaluators + # generated: (none) + def __init__(self, _no_public_constructor: typing.Never, /) -> None: ... + # reason: a list of (EdgeId, units) pairs + # generated: def drops(self) -> list: + @property + def drops(self) -> list[tuple[int, int]]: ... + # reason: an int64 array when requested, else None + # generated: def edge_flows(self) -> typing.Any: + @property + def edge_flows(self) -> numpy.typing.NDArray[numpy.int64] | None: ... + # reason: an int, or float('inf') after an infinite request + # generated: def remaining(self) -> typing.Any: + @property + def remaining(self) -> int | float: ... + # reason: an int64 array when requested, else None + # generated: def residual(self) -> typing.Any: + @property + def residual(self) -> numpy.typing.NDArray[numpy.int64] | None: ... + +class DagCacheStats: + # reason: no constructor: returned by DagCache.stats + # generated: (none) + def __init__(self, _no_public_constructor: typing.Never, /) -> None: ... + +class FlowPolicy: + # reason: sources and destinations are node sets; elig and cache may be None + # generated: def __init__(self, ledger: FlowLedger, sources: typing.Any, destinations: typing.Any, config: FlowPolicyConfig, elig: Eligibility = None, cache: DagCache = None) -> None: + def __init__( + self, + ledger: FlowLedger, + sources: typing.SupportsIndex + | collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + destinations: typing.SupportsIndex + | collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + config: FlowPolicyConfig, + elig: Eligibility | None = None, + cache: DagCache | None = None, + ) -> None: ... + # reason: volume is integer units or float('inf') (a NumPy floating +inf too); outputs may be None; the result is a PlacementResult + # generated: def place(self, volume: typing.Any, outputs: typing.Any = None) -> typing.Any: + def place( + self, + volume: typing.SupportsIndex | float | numpy.floating, + outputs: OutputOptions | None = None, + ) -> PlacementResult: ... + # reason: a FlowId is a tuple (ledger uid, id) of integers + # generated: def record(self, flow_id: typing.Any) -> FlowRecord: + def record( + self, flow_id: tuple[typing.SupportsIndex, typing.SupportsIndex] + ) -> FlowRecord: ... + +class CapacityOptions: + # reason: elig, cache and outputs may be None + # generated: def __init__(self, config: FlowPolicyConfig, *, elig: Eligibility = None, cache: DagCache = None, outputs: typing.Any = None) -> None: + def __init__( + self, + config: FlowPolicyConfig, + *, + elig: Eligibility | None = None, + cache: DagCache | None = None, + outputs: OutputOptions | None = None, + ) -> None: ... + # reason: None when no cache was given + # generated: def cache(self) -> DagCache: + @property + def cache(self) -> DagCache | None: ... + # reason: None when no Eligibility was given + # generated: def elig(self) -> Eligibility: + @property + def elig(self) -> Eligibility | None: ... + +class SensitivityOptions: + # reason: elig, cache and outputs may be None + # generated: def __init__(self, config: FlowPolicyConfig, *, elig: Eligibility = None, cache: DagCache = None, outputs: typing.Any = None) -> None: + def __init__( + self, + config: FlowPolicyConfig, + *, + elig: Eligibility | None = None, + cache: DagCache | None = None, + outputs: OutputOptions | None = None, + ) -> None: ... + # reason: None when no cache was given + # generated: def cache(self) -> DagCache: + @property + def cache(self) -> DagCache | None: ... + # reason: None when no Eligibility was given + # generated: def elig(self) -> Eligibility: + @property + def elig(self) -> Eligibility | None: ... + +class SensitivityEntry: + # reason: no constructor: the entries of a SensitivityResult + # generated: (none) + def __init__(self, _no_public_constructor: typing.Never, /) -> None: ... + +class SensitivityResult: + # reason: no constructor: returned by sensitivity + # generated: (none) + def __init__(self, _no_public_constructor: typing.Never, /) -> None: ... + +class KspOptions: + # reason: k is an integer; the ceiling is Absolute, Stretch or None; residual is integers or None; elig may be None (each validated by ksp) + # generated: def __init__(self, k: typing.Any, selection: PathSelection, *, max_path_cost: typing.Any = None, residual: typing.Any = None, elig: Eligibility = None) -> None: + def __init__( + self, + k: typing.SupportsIndex, + selection: PathSelection, + *, + max_path_cost: Absolute | Stretch | None = None, + residual: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] + | None = None, + elig: Eligibility | None = None, + ) -> None: ... + # reason: None when no Eligibility was given + # generated: def elig(self) -> Eligibility: + @property + def elig(self) -> Eligibility | None: ... + # reason: the k given at construction (an integer) + # generated: def k(self) -> typing.Any: + @property + def k(self) -> typing.SupportsIndex: ... + # reason: the ceiling given at construction + # generated: def max_path_cost(self) -> typing.Any: + @property + def max_path_cost(self) -> Absolute | Stretch | None: ... + # reason: the residual given at construction (the same object) + # generated: def residual(self) -> typing.Any: + @property + def residual( + self, + ) -> ( + collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] + | None + ): ... + +class SpfOptions: + # reason: targets is a node set or None; residual is integers or None; min_residual is an integer; elig may be None (each validated by spf) + # generated: def __init__(self, direction: Direction, selection: PathSelection, *, targets: typing.Any = None, residual: typing.Any = None, min_residual: typing.Any = 1, elig: Eligibility = None) -> None: + def __init__( + self, + direction: Direction, + selection: PathSelection, + *, + targets: typing.SupportsIndex + | collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] + | None = None, + residual: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] + | None = None, + min_residual: typing.SupportsIndex = 1, + elig: Eligibility | None = None, + ) -> None: ... + # reason: None when no Eligibility was given + # generated: def elig(self) -> Eligibility: + @property + def elig(self) -> Eligibility | None: ... + # reason: the min_residual given at construction (an integer) + # generated: def min_residual(self) -> typing.Any: + @property + def min_residual(self) -> typing.SupportsIndex: ... + # reason: the residual given at construction (the same object) + # generated: def residual(self) -> typing.Any: + @property + def residual( + self, + ) -> ( + collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] + | None + ): ... + # reason: the targets given at construction (the same object) + # generated: def targets(self) -> typing.Any: + @property + def targets( + self, + ) -> ( + typing.SupportsIndex + | collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] + | None + ): ... + +class SpfResult: + # reason: no constructor: returned by spf + # generated: (none) + def __init__(self, _no_public_constructor: typing.Never, /) -> None: ... + # reason: a read-only int64 array for a full search, None with targets + # generated: def costs(self) -> typing.Any: + @property + def costs(self) -> numpy.typing.NDArray[numpy.int64] | None: ... + # reason: always a PredDAG + # generated: def dag(self) -> typing.Any: + @property + def dag(self) -> PredDAG: ... + # reason: the nearest targets' cost, None when no target is reached and for a full search + # generated: def target_cost(self) -> typing.Any: + @property + def target_cost(self) -> int | None: ... + +# reason: one integer gives a float, integers give a float64 array (the array overload first: a NumPy array also has __index__) +# generated: def as_float(x: typing.Any, resolution: typing.SupportsFloat | typing.SupportsIndex) -> typing.Any: +@typing.overload +def as_float( + x: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + resolution: typing.SupportsFloat | typing.SupportsIndex, +) -> numpy.typing.NDArray[numpy.float64]: ... +@typing.overload +def as_float( + x: typing.SupportsIndex, resolution: typing.SupportsFloat | typing.SupportsIndex +) -> float: ... + +# reason: pairs of node sets, each pair a length-2 sequence (sources, destinations), an integer array included (so pairs may be an integer array of shape (k, 2), or (k, 2, m) for sets of m nodes); opts is CapacityOptions; per_pair_elig has one Eligibility or None per pair; workers is an integer or None; one PlacementResult per pair +# generated: def batch_capacity(graph: Graph, pairs: typing.Any, opts: typing.Any, per_pair_elig: typing.Any = None, workers: typing.Any = None) -> typing.Any: +def batch_capacity( + graph: Graph, + pairs: collections.abc.Sequence[ + collections.abc.Sequence[ + typing.SupportsIndex + | collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] + ] + | numpy.typing.NDArray[numpy.integer] + ] + | numpy.typing.NDArray[numpy.integer], + opts: CapacityOptions, + per_pair_elig: collections.abc.Sequence[Eligibility | None] | None = None, + workers: typing.SupportsIndex | None = None, +) -> list[PlacementResult]: ... + +# reason: sources and destinations are node sets; opts is CapacityOptions (TypeError otherwise) +# generated: def capacity(graph: Graph, sources: typing.Any, destinations: typing.Any, opts: typing.Any) -> typing.Any: +def capacity( + graph: Graph, + sources: typing.SupportsIndex + | collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + destinations: typing.SupportsIndex + | collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + opts: CapacityOptions, +) -> PlacementResult: ... + +# reason: src and dst are integers; opts is KspOptions; a list of (cost, Path) +# generated: def ksp(graph: Graph, src: typing.Any, dst: typing.Any, opts: typing.Any) -> list: +def ksp( + graph: Graph, src: typing.SupportsIndex, dst: typing.SupportsIndex, opts: KspOptions +) -> list[tuple[int, Path]]: ... + +# reason: src, dst and max_paths are integers (max_paths may be None); a list of Path +# generated: def resolve_to_paths(graph: Graph, dag: PredDAG, src: typing.Any, dst: typing.Any, split_parallel_edges: bool = False, max_paths: typing.Any = None) -> list: +def resolve_to_paths( + graph: Graph, + dag: PredDAG, + src: typing.SupportsIndex, + dst: typing.SupportsIndex, + split_parallel_edges: bool = False, + max_paths: typing.SupportsIndex | None = None, +) -> list[Path]: ... + +# reason: sources and destinations are node sets; opts is SensitivityOptions; workers is an integer or None +# generated: def sensitivity(graph: Graph, sources: typing.Any, destinations: typing.Any, opts: typing.Any, workers: typing.Any = None) -> typing.Any: +def sensitivity( + graph: Graph, + sources: typing.SupportsIndex + | collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + destinations: typing.SupportsIndex + | collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + opts: SensitivityOptions, + workers: typing.SupportsIndex | None = None, +) -> SensitivityResult: ... + +# reason: roots is a node set; opts is SpfOptions (TypeError otherwise) +# generated: def spf(graph: Graph, roots: typing.Any, opts: typing.Any) -> typing.Any: +def spf( + graph: Graph, + roots: typing.SupportsIndex + | collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + opts: SpfOptions, +) -> SpfResult: ... + +# reason: a scalar number gives an int, a sequence or a 1-D array of numbers an int64 array (a 0-d array gives an int, which the array overload cannot say) +# generated: def to_units(x: typing.Any, resolution: typing.SupportsFloat | typing.SupportsIndex) -> typing.Any: +@typing.overload +def to_units( + x: float | numpy.floating | numpy.integer, + resolution: typing.SupportsFloat | typing.SupportsIndex, +) -> int: ... +@typing.overload +def to_units( + x: collections.abc.Sequence[float | numpy.floating | numpy.integer] + | numpy.typing.NDArray[numpy.floating | numpy.integer], + resolution: typing.SupportsFloat | typing.SupportsIndex, +) -> numpy.typing.NDArray[numpy.int64]: ... diff --git a/dev/stubs/run.sh b/dev/stubs/run.sh new file mode 100755 index 0000000..3eec41c --- /dev/null +++ b/dev/stubs/run.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# The generated type stub python/netgraph_core/__init__.pyi: regenerate it, or check it. +# +# bash dev/stubs/run.sh generate +# Build a wheel of this checkout (the shipped configuration, without the private hooks), +# install it into a fresh venv and rewrite the stub from it (dev/stubs/generate.py: +# pybind11-stubgen, the reviewed overrides of dev/stubs/overrides.pyi, ruff format). +# Run after any change to a binding's signature or docstring, then commit the stub. +# bash dev/stubs/run.sh check (make stubs-check, CI) +# 0. the tools' rules (no build, a few seconds): dev/stubs/test_generate.py provokes each +# refusal of generate.py once on a small synthetic stub and expects its message, and +# runs its --write and --check on it; dev/stubs/test_checks.py provokes each refusal of +# the contents check (step 1) on synthetic file lists, and each abnormal end of Pyright +# and each comparison rule of the client check (step 3) on a fake Pyright; +# 1. contents: the sdist holds exactly the intended source files of this checkout (the +# rule of pyproject.toml's sdist.include, applied to the files git tracks or would +# track) and the wheel exactly the package, its extension and its metadata; both carry +# the stub, py.typed and both licence notices, and no _docs.py +# (dev/stubs/check_contents.py); +# 2. drift: the committed stub equals a fresh generation from the wheel; +# 3. clients, against the installed wheel and against an installation built from the +# sdist: tests/typing/valid_client.py type-checks without a diagnostic and runs, and +# every marked line of tests/typing/invalid_client.py gets exactly its expected Pyright +# diagnostic (dev/stubs/check_clients.py). +# +# Everything happens in STUBS_DIR (default build/stubs, emptied first): its own venvs with the +# pinned tools of pyproject.toml's dev extra (pybind11-stubgen, pyright, ruff, build) and the +# pins of .github/ci-constraints.txt, its own build directory. The development venv and the +# shared build directory are not touched. PYTHON names the interpreter (default python3, 3.11 +# or newer); CMAKE_BUILD_PARALLEL_LEVEL bounds the builds. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/../.." +ROOT=$PWD +PYTHON=${PYTHON:-python3} +mode=${1:-} +case "$mode" in + generate|check) ;; + *) echo 'Usage: bash dev/stubs/run.sh generate|check' >&2; exit 2 ;; +esac +die() { echo "dev/stubs/run.sh: $*" >&2; exit 1; } + +"$PYTHON" -c 'import sys; sys.exit(sys.version_info < (3, 11))' \ + || die "$PYTHON is older than Python 3.11" +if [[ "$mode" == check ]]; then + echo "== the tools' rules" + "$PYTHON" -I -B dev/stubs/test_generate.py + "$PYTHON" -I -B dev/stubs/test_checks.py +fi +if [[ "$(uname -s)" == Darwin ]]; then + CC=$(xcrun --find clang) + CXX=$(xcrun --find clang++) + export CC CXX + export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-15.0}" +fi +# The pins reach every pip call and every build environment: PIP_CONSTRAINT the direct installs +# and the environments of `python -m build` (which fills them with a direct install), +# PIP_BUILD_CONSTRAINT the environment pip makes itself to build the sdist (pip 26.2 and newer +# apply only build constraints there; a pip older than 25.3 ignores the variable and passes +# PIP_CONSTRAINT on). +export PIP_CONSTRAINT="$ROOT/.github/ci-constraints.txt" +export PIP_BUILD_CONSTRAINT="$ROOT/.github/ci-constraints.txt" +export PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORK=${STUBS_DIR:-build/stubs} +rm -rf "$WORK" +mkdir -p "$WORK" +WORK=$(cd "$WORK" && pwd) + +# The tools as pinned in pyproject.toml's dev extra. +TOOLS=$("$PYTHON" - <<'PY' +import re +import tomllib + +with open("pyproject.toml", "rb") as f: + dev = tomllib.load(f)["project"]["optional-dependencies"]["dev"] +want = {"pybind11-stubgen", "pyright", "ruff", "build"} +found = {re.split(r"[\s=<>!~;\[]", r, maxsplit=1)[0].lower(): r for r in dev} +missing = want - set(found) +if missing: + raise SystemExit(f"pyproject.toml's dev extra lacks {sorted(missing)}") +# The shell splits the printed list on spaces. +spaced = [found[name] for name in sorted(want) if any(c.isspace() for c in found[name])] +if spaced: + raise SystemExit(f"pyproject.toml's dev extra writes {spaced} with a space; remove it") +print(" ".join(found[name] for name in sorted(want))) +PY +) + +# new_env NAME: a fresh venv with NumPy and the tools; prints its interpreter. It runs inside +# $(...), where bash turns set -e off, so every step returns its own failure: the caller's +# assignment then fails and stops the run. +new_env() { + "$PYTHON" -m venv "$WORK/$1" || return + "$WORK/$1/bin/python" -m pip install -q -U pip >&2 || return # the pinned pip (PIP_CONSTRAINT) + # shellcheck disable=SC2086 # TOOLS: requirement strings without spaces (checked above) + "$WORK/$1/bin/python" -m pip install -q numpy $TOOLS >&2 || return + echo "$WORK/$1/bin/python" +} + +echo "== tools: $TOOLS" +WPY=$(new_env wheel-env) +echo "== the wheel of this checkout (hooks off; build directory $WORK/wheel-build)" +"$WPY" -m build --wheel --outdir "$WORK/dist" --config-setting=build-dir="$WORK/wheel-build" . \ + > "$WORK/build-wheel.log" 2>&1 || { tail -40 "$WORK/build-wheel.log" >&2; die "the wheel build failed"; } +wheels=("$WORK"/dist/*.whl) +[[ ${#wheels[@]} -eq 1 && -f ${wheels[0]} ]] || die "expected one wheel in $WORK/dist" +"$WPY" -m pip install -q --no-index --no-deps "${wheels[0]}" + +if [[ "$mode" == generate ]]; then + "$WPY" -I dev/stubs/generate.py --write --raw "$WORK/raw" + echo "Regenerated; check it with: bash dev/stubs/run.sh check" + exit 0 +fi + +echo "== the sdist" +"$WPY" -m build --sdist --outdir "$WORK/dist" . > "$WORK/build-sdist.log" 2>&1 \ + || { tail -40 "$WORK/build-sdist.log" >&2; die "the sdist build failed"; } +sdists=("$WORK"/dist/*.tar.gz) +[[ ${#sdists[@]} -eq 1 && -f ${sdists[0]} ]] || die "expected one sdist in $WORK/dist" + +echo "== contents" +"$WPY" -I dev/stubs/check_contents.py "${wheels[0]}" "${sdists[0]}" "$WORK" + +echo "== drift" +"$WPY" -I dev/stubs/generate.py --check --raw "$WORK/raw" + +echo "== clients against the installed wheel" +"$WPY" -I dev/stubs/check_clients.py wheel "$WORK/clients-wheel" + +echo "== clients against an installation built from the sdist" +SPY=$(new_env sdist-env) +"$SPY" -m pip install -q --no-deps "${sdists[0]}" > "$WORK/install-sdist.log" 2>&1 \ + || { tail -40 "$WORK/install-sdist.log" >&2; die "installing the sdist failed"; } +"$SPY" -I dev/stubs/check_clients.py sdist "$WORK/clients-sdist" +echo "Stub, package contents and typed clients: OK" diff --git a/dev/stubs/test_checks.py b/dev/stubs/test_checks.py new file mode 100644 index 0000000..36c24b4 --- /dev/null +++ b/dev/stubs/test_checks.py @@ -0,0 +1,431 @@ +"""Negative controls of the two package checks of ``bash dev/stubs/run.sh check``. + +* check_contents.py: its sdist rule equals pyproject.toml's file list; the files of a checkout + it takes as intended are the ones git tracks or would track; and on synthetic member lists, + every kind of file it must refuse (a stray or ignored file, a left-out developer file, a + missing file, a second extension module, ...) is refused, while the intended lists pass. +* check_clients.py: Pyright is replaced by a small program that prints a real run's output for + the clients and then ends abnormally in one of its runs (killed by a signal, a fatal exit + status, output that is not its report, a report that disagrees with the status); each must + stop the check, and the same program ending normally must pass it. The same program also + breaks each comparison rule in turn (search paths without the installation or with the source + tree, a diagnostic on the valid client, a required case without its mark, a diagnostic count + differing from the marks, a control line with no diagnostic, a valid client that does not + run): each must fail the check with that rule's message and no other. + +Nothing is built or installed. Run by ``bash dev/stubs/run.sh check``, or directly: + + python -I -B dev/stubs/test_checks.py +""" + +from __future__ import annotations + +import contextlib +import io +import os +import re +import subprocess +import sys +import tempfile +import tomllib +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import check_clients +import check_contents + +ROOT = Path(__file__).resolve().parents[2] + +# A checkout's files as git lists them: the intended sdist files and every kind it leaves out. +SHIPPED = [ + ".coveragerc", + ".gitignore", + "CHANGELOG.md", + "CMakeLists.txt", + "CONTRIBUTING.md", + "LICENSE", + "README.md", + "pyproject.toml", + "bindings/python/module.cpp", + "docs/semantics.md", + "include/netgraph/core/graph.hpp", + "python/netgraph_core/__init__.py", + "python/netgraph_core/__init__.pyi", + "python/netgraph_core/py.typed", + "src/graph.cpp", + "tests/py/test_graph.py", + "tests/data/policy_fixtures.json", + "third_party/pybind11/LICENSE", +] +LEFT_OUT = [ + "AGENTS.md", + "CMakeLists.txt.user", + "CLAUDE.md", + "Makefile", + ".pre-commit-config.yaml", + ".github/workflows/tests.yml", + ".superset/config.json", + "dev/run-checks.sh", + "tests/reference/policy/README.md", +] +INFO = "netgraph_core-0.12.0.dist-info/" +WHEEL = [ + "_netgraph_core.cpython-314-darwin.so", + "netgraph_core/__init__.py", + "netgraph_core/__init__.pyi", + "netgraph_core/py.typed", +] + [INFO + n for n in sorted(check_contents.DIST_INFO_FILES)] +SDIST = SHIPPED + ["PKG-INFO"] + + +class Contents(unittest.TestCase): + def problems(self, wheel=WHEEL, sdist=SDIST, intended=SHIPPED + LEFT_OUT): + return check_contents.problems(wheel, sdist, intended) + + def refused(self, want: str, **lists) -> None: + got = self.problems(**lists) + self.assertIn(want, got) + + def test_the_rule_is_pyproject_toml_s(self): + with open(ROOT / "pyproject.toml", "rb") as f: + sdist = tomllib.load(f)["tool"]["scikit-build"]["sdist"] + self.assertEqual(sdist["inclusion-mode"], "explicit") + self.assertEqual( + sorted(p.lstrip("/") for p in sdist["include"]), + sorted(check_contents.SDIST_TOP), + ) + left_out = [p.lstrip("/") for p in sdist["exclude"] if p.startswith("/")] + self.assertEqual(sorted(left_out), sorted(check_contents.SDIST_LEFT_OUT)) + + def test_the_intended_lists_pass(self): + self.assertEqual(self.problems(), []) + self.assertEqual( + [n for n in SHIPPED + LEFT_OUT if check_contents.shipped(n)], SHIPPED + ) + + def test_the_sdist_refuses_every_other_file(self): + for stray in ( + ".playwright-mcp/console.log", + "notes.txt", + "tests/py/__pycache__/test_graph.cpython-314.pyc", + "python/netgraph_core/_netgraph_core.cpython-314-darwin.so", + *LEFT_OUT, + ): + self.refused( + f"the sdist carries {stray}, not an intended file", + sdist=SDIST + [stray], + ) + # A stray file the checkout holds: only the files the rule ships are intended. + self.refused( + "the sdist carries notes.txt, not an intended file", + sdist=SDIST + ["notes.txt"], + intended=SHIPPED + LEFT_OUT + ["notes.txt"], + ) + + def test_the_sdist_refuses_a_missing_file(self): + for name in SDIST: + rest = [n for n in SDIST if n != name] + self.refused(f"the sdist lacks {name}", sdist=rest) + # Required even when the checkout lost it. + self.refused( + "the sdist lacks python/netgraph_core/py.typed", + sdist=[n for n in SDIST if not n.endswith("py.typed")], + intended=[n for n in SHIPPED if not n.endswith("py.typed")], + ) + + def test_the_wheel_refuses_every_other_file(self): + for stray in ( + "netgraph_core/notes.txt", + "netgraph_core/__pycache__/__init__.cpython-314.pyc", + INFO + "entry_points.txt", + "tests/py/test_graph.py", + ): + self.refused( + f"the wheel carries {stray}, not an intended file", + wheel=WHEEL + [stray], + ) + for name in WHEEL: + if name.startswith("netgraph_core/") or name.startswith(INFO): + rest = [n for n in WHEEL if n != name] + self.refused(f"the wheel lacks {name}", wheel=rest) + two = WHEEL + ["_netgraph_core.cpython-313-darwin.so"] + self.assertTrue( + any("2 extension modules" in p for p in self.problems(wheel=two)) + ) + none = [n for n in WHEEL if not n.startswith("_netgraph_core.")] + self.assertTrue( + any("0 extension modules" in p for p in self.problems(wheel=none)) + ) + other = WHEEL + ["netgraph_core-0.11.0.dist-info/METADATA"] + self.assertTrue(any("2 .dist-info" in p for p in self.problems(wheel=other))) + + def test_no_docs_module(self): + self.refused( + "the wheel carries netgraph_core/_docs.py", + wheel=WHEEL + ["netgraph_core/_docs.py"], + ) + docs = "python/netgraph_core/_docs.py" + self.refused( + f"the sdist carries {docs}", + sdist=SDIST + [docs], + wheel=WHEEL + ["netgraph_core/_docs.py"], + intended=SHIPPED + [docs], + ) + + def test_checkout_files(self): + """Tracked files and new files git does not ignore; not an ignored file, nor a tracked + file deleted from the work tree.""" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + + def git(*args: str) -> None: + subprocess.run( + ["git", "-C", tmp, *args], check=True, capture_output=True + ) + + git("init", "-q") + files = { + ".gitignore": "__pycache__/\n", + "src/a.cpp": "", + "src/gone.cpp": "", + "a dir/with space.txt": "", + } + for name, text in files.items(): + (root / name).parent.mkdir(parents=True, exist_ok=True) + (root / name).write_text(text) + git("add", "-A") + (root / "src/gone.cpp").unlink() + (root / "src/new.cpp").write_text("") + (root / "src/__pycache__").mkdir() + (root / "src/__pycache__/m.pyc").write_text("") + self.assertEqual( + check_contents.checkout_files(root), + [".gitignore", "a dir/with space.txt", "src/a.cpp", "src/new.cpp"], + ) + + +# Stands in for Pyright: in the project directory it is given, it reports one diagnostic per +# rule of each "# expect:" marker and one for the control's planted line, as a real run does, +# and prints the search paths of FAKE_PATHS (os.pathsep-separated). FAKE_BREAK names the run +# that ends abnormally or reports wrongly (verbose, report or control) and FAKE_HOW how: "drop" +# loses the run's first diagnostic, "extra-valid" and "extra-invalid" add one on line 1 of that +# client. +FAKE_PYRIGHT = r""" +import json, os, re, signal, sys +from pathlib import Path + +args = sys.argv[1:] +work = Path(args[args.index("--project") + 1]) +run = "verbose" if "--verbose" in args else ("control" if work.name == "control" else "report") +how = os.environ["FAKE_HOW"] if run == os.environ["FAKE_BREAK"] else "normal" +def diag(f, n, rule): + span = {"line": n, "character": 0} + return {"file": str(f), "severity": "error", "rule": rule, + "message": "a diagnostic", "range": {"start": span, "end": span}} +diags = [] +for f in sorted(work.glob("*client.py")): + for n, line in enumerate(f.read_text().splitlines()): + m = re.search(r"#\s*expect:\s*(\w+(?:\s*,\s*\w+)*)\s*$", line) + rules = re.split(r"\s*,\s*", m.group(1)) if m else [] + if line == "oops: str = g.num_edges()": + rules = ["reportAssignmentType"] + diags += [diag(f, n, rule) for rule in rules] +if how == "drop": + del diags[0] +if how.startswith("extra-"): + diags.append(diag(work / (how[len("extra-"):] + "_client.py"), 0, "reportExtra")) +errors = len(diags) +status = 1 if errors else 0 +if run == "verbose": + paths = os.environ["FAKE_PATHS"].split(os.pathsep) + print("Search paths for file:///\n" + "\n".join(" " + p for p in paths)) +else: + report = {"generalDiagnostics": diags, "summary": {"errorCount": errors}} + if how == "no-diagnostics": + del report["generalDiagnostics"] + if how == "no-count": + del report["summary"]["errorCount"] + if how == "text-count": + report["summary"]["errorCount"] = str(errors) + if how == "disagree": + status = 1 - status + print("[1, 2]" if how == "list" else + "Pyright crashed" if how == "not-json" else json.dumps(report)) +sys.stdout.flush() +if how == "signal": + os.kill(os.getpid(), signal.SIGTERM) +sys.exit({"exit-2": 2, "exit-3": 3, "exit-4": 4}.get(how, status)) +""" + + +class Pyright(unittest.TestCase): + """``check_clients.main`` with Pyright replaced by FAKE_PYRIGHT (a real child process) and + the installation by a directory of its own; the valid client's run ends as `client_run` + says (status, stderr), and the clients come from `clients` (tests/typing by default).""" + + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.tmp = Path(os.path.realpath(tmp.name)) + self.site = self.tmp / "site" + (self.site / "netgraph_core").mkdir(parents=True) + self.fake = self.tmp / "fake_pyright.py" + self.fake.write_text(FAKE_PYRIGHT) + + def main( + self, + brk: str = "none", + how: str = "normal", + paths: list[Path] | None = None, + clients: Path = check_clients.CLIENTS, + client_run: tuple[int, str] = (0, ""), + ) -> tuple[int | str | None, str]: + real_run = subprocess.run + + def run(cmd, **kwargs): + if cmd[1:3] == ["-m", "pyright"]: + return real_run( + [sys.executable, "-I", str(self.fake), *cmd[3:]], **kwargs + ) + return subprocess.CompletedProcess(cmd, client_run[0], "", client_run[1]) + + searched = os.pathsep.join(str(p) for p in (paths or [self.site])) + env = {"FAKE_BREAK": brk, "FAKE_HOW": how, "FAKE_PATHS": searched} + out, err = io.StringIO(), io.StringIO() + with ( + mock.patch.object( + check_clients, "installation", lambda p: self.site / "netgraph_core" + ), + mock.patch.object(check_clients, "CLIENTS", clients), + mock.patch.object(check_clients.subprocess, "run", run), + mock.patch.dict(os.environ, env), + mock.patch.object( + sys, "argv", ["check_clients.py", "t", str(self.tmp / "w")] + ), + contextlib.redirect_stdout(out), + contextlib.redirect_stderr(err), + ): + try: + status = check_clients.main() + except SystemExit as exc: + status = exc.code + return status, out.getvalue() + err.getvalue() + + def test_a_normal_end_passes(self): + status, out = self.main("none", "normal") + self.assertEqual(status, 0, out) + self.assertIn("invalid client: ", out) + + def test_every_abnormal_end_stops_the_check(self): + cases = { + "signal": "pyright was killed by signal 15", + "exit-2": "pyright failed (exit 2)", + "exit-3": "pyright failed (exit 3)", + "exit-4": "pyright failed (exit 4)", + "not-json": "pyright gave no JSON (exit ", + "list": "pyright's report is not one of a complete run", + "no-diagnostics": "pyright's report is not one of a complete run", + "no-count": "pyright's report is not one of a complete run", + "text-count": "pyright's report is not one of a complete run", + "disagree": "pyright's report is not one of a complete run", + } + for brk in ("verbose", "report", "control"): + for how, message in cases.items(): + if brk == "verbose" and how not in ( + "signal", + "exit-2", + "exit-3", + "exit-4", + ): + continue # the verbose run prints a log, not a report + with self.subTest(run=brk, how=how): + status, out = self.main(brk, how) + self.assertIsInstance(status, str, out) + self.assertIn(message, status) + + def refused(self, problem: str, **kwargs) -> None: + """The check fails with exactly this one problem.""" + status, out = self.main(**kwargs) + self.assertEqual(status, 1, out) + self.assertEqual(out, f"dev/stubs/check_clients.py [t]: {problem}\n") + + def test_the_search_paths_must_hold_the_installation(self): + elsewhere = self.tmp / "elsewhere" + elsewhere.mkdir() + self.refused( + f"Pyright's search paths do not hold {self.site} (pyright-verbose.log)", + paths=[elsewhere], + ) + + def test_the_search_paths_must_not_hold_the_source_tree(self): + self.refused( + "Pyright searches the source tree's python/ directory", + paths=[self.site, check_clients.ROOT / "python"], + ) + + def test_a_diagnostic_on_the_valid_client_fails(self): + self.refused( + "valid_client.py:1: 1 unexpected diagnostic(s) reportExtra", + brk="report", + how="extra-valid", + ) + + def test_a_required_case_without_its_mark_fails(self): + # a copy of the clients in which the first required case's line lost its mark (and + # so, from the fake as from Pyright, its diagnostic) + case = check_clients.REQUIRED[0] + clients = self.tmp / "clients" + clients.mkdir() + for name in ("valid_client.py", "invalid_client.py"): + lines = (check_clients.CLIENTS / name).read_text().splitlines() + if name == "invalid_client.py": + lines = [ + check_clients.EXPECT.sub("", t) if case in t else t for t in lines + ] + (clients / name).write_text("\n".join(lines) + "\n") + self.refused( + f"invalid_client.py lost the marked case {case!r}", clients=clients + ) + + def test_a_count_differing_from_the_marks_fails(self): + # the fake's first diagnostic is the first rule of the invalid client's first mark + lines = (check_clients.CLIENTS / "invalid_client.py").read_text().splitlines() + line, mark = next( + (i, m) + for i, t in enumerate(lines, 1) + if (m := check_clients.EXPECT.search(t)) + ) + rules = re.split(r"\s*,\s*", mark.group(1)) + n = rules.count(rules[0]) + with self.subTest("a marked diagnostic missing"): + self.refused( + f"invalid_client.py:{line}: {rules[0]} expected {n}, got {n - 1}", + brk="report", + how="drop", + ) + with self.subTest("an unmarked diagnostic"): + self.refused( + "invalid_client.py:1: reportExtra expected 0, got 1", + brk="report", + how="extra-invalid", + ) + + def test_the_control_line_must_give_its_one_diagnostic(self): + planted = (check_clients.CLIENTS / "valid_client.py").read_text() + last = len(planted.splitlines()) + 1 + self.refused( + f"control: the planted line {last} gave {{}}", brk="control", how="drop" + ) + + def test_the_valid_client_must_run(self): + self.refused( + "valid_client.py does not run:\nTraceback: boom\n", + client_run=(1, "Traceback: boom\n"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/dev/stubs/test_generate.py b/dev/stubs/test_generate.py new file mode 100644 index 0000000..37eed35 --- /dev/null +++ b/dev/stubs/test_generate.py @@ -0,0 +1,426 @@ +"""Negative controls of generate.py's rules (its module docstring, steps 2 to 6) and of its +check of the installation. + +A small synthetic module stands in for the compiled one: a raw stub as pybind11-stubgen writes +it, the module objects it describes and reviewed overrides. The stub built from them is checked +once, then each refusal of steps 2 to 5 is provoked once and ``build_stub`` must refuse with its +message. ``--write`` and ``--check`` run on that module (step 6: a changed, shortened, longer, +empty or missing stub fails the check with the difference), and the installation check refuses a +package or an extension outside site-packages and an extension with the private hooks. Nothing +is built or installed. Run by ``bash dev/stubs/run.sh check``, or directly: + + python -I -B dev/stubs/test_generate.py +""" + +from __future__ import annotations + +import contextlib +import io +import os +import sys +import sysconfig +import tempfile +import types +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import generate + +RAW = '''"""raw""" +from __future__ import annotations +import typing +__all__: list[str] = ["Graph", "Result", "f"] +class Graph: + def __init__(self, n: typing.Any) -> None: + """Graph doc""" + def __eq__(self, other: typing.Any) -> bool: + ... + def _hook(self) -> None: + ... +class Result: + def value(self) -> int: + ... +def f(x: typing.Any, y: int = 1) -> typing.Any: + ... +def _private() -> None: + ... +''' + +OVERRIDES = """import typing + +class Graph: + # reason: an integer + # generated: def __init__(self, n: typing.Any) -> None: + def __init__(self, n: typing.SupportsIndex) -> None: ... + +class Result: + # reason: built by the module only + # generated: (none) + def __init__(self, _no_public_constructor: typing.Never, /) -> None: ... + +# reason: integers in and out +# generated: def f(x: typing.Any, y: int = 1) -> typing.Any: +def f(x: int, y: int = 1) -> int: ... +""" + + +EQ_DOC = "__eq__(self: Graph, other: object, /) -> bool" + + +def module( + result_constructible: bool = False, eq_doc: str = EQ_DOC +) -> types.ModuleType: + """The runtime side: __eq__ takes its operand positional-only (``eq_doc``, pybind11's + signature line), Result has no constructor (pybind11's TypeError) unless + ``result_constructible``.""" + + class Graph: + def __init__(self, n): + self.n = n + + def __eq__(self, other): + return self is other + + __eq__.__doc__ = eq_doc + + class Result: + def __init__(self): + if not result_constructible: + raise TypeError("Result: No constructor defined!") + + def value(self): + return 0 + + def f(x, y=1): + return x + + pkg = types.ModuleType("fake", "The package docstring.") + pkg.__dict__.update( + Graph=Graph, Result=Result, f=f, __all__=["Graph", "Result", "f"] + ) + return pkg + + +class Rules(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + root = Path(tmp.name) + saved = generate.ROOT, generate.OVERRIDES + self.addCleanup(lambda: setattr(generate, "ROOT", saved[0])) + self.addCleanup(lambda: setattr(generate, "OVERRIDES", saved[1])) + generate.ROOT, generate.OVERRIDES = root, root / "overrides.pyi" + + def build(self, raw=RAW, overrides=OVERRIDES, pkg=None) -> str: + generate.OVERRIDES.write_text(overrides) + return generate.build_stub(raw, pkg or module()) + + def refused(self, why: str, **kwargs) -> None: + with self.assertRaises(generate.Failure) as ctx: + self.build(**kwargs) + self.assertIn(why, str(ctx.exception)) + + def test_the_valid_stub(self): + stub = self.build() + for line in ( + "def __init__(self, n: typing.SupportsIndex) -> None:", # an override + "def __init__(self, _no_public_constructor: typing.Never, /) -> None:", # marker + "def __eq__(self, other: typing.Any, /) -> bool:", # positional-only restored + "def f(x: int, y: int=1) -> int:", # before ruff formats it + ): + self.assertIn(line, stub) + self.assertIn('"""Graph doc"""', stub) # the generated body kept + self.assertNotIn("_hook", stub) # private members and names left out + self.assertNotIn("_private", stub) + + def test_an_override_changes_annotations_only(self): + for changed in ("def f(z: int, y: int = 1)", "def f(x: int, y: int = 2)"): + overrides = OVERRIDES.replace("def f(x: int, y: int = 1)", changed) + self.refused("an override changes only annotations", overrides=overrides) + + def test_the_quoted_generated_line(self): + raw = RAW.replace("y: int = 1) -> typing.Any", "y: float = 1) -> typing.Any") + self.refused("the generated declaration changed", raw=raw) + no_reason = OVERRIDES.replace("# reason: integers in and out\n", "") + self.refused("an override needs a '# reason:' line", overrides=no_reason) + stale = ( + OVERRIDES + + "\n# reason: gone\n# generated: def g() -> None:\ndef g() -> None: ...\n" + ) + self.refused( + "overrides without a generated declaration: ['g']", overrides=stale + ) + + def test_the_marker_matches_the_runtime_both_ways(self): + pkg = module(result_constructible=True) + self.refused( + "Result: marked not constructible, but the module constructs it", pkg=pkg + ) + start = OVERRIDES.index("class Result:") + unmarked = ( + OVERRIDES[:start] + OVERRIDES[OVERRIDES.index("# reason: integers") :] + ) + self.refused("Result: the module refuses to construct it", overrides=unmarked) + + def test_no_any_outside_its_domain(self): + overrides = OVERRIDES.replace("y: int = 1) -> int", "y: int = 1) -> typing.Any") + self.refused( + "typing.Any left in the stub (add a reviewed override): f -> ...", + overrides=overrides, + ) + + def test_positional_only_names_match(self): + raw = RAW.replace( + "__eq__(self, other: typing.Any)", "__eq__(self, rhs: typing.Any)" + ) + self.refused("the module makes ['self', 'other'] positional-only", raw=raw) + + def test_the_overrides_file(self): + f_line = "# generated: def f(x: typing.Any, y: int = 1) -> typing.Any:\n" + f_def = "def f(x: int, y: int = 1) -> int: ..." + overload = f"@typing.overload\n{f_def}\n" + for overrides, why in ( + (OVERRIDES.replace(f_line, ""), "needs exactly one '# generated:' line"), + (OVERRIDES.replace(f_line, f_line * 2), "needs exactly one '# generated:'"), + ( + OVERRIDES.replace( + f"{f_def}\n", overload + "# reason: again\n" + overload + ), + "'# reason:'/'# generated:' belong above the first overload only", + ), + ( + OVERRIDES.replace(f"{f_def}\n", overload + f"{f_def}\n"), + "repeated declarations must all be @typing.overload", + ), + ( + OVERRIDES.replace("class Graph:", f"{overload}\nclass Graph:"), + "f: overloads must be adjacent", + ), + ( + OVERRIDES.replace("class Result:\n", "class Result:\n x: int\n"), + "only method overrides inside a class", + ), + (OVERRIDES + "x = 1\n", "unexpected statement"), + ): + self.refused(why, overrides=overrides) + + def test_what_an_override_replaces(self): + overloads = RAW.replace( + "def f(x: typing.Any, y: int = 1) -> typing.Any:\n ...\n", + "@typing.overload\ndef f(x: typing.Any, y: int = 1) -> typing.Any:\n ...\n" + "@typing.overload\ndef f(x: str) -> str:\n ...\n", + ) + self.refused( + "f: generated as overloads; an override replaces one declaration", + raw=overloads, + ) + none = OVERRIDES.replace( + "# generated: def __init__(self, n: typing.Any) -> None:", + "# generated: (none)", + ) + self.refused( + "Graph.__init__: the override says '(none)', but Graph.__init__ is generated", + overrides=none, + ) + decorated = OVERRIDES.replace("def f(x: int", "@staticmethod\ndef f(x: int") + self.refused( + "f: decorators ['staticmethod'] differ from the generated []", + overrides=decorated, + ) + marker = OVERRIDES.replace( + "_no_public_constructor: typing.Never", "_no_public_constructor: int" + ) + self.refused( + "Result.__init__: not generated; the only addition allowed is the constructor" + " marker", + overrides=marker, + ) + + def test_positional_only_signature_count(self): + doc = ( + "__eq__(*args, **kwargs)\nOverloaded function.\n\n" + "1. __eq__(self: Graph, other: object, /) -> bool\n\n" + "2. __eq__(self: Graph, other: int, /) -> bool\n" + ) + self.refused( + "Graph.__eq__: 2 module signatures, 1 stub declarations", + pkg=module(eq_doc=doc), + ) + + def test_no_any_in_parameters_or_attributes(self): + raw = RAW.replace( + "def value(self) -> int:", "def value(self, x: typing.Any) -> int:" + ) + self.refused( + "typing.Any left in the stub (add a reviewed override): Result.value(x)", + raw=raw, + ) + raw = RAW.replace("class Result:\n", "class Result:\n y: typing.Any\n") + self.refused( + "typing.Any left in the stub (add a reviewed override): Result.y", raw=raw + ) + # a module attribute, exported + pkg = module() + pkg.__all__ = [*pkg.__all__, "z"] + pkg.z = 0 + self.refused( + "typing.Any left in the stub (add a reviewed override): z", + raw=RAW + "z: typing.Any\n", + pkg=pkg, + ) + + def test_no_any_in_any_parameter_position(self): + pkg = module() + pkg.__all__ = [*pkg.__all__, "g"] + pkg.g = lambda *args, **kwargs: 0 + for signature, where in ( + ("x: typing.Any, /", "g(x)"), # positional-only + ("x: typing.Any", "g(x)"), # positional + ("*args: typing.Any", "g(args)"), + ("*, x: typing.Any", "g(x)"), # keyword-only + ("**kwargs: typing.Any", "g(kwargs)"), + ): + with self.subTest(signature): + self.refused( + f"typing.Any left in the stub (add a reviewed override): {where}", + raw=RAW + f"def g({signature}) -> int:\n ...\n", + pkg=pkg, + ) + self.refused( + "typing.Any left in the stub (add a reviewed override): g -> ...", + raw=RAW + "def g(*args: int) -> typing.Any:\n ...\n", + pkg=pkg, + ) + # typed variadic parameters are kept as generated + stub = self.build( + raw=RAW + "def g(*args: int, **kwargs: str) -> int:\n ...\n", pkg=pkg + ) + self.assertIn("def g(*args: int, **kwargs: str) -> int:", stub) + + def test_no_any_in_a_nested_class(self): + raw = RAW.replace( + "class Result:\n", + "class Result:\n class Part:\n" + " def h(self, x: typing.Any) -> int:\n ...\n", + ) + self.refused( + "typing.Any left in the stub (add a reviewed override): Result.Part.h(x)", + raw=raw, + ) + + def test_exports_match(self): + self.refused( + "_netgraph_core.g is public but not exported", + raw=RAW + "def g() -> None:\n ...\n", + ) + raw = RAW.replace("def f(", "def _f(") + self.refused("exported by netgraph_core, not generated: ['f']", raw=raw) + + +class CommandLine(unittest.TestCase): + """``main`` on the synthetic module (the installation, pybind11-stubgen and ruff replaced): + ``--write`` writes the stub, ``--check`` passes on an equal stub and fails on any other + with the difference.""" + + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + root = Path(tmp.name) + names = ("ROOT", "STUB", "OVERRIDES", "installed_package", "run_stubgen") + saved = {name: getattr(generate, name) for name in names + ("ruff_format",)} + self.addCleanup(lambda: [setattr(generate, n, v) for n, v in saved.items()]) + generate.ROOT, generate.STUB = root, root / "stub.pyi" + generate.OVERRIDES = root / "overrides.pyi" + generate.OVERRIDES.write_text(OVERRIDES) + ext = types.SimpleNamespace(__file__="the compiled module") + generate.installed_package = lambda: (module(), ext) + generate.run_stubgen = lambda raw_dir: RAW + generate.ruff_format = lambda text: text + self.want = generate.build_stub(RAW, module()) + + def main(self, *argv: str) -> tuple[int, str, str]: + out, err = io.StringIO(), io.StringIO() + with ( + mock.patch.object(sys, "argv", ["generate.py", *argv]), + contextlib.redirect_stdout(out), + contextlib.redirect_stderr(err), + ): + status = generate.main() + return status, out.getvalue(), err.getvalue() + + def drift(self, committed: str | None) -> str: + if committed is None: + generate.STUB.unlink(missing_ok=True) + else: + generate.STUB.write_text(committed) + status, out, err = self.main("--check") + self.assertEqual(status, 1, out) + self.assertIn("stub.pyi differs from a fresh generation", err) + return out + + def test_write_then_check(self): + status, out, _ = self.main("--write") + self.assertEqual(status, 0) + self.assertEqual(generate.STUB.read_text(), self.want) + status, out, _ = self.main("--check") + self.assertEqual(status, 0) + self.assertIn("stub.pyi is up to date", out) + + def test_check_fails_on_any_difference(self): + changed = self.want.replace("-> int:", "-> str:", 1) + self.assertNotEqual(changed, self.want) + out = self.drift(changed) + self.assertIn("\n-", out) + self.assertIn("\n+", out) + self.assertIn("-> str:", out) + self.drift(self.want + "\n") + self.drift(self.want[:-1]) + self.drift("") + self.assertIn("(generated)", self.drift(None)) + + +class InstalledPackage(unittest.TestCase): + """``installed_package``: both modules imported from this interpreter's site-packages and + the extension without the private hooks, else a Failure.""" + + def check(self, pkg_file: str, ext_file: str, hooks: bool = False): + pkg = types.ModuleType("netgraph_core") + ext = types.ModuleType("_netgraph_core") + pkg.__file__, ext.__file__ = pkg_file, ext_file + if hooks: + ext._max_flows_per_call = lambda: 0 + with mock.patch.dict( + sys.modules, {"netgraph_core": pkg, "_netgraph_core": ext} + ): + return generate.installed_package() + + def test_refusals(self): + site = os.path.realpath(sysconfig.get_paths()["platlib"]) + inside = os.path.join(site, "netgraph_core", "__init__.py") + ext = os.path.join(site, "_netgraph_core.so") + with tempfile.TemporaryDirectory() as tmp: + outside = os.path.join(os.path.realpath(tmp), "python", "netgraph_core.py") + for pkg_file, ext_file, name in ( + (outside, ext, "netgraph_core"), + (inside, outside, "_netgraph_core"), + ): + with self.assertRaises(generate.Failure) as ctx: + self.check(pkg_file, ext_file) + self.assertIn( + f"{name} is imported from {outside}, not from an installation", + str(ctx.exception), + ) + with self.assertRaises(generate.Failure) as ctx: + self.check(inside, ext, hooks=True) + self.assertIn( + "the installed extension carries the private hooks", str(ctx.exception) + ) + pkg, got = self.check(inside, ext) + self.assertEqual((pkg.__file__, got.__file__), (inside, ext)) + + +if __name__ == "__main__": + unittest.main() diff --git a/dev/test_coverage_summary.py b/dev/test_coverage_summary.py new file mode 100644 index 0000000..8cfcd61 --- /dev/null +++ b/dev/test_coverage_summary.py @@ -0,0 +1,165 @@ +"""The rules of ``dev/coverage_summary.py``: what it accepts, every report it refuses, and its +exit status, on small synthetic Cobertura reports. + +Nothing is built. Run first by ``make cov``, or directly: + + python -I -B dev/test_coverage_summary.py +""" + +from __future__ import annotations + +import contextlib +import io +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import coverage_summary as cs + + +def report(**totals: str) -> str: + attrs = "".join(f' {k.replace("_", "-")}="{v}"' for k, v in totals.items()) + return ( + f'\n\n' + ) + + +class Summary(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.dir = Path(self.tmp.name) + + def tearDown(self): + self.tmp.cleanup() + + def write(self, name: str, text: str) -> str: + path = self.dir / name + path.write_text(text) + return str(path) + + def main(self, *argv: str) -> tuple[int, str, str]: + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + status = cs.main(list(argv)) + return status, out.getvalue(), err.getvalue() + + def test_totals_read_exactly(self): + for totals, want in ( + (dict(lines_valid="10", lines_covered="7"), (10, 7, None, None)), + ( + dict( + lines_valid="12.0", + lines_covered="0012", + branches_valid="4.00", + branches_covered="0", + ), + (12, 12, 4, 0), + ), + # the largest count, and one below: compared exactly, not as floats + ( + dict(lines_valid=str(2**63 - 1), lines_covered=str(2**63 - 2)), + (2**63 - 1, 2**63 - 2, None, None), + ), + ): + path = self.write("r.xml", report(**totals)) + self.assertEqual(cs.parse_cobertura_totals(path), want, totals) + + def test_refusals(self): + good = dict(lines_valid="10", lines_covered="7") + for name, text, why in ( + ("missing", None, "cannot read the report"), + ("not xml", "", "not a Cobertura report (root )"), + ("no lines-valid", report(lines_covered="1"), "no lines-valid total"), + ("no lines-covered", report(lines_valid="1"), "no lines-covered total"), + ( + "zero lines", + report(lines_valid="0", lines_covered="0"), + "counts no line", + ), + ( + "lines above", + report(lines_valid="7", lines_covered="8"), + "8 lines covered of 7", + ), + ( + "branches above", + report(**good, branches_valid="2", branches_covered="3"), + "3 branches covered of 2", + ), + ( + "one branch total", + report(**good, branches_valid="2"), + "must both be present or both absent", + ), + ( + "the other branch total", + report(**good, branches_covered="0"), + "must both be present or both absent", + ), + ( + "above the largest count", + report(lines_valid=str(2**63), lines_covered="1"), + "above the largest count", + ), + *( + ( + f"spelling {value}", + report(lines_valid=value, lines_covered="1"), + f"lines-valid={value!r} is not a count", + ) + for value in ("-1", "+1", "1.5", "1e3", "nan", "inf", "", " 1", "0x10") + ), + ): + path = ( + str(self.dir / "absent.xml") + if text is None + else self.write(f"{len(name)}.xml", text) + ) + with self.assertRaises(cs.ReportError, msg=name) as ctx: + cs.parse_cobertura_totals(path) + self.assertIn(why, str(ctx.exception), name) + self.assertTrue(str(ctx.exception).startswith(path), name) + + def test_exit_status_and_outputs(self): + py = self.write("py.xml", report(lines_valid="10", lines_covered="5")) + cpp = self.write("cpp.xml", report(lines_valid="30", lines_covered="29")) + bad = self.write("bad.xml", report(lines_valid="0", lines_covered="0")) + html = self.dir / "sub" / "c.html" + status, out, err = self.main(py, cpp, f"--html={html}") + self.assertEqual((status, err), (0, "")) + rows = out.splitlines() + self.assertEqual(rows[3].split(), ["python", "10", "5", "50%"]) + self.assertEqual(rows[4].split(), ["c++", "30", "1", "97%"]) + self.assertEqual(rows[6].split(), ["TOTAL", "40", "6", "85%"]) + self.assertIn("TOTAL", html.read_text()) + # branch columns when one report has branch totals; the other counts 0 of 0 + branched = self.write( + "br.xml", + report( + lines_valid="10", + lines_covered="5", + branches_valid="4", + branches_covered="1", + ), + ) + status, out, err = self.main(branched, cpp) + self.assertEqual(status, 0, err) + self.assertEqual(out.splitlines()[6].split()[-3:], ["4", "3", "25%"]) + # any refused report: exit 1, the reason on stderr, no table + for argv in ((bad, cpp), (py, bad), (py, str(self.dir / "absent.xml"))): + status, out, err = self.main(*argv) + self.assertEqual((status, out), (1, ""), argv) + self.assertTrue(err.startswith("coverage_summary.py: "), argv) + # an HTML output that cannot be written: exit 1 + blocker = self.write("file", "") + status, out, err = self.main(py, cpp, f"--html={blocker}/c.html") + self.assertEqual(status, 1) + self.assertIn(f"cannot write {blocker}/c.html", err) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/semantics.md b/docs/semantics.md new file mode 100644 index 0000000..2e1cd85 --- /dev/null +++ b/docs/semantics.md @@ -0,0 +1,1070 @@ +# NetGraph-Core semantics + +This document says exactly what NetGraph-Core computes: how amounts are counted, how edges are +numbered, which route a search returns, how a placement spreads traffic, what a placement call +reports and when it fails, how the shortest-path cache behaves, and what the evaluators mean. It +is the full reference behind the API docstrings. For installation and a first example, see the +[README](../README.md#installation). + +Each section starts with a short explanation in plain words and then gives the exact rules. When +the two seem to differ, the exact rules are the authority. Names are the Python spellings +(`import netgraph_core as ngc`); [the last section](#11-c-names) lists the C++ spellings. Names that +start with an underscore are private test and verification entries, not part of the API. + +Contents: + +1. [Units: the one numeric domain](#1-units-the-one-numeric-domain) +2. [The graph](#2-the-graph) +3. [Route values: PredDAG and Path](#3-route-values-preddag-and-path) +4. [Search](#4-search) +5. [Placement kernels and admission](#5-placement-kernels-and-admission) +6. [The ledger](#6-the-ledger) +7. [Policies](#7-policies) +8. [Failures, threads and lifetimes](#8-failures-threads-and-lifetimes) +9. [The DAG cache](#9-the-dag-cache) +10. [Evaluators](#10-evaluators) +11. [C++ names](#11-c-names) + +## Terms + +- **Unit, resolution `q`.** Traffic is counted in whole units; one unit is `q` of a physical + quantity (for example `q = 2**-12` Gbit/s). The graph fixes `q` when it is built. +- **Amount.** A whole number of units: a capacity, a residual, a request, a delivery or a drop. +- **Residual.** The capacity of an edge that is still free, in units. +- **EdgeId.** The public number of an edge. It is not the edge's position in the input arrays + (see [section 2](#2-the-graph)). +- **Tight edge.** An edge `u -> v` that lies on a shortest walk of a search. Searching from the + roots, the distance of `u` plus the edge's cost equals the distance of `v`; searching toward + them (reverse), where a distance is the distance to the roots, the distance of `v` plus the + cost equals the distance of `u`. +- **Shortest-path DAG.** The set of all tight edges: every shortest route at once. A DAG (directed + acyclic graph) is a directed graph with no directed cycle. +- **Parallel members.** Several edges between the same two nodes in the same direction. +- **Canonical path.** The one walk a search returns when a single path is asked for, chosen by + the fixed order of [section 4](#the-canonical-single-path). +- **Bottleneck of a walk.** The smallest priority value (residual, capacity or headroom) among the + walk's edges. +- **A flow** is one routed amount on one route, recorded in the ledger as a `FlowRecord`. +- **Policy.** The routing model of one demand, the traffic from a set of sources to a set of + destinations (a `FlowPolicy`): it creates, fills, grows and removes that demand's flows. +- **Cohort.** The flows a policy manages for one source; growth adds a flow to it + (`CapacityAware`). +- **Roster.** The sources of a policy that take part in placement, fixed when the policy is built. +- **Offer.** The part of a request that a call asks one source to place. +- **Epoch.** The period from a policy's construction, or a `remove()`, to the next `remove()`; + requests add up within it. +- **Lossless / Lossy.** Admission control: Lossless places only what fits end to end; Lossy + forwards hop by hop and drops what does not fit on each edge. +- **Static.** Depending only on the graph, the costs and the Eligibility, not on traffic already + placed (a static walk, the static shortest cost). +- **Member graph.** The edges a search may use: eligible, at least 1 unit of capacity (and, when + a residual is supplied, at least the search's residual threshold), and one member per pair + with `multi_edge=False`. +- **Kernel.** The rule that spreads one flow over its route: `MAX_FLOW`, `WEIGHTED` or + `EQUAL_BALANCED`. +- **Bundle.** The route of one flow, as a `PredDAG`. +- **View.** The capacity a solve may use: the free capacity plus the flow's (or the `Global` + policy's) own allocation. +- **Minimum cut.** A set of edges whose removal separates the sources from the destinations, + with the smallest total capacity; that capacity equals the maximum flow. +- **Marginal cost curve.** `(cost, units)` pairs in ascending cost: delivering `units` more units + raises the minimum total cost of the flow by `cost` per unit. Delivering more can reroute flow + already placed, so the pairs are not the costs of the paths in the final allocation. + +The examples below run in sequence and share these helpers. Each output block is the printed +output of the example above it. + +```python +import netgraph_core as ngc + +def graph(num_nodes, edges, q=1.0): + """edges: (src, dst, capacity_units, cost) tuples in input order.""" + src, dst, cap, cost = zip(*edges) + return ngc.Graph(num_nodes, list(src), list(dst), list(cap), list(cost), q) + +DET = ngc.PathSelection(ngc.EdgeTieBreak.DETERMINISTIC, ngc.EdgePriority.CAPACITY) +PREFER = ngc.PathSelection(ngc.EdgeTieBreak.PREFER_HIGHER_PRIORITY, ngc.EdgePriority.CAPACITY) + +def config(routing, placement=ngc.FlowPlacement.EQUAL_BALANCED, + admission=ngc.Admission.LOSSLESS, selection=DET): + return ngc.FlowPolicyConfig(routing=routing, placement=placement, + admission=admission, selection=selection) +``` + +## 1. Units: the one numeric domain + +**In plain words.** Every amount of traffic is a whole number of units, and the graph says how +big a unit is. Physical numbers enter through `to_units` and leave through `as_float`; nothing +in between is a float, so totals add up exactly and nothing is lost to rounding. Costs are a +separate kind of number: positive integers that are never converted. + +**Exact rules.** + +- **Resolution.** A graph's resolution `q` is a power of two in `[2^-32, 2^32]`, given + explicitly when the graph is built; there is no default. The resolution is the precision of + every result: there is no finer grid. A model that needs finer splits uses a smaller `q`. +- **Amounts.** Capacities, residuals, allocations, requests, deliveries, offers and drops are + integers in units of `q`. In Python they are `int` (exact, unbounded); per-edge arrays are + NumPy `int64[E]`. Cumulative counters that are sums over many calls (a Lossy record's + `offered` and its per-edge `drops`) may exceed 64 bits; Python returns them as exact `int`. +- **Domain.** Every edge capacity, and the sum of all capacities, is below `2^62` units. Edge + costs are integers of at least 1 and their sum is below `2^62`, so no path cost reaches the + sentinel `UNREACHABLE` (the largest `int64`). A request is an integer in `[0, 2^62)` units or + positive infinity (`float('inf')`, "as much as possible"). Everything outside the domain is a + `ValueError` raised before anything changes. +- **`to_units(x, q)`** converts physical values to units, rounding half up: a scalar gives an + `int`, an array an `int64` array. NaN, infinity, negative values and results at or above + `2^62` units raise `ValueError`. It is the only way physical numbers enter the library. +- **`as_float(x, q)`** converts units to physical values (`x * q`, correctly rounded, exact + below `2^53` units): an `int` gives a `float`, an integer array a `float64` array. A result + beyond the finite doubles raises `ValueError`. Costs are never converted. +- **Routability.** An edge with less than 1 unit of capacity (or, when a residual is supplied, + less than the search's residual threshold) carries nothing and is never part of a route. A + physical request below `q / 2` converts to 0 units. A request of 0 units is accepted after + validation and does nothing (its result is `COMPLETE`). +- **Splitting units.** When a node forwards `I` units over `k` member edges: + - **round robin** (`EQUAL_BALANCED`): every member gets `I // k` units and the first `I % k` + members, in ascending EdgeId, get one more; + - **highest averages** (`WEIGHTED`, the D'Hondt rule): the units are handed out one at a time, + each to the member with the largest `w / (a + 1)` (its weight `w` over its current count + `a` plus one), ties to the lower EdgeId. Equal weights give round robin. + + Both rules are exact and never lose a unit: a share that rounds down to zero leaves its unit to + a sibling. Both are monotone: when `I` grows, no member's share shrinks. +- **Conservation.** At every node, inflow plus supply equals outflow plus delivery plus drops, + exactly. The ledger's totals are exactly the sums of its records. + +```python +print(ngc.to_units([1.5, 2.5, 0.4, 0.5], 1.0).tolist()) # half up +print(ngc.to_units(10.0, 2.0**-12), ngc.as_float(40960, 2.0**-12)) +``` + +```text +[2, 3, 0, 1] +40960 10.0 +``` + +## 2. The graph + +**In plain words.** A `Graph` is built once from arrays and never changes. Every edge gets a +public number, its EdgeId, by sorting the edges by source, cost and destination; only edges +that agree on all three keep their input order among themselves. A map gives back each edge's +input position. All per-edge arrays the library reads or returns are in EdgeId order, and every +tie rule of the library ("the lowest EdgeId") refers to this order. + +**Exact rules.** + +- **Construction.** `Graph(num_nodes, src, dst, capacity_units, cost, resolution)` builds an + immutable directed multigraph: parallel edges and self-loops are allowed. The arrays are in + input order (the caller's). Capacities are already in units; use `to_units` for physical + values. +- **Validation order.** When an input has several defects, the first in this order is reported as a + `ValueError`: the resolution; the capacity values (each an integer in `[0, 2^62)`; floats and + booleans are rejected, not rounded); the array shapes; a zero or negative cost; the remaining + bounds (node ids in range, then the capacity and cost sums below `2^62`, both added up edge by + edge in input order: the first edge at which a running sum reaches `2^62` reports that sum, the + capacity sum before the cost sum at the same edge). Integers are read + without narrowing, so the reported error does not depend on how the same input is represented. +- **Size.** Node ids and EdgeIds are `int32`: a graph has at most `INT32_MAX` (`2^31 - 1`) nodes + and at most as many edges. The constructor accepts every graph within these bounds, and the + searches and route values work on all of them. The flow core (`Global` routing, the `MAX_FLOW` kernel and the + evaluators that use them) numbers a solve's network in `int32` as well: it adds a virtual + source and a virtual sink, and gives every edge and every virtual arc (one per source, one per + destination) two slots, one to add flow and one to cancel it. A solve therefore raises + `ValueError`, before the solver sizes anything and with nothing changed, on a network of more + than `INT32_MAX - 2` nodes, or with more than `2^30 - 1` edges, sources and destinations + together. A `Global` solve counts the graph's nodes, the eligible edges, the roster sources and + the eligible destinations; a `MAX_FLOW` solve counts the nodes of the flow's member edges and + endpoints, its member edges, its source and its destinations. Only graphs of billions of nodes + or about a billion edges reach these limits. +- **EdgeId.** The EdgeId of an edge is its rank in the stable order `(src, cost, dst, input + position)`. `input_index()[e]` is the input position of edge `e`; the inverse map is the + caller's to build (one pass over `E`). +- **EdgeId order is the coordinate of every per-edge value.** Residual arrays, Eligibility + exclusions, DAG and Path edges, flows, records, `bottleneck`, `min_cut` and sensitivity entries + all use EdgeIds. A caller that keeps per-edge data in input order translates it first: an + in-range id in the wrong coordinate is silently a different edge, and no check can catch that. +- **Layout guarantees.** The out-edges of node `u` are exactly the ids + `row_offsets()[u]` to `row_offsets()[u + 1] - 1`, in `(cost, dst, input)` order, and + `edge_dst()[e]` is the head of `e`. The in-edges of node `v` are + `in_adj_edge_index()[in_row_offsets()[v]:in_row_offsets()[v + 1]]`, as EdgeIds in + `(cost, src, input)` order. Both offset arrays have `N + 1` entries, starting at 0 and ending + at `E`, also for an empty graph. +- **Accessors.** `capacity_units()`, `cost()` (`int64[E]`), `edge_src()`, `edge_dst()`, + `input_index()`, `row_offsets()`, `in_row_offsets()` and `in_adj_edge_index()` (`int32`) are + read-only NumPy views that keep the graph alive; `max_cost()` and `cost_gcd()` are 0 for a + graph without edges; `uid()` is a process-unique number, never reused. +- **Ids belong to one graph.** Building a graph from different arrays, or with another release of + the library, may renumber the edges. Every value bound to a graph (Eligibility, PredDAG, + Path, ledger, cache) is rejected with another graph. + +```python +# Input order: edge 0 = 1->2, edge 1 = 0->2 (cost 5), edge 2 = 0->1. +g = ngc.Graph(3, [1, 0, 0], [2, 2, 1], [7, 8, 9], [1, 5, 1], 1.0) +print("src", g.edge_src().tolist(), "dst", g.edge_dst().tolist(), "cost", g.cost().tolist()) +print("input_index", g.input_index().tolist(), "row_offsets", g.row_offsets().tolist()) +``` + +```text +src [0, 0, 1] dst [1, 2, 2] cost [1, 5, 1] +input_index [2, 1, 0] row_offsets [0, 2, 3, 3] +``` + +### Eligibility + +**In plain words.** An `Eligibility` says which nodes and edges of one graph may be used, for +example to model a failure or a maintenance window. It is built from lists of exclusions and +never changes. + +**Exact rules.** + +- `graph.eligibility(excluded_nodes=(), excluded_edges=())` builds the value. Duplicates and + unsorted lists are accepted; out-of-range ids raise `ValueError`. An edge is allowed when it is + not excluded and both its endpoints are allowed. `node_allowed()` and `edge_allowed()` are + read-only boolean views. +- Empty lists give the all-allowed value, whose `uid()` is 0. Every other value has a fresh uid; + equal lists built twice give two different uids. Wherever an Eligibility is optional, `None` + means everything is allowed. +- `Eligibility.intersect(a, b)` allows what both allow. When the result equals one operand, that + operand is returned (same uid); otherwise the result is a new value with a new uid. Both + operands must belong to the same graph. +- Uids matter for the [DAG cache](#9-the-dag-cache): its key contains the Eligibility uid, so + build each state once and reuse the value. +- An excluded endpoint takes no part in a search or a policy: an excluded root or target in a + set is skipped, and an excluded single endpoint gives an empty result. + +## 3. Route values: PredDAG and Path + +**In plain words.** A route is either a DAG of predecessor edges (the shape a search returns: +possibly many equal-cost walks) or a `Path` (one explicit walk, each hop possibly a bundle of +parallel edges). Both are immutable, bound to their graph and checked once when built. + +**Exact rules.** + +- **`PredDAG`.** For node `v`, its entries are `parents()[k]` (the parent node) and + `via_edges()[k]` (the edge from that parent) for `k` from `parent_offsets()[v]` to + `parent_offsets()[v + 1] - 1`, in strictly ascending EdgeId. This is the one canonical form: + two DAGs of the same graph are equal exactly when their entries are equal; DAGs of different + graphs are never equal. `parents()` and `via_edges()` are read-only views; `parent_offsets()` + is built on demand. +- **`PredDAG.from_csr(graph, offsets, parents, via)`** validates, before reading any structure: + `offsets` has `N + 1` entries, starts at 0, never decreases and ends at + `len(parents) == len(via)`; then every id is in range, every via edge joins its parent to its + node, no `(node, via)` pair repeats, the DAG has no cycle and every node's entries are in + ascending via EdgeId. Any failure is a `ValueError`. +- **`Path(graph, hops)`.** `hops` is a list of hops, each a non-empty list of EdgeIds that share + one `(u, v)` pair (parallel members). Validation, in order: every member id in range, no + member twice, at least one hop, no empty hop, the members of a hop share `(u, v)`, consecutive + hops connect, no node is visited twice. The members keep the caller's order. `cost()` is the + sum over the hops of each hop's cheapest member; `src()`, `dst()` and `hops()` read the walk; + `to_dag()` gives the equivalent `PredDAG`. A single-member walk is `[[e1], [e2], ...]`. +- **`resolve_to_paths(graph, dag, src, dst, split_parallel_edges=False, max_paths=None)`** lists + the `src -> dst` walks of a DAG as `Path` values in increasing `(cost, hop count, edge-id + sequence)`, where a hop costs its cheapest member and is represented in the key by its lowest + member id. With `split_parallel_edges=False` the parallel members of a pair stay together in + one hop (ascending); with `True` every member combination is a separate path, and the paths are + sorted by each path's own `(cost, hop count, edge-id sequence)`. `max_paths` (at least 1) keeps + the first paths of that order. `src == dst`, a DAG of + another graph or `max_paths < 1` raise `ValueError`; an endpoint outside the DAG, or no walk, + gives `[]`. + +## 4. Search + +**In plain words.** `spf` finds shortest paths from a set of roots, forward or in reverse, +either to every node or only to the nearest of a set of targets. It returns the shortest-path +DAG, which contains every shortest route at once, or, on request, one canonical path chosen by a +fixed rule. `ksp` lists the K shortest simple paths in a fixed order. All searches see only +eligible edges with at least 1 unit of capacity; a supplied residual can exclude more. + +### Options shared by the searches + +- **`PathSelection(tie_break, priority, multipath=True, multi_edge=True)`.** `tie_break` and + `priority` have no default. + - `multipath=True` returns the DAG of every equal-cost walk; `False` the one canonical path. + - `multi_edge=True` keeps every eligible parallel member of a `(u, v)` pair; `False` keeps + one per pair (the member rule below). + - `tie_break`: `DETERMINISTIC` never compares priority values; ties fall to the lowest + EdgeId. `PREFER_HIGHER_PRIORITY` prefers the higher priority value first, then the lowest + EdgeId. + - `priority`: the per-edge value that `PREFER_HIGHER_PRIORITY` reads (and that the Weighted + kernel uses for membership and weights): `RESIDUAL` (residual units), `CAPACITY` (static + capacity units) or `HEADROOM` (residual divided by capacity, compared exactly as a + fraction). A search without a residual array reads static capacities for every priority. +- **The member rule** (`multi_edge=False`). Of the eligible parallel members of a pair, keep the + one with the lowest cost; among those, under `PREFER_HIGHER_PRIORITY`, the one with the + highest priority value; then the lowest EdgeId. This choice happens before any route is + searched, so it can change which walks exist. +- **Static eligibility** (always): the Eligibility and at least 1 unit of capacity. +- **Residual gating** (only when a `residual` array is supplied): `residual` is an `int64` array + in EdgeId order with every entry in `[0, capacity_units[e]]`. It excludes edges whose residual + is below the threshold (`min_residual` units for `spf`, 1 unit for `ksp`) and supplies the + residual values of the priority. It is read during the call only and must not change during + it. + +### Shortest paths + +`spf(graph, roots, SpfOptions(direction, selection, targets=None, residual=None, +min_residual=1, elig=None))` returns an `SpfResult` with `costs`, `dag` and `target_cost`. + +- **Roots and targets** are one node id or a sequence. Every root starts at distance 0. + Duplicates are removed. Validated in this order before any work (`ValueError`): the roots + (non-empty, ids in range), the targets (the same), a node in both sets, `multipath=False` + without targets, the residual (length, then every entry), `min_residual` (an integer of at + least 1), a foreign Eligibility. Excluded roots and targets are skipped; when every root is + excluded the result is empty, not an error. +- **Direction.** `FORWARD` searches from the roots along the edges. `REVERSE` searches toward the + roots against the edges, but the DAG it returns keeps the edges' own orientation: its walks + run from a target toward a root. A forward and a reverse search with the roles of roots and + targets exchanged describe the same shortest walks. +- **Without targets** every reachable node gets its final distance: `costs` is a read-only `int64[N]` array + with `UNREACHABLE` for the nodes not reached, `dag` holds every tight edge, `target_cost` is + `None`. +- **With targets** the search stops at the cost of the nearest targets (anycast: the nearest of + a set). `target_cost` is that cost (`None` when no target is reached), `dag` keeps only the + walks that end in a target at that cost, and `costs` is `None`. +- **The DAG is unique.** With `multipath=True`, costs are positive, so the member graph, the + roots, the direction and the targets (or their absence) determine one DAG. The member graph + changes through `multi_edge`, the member rule, static eligibility and residual gating; with the + member graph fixed, the tie mode, the priority, the capacity and residual values and the C++ + queue choice do not change the DAG. A forward search with targets and a reverse search with the + roots and the targets exchanged return the same DAG. Without targets the two directions can + return different DAGs, also when the reverse search is rooted at a node the forward search + reaches: each keeps the edges tight in its own direction. + +### The canonical single path + +With `multipath=False` (targets required), `spf` returns the one walk from a root to a target +that is first in this order: + +1. the lowest **cost**; +2. under `PREFER_HIGHER_PRIORITY` only: the highest **bottleneck**, the smallest priority value + along the walk (`DETERMINISTIC` skips this step); +3. the fewest **hops**; +4. the lexicographically smallest **sequence of EdgeIds** along the walk. + +The order is total, so the walk is unique. The same walk is returned by a forward search, by a +reverse search with the roles exchanged, and by `ksp` with `k=1`. Note the order of steps 3 and +4: a walk with fewer hops wins even when its first EdgeId is larger. The member rule above is a +separate, earlier choice: with `multi_edge=False` each pair is first reduced to one member, and +the canonical path is then chosen among the walks that remain. + +```python +F = ngc.Direction.FORWARD +# Two equal-cost walks 0->1->3 (capacity 1) and 0->2->3 (capacity 5). +g = graph(4, [(0, 1, 1, 1), (0, 2, 5, 1), (1, 3, 1, 1), (2, 3, 5, 1)]) +for sel in (DET, PREFER): + single = ngc.PathSelection(sel.tie_break, sel.priority, multipath=False) + r = ngc.spf(g, 0, ngc.SpfOptions(F, single, targets=3)) + print(sel.tie_break.name, r.target_cost, r.dag.via_edges().tolist()) + +# Equal cost 2: the direct edge (EdgeId 1) against the two-hop walk [0, 2]. +g = graph(3, [(0, 1, 1, 1), (1, 2, 1, 1), (0, 2, 1, 2)]) +single = ngc.PathSelection(ngc.EdgeTieBreak.DETERMINISTIC, ngc.EdgePriority.CAPACITY, + multipath=False) +r = ngc.spf(g, 0, ngc.SpfOptions(F, single, targets=2)) +print("fewest hops:", [p.hops() for p in ngc.resolve_to_paths(g, r.dag, 0, 2)]) +full = ngc.spf(g, 0, ngc.SpfOptions(F, DET)) +print("costs", full.costs.tolist(), "dag", full.dag.via_edges().tolist()) +``` + +```text +DETERMINISTIC 2 [0, 2] +PREFER_HIGHER_PRIORITY 2 [1, 3] +fewest hops: [[[1]]] +costs [0, 1, 2] dag [0, 1, 2] +``` + +### K shortest paths + +`ksp(graph, src, dst, KspOptions(k, selection, max_path_cost=None, residual=None, elig=None))` +returns a list of `(cost, Path)` pairs; every hop of every Path has one member. + +- **Order.** The simple `src -> dst` paths of the member graph in increasing `(cost, + -bottleneck under PREFER_HIGHER_PRIORITY, hop count, edge-id sequence)`: the canonical order + above applied to whole paths. The result is the first `k` paths of that order that are within + the ceiling. +- **Parallel members.** `multi_edge=True` makes every eligible member a separate path; + `multi_edge=False` keeps one member per pair by the member rule. `multipath` is + ignored. +- **Ceiling.** `max_path_cost` is `None`, `Absolute(c)` (cost at most `c`, `c >= 0`; `Absolute(0)` + is a valid budget) or `Stretch(f)` (cost at most `f` times the static shortest cost of the + member graph under the Eligibility, residual ignored; `f` finite and at least 1). Both are + inclusive and compared exactly: `f` is read as the exact binary fraction a float is. With + `Stretch`, no static route gives `[]`. +- **Residual gating** at 1 unit when a residual is supplied. +- Validated in this order before any work (`ValueError`): `src` and `dst` in range, `src == + dst`, `k >= 1`, the ceiling, the residual (length, then every entry), a foreign Eligibility. An + excluded endpoint or no path gives `[]`. + +```python +g = graph(3, [(0, 1, 3, 1), (0, 1, 5, 1), (1, 2, 9, 1)]) # parallel members 0 and 1 +collapse = ngc.PathSelection(ngc.EdgeTieBreak.PREFER_HIGHER_PRIORITY, + ngc.EdgePriority.CAPACITY, multi_edge=False) +for sel in (DET, PREFER, collapse): + print([(c, p.hops()) for c, p in ngc.ksp(g, 0, 2, ngc.KspOptions(5, sel))]) +r = ngc.spf(g, 0, ngc.SpfOptions(F, DET, targets=2)) +print([p.hops() for p in ngc.resolve_to_paths(g, r.dag, 0, 2)], + [p.hops() for p in ngc.resolve_to_paths(g, r.dag, 0, 2, split_parallel_edges=True)]) +``` + +```text +[(2, [[0], [2]]), (2, [[1], [2]])] +[(2, [[1], [2]]), (2, [[0], [2]])] +[(2, [[1], [2]])] +[[[0, 1], [2]]] [[[0], [2]], [[1], [2]]] +``` + +## 5. Placement kernels and admission + +**In plain words.** A kernel decides how one flow spreads over its route. `MAX_FLOW` behaves like +a controller that pushes as much as the route can carry, cheapest walks first. `WEIGHTED` is a +router splitting traffic at every node by fixed weights (WCMP). `EQUAL_BALANCED` is a router +splitting equally at every node (ECMP), or equal-share tunnels. Admission is a separate choice: +`LOSSLESS` places only what fits end to end; `LOSSY` forwards hop by hop, as routers do, and +drops on each edge what does not fit. + +**Exact rules.** + +- **Split graph.** A flow uses the part of its route (DAG or bundle) that reaches its destination + set through the edges its kernel may use (its member edges). Which edges: `MAX_FLOW` uses the + edges with capacity in its view (the free capacity plus the flow's own allocation); + `EQUAL_BALANCED` uses the edges with at least 1 unit of static capacity; `WEIGHTED` uses, by its + `priority`, the edges with positive residual when the flow was created (`RESIDUAL`) or with at + least 1 unit of static capacity (`CAPACITY`). `WEIGHTED` with `HEADROOM` is rejected. Lossy + keeps its kernel's membership. +- **`MAX_FLOW`** solves the minimum-cost flow of the flow's cumulative amount on its route, over + the free capacity plus the flow's own current allocation. It may move the flow's own earlier + allocation (that is what lets it reach the maximum); it never moves another flow's. It fills + the cheapest walks of a mixed-cost bundle first. Per-edge changes between calls can be + negative. `MAX_FLOW` is Lossless only: a controller never drops. A flow beyond the flow core's + size limit raises `ValueError` ([section 2](#2-the-graph), Size). +- **`WEIGHTED`** splits at every node by highest averages over weights frozen when the flow is + created: the members' priority values at that moment (residual units under `RESIDUAL`, static + capacity units under `CAPACITY`). Weights are never renormalised; a member whose residual later + shrinks limits the flow. +- **`EQUAL_BALANCED`** splits at every node by round robin over its members, which are frozen + when the flow is created. +- **Lossless filling** (`WEIGHTED`, `EQUAL_BALANCED`). On a fixed route and plan, a flow's delivered + amount `T` only grows (an equal-share cohort that gains a tunnel re-splits its total; see [the + placement call](#the-placement-call)): a call raises it to the smallest of `T` plus the call's + budget and `T_max`, the largest amount whose allocation fits every member edge (found by exact + binary search). At `T_max` at least one member edge is exactly full; there is no safety margin. + Because the split rules are monotone, raising `T` never takes units away from an edge. +- **Lossy forwarding** (`WEIGHTED` or `EQUAL_BALANCED`, and only with `CostOnly` or `Pinned` + routing). Each call's offer is split at every node by the kernel's rule; each edge carries the + smaller of its share and its residual, and the rest is dropped on that edge. The delivery is + what reaches the destination set. An infinite offer injects into each first edge exactly its + residual. Records keep `offered`, `delivered` and per-edge `drops`; the transit drops are the + sum of `drops`, and the source drops are `offered - delivered - transit drops`. Lossy calls are + independent: nothing is re-offered and two calls are not equivalent to one. + +On a diamond with paths of capacity 10 and 2, equal-share forwarding stops at 5 units: round +robin gives the odd unit to the lower EdgeId (3 and 2), and a sixth unit would put 3 units on the +2-unit path. The weighted and maximum-flow kernels carry 12: + +```python +g = graph(4, [(0, 1, 10, 1), (1, 3, 10, 1), (0, 2, 2, 1), (2, 3, 2, 1)]) +kernels = ngc.FlowPlacement.EQUAL_BALANCED, ngc.FlowPlacement.WEIGHTED, ngc.FlowPlacement.MAX_FLOW +for kernel in kernels: + ledger = ngc.FlowLedger(g) + r = ngc.FlowPolicy(ledger, 0, 3, config(ngc.CostOnly(), placement=kernel)).place(100) + print(kernel.name, r.placed, r.remaining, r.termination.name, ledger.allocated().tolist()) + +ledger = ngc.FlowLedger(g) +pol = ngc.FlowPolicy(ledger, 0, 3, config(ngc.CostOnly(), admission=ngc.Admission.LOSSY)) +r = pol.place(10) +print("LOSSY", r.placed, r.remaining, r.termination.name, "drops", r.drops) +rec = pol.records()[0] +print("record", rec.offered, rec.delivered, rec.drops) +``` + +```text +EQUAL_BALANCED 5 95 EXHAUSTED [3, 2, 3, 2] +WEIGHTED 12 88 EXHAUSTED [10, 2, 10, 2] +MAX_FLOW 12 88 EXHAUSTED [10, 2, 10, 2] +LOSSY 7 3 COMPLETE drops [(1, 3)] +record 10 7 [(1, 3)] +``` + +The Lossy call is `COMPLETE` although 3 units were dropped: the whole offer of 10 was forwarded, +and what did not fit is accounted as drops, not as a shortfall ([section 7](#outcomes)). + +## 6. The ledger + +**In plain words.** A `FlowLedger` holds the traffic placed on one graph: a starting residual +(the baseline) and one record per flow. Policies place and remove its flows; it has no public +placement or removal of its own. Its own `reset()`, rejected while a policy is bound, replaces +the baseline and clears the records. Its residual is always the baseline minus everything its +records hold. + +**Exact rules.** + +- **`FlowLedger(graph, residual=None)`.** The baseline is an integer array in EdgeId order with + every entry in `[0, capacity_units[e]]` (a physical baseline goes through `to_units` first; + anything above the capacity is rejected); `None` means the capacities. The baseline never + changes except through `reset`. +- **Snapshots.** `allocated()` is the exact sum of every record's edges and `residual()` is + `baseline - allocated()`, both `int64[E]` copies. `capacity_units()` is the graph's view. + `graph()` returns the graph. +- **Records.** `records()` lists the live records (of bound policies and orphans) as snapshots + in FlowId order; `record(flow_id)` reads one. A FlowId is the pair `(ledger uid, id)`; ids are + never reused. A FlowId of another ledger, or a retired or unknown id, raises `ValueError`. +- **`FlowRecord`** fields: `flow_id`; `owner` (the policy's id); `sources` and `destinations` + (node sets); `placement` and `admission`; `bundle` (the flow's route as a `PredDAG`); + `route_cost` (the cheapest walk of the bundle, cached; `None` for an empty bundle); `offered` + (Lossy records only, else `None`); `delivered`; `edges` (`[(EdgeId, units)]`, ascending) and + `drops` (`[(EdgeId, units)]`, cumulative, ascending). +- **`bottleneck(sources, elig=None)`** is a saturation report: the edges (eligible, at least 1 + unit of capacity) that leave the set of nodes reachable from the allowed sources over edges + with at least 1 unit of residual, in ascending EdgeId. It follows edges forward only. It ignores + the destinations, so it is not a source-destination cut, and it can be empty while a route + exists. +- **Lifetimes.** The graph outlives the ledger and the ledger outlives its policies (Python keeps + them alive for you). + +| Ledger state | Reached by | `reset(residual)` | `copy()` | +| --- | --- | --- | --- | +| a policy is bound | building a policy on it | `ValueError` | `ValueError` | +| no policy bound | no policy built yet, or every policy destroyed | allowed: orphaned records and their allocations are cleared, their ids retired, the new baseline set | a new ledger with a new uid and the same records and ids | + +A policy destroyed without `remove()` leaves its records and allocations in the ledger as +**orphans**: readable, never changed again, cleared only by `reset`. + +## 7. Policies + +**In plain words.** A `FlowPolicy` is the routing model of one demand between a set of sources +and a set of destinations. `place(volume)` asks it to carry more; it routes, fills and possibly +grows its flows, commits everything at once, and reports how much was placed, what stopped it +and whether routes or weights changed. `remove()` takes all of its traffic back out. + +### Configuration + +`FlowPolicyConfig(routing=..., placement=..., admission=..., selection=...)` has four explicit +axes (independent settings) and no defaults. The routing is one of: + +| Routing | Models | Fields (defaults) | +| --- | --- | --- | +| `CostOnly()` | IGP forwarding (as OSPF or IS-IS routers do): static shortest paths, never refreshed | none | +| `CapacityAware(...)` | distributed CSPF tunnels (constrained shortest path first: each tunnel head picks a shortest path with enough free capacity) | `flow_count=1`, `max_flow_count=None`, `max_path_cost=None`, `min_flow_bandwidth=0`, `reoptimize=False`, `diminishing=None` | +| `Global()` | an ideal controller that sees the whole network | none | +| `Pinned(bundles)` | explicit routes given by the caller | a list of `PredDAG` (a `Path` converts with `to_dag()`); copied when the policy is built | + +`FlowPolicy(ledger, sources, destinations, config, elig=None, cache=None)` binds the policy to +its ledger. `sources` and `destinations` are one node id or a sequence; the two sets must not +overlap. Construction validates everything before the policy binds (a field that a routing does +not have is a `TypeError`; every other defect a `ValueError`): the endpoint sets, a foreign +`Eligibility` or `DagCache`, and the configuration below. The order between these three groups +of checks is not specified: when defects of two groups meet, the one reported may differ between +the C++ constructor and Python. The configuration's rules: + +- `WEIGHTED` with priority `HEADROOM` is rejected. +- `LOSSY` is rejected with `MAX_FLOW`, with `Global` and with `CapacityAware`. +- `Global` requires `MAX_FLOW`. +- `flow_count >= 1`; `max_flow_count` is `None` (no ceiling) or at least `flow_count`; + `min_flow_bandwidth` is in [0, 2^62) units; `max_path_cost` is `None`, `Absolute(c)` with + `c >= 0` or `Stretch(f)` with `f` finite and at least 1 (as for `ksp`); `diminishing` is `None` + (off) or `DiminishingReturns(window, fraction)` with `window >= 1` and `fraction` strictly + between 0 and 1. +- `Pinned`: one source and one destination; at least one bundle; no duplicate bundles; every + bundle runs from the policy's source to its destination and has at least one walk. + +What each routing reads from `selection`: `CostOnly` and `CapacityAware` read all four fields. +`Global` and `Pinned` read only `priority`, and only with the `WEIGHTED` kernel (for membership +and weights). Ignoring a field is not an error. + +### The source roster + +The roster is fixed when the policy is built. For `CostOnly`, `CapacityAware` and `Global` it is +the allowed sources that have a static walk to an allowed destination over edges of at least 1 +unit, in ascending node id. For `Pinned` it is the single source when at least one bundle is +usable. The roster decides which records exist and which `local_outcomes` are reported. A +positive request with an empty roster creates nothing and ends `NO_ROUTE`. + +### Routing modes + +- **`CostOnly`** creates one flow per roster source, on the static shortest-path DAG toward the + destination set, or with `multipath=False` on that source's canonical path. The route is read + from the [DAG cache](#9-the-dag-cache) and the selection is applied when the flow is created + (priorities read from the residual at that moment for `RESIDUAL` and `HEADROOM`, from the + static capacities for `CAPACITY`). The route is never refreshed, grown or reoptimised. +- **`CapacityAware`** routes by the canonical path or the DAG of [section 4](#4-search) on the + current free capacity, excluding edges with less than `max(1, min_flow_bandwidth)` units free and + routes above `max_path_cost` (`Stretch` is relative to the static shortest cost, computed when the + policy is built). A source first creates `flow_count` flows, one search at a time, each search + seeing the capacity the earlier ones reserved; with `reoptimize=True` it moves a retained flow to + a newly found route only if the flow keeps its whole amount there; while the offer is not met it + grows its cohort by one flow at a time, up to `max_flow_count`. Growth stops when the new route is + a multipath DAG equal to an existing flow's route (it would add nothing); a repeated single path + is a real candidate (tunnels of equal share fill a fatter path with more tunnels). `diminishing` + stops growth once at least `window` growth steps were accepted in the call and the last `window` + gains sum below `fraction` times the source's (finite) offer. A call creates at most + `MAX_FLOWS_PER_CALL` (10,000) flows over all sources. This mode is greedy: route order matters and + the result is not an upper bound. +- **`Global`** re-solves, on every positive call, the minimum-cost flow of the amount the + policy already delivers plus this call's request (`placed_demand()` plus the request; what an + earlier call could not place is not requested again) from its whole source set to its whole + destination set over every eligible edge, on the free capacity plus its own allocation (it may + move its own traffic, never another owner's). Hence `place(a)` followed by `place(b)` ends in + the same allocation as `place(a + b)` on an unchanged network, and the result is always a + minimum-cost flow of the amount the policy delivers. It keeps one record per roster source; each record's bundle is the set of edges + that carry that source's share, extracted in a canonical order, and is rebuilt on every positive call + under the same FlowId. A source with no share has an empty bundle and `route_cost` `None`. + `cost_distribution` reports the marginal cost curve of the cumulative flow: `(cost, units)` + pairs in ascending cost, saying how many units cost that much at the margin. A positive call on + a network beyond the flow core's size limit raises `ValueError` ([section 2](#2-the-graph), + Size). +- **`Pinned`** creates one flow per usable bundle, in the order given. A bundle is usable when at + least one of its walks survives the Eligibility over edges of at least 1 unit of capacity; its + `route_cost` is the cheapest such walk. Bundles that become equal after that pruning keep separate flows. When no bundle is + usable, `usable_bundles()` is 0 and a positive request ends `NO_ROUTE`. + +### Several sources and destinations + +- **Destinations** (`CostOnly`, `CapacityAware`): a route goes to the nearest destinations, and + one flow may deliver to several of them at that cost. A record's `delivered` is its total; the + delivery to one destination is the sum of the record's `edges` entering it. +- **Sources** (`CostOnly`, `CapacityAware`) act as independent routers or tunnel heads. The + policy's cumulative request is split over the roster by round robin in ascending node id, and + each source is offered the increase of its share in this call (an infinite request offers + infinity to every source). Sources are placed one after another, in ascending node id, on the + shared free capacity; what one source cannot place is not passed to another. Lossy sources are + split the same way: two calls of 1 unit over two sources offer 1 to each. +- **`Global`** optimises the whole source set and destination set together and may use a + costlier destination to carry more. +- **`Pinned`** has a single source and a single destination. + +Because sources are placed in order, a split request is not equivalent to one large request: +two sources sharing a 2-unit edge get `[1, 1]` from `place(2); place(2)` but `[2, 0]` from +`place(4)`. + +### The placement call + +`place(volume, outputs=None)` takes a request in units (an `int`) or `float('inf')`. + +1. **Validation** happens before anything changes. Within an epoch, a finite request after an + infinite one is rejected (the cumulative request would be undefined), `place(0)` included. +2. **A request of 0** is `COMPLETE` and does nothing: it creates no flow. +3. **`Global`** re-solves as described above. +4. **`CostOnly`, `CapacityAware`, `Pinned`** run, per source and on one shared working copy of + the free capacity: creation (while the source has no flows), reoptimisation (`CapacityAware` + with `reoptimize`), filling (each flow, or each equal-share cohort, raised toward its + maximum), growth (`CapacityAware`). +5. **Commit.** All changes to the ledger and the policy are committed together at the end, or + none are ([section 8](#8-failures-threads-and-lifetimes)). + +What the call guarantees: + +- `placed` is the increase in delivery made by this call, and `0 <= placed <= volume`. For Lossy + it is this call's delivery. `remaining = volume - placed` (`float('inf')` for an infinite + request; for Lossy it includes what was dropped). +- A call never reduces `placed_demand()`, the sum of `delivered` over the policy's records. The + total of an equal-share cohort never decreases; one tunnel's amount can decrease when growth + re-splits a larger total over more tunnels (`[3, 3]` plus a new tunnel can become + `[3, 2, 2]`). +- The first positive request creates the source's flows even when none of them can deliver + anything yet (`CapacityAware` only creates a flow on a route it finds). Such a zero-delivery + record keeps its FlowId for the rest of the epoch and can fill on a later call. +- **Additivity.** For one source and a Lossless kernel on a fixed route or cohort, with the rest + of the network unchanged between the calls, `place(a); place(b)` equals `place(a + b)` + exactly; `Global` is additive in value and cost curve on an unchanged network. Competing + sources of one policy and Lossy are not additive. + +### Epochs + +`remove()` takes every flow of the policy out of the ledger (allocations reverted, records gone, +ids retired) and starts a new epoch: the cumulative request, the round-robin position of the +source split and the finite-or-infinite state are reset. The next positive request creates new +flows with new ids; ids are never reused. + +```python +g = graph(4, [(0, 1, 10, 1), (1, 3, 10, 1), (0, 2, 2, 1), (2, 3, 2, 1)]) +ledger = ngc.FlowLedger(g) +pol = ngc.FlowPolicy(ledger, [0, 1], 3, config(ngc.CostOnly())) +for v in (1, 1, 0): + r = pol.place(v) + print(v, r.placed, r.routes_changed, [(x.flow_id[1], x.delivered) for x in pol.records()]) +pol.remove() +r = pol.place(1) +print(1, r.placed, r.routes_changed, [(x.flow_id[1], x.delivered) for x in pol.records()]) +``` + +```text +1 1 True [(0, 1), (1, 0)] +1 1 False [(0, 1), (1, 1)] +0 0 False [(0, 1), (1, 1)] +1 1 True [(2, 1), (3, 0)] +``` + +The first unit goes to source 0 and the second to source 1 (round robin of the cumulative +request); the first call creates both records, source 1's at zero delivery. After `remove()` the +split starts again at source 0, under new ids. + +### Outcomes + +Each roster source ends a call with a **local outcome**; the call's `termination` combines them. +A source's offer is **served** when it is 0, when (Lossless) the delivery equals the offer, or +when (Lossy, finite offer) the whole offer was forwarded, drops included. A Lossy infinite offer +is never served. The local outcome is the first row that applies: + +| Local outcome | When | +| --- | --- | +| `COMPLETE` | the offer is served | +| `LIMIT_FLOWS` | not served, and creation or growth was stopped by `MAX_FLOWS_PER_CALL` or by `max_flow_count` when `max_flow_count > flow_count` | +| `LIMIT_DIMINISHING` | not served, and the `diminishing` guard stopped growth | +| `NO_ROUTE` | not served, and the source has no flows after the call (no route was found; `Pinned`: no usable bundle; `Global`: the maximum flow is 0 and nothing was ever placed) | +| `EXHAUSTED` | not served otherwise: every flow is at its maximum on its route and growth found nothing that helps (a costlier helpful route may still exist), a fixed-size cohort (`max_flow_count == flow_count`) is full, `Global` is at the maximum flow, or a Lossy infinite offer | + +`local_outcomes` lists `(source, outcome)` for every roster source in ascending node id. For +`Global` and `Pinned` the policy acts as one, so every entry equals the call's `termination`. + +The call's **`termination`**: an invalid call raises instead of returning; a request of 0 is +`COMPLETE`; a positive request with an empty roster is `NO_ROUTE`; otherwise, if every source is +served, `COMPLETE`, else the highest local outcome among the unserved sources in the order + +`LIMIT_FLOWS` > `LIMIT_DIMINISHING` > `EXHAUSTED` > `NO_ROUTE`. + +A served source never changes the result: two sources without a route give `NO_ROUTE`, one +`EXHAUSTED` and one `NO_ROUTE` give `EXHAUSTED`, one `LIMIT_FLOWS` and one `EXHAUSTED` give +`LIMIT_FLOWS`. `remaining` is an amount, never a status: a Lossy call can be `COMPLETE` with a +positive `remaining`. + +```python +# Source 0 has two disjoint 1-unit tunnels to node 4; source 1 one 1-unit edge. +g = graph(5, [(0, 2, 1, 1), (0, 3, 1, 1), (2, 4, 1, 1), (3, 4, 1, 1), (1, 4, 1, 1)]) +tunnel = ngc.PathSelection(ngc.EdgeTieBreak.DETERMINISTIC, ngc.EdgePriority.RESIDUAL, + multipath=False) + +def show(r): + print(r.placed, r.termination.name, [(s, t.name) for s, t in r.local_outcomes]) + +# Request 10: each source is offered 5. Source 0 stops at its ceiling of 2 tunnels. +pol = ngc.FlowPolicy(ngc.FlowLedger(g), [0, 1], 4, + config(ngc.CapacityAware(max_flow_count=2), ngc.FlowPlacement.MAX_FLOW, + selection=tunnel)) +show(pol.place(10)) + +# Another demand holds source 1's only edge; request 6 offers 3 to each source. +ledger = ngc.FlowLedger(g) +ngc.FlowPolicy(ledger, 1, 4, config(ngc.CostOnly())).place(1) +pol = ngc.FlowPolicy(ledger, [0, 1], 4, + config(ngc.CapacityAware(max_flow_count=3), ngc.FlowPlacement.MAX_FLOW, + selection=tunnel)) +show(pol.place(6)) +``` + +```text +3 LIMIT_FLOWS [(0, 'LIMIT_FLOWS'), (1, 'EXHAUSTED')] +2 EXHAUSTED [(0, 'EXHAUSTED'), (1, 'NO_ROUTE')] +``` + +`LIMIT_FLOWS` is reported when the ceiling is reached before the offer is met, even if no further +route would have existed. + +### Flags + +`routes_changed` and `weights_changed` start false in every call and record only changes that +were committed. Once set in a call, a flag stays set; a call with no committed structural change +ends with both false, even when it places nothing. + +| Committed change | `routes_changed` | `weights_changed` | +| --- | --- | --- | +| a cohort is created, in any routing mode, also at zero delivery and in a new epoch after `remove()` (`CapacityAware` only with a route, possibly on a later call) | true | unchanged | +| a retained flow is moved to a new route (`reoptimize`) | true | true when a `WEIGHTED` flow's weights were captured again (also when the call places nothing) | +| growth adds a flow (also on a route equal to an existing flow's: routes belong to flows) | true | unchanged | +| a `Global` call changes the edges some source's share uses, also with `placed == 0` | true | `Global` never sets it | +| nothing of the above: a request of 0, rejected candidates only, `Global` with every source's edges unchanged | unchanged | unchanged | + +A candidate route equal to the flow's current route is not a change. + +```python +# A first request on a route whose only unit another demand holds. +g = graph(5, [(0, 2, 1, 1), (0, 3, 1, 1), (2, 4, 1, 1), (3, 4, 1, 1), (1, 4, 1, 1)]) +ledger = ngc.FlowLedger(g) +ngc.FlowPolicy(ledger, 1, 4, config(ngc.CostOnly())).place(1) +pol = ngc.FlowPolicy(ledger, 1, 4, config(ngc.CostOnly())) +r = pol.place(1) +print(r.placed, r.termination.name, r.routes_changed, [x.delivered for x in pol.records()]) +``` + +```text +0 EXHAUSTED True [0] +``` + +### The result + +`place` returns a `PlacementResult`: + +- `placed`, `remaining`, `termination`, `local_outcomes`, `routes_changed`, `weights_changed` as + above; +- `drops`: this call's transit drops as `[(EdgeId, units)]` (records keep the cumulative drops); +- `cost_distribution`: `Global` only, the marginal cost curve of the policy's cumulative flow (a + request of 0 reports it unchanged); `None` for every other mode. For other modes, the sum of + `(route_cost, delivered)` over the records is a histogram of delivery by each record's cheapest + walk cost: the cost every unit travels at for `CostOnly` and `CapacityAware`, whose routes have + one cost, but not for a `Pinned` bundle whose walks differ in cost; +- optional outputs, `None` unless requested through `OutputOptions(edge_flows=False, + residual=False, bottleneck=False)`: `edge_flows` is the policy's own allocation per edge after + the call (the ledger's total is `allocated()`); `residual` and `bottleneck` are the ledger's + after the call; on `Global`, requesting `bottleneck` also returns `min_cut`. +- **`bottleneck` and `min_cut` are different things.** `bottleneck` is the saturation report of + [section 6](#6-the-ledger), available in every mode. `min_cut` exists on `Global` only: the + edges of a minimum cut of the policy's view, the one on the sources' side: the edges of + positive capacity in that view that leave the nodes the sources can still reach once the flow is maximal + (counting the option to undo flow on an edge), in ascending EdgeId. It is present only when the solve reached the + maximum on its view; when a request below the maximum stopped it, and on a request of 0 + (nothing is solved), `min_cut` is `None`. + +```python +g = graph(4, [(0, 1, 10, 1), (1, 3, 10, 1), (0, 2, 5, 2), (2, 3, 5, 2)]) +ledger = ngc.FlowLedger(g) +pol = ngc.FlowPolicy(ledger, 0, 3, config(ngc.Global(), ngc.FlowPlacement.MAX_FLOW)) +want = ngc.OutputOptions(bottleneck=True) +r = pol.place(12, want) +print(r.placed, r.cost_distribution, r.min_cut, r.bottleneck, r.termination.name) +r = pol.place(float("inf"), want) +print(r.placed, r.cost_distribution, r.min_cut, r.bottleneck, r.termination.name) +``` + +```text +12 [(2, 10), (4, 2)] None [0] COMPLETE +3 [(2, 10), (4, 5)] [0, 1] [0, 1] EXHAUSTED +``` + +After 12 units the cheap path (cost 2) is full and the solve stopped below the maximum, so there +is no cut; `bottleneck` already reports the full edge 0. The second call adds 3 units at cost 4 +and reaches the maximum of 15. + +## 8. Failures, threads and lifetimes + +**In plain words.** A call either does everything or changes nothing: if it fails, for any +reason, every ledger, policy and cache is as it was and you get one exception. There are two +stated exceptions to this, described below. Immutable values can be shared freely between +threads; ledgers, policies and building caches have one writer at a time. + +**Exact rules.** + +- **All or nothing.** A failure anywhere in a public call (validation, memory allocation, a + worker thread, the construction of the Python result) leaves every ledger, policy, cache and + internal buffer as it was before the call and raises one exception in the caller's thread. + Worker threads are always joined first; an exception never escapes a worker. When several pairs + or candidates of an evaluator fail, the exception of the lowest index is raised. +- **Limit 1: pybind11's own object machinery.** Constructing a bound object from Python (any of + the library's classes) and converting a returned value into a new Python object or container + outside the library's checked result construction (for example `PredDAG.from_csr`, + `Graph.eligibility`, `SensitivityResult.baseline`, `FlowPolicy.records()`) use pybind11's own + allocation paths, which do not all check for failure. A memory failure at exactly that point + may crash the process instead of raising. Library state is never left half-changed by it. The + results of `spf`, `ksp`, `resolve_to_paths`, `FlowPolicy.place`, `capacity`, `batch_capacity` + and `sensitivity` are built through a checked path and stay inside the guarantee. +- **Limit 2: a `DagCache` passed to the call.** A building cache is a memo: when a call fails + after some cache lookups succeeded (a policy constructor failing after its lookup, an evaluator + failing midway), the cache keeps those complete entries and their statistics. It never holds a + partial entry, every value it handed out stays valid, and a kept entry never changes any later + result. A failed lookup counts one miss. A frozen cache never changes its entries; a failed + call changes none of its statistics, except one miss when the call raised `DagCacheMiss`. +- **Threads.** `Graph`, `Eligibility`, `PredDAG`, `Path` and a frozen `DagCache` may be shared + by any number of threads. A `FlowLedger`, a `FlowPolicy` and a building `DagCache` have one + writer and no concurrent readers. An array lent to a call (a residual) must not change during + the call. +- **Lifetimes.** The graph outlives the ledger, the ledger outlives its policies; Python keeps + the graph, ledger, Eligibility and cache alive while a policy needs them. +- **Exception classes.** `ValueError` for a value outside its domain, an invalid combination, a + foreign object (a value of another graph, ledger or policy) or a call in the wrong state; + `TypeError` for a field or option that does not exist (for example `CostOnly(flow_count=2)`), + an argument of the wrong type, or an argument that cannot be converted to its parameter's type: + an integer beyond the float range where a float is expected (the `resolution` of `Graph`, + `to_units` and `as_float`) is a `TypeError`. The fields that convert their own values (the + factor of `Stretch`, the fraction of `DiminishingReturns`, the request of `place`) raise + `ValueError` for such an integer and pass on unchanged any exception raised by the value's own + conversion (its `__float__` or `__index__`), whatever its class. `DagCacheMiss` for a miss in + a frozen cache (its own class, neither a `ValueError` nor a `RuntimeError`). + +```python +g = graph(4, [(0, 1, 10, 1), (1, 3, 10, 1), (0, 2, 2, 1), (2, 3, 2, 1)]) +ledger = ngc.FlowLedger(g) +pol = ngc.FlowPolicy(ledger, 0, 3, config(ngc.CostOnly())) +print(pol.place(float("inf")).placed) +try: + pol.place(1) # a finite request after +inf in the same epoch +except ValueError: + print("ValueError; unchanged:", pol.placed_demand(), ledger.allocated().tolist()) +``` + +```text +5 +ValueError; unchanged: 5 [3, 2, 3, 2] +``` + +## 9. The DAG cache + +**In plain words.** Many placements need the same static shortest-path DAG toward the same +destinations. A `DagCache` remembers those DAGs for one graph. It stores the full DAG and applies +each caller's selection afterwards, so one entry serves every selection. A cache is either +building (one writer adds entries) or frozen (read-only, shared by many threads, and a miss is an +error). + +**Exact rules.** + +- **Binding and key.** `DagCache(graph)` is bound to one graph (`graph_uid()`). The key is + `(the root set as a sorted set, the direction, the Eligibility uid)`. The value is the full + static shortest-path DAG: the DAG with `multipath=True`, `multi_edge=True` and no residual. Values are + immutable and stay valid after `clear()` and after the cache is destroyed. +- **Selection is never cached.** The single path and the member rule are applied when a flow is + created, on the cached DAG, reading priorities from the residual at that moment for + `RESIDUAL` and `HEADROOM` and from static capacities for `CAPACITY`. Because the cached DAG keeps + every equal-cost parallel member, a member that a static choice would have dropped is still + available when its residual makes it the right one. +- **Who reads it.** `CostOnly` policies read the cache (keyed by their destination set, reverse + direction, and Eligibility uid); so do the evaluators when they run a `CostOnly` configuration. + The other routing modes do not read it. + +| Operation | Building | Frozen | +| --- | --- | --- | +| lookup hit | returns the value | returns the value; safe from many threads | +| lookup miss | searches, inserts, returns | raises `DagCacheMiss` | +| `freeze()` | becomes frozen | no effect | +| `clear()` | empties the cache; hit and miss counters persist | `ValueError` | +| `thaw()` | no effect | becomes building; join every reader first | +| destruction | allowed | allowed after joining every reader; values already handed out stay valid | + +- **Statistics.** `stats()` returns `hits`, `misses`, `entries`, `bytes` (the entries' retained + arrays, without container overhead) and `live_values` (values alive anywhere, cache entries + included). Each field is atomic; together they are not one consistent snapshot. An invalid + query (foreign graph, bad root) counts nothing. A frozen cache's statistics follow the public + call: its hits count only when the call succeeds, its Python result included; a failed call + changes no statistic, except one miss for a call that raises `DagCacheMiss`. +- **Workers.** A supplied building cache is accepted only with `workers == 1` (`ValueError` + otherwise). A frozen cache may be shared by every worker. Without a supplied cache, each + worker uses its own private building cache. +- **Uids and frozen caches.** A per-pair Eligibility that the evaluator must intersect with a + common one gets a new uid when the result differs from both, and that key cannot be in a + frozen cache. To reuse prefilled entries, pass already-combined per-pair values with + `elig=None`, or a common value that contains them (so the intersection returns the + prefilled operand). +- **Scope.** Use one cache per series of analyses on one graph, and drop it at the end of the + series. + +```python +g = graph(5, [(0, 1, 10, 1), (1, 3, 10, 1), (0, 2, 2, 1), (2, 3, 2, 1), (3, 4, 9, 1)]) +cache = ngc.DagCache(g) +opts = ngc.CapacityOptions(config(ngc.CostOnly()), cache=cache) +rs = ngc.batch_capacity(g, [(0, 3), (1, 3)], opts) # building: workers defaults to 1 +print([r.placed for r in rs], cache.stats().hits, cache.stats().misses) +cache.freeze() +rs = ngc.batch_capacity(g, [(0, 3), (1, 3)], opts, workers=2) # frozen: shared by 2 workers +print([r.placed for r in rs], cache.stats().hits, cache.stats().misses) +try: + ngc.capacity(g, 0, 4, opts) # destination 4: not in the cache +except ngc.DagCacheMiss: + print("DagCacheMiss; misses =", cache.stats().misses) +``` + +```text +[5, 10] 1 1 +[5, 10] 3 1 +DagCacheMiss; misses = 2 +``` + +## 10. Evaluators + +**In plain words.** The evaluators answer questions about a model without touching your ledgers. +`capacity` asks how much one model can carry between two node sets by placing an unlimited +request on a fresh, empty ledger. `batch_capacity` asks that for many pairs. `sensitivity` asks, +for each link, how much the answer drops when that link is removed. A fresh ledger means these +answers can differ from placing into a ledger that other demands already occupy. + +**Exact rules.** + +- **`capacity(graph, sources, destinations, CapacityOptions(config, elig=None, cache=None, + outputs=None))`** is `place(float('inf'))` of a `FlowPolicy` with `config` on a fresh ledger of + the graph, and returns its `PlacementResult`. The exact maximum flow is the configuration + `Global` with `MAX_FLOW`: its result carries the marginal cost curve and, with + `OutputOptions(bottleneck=True)`, the minimum cut. +- **`batch_capacity(graph, pairs, opts, per_pair_elig=None, workers=None)`** runs `capacity` for + each `(sources, destinations)` pair and returns the results in pair order. Pair `i` uses + `opts.elig` intersected with `per_pair_elig[i]` (entries may be `None`; a given list has one + entry per pair). Everything is validated before any worker starts: first the arguments (the + worker count, the graph of `opts.elig` and of the cache, the cache rule, `Pinned`, which is + rejected because its bundles fix the endpoints, the configuration, then the length of + `per_pair_elig`), then each pair completely (its endpoint sets, their overlap, its + Eligibility's graph) in pair order, so the error of the lowest failing pair is the one raised. + An empty batch returns `[]`. +- **`sensitivity(graph, sources, destinations, SensitivityOptions(config, elig=None, cache=None, + outputs=None), workers=None)`** returns a `SensitivityResult`: `baseline` (the result of + `capacity`, with the requested outputs) and `entries`, one `SensitivityEntry(edge, loss)` per + candidate edge, in ascending EdgeId. + - **Candidates.** With `Global`: the edges that carry flow in the baseline (removing an edge + without flow cannot lower a maximum flow). With every other routing: every allowed edge with + at least 1 unit of capacity. An edge without an entry has loss 0. + - **Loss** is `baseline.placed` minus the `placed` of `capacity` with that edge also excluded, + in units, signed and never rounded or thresholded. A negative loss means the model carries + more without the edge (for example, equal splitting no longer sends a share down a thin + path). `Global` computes each deletion by repairing a copy of the baseline's solve, which + gives exactly the value of a fresh solve; `CostOnly` gives loss 0 without recomputation to an + edge outside the static shortest-path support reachable from the roster sources (no route + can use it); these are equivalent forms of the same definition. + - **Cache.** Each deletion uses a new Eligibility uid, so a `CostOnly` deletion that has to + be placed raises `DagCacheMiss` through a frozen cache. The other routing modes read no + cache. Private caches are cleared between candidates. +- **Workers.** An explicit `workers` (an integer of at least 1) is always honoured; the + evaluator uses at most one thread per pair or candidate. With `workers=None` the default is 1 + when a cache is supplied, and otherwise comes from the environment: + + | Variable | Default worker count of | Accepted values | + | --- | --- | --- | + | `NGRAPH_CORE_BATCH_THREADS` | `batch_capacity` | an integer in `[1, 4096]`; unset or anything else means 1 | + | `NGRAPH_CORE_SENSITIVITY_THREADS` | `sensitivity` | the same | + + The variables only supply defaults; they never override an explicit `workers`. The C++ + functions take the worker count as an explicit argument. +- **Failures** follow [section 8](#8-failures-threads-and-lifetimes): a failed evaluator call + raises (also a solve beyond the flow core's size limit of [section 2](#2-the-graph), with + `Global` or `MAX_FLOW`), and a failed deletion fails the whole `sensitivity` call (the + exception of the lowest failing candidate; no entry is returned); a supplied building cache + keeps the entries of the lookups that succeeded before the failure; a supplied frozen cache's + statistics are unchanged, except one miss when the call raises `DagCacheMiss`. + +```python +g = graph(4, [(0, 1, 10, 1), (1, 3, 10, 1), (0, 2, 2, 1), (2, 3, 2, 1)]) +for routing, kernel in ((ngc.CostOnly(), ngc.FlowPlacement.EQUAL_BALANCED), + (ngc.Global(), ngc.FlowPlacement.MAX_FLOW)): + s = ngc.sensitivity(g, 0, 3, ngc.SensitivityOptions(config(routing, kernel))) + print(type(routing).__name__, s.baseline.placed, + [(e.edge, e.loss) for e in s.entries]) +``` + +```text +CostOnly 5 [(0, 3), (1, -5), (2, 3), (3, -5)] +Global 12 [(0, 10), (1, 2), (2, 10), (3, 2)] +``` + +Under equal splitting the thin path (edges 1 and 3) holds the whole diamond back to 5 units; +without it the wide path alone carries 10, a loss of -5. + +## 11. C++ names + +The C++ library (namespace `netgraph::core`) has the same semantics. Its public headers are +self-contained: + +| Header | Declares | +| --- | --- | +| `netgraph/core/types.hpp` | `NodeId`, `EdgeId` (`int32`), the cost and amount aliases (`int64`), `kDomainLimit` (`2^62`), `kUnreachable` | +| `netgraph/core/graph.hpp` | `Graph`, `to_units`, `as_float` | +| `netgraph/core/eligibility.hpp` | `Eligibility` | +| `netgraph/core/pred_dag.hpp` | `PredDAG`, `Path` | +| `netgraph/core/options.hpp` | `Direction`, `Absolute`, `Stretch`, `EdgeTieBreak`, `EdgePriority`, `PathSelection`, `FlowPlacement`, `Admission` | +| `netgraph/core/search.hpp` | `spf`, `ksp`, `resolve_to_paths`, `SpfOptions`, `SpfResult`, `KspOptions`, `SpfQueue` | +| `netgraph/core/flow_record.hpp` | `FlowId`, `Counter128` (the 128-bit cumulative counter), `FlowRecord` | +| `netgraph/core/flow_ledger.hpp` | `FlowLedger` | +| `netgraph/core/flow_policy.hpp` | `FlowPolicy`, its configuration and result types, `Termination`, `kMaxFlowsPerCall` | +| `netgraph/core/dag_cache.hpp` | `DagCache`, `DagCacheMiss` | +| `netgraph/core/evaluators.hpp` | `capacity`, `batch_capacity`, `sensitivity` and their option and result types | + +Spelling differences: Python enum members and constants are upper case +(`EdgeTieBreak.DETERMINISTIC`, `MAX_FLOWS_PER_CALL`, `UNREACHABLE`), C++ enumerators are capitalised +(`EdgeTieBreak::Deterministic`) and C++ constants carry a `k` prefix (`kMaxFlowsPerCall`, +`kUnreachable`). Where Python returns `None` for an unreachable cost (`target_cost`, `route_cost`), +C++ returns `kUnreachable`; an infinite request is an empty `std::optional` volume, and its +`remaining` is empty too. A Python `ValueError` is a `std::invalid_argument` in C++. `spf` takes a +trailing C++-only `SpfQueue` argument that chooses the search's internal queue; the results never +depend on it. diff --git a/include/netgraph/core/dag_cache.hpp b/include/netgraph/core/dag_cache.hpp index 01b8e6c..5d55d8c 100644 --- a/include/netgraph/core/dag_cache.hpp +++ b/include/netgraph/core/dag_cache.hpp @@ -1,4 +1,4 @@ -/* The DagCache of APPROACH 10: building and frozen modes, the dedicated frozen-miss exception. */ +/* The DagCache: building and frozen modes, the dedicated frozen-miss exception. */ #pragma once #include @@ -25,13 +25,9 @@ struct DagCacheAccess; class FrozenTally; } // namespace detail -// A lookup in a frozen DagCache whose key is absent (APPROACH 10): a dedicated exception, not a -// std::runtime_error, so no evaluator converts it into a status (a sensitivity entry's -// `unevaluable` comes from a std::runtime_error); every evaluator propagates it unchanged. Its -// message is shared, so copying it never allocates. what() is defined in dag_cache.cpp: as the -// key function it gives the type one vtable and one type_info. An all-inline class gets a weak -// copy per translation unit, and the LTO build of the extension kept two, so the binding's catch -// of DagCacheMiss missed the thrown one (NOTES R26). +// A frozen-cache lookup with no matching key throws DagCacheMiss. The shared message can be +// copied without allocation. Keep what() out of line: this gives the exception one vtable and +// type_info, so the binding can reliably catch it. class DagCacheMiss : public std::exception { public: explicit DagCacheMiss(const std::string& what) : what_(std::make_shared(what)) {} @@ -41,12 +37,13 @@ class DagCacheMiss : public std::exception { std::shared_ptr what_; }; -// A cache of shortest-path supports bound to one graph (by uid). The key is (the roots as a -// sorted set, the direction, the Eligibility uid; no Eligibility is uid 0); the value is the full -// static support of that search (multipath = true, multi_edge = true, no residual: every tight -// edge of the statically eligible member graph over the nodes it reaches, forward-oriented for a -// reverse search) as an immutable shared PredDAG, valid after clear() and after the cache is -// destroyed. Selection is never cached: a policy projects it at creation (APPROACH 9.1, 10). +// A cache of static shortest-path supports bound to one graph uid. A support contains every +// statically eligible edge with capacity >= 1 unit on a shortest path from the roots (Forward) +// or to them (Reverse), ignoring residual capacity. It keeps every equal-cost path and every +// parallel edge. The key is (sorted root set, direction, Eligibility uid; 0 for no Eligibility). +// Values are immutable shared PredDAGs; reverse searches keep the original edge orientation. +// Values remain valid after clear() or cache destruction. Selection is applied when a policy +// creates a flow and is never cached. // // Building mode (a new cache): one writer, no concurrent readers. A hit returns the value; a miss // runs the search, inserts and returns (a failed search counts one miss and inserts nothing); an @@ -60,14 +57,14 @@ class DagCacheMiss : public std::exception { // building mode once the caller has joined every reader (no lookup may run concurrently with // freeze(), thaw() or destruction). // -// Statistics are atomic per field, not a consistent snapshot: `bytes` is the retained payload of -// the entries (the DAGs' stored arrays), `live_values` the values alive anywhere, entries -// included. A supplied building cache whose public call fails later keeps the complete entries and -// statistics of its successful lookups (APPROACH 13, limit 2). A frozen cache's statistics follow -// the public call that reads it (a policy constructor, an evaluator; NOTES R27): its hits are -// counted when the call succeeds, its Python result included, and a failed call changes no -// statistic, except that a call raising DagCacheMiss counts one miss. A direct lookup() is such a -// call: a hit counts one hit, a miss one miss. +// Each statistic is atomic, but reading several fields need not give one consistent snapshot. +// `bytes` counts the entries' stored DAG arrays; `live_values` counts values alive anywhere, +// including in the cache. A building cache keeps completed entries and lookup statistics even +// if the enclosing public call fails. Entries are always complete; keeping them changes no +// later result. This is the exception to public calls' all-or-nothing state guarantee. +// For a frozen cache, hits count only after the whole public call succeeds, including its +// Python result. A failed call counts nothing, except one miss if it raises DagCacheMiss. +// Direct lookup() follows the same rule: one hit on success, one miss on DagCacheMiss. class DagCache { public: struct Stats { diff --git a/include/netgraph/core/detail/cache_access.hpp b/include/netgraph/core/detail/cache_access.hpp index 152e2ad..82687e7 100644 --- a/include/netgraph/core/detail/cache_access.hpp +++ b/include/netgraph/core/detail/cache_access.hpp @@ -1,5 +1,5 @@ /* Internal access to a DagCache: its mode (the evaluators' rule for a supplied cache) and the - frozen statistics of one public call (APPROACH 13; NOTES R27). */ + frozen statistics of one public call. */ #pragma once #include @@ -16,7 +16,7 @@ namespace netgraph::core::detail { class FrozenTally; struct DagCacheAccess { - // Whether the cache is frozen (APPROACH 10: a frozen cache may be shared by every worker). + // Whether the cache is frozen (a frozen cache may be shared by every worker). [[nodiscard]] static bool frozen(const DagCache& cache) noexcept { return cache.frozen_; } // A lookup inside a public call (FrozenTally): a frozen hit is recorded in `tally` instead of // the cache, a frozen miss is counted by the call's boundary (frozen_call); a building cache @@ -32,14 +32,11 @@ struct DagCacheAccess { static void add_miss(DagCache& cache) noexcept { cache.misses_.fetch_add(1, std::memory_order_relaxed); } }; -// The frozen-cache statistics of one public call that reads a supplied cache (NOTES R27). A -// frozen cache's counters are observable state under the all-or-nothing rule (APPROACH 13; only -// a supplied building cache, a memo, keeps the statistics of a failed call: limit 2). So inside -// the call a frozen hit is recorded here, and the call's boundary (frozen_call) publishes the -// hits after the call's last failing step, its Python result included; a failed call drops -// them. A call that raises DagCacheMiss counts that one miss (R20), whatever its workers did. All -// workers of the call record into the one atomic count; every call has its own tally, so the -// increments of concurrent successful calls are all kept. +// Collect one public call's frozen-cache hits here; publish them only after all fallible work, +// including the Python result, succeeds. Failure discards the hits, except that DagCacheMiss +// records one miss regardless of its workers' lookups. A building cache keeps its statistics +// even on failure. Workers share this call's atomic tally; separate calls have separate +// tallies, so concurrent successful calls keep all their increments. class FrozenTally { public: explicit FrozenTally(DagCache* cache) noexcept : cache_(cache) {} diff --git a/include/netgraph/core/detail/edge_amounts.hpp b/include/netgraph/core/detail/edge_amounts.hpp new file mode 100644 index 0000000..98f6f1b --- /dev/null +++ b/include/netgraph/core/detail/edge_amounts.hpp @@ -0,0 +1,34 @@ +/* Per-edge amount arrays (internal): one check and one pair of messages for an array of + units indexed by EdgeId (a search's residual, the ledger's baseline, the flow core's view). + The bindings check unnarrowed Python integers with their own labels before calling in. */ +#pragma once + +#include +#include +#include +#include + +#include "netgraph/core/graph.hpp" +#include "netgraph/core/types.hpp" + +namespace netgraph::core::detail { + +// std::invalid_argument unless `amounts` has one entry per edge of g, each in +// [0, capacity_units]: the length first, then the first entry outside, in EdgeId order. `label` +// names the array in the message ("residual", "view"). +inline void check_edge_amounts(const Graph& g, std::span amounts, const char* label) { + const auto capacity = g.capacity_units(); + if (amounts.size() != capacity.size()) { + throw std::invalid_argument(std::string(label) + " has length " + std::to_string(amounts.size()) + ", expected " + + std::to_string(capacity.size()) + " (one entry per edge)"); + } + for (std::size_t e = 0; e < amounts.size(); ++e) { + if (amounts[e] < 0 || amounts[e] > capacity[e]) { + throw std::invalid_argument(std::string(label) + "[" + std::to_string(e) + "] = " + std::to_string(amounts[e]) + + " is outside [0, capacity_units[" + std::to_string(e) + "] = " + + std::to_string(capacity[e]) + "]"); + } + } +} + +} // namespace netgraph::core::detail diff --git a/include/netgraph/core/detail/endpoints.hpp b/include/netgraph/core/detail/endpoints.hpp index 6045146..5a05b14 100644 --- a/include/netgraph/core/detail/endpoints.hpp +++ b/include/netgraph/core/detail/endpoints.hpp @@ -1,4 +1,4 @@ -/* Endpoint and root sets (internal; APPROACH 4, 6 and 9.3; rule 13): one normalisation, one +/* Endpoint and root sets (internal): one normalisation, one overlap check and one message for each defect, shared by the flow core, the searches, the ledger's bottleneck, the policies, the evaluators and the bindings (which check unnarrowed Python integers and report the same texts). */ @@ -45,7 +45,7 @@ inline void check_range(std::span nodes, std::int32_t num_nodes, c return out; } -// std::invalid_argument when the two sorted sets share a vertex (the first shared one; rule 13). +// std::invalid_argument when the two sorted sets share a vertex (the first shared one). inline void check_disjoint(std::span a, std::span b, const char* a_name, const char* b_name) { for (std::size_t i = 0, k = 0; i < a.size() && k < b.size();) { diff --git a/include/netgraph/core/detail/enum_domain.hpp b/include/netgraph/core/detail/enum_domain.hpp index c88b6f0..b1be81b 100644 --- a/include/netgraph/core/detail/enum_domain.hpp +++ b/include/netgraph/core/detail/enum_domain.hpp @@ -1,4 +1,4 @@ -/* Membership of enumeration values (internal; rule 13). An enum-typed field can hold a value +/* Membership of enumeration values (internal). An enum-typed field can hold a value outside its enumeration (a C++ cast, a Python enum built from an integer); such a value is outside the domain and is rejected (std::invalid_argument naming the field) before it reaches any rule or record. */ diff --git a/include/netgraph/core/detail/evaluator_checks.hpp b/include/netgraph/core/detail/evaluator_checks.hpp index b50bfa9..1c50b06 100644 --- a/include/netgraph/core/detail/evaluator_checks.hpp +++ b/include/netgraph/core/detail/evaluator_checks.hpp @@ -1,6 +1,6 @@ /* The evaluators' argument checks, worker default and in-call forms (internal), shared with the - Python binding so that both report the same first error (APPROACH 4 and 11) and publish a frozen - cache's statistics at the same boundary (NOTES R27). */ + Python binding so that both report the same first error and publish a frozen cache's + statistics at the same boundary. */ #pragma once #include @@ -25,8 +25,7 @@ void check_batch_pair(const Graph& g, std::span sources, // The evaluators' default worker count from the environment (NGRAPH_CORE_BATCH_THREADS for // batch_capacity, NGRAPH_CORE_SENSITIVITY_THREADS for sensitivity): an integer in [1, 4096], -// else 1. An explicit workers >= 1 is always honoured; the environment supplies defaults only -// (APPROACH 11). +// else 1. An explicit workers >= 1 is always honoured; the environment supplies defaults only. std::int32_t default_workers(bool sensitivity) noexcept; // The evaluators inside a public call: as capacity, batch_capacity and sensitivity, with a diff --git a/include/netgraph/core/detail/flow_core.hpp b/include/netgraph/core/detail/flow_core.hpp index 07a414f..3a3d962 100644 --- a/include/netgraph/core/detail/flow_core.hpp +++ b/include/netgraph/core/detail/flow_core.hpp @@ -1,7 +1,14 @@ -/* The one integer minimum-cost flow core (internal; APPROACH 2.2): the solve on a graph's - eligible edges behind the recorder's private entry and Global routing, the solve on an edge - subset behind the MaxFlow kernel, and the deletion re-solve behind the exact sensitivity - model. */ +/* Integer minimum-cost flow (internal): Global and the recorder use the eligible graph; + MaxFlow uses an edge subset; exact sensitivity repairs a solved flow after deleting an edge. + A view gives each edge's available capacity. The residual network has forward arcs for + adding flow and reverse arcs for cancelling it. A potential pi is a vertex cost offset: + reduced cost on u -> v is cost + pi(u) - pi(v). Non-negative reduced costs allow + Dijkstra search, which finalises the nearest pending vertex first. A tier is one + such search followed by pushes on paths whose reduced cost is zero. Dinic phases assign hop + levels with breadth-first search (BFS), then fill paths through increasing levels with + depth-first search (DFS). The full residual closure of the real sources is the set they reach + through positive residual arcs, including reverse and virtual arcs. Virtual source/sink + vertices join the real source/destination sets. */ #pragma once #include @@ -15,7 +22,7 @@ #include #include "netgraph/core/detail/int128.hpp" -#include "netgraph/core/detail/private_hooks.hpp" +#include "netgraph/core/detail/hooks_flag.hpp" #include "netgraph/core/graph.hpp" #include "netgraph/core/types.hpp" @@ -28,8 +35,8 @@ inline constexpr Cost kNeverReached = -1; // strictly above every attainable value (below 2^62), so a virtual arc never saturates. inline constexpr Amount kMaxFlowCap = INT64_MAX - 1; -// Operation counters of one call: diagnostics for the performance gate's drift check, identical -// across identical calls, never an oracle. Virtual vertices and arcs count like real ones. +// Repeatable work counters for one call; they detect changes in work, not correctness. +// Virtual vertices and arcs count like real ones. struct FlowCoreCounters { std::int64_t dijkstra_runs {0}; // every run, the final failed one included std::int64_t dijkstra_pops {0}; // heap pops, stale ones included @@ -42,7 +49,10 @@ struct FlowCoreCounters { // The payload of a solve on a graph (the recorder's _place_global_units, Global routing). struct FlowCoreResult { Amount placed {0}; - // The marginal cost curve: (cost, units) with costs strictly increasing and units positive. + // Marginal cost curve: (cost, units) pairs in ascending cost, where delivering `units` more + // units raises the minimum total cost of the flow by `cost` per unit. Delivering more can + // reroute flow already placed, so the pairs are not the costs of the paths in the final + // allocation. Costs strictly increase; units are positive. std::vector> cost_distribution {}; std::vector edge_flows {}; // per edge, id order // Edges with positive view from the full residual closure of the real sources to its @@ -57,7 +67,8 @@ struct FlowCoreResult { // t* = num_nodes + 1. The input is the view's edges in ascending EdgeId (tail, head, cost and // view capacity per edge) and the endpoint sets in ascending NodeId; the arcs are slots in // adjacency order (the slots of x are [off[x], off[x + 1])), each with its head, its cost and -// the slot of its partner, in the arc order of APPROACH 2.2. +// the slot of its partner, in the flow core's arc order (flow_core.cpp, "Arc identity"). Every +// solver's network is within the limits of network_vertices(). struct Network { std::int32_t num_nodes {0}; std::vector edges {}; @@ -76,12 +87,22 @@ struct Network { std::vector dst_fwd {}; // per destination: the forward slot of its virtual arc }; -// The final state of a solve, captured for the deletion re-solve (APPROACH 2.2): bound to the +// The number of vertices of a solve's network, num_nodes + 2 (the real vertices, then s* and +// t*), for num_nodes >= 0. Vertex ids, arc slots and slot offsets are int32, so the vertices need +// num_nodes <= INT32_MAX - 2, and the two slots (forward and reverse) of every edge and of every +// virtual arc, one per source and one per destination, need +// num_edges + num_sources + num_destinations <= 2^30 - 1 (twice that, 2^31 - 2, is the largest +// even int32). Throws std::invalid_argument for a larger network, the node limit first. Every +// solver takes its vertex count from here before it sizes or allocates anything of its own. +[[nodiscard]] std::int32_t network_vertices(std::int32_t num_nodes, std::size_t num_edges, + std::size_t num_sources, std::size_t num_destinations); + +// The final state of a solve, captured for the deletion re-solve: bound to the // solve's graph (whose owner it holds), effective view and endpoint sets (copied into its // network, shared immutable between copies); the residual capacities, the attained value, the // working potentials of all N + 2 vertices, the maximum certificate and the solve's counters. // A copyable value: copies share the network only. Every field belongs to the one solve, so an -// assignment is all or nothing (APPROACH 13): the copy is built aside, then exchanged by moves, +// assignment is all or nothing: the copy is built aside, then exchanged by moves, // which never throw; a failed copy assignment leaves the target as it was. class DeletionState { public: @@ -142,7 +163,7 @@ struct DeletionCapture { static_assert(std::is_nothrow_move_constructible_v && std::is_nothrow_move_assignable_v); -// The result of one deletion re-solve: the repaired value and its certificates (APPROACH 2.2). +// The result of one deletion re-solve: the repaired value and its certificates. struct DeletionResult { Amount value {0}; // The working potentials of all real and virtual vertices (local coordinates, s* = N and @@ -178,7 +199,8 @@ class FlowSolver; // The deletion re-solve on one worker's copy of a captured state: each call resets the // residuals and potentials from that copy, deletes a real arc e = (u, v) with flow f_e > 0 and // repairs the state by three fixed-origin repairs (u -> v for f_e; u -> s*, then t* -> v, for -// the remainder). The working storage is the resolver's own, allocated at construction. +// the remainder). The network is read in place, shared with every copy of the state (the copy +// keeps it alive); the working storage is the resolver's own, allocated at construction. class DeletionResolver { public: explicit DeletionResolver(const DeletionState& state); @@ -209,7 +231,10 @@ class DeletionResolver { // order, for a view of the wrong length or with an entry outside [0, capacity_units[e]], an // empty or out-of-range endpoint set, a vertex in both sets, a cap outside [0, 2^63 - 2], or a // foreign Eligibility. Excluded endpoints are skipped. With `capture`, the final state is -// stored there as well. +// stored there as well. A solve (a cap other than 0, or a capture) then throws +// std::invalid_argument, before the solver sizes anything, for a network beyond the limits of +// network_vertices(): the graph's nodes, the eligible edges, the eligible sources and +// destinations. [[nodiscard]] FlowCoreResult place_global_units(const Graph& g, std::span view, std::span sources, std::span destinations, const Eligibility* elig, @@ -229,7 +254,9 @@ struct SubsetFlow { // place_global_units (ascending EdgeId at every node, the virtual arc first, virtual arcs by // ascending endpoint NodeId), from zero flow; the vertices are compressed to the ones the edges // and endpoints touch, which changes no flow, curve or augmenting path. The endpoint sets are -// ascending, distinct and disjoint; the caller validated every input. +// ascending, distinct and disjoint; the caller validated every input. A solve (a cap other than +// 0) throws std::invalid_argument, before the solver sizes anything, for a network beyond the +// limits of network_vertices(): the touched vertices, the edges and the endpoints. [[nodiscard]] SubsetFlow solve_subset(const Graph& g, std::span edges, std::span caps, std::span sources, std::span destinations, std::optional cap); diff --git a/include/netgraph/core/detail/graph_access.hpp b/include/netgraph/core/detail/graph_access.hpp index 3ad4913..5e90b2d 100644 --- a/include/netgraph/core/detail/graph_access.hpp +++ b/include/netgraph/core/detail/graph_access.hpp @@ -1,10 +1,8 @@ -/* The graph's internal entry points (internal, APPROACH 3). - - The internal reverse-source copy (APPROACH 3: "the stored copy is internal, for locality") is - read by the reverse search through this struct; the tests read it, and the retained array - bytes, the same way. Header-only and inline: nothing here is a library symbol, and no Python - name exists for it. The construction counters exist in hooks builds only (the C++ tests' - branch observations); the hooks-off symbol check lists their names. */ +/* Internal graph access for reverse searches and tests. The stored reverse-source array + avoids fetching each source through a second array; tests also read retained array bytes. + Everything is inline, with no Python name. Construction counters exist only in private-hooks + builds (their accessor, the one library symbol, is defined in graph.cpp) and let C++ tests + observe which construction branches ran. */ #pragma once #include @@ -12,6 +10,7 @@ #include #include +#include "netgraph/core/detail/hooks_flag.hpp" #include "netgraph/core/graph.hpp" namespace netgraph::core::detail { @@ -61,7 +60,8 @@ struct GraphAccess { }; #if NETGRAPH_CORE_PRIVATE_HOOKS -// Test evidence (C++ tests, hooks builds only): what the last construction on this thread did. +// Test evidence (C++ tests, hooks builds only): this thread's construction counts so far, and +// what its last construction did (the last_* fields). struct ConstructionCounts { std::int64_t hashed {0}; // constructions whose ranks came from the hash table std::int64_t fallback {0}; // constructions whose ranks came from the sort of all costs diff --git a/include/netgraph/core/detail/hooks_flag.hpp b/include/netgraph/core/detail/hooks_flag.hpp new file mode 100644 index 0000000..785865e --- /dev/null +++ b/include/netgraph/core/detail/hooks_flag.hpp @@ -0,0 +1,11 @@ +/* The build flag of the private hooks (internal): NETGRAPH_CORE_PRIVATE_HOOKS, 1 when the test + controls of private_hooks.hpp are compiled in. CMake defines it for the library and, through + the library target, for the extension and the tests (ON for the editable development build + and the test suites, OFF for an installed wheel); a native caller compiling without that + definition gets OFF. Code that only asks whether the hooks exist includes this header, not the + hook declarations, which reach the policy and the trace. */ +#pragma once + +#ifndef NETGRAPH_CORE_PRIVATE_HOOKS +#define NETGRAPH_CORE_PRIVATE_HOOKS 0 +#endif diff --git a/include/netgraph/core/detail/int128.hpp b/include/netgraph/core/detail/int128.hpp index d001c60..66fb99f 100644 --- a/include/netgraph/core/detail/int128.hpp +++ b/include/netgraph/core/detail/int128.hpp @@ -1,16 +1,17 @@ -/* Portable 128-bit values (internal). - - U128, unsigned, for exact rational arithmetic: the highest-averages apportionment (quotients - w / d compared by cross products, lower quotas floor(I * w / W)), the Headroom priority - (residual / capacity compared by cross products), the comparisons with a double factor read - as the exact dyadic rational it is (the Stretch ceiling, the diminishing guard) and the - cumulative counters (Counter128). Every product operand is below 2^63, so every product is - below 2^126 and every quotient that is formed fits 64 bits. Compilers with __int128 use it; - others use 32-bit limbs. - - I128, signed, for the deletion re-solve of the flow core (APPROACH 2.2): working potentials, - distance keys and sums of tier maxima, which stay within (22 n + 15) C for n vertices and the - cost sum C < 2^62. Only addition, subtraction and comparison, on two limbs everywhere. +/* Portable 128-bit arithmetic (internal). + + U128 holds unsigned values for exact allocation shares, priorities and factor comparisons + (Counter128 keeps its own two words). Compare fractions a/b and c/d by comparing a*d and + c*b; compute a lower quota as floor(I*w/W), where I is the total, w one weight and W the sum + of weights. + A double factor is used exactly as stored: an integer divided by a power of two. + Product operands stay below 2^63, products below 2^126, and quotients fit 64 bits. + Use __int128 when available; otherwise split products into 32-bit parts (limbs). + + I128 holds signed costs for deletion repairs: vertex cost offsets (potentials), distance + keys and sums of each shortest-path search's largest distance (tier maxima). These stay + within (22 n + 15) C for n vertices and cost sum C < 2^62. Only addition, subtraction and + comparison are needed, always using two 64-bit parts. */ #pragma once @@ -59,8 +60,8 @@ inline U128 mul_u64(std::uint64_t a, std::uint64_t b) noexcept { return r; } -// floor(n / d) for d > 0 and a quotient below 2^64 (n.hi < d): restoring division, one -// quotient bit per step. +// floor(n / d), with d > 0 and quotient < 2^64 (n.hi < d). Build one quotient bit per step: +// shift in the next dividend bit, then subtract d if the current remainder is large enough. inline std::uint64_t div_u128(U128 n, std::uint64_t d) noexcept { std::uint64_t rem = n.hi; std::uint64_t q = 0; @@ -133,7 +134,7 @@ inline Dyadic dyadic(double f) noexcept { return d; } -// cost <= f * base, exactly (the Stretch ceiling of APPROACH 6 and 9.1): f is the dyadic rational +// cost <= f * base, exactly (the Stretch cost ceiling): f is the dyadic rational // it is (finite, f >= 1), cost >= 0 and base >= 1 below 2^63. f = m * 2^x with m odd below 2^53; // bit lengths are compared before any shift, so no product exceeds 128 bits. inline bool stretch_le(std::int64_t cost, std::int64_t base, double f) noexcept { diff --git a/include/netgraph/core/detail/kernels.hpp b/include/netgraph/core/detail/kernels.hpp index 9f4d97a..0d15d61 100644 --- a/include/netgraph/core/detail/kernels.hpp +++ b/include/netgraph/core/detail/kernels.hpp @@ -1,10 +1,11 @@ -/* Apportionment and the placement kernels in units (internal; APPROACH 2.1, 7 and 16). - - A kernel computes the pushes of one flow on its route. The plan of a flow is frozen at its - creation: the route, its split graph (the route's edges that pass the kernel's membership - rule, pruned to the walks from the source to the destination set, destinations terminal) - and, for Weighted, the weights. These are the per-flow routines of the placement transaction - (APPROACH 9.2 stage 3); the policy engine composes them and the ledger commits their output. +/* Placement kernels assign one flow's amounts to route edges. Apportionment splits an integer + total among recipients. + + A plan freezes the route, its split graph and any Weighted weights at creation. The split + graph keeps edges allowed by the kernel that lie on a source-to-destination walk; it removes + edges leaving destinations. Its edges are the plan's members. A member-aligned array has one + entry per member, in the same order as plan.members. Policies combine these allocations; the + ledger commits them. */ #pragma once @@ -24,16 +25,16 @@ namespace netgraph::core::detail { -// ---------------------------------------------------------------- apportionment (2.1) +// ---------------------------------------------------------------- apportionment -// Round robin (EqualBalanced and Lossy EqualBalanced at every node; the cohort's split over its -// flows and a source's offer over its flows): floor(total / k) to each of the k members, one -// more unit to the first total mod k members (ascending EdgeId). Only the n leading members with -// a positive share (n = total when total < k, else k) are visited: the members from n on carry -// 0, which a caller with a zeroed output need not write or visit. The callback form computes the -// split once and calls share(i, x) for i in [0, n), in index order; share returns false to stop -// early. It returns the number of shares accepted: n, or the index i whose call returned false. -// The span form writes out[0 .. n) of the k = out.size() members and returns n. +// Round robin: give floor(total / k) to each of k recipients, plus one to the first +// total mod k in caller order. Edge shares use ascending EdgeId; shares among a source's +// flows use flow order. Used by EqualBalanced (Lossless and Lossy) at every node and to split a +// source's amount over its flows. +// Visit only the n positive shares (n = min(total, k)); later entries stay 0 in a zeroed output. +// The callback form computes the split once and calls share(i, x) in index order for i in +// [0, n). Stop if it returns false; return the accepted count: n, or that rejected index i. +// The span form writes out[0 .. n), where k = out.size(), and returns n. template inline std::size_t round_robin(Amount total, std::size_t k, Share&& share) { const auto kk = static_cast(k); @@ -53,18 +54,23 @@ inline std::size_t round_robin(Amount total, std::span out) noexcept { }); } -// Highest averages (Jefferson, D'Hondt; Weighted and Lossy Weighted): the units go one at a -// time to the member maximising w / (a + 1), ties by the lower member index (ascending -// EdgeId). Closed form: every member starts at its lower quota floor(total * w / W), then the -// fewer than k remaining units go by the exact next quotients, compared by 128-bit cross -// products. Weights positive with a sum below 2^62; total below 2^63. House-monotone. +// Highest averages (Jefferson/D'Hondt; Weighted and Lossy Weighted): give each next unit to +// the member with largest w / (a + 1), where w is its weight and a its allocation so far. +// Break ties by lower member index (ascending EdgeId). Start each member at its lower quota +// floor(total * w / W), where W is the weight sum; then assign the fewer than k remaining units +// by those quotients, comparing with exact 128-bit cross products. Weights are positive, +// W < 2^62, total < 2^63. Increasing total never reduces any member's allocation (house +// monotonicity). void highest_averages(Amount total, std::span weights, std::span out); // ---------------------------------------------------------------- plans -// Throws std::invalid_argument for a value outside its enumeration and for the combinations the -// contract rejects (Lossy with MaxFlow, Weighted with priority Headroom). +// Throws std::invalid_argument for a value outside its enumeration and for the unsupported +// combinations (Lossy with MaxFlow, Weighted with priority Headroom). void check_kernel(FlowPlacement placement, Admission admission, EdgePriority priority); +// The combination rule of check_kernel alone, for a caller that has already checked the three +// enumerations (validate_config). +void check_kernel_combination(FlowPlacement placement, Admission admission, EdgePriority priority); struct KernelPlan { FlowPlacement placement {FlowPlacement::MaxFlow}; @@ -73,11 +79,11 @@ struct KernelPlan { std::vector route {}; // ascending: the flow's route (its record's bundle) std::vector members {}; // ascending: the split graph std::vector weights {}; // Weighted: per member (positive), frozen; else empty - // The destinations (ascending) and the split graph's nodes in a topological order (the source - // first), with per position its out-members members[out_begin[p] .. out_end[p]) (consecutive in - // ascending EdgeId: B' ids rank edges by source first) and per member the position of its head; - // the structure is empty when the split graph is empty. Views of one block the plan owns - // (layout()), so a plan costs one allocation for all of them. + // Destinations ascend. Nodes have a topological order: every edge's tail precedes its head, + // with the source first. Position p's out-members are members[out_begin[p] .. out_end[p]); + // they are consecutive in ascending EdgeId because EdgeIds sort by source first. Each member + // also stores its head's position. Empty split graph means empty structure. All views share + // one block owned by the plan (layout()), so the structure takes one allocation. std::span dsts {}; std::span topo {}; std::span out_begin {}; @@ -118,8 +124,8 @@ struct KernelPlan { [[nodiscard]] std::vector prune(const Graph& g, const PredDAG& dag, NodeId src, std::span dsts); -// The minimum walk cost from `src` to the destination set over `route` (a record's bundle; -// APPROACH 8, rule 12); kUnreachable when the route has no such walk. The split-graph builder's +// The minimum walk cost from `src` to the destination set over `route` (a record's bundle: +// its route cost); kUnreachable when the route has no such walk. The split-graph builder's // cost pass alone: no plan is built. [[nodiscard]] Cost route_cost(const Graph& g, const PredDAG& route, NodeId src, std::span dsts); @@ -149,11 +155,11 @@ struct KernelPlan { EdgePriority priority, NodeId src, std::span dsts, const PredDAG& bundle, const Eligibility* elig, Cost* cost = nullptr); -// ---------------------------------------------------------------- CostOnly creation (9.1, 10) +// ---------------------------------------------------------------- CostOnly creation // std::lower_bound(a, x) on an ascending `a`, found from position `hint` (the creation walk's -// previous range): a galloping probe toward x (1, 2, 4, ... away from the hint), then a binary -// search inside the last step. The answer is std::lower_bound's whatever the hint (the first +// previous range): a galloping probe toward x (steps of 1, 2, 4, ... between probes), then a +// binary search inside the last step. The answer is std::lower_bound's whatever the hint (the first // position holding a value >= x, a.size() when none); only the probes differ. [[nodiscard]] std::size_t lower_bound_from(std::span a, std::size_t hint, EdgeId x) noexcept; @@ -164,24 +170,23 @@ struct CreationProducts { Cost cost {kUnreachable}; }; -// The products of a CostOnly flow of `src` created on its policy's static support (`support`: a -// reverse multipath support rooted at the destination set `dsts`) in one pass over its route: -// support_route's route for `selection` (Collapse on `view`), its record bundle (`support` itself -// when the route is the whole support, else PredDAGBuilder::of_edges of the route), and make_plan's -// plan and cost on that bundle, identical to those functions' for every input it accepts. It -// accepts a multipath selection with a kernel of static membership (every kernel but Weighted with -// Residual priority) and checks, while it builds, that the plan's members are the whole route: -// every route edge a member candidate, none leaving a destination, every route node reaching a -// destination, the source first in the topological order, no cycle, the members of a tail -// consecutive. Otherwise, and when src reaches no destination, it returns nullopt, and the caller -// takes those functions' path. Nothing proportional to the graph's size is allocated per call. +// Build a CostOnly flow's route, record bundle, plan and cost in one pass. `support` contains +// all static shortest paths to sorted destinations `dsts`; start at `src` and apply selection +// on `view`. The result matches support_route, bundle construction and make_plan: reuse +// `support` if it is the whole route, otherwise build its bundle with PredDAGBuilder::of_edges. +// Requires multipath and static membership (all kernels except Weighted with Residual). +// While building, check: every route edge can be a member; none leaves a destination; every +// route node reaches a destination; the source is first in topological order; no cycle exists; +// and each tail's members are consecutive. These checks ensure members == route. +// Return nullopt on a failed condition or if src reaches no destination; the caller then uses +// the separate routines. No per-call allocation is proportional to the whole graph's size. [[nodiscard]] std::optional cost_only_creation(const Graph& g, const std::shared_ptr& support, NodeId src, std::span dsts, const PathSelection& selection, PriorityFn view, FlowPlacement placement, Admission admission); -// ---------------------------------------------------------------- allocation (16.2, 16.3) +// ---------------------------------------------------------------- allocation // The cumulative allocation alloc(T) of a Weighted or EqualBalanced plan, member-aligned: // node by node in topological order, the source's inflow T, every other node's inflow the sum @@ -189,21 +194,21 @@ struct CreationProducts { // (member-aligned) it stops at the first member above its limit and returns false. bool allocate(const KernelPlan& plan, Amount total, std::span out, std::span limit = {}); -// The largest T whose allocation fits `view_m` (member-aligned) member by member, by search on -// alloc(T) (exact: alloc is non-decreasing member by member, so fits(T) is monotone); upper -// bound the source members' view. APPROACH 7 (revision 26): approximate arithmetic only -// chooses probes. The first probe is at a floating-point estimate, then a galloping bracket, -// then bisection; every predicate, both bracket ends, the value and the allocation are exact, -// and `iterations` counts the probes, which stay within 62 (the bound of a plain bisection over -// units below 2^62; checked, std::logic_error otherwise). A first evaluation at the upper bound -// (or a Lossless step's target) is another allocation evaluation, not a search probe. +// Find the largest T whose allocation fits each entry of member-aligned view_m. The upper +// bound is the sum of the view on source members. Each member's allocation never decreases as +// T grows, so a fitting and a failing T bound the answer. A floating-point estimate chooses +// the first probe only; then double the step size to narrow the bounds and bisect them. +// All fit checks, bounds, the returned value and allocation are exact. `iterations` counts +// at most 62 search probes (enough to bisect the units domain below 2^62); throw std::logic_error +// if that bound is exceeded. Evaluating the upper bound first, or a Lossless step's target, +// is an allocation evaluation and is not counted as a search probe. struct TMax { Amount value {0}; std::int32_t iterations {0}; }; [[nodiscard]] TMax t_max(const KernelPlan& plan, std::span view_m); -// One Lossless step of one flow (APPROACH 9.2 stage 3, 16.3): T_new = max(T, min(T + request, +// One Lossless step of one flow (the amounts stage): T_new = max(T, min(T + request, // T_max(V))) on the replacement view V = W + own (member-aligned `view_m`), with `own_m` the // flow's committed allocation alloc(T) (member-aligned). MaxFlow solves the flow core on the // split graph with cap T + request (uncapped for +inf) and reports the solve's marginal curve; diff --git a/include/netgraph/core/detail/ledger_access.hpp b/include/netgraph/core/detail/ledger_access.hpp index 9496627..aa91acf 100644 --- a/include/netgraph/core/detail/ledger_access.hpp +++ b/include/netgraph/core/detail/ledger_access.hpp @@ -1,9 +1,10 @@ /* The ledger's internal entry points: what a policy uses to place on and remove from it. - APPROACH 8: the ledger is mutated only through policies. A policy binds (an owner id), then - every call ends with one atomic exchange of all of its records (`commit`); remove() reverts - its allocations and retires its ids (`detach`); a policy destroyed without remove() leaves - its records as orphans (`release`). None of this is public API. + Policies place and remove flows through these entry points; an unbound ledger can also be + reset, through its public reset(), which is not one of them. A policy binds (an owner id), + then every call ends with one atomic exchange of all of its records (`commit`); remove() + reverts its allocations and retires its ids (`detach`); a policy destroyed without remove() + leaves its records as orphans (`release`). None of this is public API. */ #pragma once diff --git a/include/netgraph/core/detail/node_map.hpp b/include/netgraph/core/detail/node_map.hpp index 6af17a5..55afbd3 100644 --- a/include/netgraph/core/detail/node_map.hpp +++ b/include/netgraph/core/detail/node_map.hpp @@ -20,12 +20,8 @@ namespace netgraph::core::detail { // use. One instance per thread (`local()`); not reentrant: a caller holding it must not call // another routine that uses it. // -// reset() sizes both arrays for the graph, so add() never allocates: it is a few instructions, -// noexcept and inlined into the callers' per-edge loops. An out-of-line add() (it grew its list -// with push_back) saved and restored callee-saved registers on every call, and in some process -// layouts those stack reloads stalled: the flow core's subset solve then ran about 1.45 times -// slower for a whole process (the bimodal `kernel.proportional.grid` blocks; NOTES -// "Bimodality"). +// reset() sizes both arrays for the graph, so add() never allocates and is noexcept. +// Keep add() inline: it runs in the callers' per-edge loops, where call overhead matters. class NodeMap { public: void reset(std::size_t num_nodes) { diff --git a/include/netgraph/core/detail/policy_engine.hpp b/include/netgraph/core/detail/policy_engine.hpp index d9bab86..fbe135c 100644 --- a/include/netgraph/core/detail/policy_engine.hpp +++ b/include/netgraph/core/detail/policy_engine.hpp @@ -1,4 +1,4 @@ -/* The state of a FlowPolicy (internal; APPROACH 8 and 9). The transaction lives in +/* The state of a FlowPolicy (internal). The transaction lives in src/flow_policy.cpp; the evaluators, the private bindings and the tests read this state through PolicyAccess. */ #pragma once @@ -52,28 +52,29 @@ struct PolicyEngine { std::vector destinations {}; std::vector dests_ok {}; // the allowed destinations (the records' destination set) std::vector roster {}; // fixed at construction, ascending - std::vector baseline {}; // CapacityAware: the static shortest cost per roster source + std::vector baseline {}; // CapacityAware with a Stretch ceiling only: the static shortest + // cost per roster source (empty otherwise) std::shared_ptr support {}; // CostOnly: the full static support std::vector bundles {}; // Pinned std::vector usable {}; // Pinned: indices of usable bundles std::int64_t owner {0}; - // Epoch state. + // State accumulated since construction or the last remove() (the policy epoch). std::vector> cohorts {}; // per roster index, in FlowId order - std::vector> curve {}; // Global: the cumulative marginal curve (9.1) + std::vector> curve {}; // Global: the cumulative marginal curve Counter128 requested {}; bool infinite {false}; // Global: a capture request for the next solve's final state (the exact sensitivity model's - // baseline, APPROACH 11); set and cleared by the evaluator. + // baseline); set and cleared by the evaluator. DeletionCapture* capture {nullptr}; }; // The internal access to a policy's engine (the pattern of LedgerAccess): FlowPolicy's public -// interface is the contract's; everything else reaches the state here. +// interface is the documented API; everything else reaches the state here. struct PolicyAccess { [[nodiscard]] static const PolicyEngine& engine(const FlowPolicy& policy) noexcept { return *policy.engine_; } [[nodiscard]] static PolicyEngine& engine(FlowPolicy& policy) noexcept { return *policy.engine_; } // A policy constructed inside another public call, whose tally records a supplied frozen - // cache's hits (NOTES R27); returned as a prvalue (FlowPolicy does not move). + // cache's hits; returned as a prvalue (FlowPolicy does not move). [[nodiscard]] static FlowPolicy make(FlowLedger& ledger, std::span sources, std::span destinations, FlowPolicyConfig config, const Eligibility* elig, DagCache* cache, FrozenTally& tally) { @@ -81,7 +82,7 @@ struct PolicyAccess { } }; -// The configuration checks of FlowPolicy (APPROACH 9; enumeration values first, rule 13), +// The configuration checks of FlowPolicy (enumeration values first, then the combinations), // shared with the evaluators' argument checks. void validate_config(const FlowPolicyConfig& cfg, std::size_t num_sources, std::size_t num_destinations); diff --git a/include/netgraph/core/detail/pred_dag_builder.hpp b/include/netgraph/core/detail/pred_dag_builder.hpp index 1e3dc35..0bc674b 100644 --- a/include/netgraph/core/detail/pred_dag_builder.hpp +++ b/include/netgraph/core/detail/pred_dag_builder.hpp @@ -12,12 +12,11 @@ namespace netgraph::core::detail { struct PredDAGBuilder { - // The DAG of an edge set known to be acyclic and duplicate-free: per node its predecessor - // entries (edge_src, e) in ascending EdgeId. Not validated beyond what the callers guarantee. - // A small set (8 |edges| < N) is grouped by sorting, O(|edges| log |edges|), with nothing - // proportional to the graph's size; a larger one by a stable counting sort over a temporary - // N + 1 array, O(|edges| + N) (a sort first when the input is not ascending). Nothing retained - // is proportional to the graph's size. + // Build a DAG from acyclic, duplicate-free edges; callers guarantee these preconditions. + // Group predecessor entries (edge_src, e) by node, with ascending EdgeId within each node. + // For graph node count N and 8 |edges| < N, sort in O(|edges| log |edges|) without graph-sized + // scratch. Otherwise use stable counting sort with an N + 1 temporary in O(|edges| + N), + // sorting first if edges are not ascending. Retained storage never scales with graph size. [[nodiscard]] static PredDAG of_edges(const Graph& g, std::span edges); // The DAG without entries. [[nodiscard]] static PredDAG empty(const Graph& g); diff --git a/include/netgraph/core/detail/private_hooks.hpp b/include/netgraph/core/detail/private_hooks.hpp index 6f09fb4..3c9c393 100644 --- a/include/netgraph/core/detail/private_hooks.hpp +++ b/include/netgraph/core/detail/private_hooks.hpp @@ -1,15 +1,13 @@ /* The private hooks of the development build (internal). CMake option NETGRAPH_CORE_PRIVATE_HOOKS: ON for the editable development build and the test suites, OFF for an installed wheel. The - hooks are test controls and prototype-parity diagnostics that the contract does not name: - a lowered creation cap, an injected sensitivity model failure and the transaction trace. - Without the option none of them exists: the engine reads the constant kMaxFlowsPerCall, - sensitivity has no injection branch, and every trace block is a compile-time-dead branch - (kTraced), so no trace code is emitted. */ + hooks declared here are test controls and diagnostics the tests compare with their + independent model of the policy; none of them is public API: + a lowered creation cap and the transaction trace. + Without the option neither is built: the engine reads the constant kMaxFlowsPerCall, and every + trace write is a compile-time-dead branch (kTraced), so only empty trace values are built. */ #pragma once -#ifndef NETGRAPH_CORE_PRIVATE_HOOKS -#define NETGRAPH_CORE_PRIVATE_HOOKS 0 -#endif +#include "netgraph/core/detail/hooks_flag.hpp" namespace netgraph::core::detail { @@ -34,12 +32,8 @@ namespace netgraph::core::detail { // lowers it to reach the cap on small graphs). std::atomic& max_flows_per_call() noexcept; -// The sensitivity candidate whose deletion raises a model exception (-1: none). No valid -// configuration raises under an edge deletion (every construction check is -// deletion-independent), so this is how the tests reach `unevaluable` (APPROACH 11). -std::atomic& sensitivity_fail_edge() noexcept; - -// FlowPolicy::place with the transaction's trace (prototype 2's keys). `staged`, when given, runs +// FlowPolicy::place with the transaction's trace (a Trace: the call's intermediate values, +// keyed as the tests' model of the policy keys them). `staged`, when given, runs // once the result and the trace are final and before anything is published (the commit, the // epoch state): an exception from it aborts the call with nothing changed (the binding builds // its Python objects there). diff --git a/include/netgraph/core/detail/trace.hpp b/include/netgraph/core/detail/trace.hpp index ecf3d64..a35ddad 100644 --- a/include/netgraph/core/detail/trace.hpp +++ b/include/netgraph/core/detail/trace.hpp @@ -1,6 +1,10 @@ -/* The transaction trace of a placement call (internal diagnostics for the tests and the - prototype parity check): a small JSON-like value whose keys follow prototype 2's trace. Built - only in a build with the private hooks, when a call asks for it (detail::place_traced). */ +/* The transaction trace of a placement call (internal diagnostics for the tests, which compare + it key by key with the same trace of their independent model of the policy): a small + JSON-like value of the call's intermediate values, per source the offer, the rows of the + stages (creation, reoptimisation, filling, growth) and the local outcome, then the flows + created and the final working residual; for Global the solve's input, result and counters. + Filled only in a build with the private hooks, when a call asks for it (detail::place_traced); + other calls build only empty values. */ #pragma once #include diff --git a/include/netgraph/core/detail/traversal.hpp b/include/netgraph/core/detail/traversal.hpp index 4ad0be6..b9efd4b 100644 --- a/include/netgraph/core/detail/traversal.hpp +++ b/include/netgraph/core/detail/traversal.hpp @@ -1,21 +1,24 @@ -/* The one traversal core of every search (internal; APPROACH 6 and 14): public spf, the - policies' route searches, the DagCache's supports and KSP's spur searches. - - One Dijkstra loop, parametrised by the direction (Forward scans a node's out-edge id range, - Reverse its in-edge slots) and the queue (a binary heap, or monotone cyclic buckets when the - graph's costs fit 65,536 buckets), both popping in (distance, node) order. Positive costs make - the set of tight edges d(u) + cost(e) = d(v) of a fixed member graph unique (2.4), whatever the - pop order among equal distances; the loop records them in per-node lists while it runs. An edge - is a candidate when it passes static eligibility, capacity >= 1 unit and the residual gate; - Collapse (multi_edge = false) keeps one member per (u, v) pair by select_adjacency when the - tight edges are emitted (it never changes a distance). With targets the loop stops at the first - target popped: every node nearer than that distance d* is settled by then, so every target at - d* has its final distance, and the walks back from the targets at d* are the anycast-pruned DAG. - The canonical single path is extracted from that DAG in stages (section 6) with the search's - own distances as the topological order: no second traversal. - - Every call leases the thread's workspace (node arrays reset through touched lists); the lease - restores it on every exit, exceptions included, and overlapping leases are refused. +/* Shared traversal for spf, policy routes, cached shortest-path supports and KSP alternatives. + A support contains every edge on the eligible shortest paths. A KSP spur search finds an + alternative suffix from a node on an earlier path. + + Dijkstra finalises the nearest pending node, scanning out-edges for Forward and in-edges + for Reverse. Both heap and cyclic distance-bucket queues pop in (distance, node) order; + buckets are used when the graph's costs fit 65,536 buckets. A candidate must pass static + eligibility, have capacity >= 1 unit, and pass any residual-capacity threshold. + A tight edge extends a shortest path exactly: d(u) + cost(e) = d(v) in traversal direction. + Positive costs make these edges unique for a fixed member graph regardless of equal-distance + pop order. Record them in per-node lists; on output, multi_edge = false (Collapse) selects + one member per (u, v) by select_adjacency, without changing distances. + With targets, stop when the first target at distance d* is popped. Every nearer node is + already final, so positive costs ensure every target at d* has its final distance too. + Keep only tight edges on walks to these nearest targets (anycast pruning). + Extract the single path from that DAG by bottleneck under PreferHigherPriority (largest + minimum edge priority), then fewest hops, then smallest EdgeId sequence. Increasing search + distance orders every predecessor before its child, so no second traversal is needed. + Each call exclusively borrows the thread's workspace (a lease). Reset it on every exit, + including exceptions: the touched nodes only, or the whole arrays when a quarter or more of + the nodes were touched; refuse overlapping leases. */ #pragma once @@ -24,6 +27,7 @@ #include #include +#include "netgraph/core/detail/hooks_flag.hpp" #include "netgraph/core/graph.hpp" #include "netgraph/core/options.hpp" #include "netgraph/core/pred_dag.hpp" @@ -46,7 +50,7 @@ struct Priority { [[nodiscard]] Priority priority_value(EdgePriority priority, Amount residual, Amount capacity) noexcept; // The per-edge priority values a search reads: `residual` empty (no residual) reads the static -// capacity for every priority (APPROACH 10); else priority_value on the residual. +// capacity for every priority; else priority_value on the residual. struct PriorityValues { const Graph* g {nullptr}; EdgePriority priority {EdgePriority::Capacity}; @@ -63,7 +67,7 @@ struct PriorityFn { [[nodiscard]] static PriorityFn of(const PriorityValues& values) noexcept; }; -// select_adjacency (APPROACH 6): whether member `e` replaces `cur`, the choice so far among the +// select_adjacency: whether member `e` replaces `cur`, the choice so far among the // eligible members of one (u, v) pair seen in ascending EdgeId (cur < e): the lower cost, then // under PreferHigherPriority (`prefer`) the higher priority value; on a tie the lower EdgeId // (cur) stays. `value(e)` reads the priority value of an edge on the view of the selection. @@ -103,13 +107,13 @@ struct SearchRoute { Cost cost {kUnreachable}; }; -// CapacityAware's search (APPROACH 9.1): forward from `src` toward the destination set (allowed +// CapacityAware's search: forward from `src` toward the destination set (allowed // ones; disjoint from src) over the candidates of `gate`, Collapse and the staged single path by // `selection` with the priority values on `gate.residual`; nullopt when no destination is reached. [[nodiscard]] std::optional route_search(const Graph& g, const Gate& gate, const PathSelection& selection, NodeId src, std::span dests); -// CostOnly's projection of a cached full static support (APPROACH 9.1, 10): the support edges +// CostOnly's projection of a cached full static support: the support edges // reachable from `src` (ascending `support`: a reverse multipath DAG rooted at the destination // set), Collapse by `selection` on the priority values `view` (no residual gating), and for // multipath = false the staged canonical single path on that part, its distances derived from the @@ -117,27 +121,29 @@ struct SearchRoute { [[nodiscard]] SearchRoute support_route(const Graph& g, std::span support, NodeId src, std::span dests, const PathSelection& selection, PriorityFn view); -// The full static support of a search from (Forward) or toward (Reverse) the root set (every root -// in range; excluded roots skipped): every tight edge of the statically eligible member graph -// (multipath, multi_edge, no residual) over the nodes it reaches, forward-oriented (the DagCache's -// value, and a CostOnly policy's DAG when it has no cache). +// The full static support of a search from (Forward) or toward (Reverse) the root set (a +// normalised set: nonempty, in range, ascending, no duplicates; excluded roots skipped): every +// tight edge of the statically eligible member graph (multipath, multi_edge, no residual) over the +// nodes it reaches, forward-oriented (the DagCache's value, and a CostOnly policy's DAG when it has +// no cache). The empty DAG when no root is allowed. [[nodiscard]] PredDAG static_support(const Graph& g, std::span roots, Direction direction, const Eligibility* elig); -// The static cost from each of `sources` to the nearest of `dests` (allowed ones) over the -// statically eligible edges of at least 1 unit, kUnreachable where there is no walk (the +// The static cost from each of `sources` to the nearest of `dests` (in range, ascending, no +// duplicates, possibly empty; allowed ones) over the statically eligible edges of at least 1 unit, +// kUnreachable where there is no walk, and everywhere when no destination is allowed (the // CapacityAware and Global rosters and the Stretch baselines). [[nodiscard]] std::vector static_costs(const Graph& g, const Eligibility* elig, std::span dests, std::span sources); -// The rule-13 check of a cost ceiling (APPROACH 6; KSP and CapacityAware): Absolute's cost >= 0, +// The validation of a cost ceiling (KSP and CapacityAware): Absolute's cost >= 0, // Stretch's factor finite and in [1, inf); std::invalid_argument naming max_path_cost. void check_ceiling(const std::optional& ceiling); #if NETGRAPH_CORE_PRIVATE_HOOKS -// Test evidence (C++ tests, hooks builds only): the queue of every search this thread ran. +// Test evidence (C++ tests, hooks builds only): how many of this thread's searches ran on the +// bucket queue; a search that leaves the count unchanged ran on the heap. struct QueueCounts { - std::int64_t heap {0}; std::int64_t buckets {0}; }; QueueCounts& thread_queue_counts() noexcept; diff --git a/include/netgraph/core/eligibility.hpp b/include/netgraph/core/eligibility.hpp index c9ca76a..b3ae8a1 100644 --- a/include/netgraph/core/eligibility.hpp +++ b/include/netgraph/core/eligibility.hpp @@ -1,5 +1,6 @@ -/* Eligibility: which nodes and edges of one graph may be used (APPROACH 4). Constructed only by - Graph::eligibility(excluded_nodes, excluded_edges) (graph.hpp, which includes this header). */ +/* Eligibility: which nodes and edges of one graph may be used. + Built only by Graph::eligibility(excluded_nodes, excluded_edges) (graph.hpp, which includes + this header) and by intersect, which combines two values of one graph. */ #pragma once #include diff --git a/include/netgraph/core/evaluators.hpp b/include/netgraph/core/evaluators.hpp index 3fe7d0e..f6c68f4 100644 --- a/include/netgraph/core/evaluators.hpp +++ b/include/netgraph/core/evaluators.hpp @@ -1,12 +1,11 @@ -/* The evaluators of APPROACH 11: capacity (a FlowPolicy placement of +inf on a fresh ledger; the - exact maximum flow is capacity() with routing Global and placement MaxFlow), batch_capacity - (capacity per pair) and sensitivity (the loss of deleting each candidate edge). */ +/* The evaluators: capacity (a FlowPolicy placement of +inf on + a fresh ledger; the exact maximum flow is capacity() with routing Global and placement + MaxFlow), batch_capacity (capacity per pair) and sensitivity (the loss of deleting each + candidate edge). */ #pragma once #include -#include #include -#include #include #include @@ -33,15 +32,11 @@ struct SensitivityOptions { OutputOptions outputs {}; }; -enum class SensitivityStatus { Evaluated, Unevaluable }; - // One candidate edge: loss = baseline placed - placed without the edge (signed, in units, never -// thresholded); an unevaluable deletion has no loss and the model exception's message in reason. +// thresholded). struct SensitivityEntry { EdgeId edge {0}; - std::optional loss {}; - SensitivityStatus status {SensitivityStatus::Evaluated}; - std::string reason {}; + Amount loss {0}; }; struct SensitivityResult { @@ -57,41 +52,38 @@ struct SensitivityResult { [[nodiscard]] PlacementResult capacity(const Graph& g, std::span sources, std::span destinations, const CapacityOptions& opts); -// capacity() per pair, in pair order, with opts.elig intersected with per_pair_elig[i] (null -// entries allowed; an empty span means none: the Python binding checks the length of every -// sequence it is given). Validated before any worker starts, the arguments first (workers >= 1, -// opts.elig's and the cache's graph, a supplied building cache only with workers == 1: it has -// one writer, while a frozen cache is shared by every worker; Pinned rejected: bundles bind -// endpoints; the per_pair_elig length; the -// configuration), then each pair completely in pair order (its endpoint sets, their overlap, -// its Eligibility's graph), so the lowest failing pair index is reported. Without a supplied -// cache each worker owns a private building cache, allocated before any thread starts. A worker -// exception aborts the batch and the exception of the lowest pair index is raised; an empty -// batch returns an empty list. A frozen cache's DagCacheMiss propagates unchanged; a per-pair -// intersection that differs from both operands has a fresh uid, absent from a frozen cache -// (APPROACH 10: already effective per-pair values with a null common value, or a common value -// containing them, reuse the prepopulated operand). +// Run capacity() per pair, returning results in pair order. Intersect opts.elig with +// per_pair_elig[i]; null entries are allowed, and an empty span supplies no per-pair values. +// The Python binding checks the length of every supplied sequence. +// Before starting workers, validate in order: workers >= 1; opts.elig's and the cache's graph; +// a supplied building cache with workers > 1; Pinned (rejected because bundles bind endpoints); +// the configuration; and per_pair_elig's length. Then validate each pair completely in index +// order: endpoint sets, overlap, Eligibility's graph. This reports the lowest failing pair index. +// A supplied building cache requires workers == 1 because it allows one writer. Workers share +// a supplied frozen cache, and the one worker uses a supplied building cache; without a supplied +// cache each worker gets a private building cache before threads start. +// A worker exception aborts the batch; rethrow the exception from the lowest pair index. +// An empty batch returns an empty list. DagCacheMiss always propagates unchanged. +// An intersection equal to an operand reuses its uid and any prepopulated cache entry. +// Otherwise it gets a fresh uid, absent from a frozen cache. To reuse entries, supply already +// effective per-pair values with no common Eligibility, or one that contains them. [[nodiscard]] std::vector batch_capacity( const Graph& g, const std::vector, std::vector>>& pairs, const CapacityOptions& opts, std::span per_pair_elig, std::int32_t workers); -// The baseline capacity() and one entry per candidate edge: under Global the edges with -// positive flow in the baseline; under every other model every edge opts.elig allows with at -// least 1 unit of static capacity. A loss is the baseline's placed minus the placed amount of -// capacity() with the edge deleted (opts.elig without it; the same configuration and cache). -// Global evaluates a deletion by the flow core's deletion re-solve from the baseline solve's -// final state (APPROACH 2.2: the minimum-cost flow of the maximum value without the edge, that -// value exactly), on one copy of the state per worker; an exact deletion on valid inputs always -// has an optimum, so any failure there propagates. CostOnly gives an edge outside every roster -// source's reachable part of the static support loss 0 without a placement, a result-equivalent -// predicate (no route uses the edge, so the deletion changes no route and no amount); every -// other deletion is placed on a fresh ledger, where a model exception (std::runtime_error) makes -// the entry unevaluable with its message as the reason. Every other exception propagates (the -// lowest candidate's). Workers and the cache -// rule as batch_capacity; a worker's private cache is cleared per candidate (fresh Eligibility -// uids never hit). CostOnly is the one model reading the cache: with a frozen cache a -// placed deletion raises DagCacheMiss (a fresh uid), which propagates unchanged (it is not a -// std::runtime_error, so never `unevaluable`); the other models read no cache. +// Return baseline capacity() and one entry per candidate edge. Global candidates carry +// positive baseline flow; other models use every edge opts.elig allows with capacity >= 1 unit. +// Loss is baseline placed minus placed with that edge deleted, using the same config and cache. +// Global starts from the baseline solve's final state and repairs it after deleting the edge, +// using one state copy per worker. The result is the exact maximum flow at minimum cost. +// CostOnly skips a deletion when no roster source's reachable part of the static shortest-path +// support contains the edge: no route uses it, so loss is 0. All other deletions use a fresh +// ledger. A failed deletion fails the call (no partial result), raising the exception of the +// lowest failing candidate index. +// Worker and cache rules are as in batch_capacity. Clear each worker's private cache per +// candidate because deletion Eligibility uids are fresh and cannot reuse entries. +// Only CostOnly reads the cache. A deletion that needs placement with a frozen cache raises +// DagCacheMiss for its fresh uid. [[nodiscard]] SensitivityResult sensitivity(const Graph& g, std::span sources, std::span destinations, const SensitivityOptions& opts, std::int32_t workers); diff --git a/include/netgraph/core/flow_ledger.hpp b/include/netgraph/core/flow_ledger.hpp index cdd421f..5377736 100644 --- a/include/netgraph/core/flow_ledger.hpp +++ b/include/netgraph/core/flow_ledger.hpp @@ -1,4 +1,4 @@ -/* The integer ledger FlowLedger (APPROACH 8). */ +/* The integer ledger FlowLedger. */ #pragma once #include @@ -19,11 +19,11 @@ namespace detail { struct LedgerAccess; } // namespace detail -// The ledger of one graph: an immutable baseline residual in units and the records of the -// flows placed on it. The ledger is mutated only through policies (bound to it by the -// internal entry points of detail::LedgerAccess); there is no public placement, removal or -// reservation. Aggregates are exact: allocated() is the sum of every record's edges and -// residual() = baseline - allocated(), both int64[E] snapshots in EdgeId order. +// A ledger tracks the flows placed on one graph. Its baseline is the available capacity in +// units before these flows; its residual is the capacity still unused. Policies alone place +// or remove flows through detail::LedgerAccess; there is no public placement, removal or +// reservation. allocated() is the exact sum of all records' edge amounts, and +// residual() = baseline - allocated(); both return int64[E] snapshots in EdgeId order. // // Lifetime: the graph outlives the ledger (shared pointer), the ledger outlives its policies. // A ledger with bound policies cannot be copied, moved or reset. A copy of an unbound ledger @@ -54,9 +54,10 @@ class FlowLedger { // One record; a FlowId of another ledger, a retired id or an unknown id throws // std::invalid_argument. [[nodiscard]] FlowRecord record(const FlowId& flow_id) const; - // Saturation diagnostic: the edges (statically eligible, capacity >= 1 unit) leaving the set - // reachable from the allowed sources over eligible forward arcs with residual >= 1 unit, in - // ascending EdgeId. Not a cut; empty when the sources reach everything they can. + // Saturation diagnostic: follow eligible forward edges with residual >= 1 unit from the + // allowed sources. Return statically eligible edges with capacity >= 1 unit leaving that + // reachable set, in ascending EdgeId; empty if no such edge remains. This is not a minimum-cut + // certificate: it does not follow reverse arcs that could cancel existing flow. [[nodiscard]] std::vector bottleneck(std::span sources, const Eligibility* elig = nullptr) const; // A new baseline (the capacities when absent); orphaned records and their allocations are // cleared and their ids retired. Rejected (std::invalid_argument) while a policy is bound. diff --git a/include/netgraph/core/flow_policy.hpp b/include/netgraph/core/flow_policy.hpp index f72734f..0056036 100644 --- a/include/netgraph/core/flow_policy.hpp +++ b/include/netgraph/core/flow_policy.hpp @@ -1,4 +1,4 @@ -/* The policy engine FlowPolicy (APPROACH 8 and 9). */ +/* The policy engine FlowPolicy. */ #pragma once #include @@ -25,8 +25,9 @@ struct PolicyAccess; class FrozenTally; } // namespace detail -// CapacityAware creations per call (stage 1 and accepted growth, across sources) before the -// call stops with limit_flows (APPROACH 9.2): an engine constant. +// CapacityAware creations per call (stage 1 and accepted growth, across sources): reaching it +// stops creation, and a source whose offer is not met then ends with LimitFlows. An engine +// constant. inline constexpr std::int64_t kMaxFlowsPerCall = 10000; // The diminishing-returns guard of CapacityAware growth: stop when at least `window` growth @@ -37,17 +38,18 @@ struct DiminishingReturns { double fraction {0.5}; }; -// The routing modes (APPROACH 9.1): a field exists only in the variant it applies to. -// CostOnly: IGP forwarding, one flow per source on the static shortest-path DAG (or its -// canonical single path), never refreshed, grown or reoptimised. -// CapacityAware: distributed CSPF tunnels: flow_count flows created by the canonical search on -// the selection view, grown up to max_flow_count (absent: no ceiling), gated by -// min_flow_bandwidth (units) and the max_path_cost ceiling, optionally reoptimised and guarded -// by `diminishing` (absent: off). -// Global: the omniscient controller, the minimum-cost flow of the cumulative value over the -// whole eligible graph (a replacement kernel; placement MaxFlow only). -// Pinned: explicit route structures, one flow per usable bundle in supply order (single -// endpoints; a Path converts with to_dag()). +// Each routing mode has only the fields it uses. +// CostOnly: one flow per source on the static shortest-path DAG (all shortest paths, with no +// directed cycles), or its canonical single path; never refreshed, grown or reoptimised. +// CapacityAware: flow_count flows found by shortest-path searches that require enough unused +// capacity on each edge. Grow up to max_flow_count (absent: no ceiling), subject to +// min_flow_bandwidth (units) and max_path_cost. Rerouting is optional; `diminishing` +// stops growth when recent gains are too small (absent: off). +// Global: replace the policy's allocation with a minimum-cost flow of what it already delivers +// plus this call's request (placed_demand() + volume: what an earlier call could not place is +// not requested again) over the whole eligible graph (placement MaxFlow only). +// Pinned: one flow per usable supplied route bundle, in supply order; single endpoints only. +// Convert a Path with to_dag(). struct CostOnly {}; struct CapacityAware { std::int32_t flow_count {1}; @@ -63,12 +65,12 @@ struct Pinned { }; using Routing = std::variant; -// The four outer axes are explicit (no defaults). Global and Pinned read selection.priority only -// for the Weighted kernel (membership and weights) and ignore the rest; CostOnly and -// CapacityAware read all four fields (APPROACH 9). The constructor rejects a placement, -// admission or selection field outside its enumeration (std::invalid_argument naming the field; -// rule 13); the fields stay assignable, so the policy and the evaluators check them again with -// the combination rules. +// routing, placement, admission and selection are required; none has a default. +// Global and Pinned use selection.priority only for Weighted membership and weights, and +// ignore the other selection fields. CostOnly and CapacityAware use all four selection fields. +// Setting an ignored field is not an error. Invalid placement, admission or selection enum +// values throw std::invalid_argument naming the field. Fields remain assignable, so policies +// and evaluators check them again, then check the supported combinations. struct FlowPolicyConfig { FlowPolicyConfig(Routing routing_, FlowPlacement placement_, Admission admission_, PathSelection selection_); Routing routing; @@ -86,13 +88,32 @@ struct OutputOptions { bool bottleneck {false}; }; -// The local outcome of a source and the call's termination (APPROACH 9.4). +// A source is served if its offer is 0, Lossless delivers the offer, or Lossy forwards the +// entire finite offer (including drops). Choose its first matching outcome: +// Complete: served. +// LimitFlows: creation/growth hit kMaxFlowsPerCall, or growth hit max_flow_count > flow_count. +// LimitDiminishing: the diminishing-returns guard stopped growth. +// NoRoute: no flows remain; for Global, maximum flow is 0 and nothing was ever placed. +// Exhausted: any other unserved case, including full flows with no growth, a full fixed-size +// group of flows, Global at maximum flow, or an infinite Lossy offer. +// Global and Pinned act as one source: every local outcome equals the call's termination. +// The call is Complete for request 0 or when all sources are served, and NoRoute for a positive +// request with an empty roster: no eligible source statically reaches an allowed destination. +// Otherwise, take the highest unserved outcome: LimitFlows > LimitDiminishing > Exhausted > +// NoRoute. enum class Termination { Complete, LimitFlows, LimitDiminishing, NoRoute, Exhausted }; -// The result of one placement call: the deltas of this call (placed, remaining = request - -// placed, nullopt for +inf; drops = this call's transit drops), the termination and one local -// outcome per roster source (ascending NodeId), the two flags of the 9.4 table; Global adds the -// marginal cost curve of its cumulative solve. The optional outputs are present when requested. +// Per-call changes: placed, remaining = request - placed (nullopt for +inf), and transit drops. +// Include the termination and one local outcome per roster source in ascending NodeId. +// Global also returns its cumulative marginal cost curve: (cost, units) pairs in ascending cost, +// where delivering `units` more units raises the minimum total cost of the flow by `cost` per +// unit. Delivering more can reroute flow already placed, so the pairs are not the costs of the +// paths in the final allocation. Optional outputs appear only if asked. +// Both change flags start false and report committed changes only. routes_changed means a +// source's group of flows was created (even at zero delivery), a retained flow was rerouted, +// growth added a flow, or Global changed a source's used-edge set. weights_changed means a +// rerouted Weighted flow captured its weights again; Global never sets it. A candidate equal +// to the current route is not a change. struct PlacementResult { Amount placed {0}; std::optional remaining {}; @@ -105,25 +126,34 @@ struct PlacementResult { std::optional> edge_flows {}; std::optional> residual {}; std::optional> bottleneck {}; - // Global with bottleneck requested: the full-residual cut when the solve was maximal on its - // view (an empty roster's maximal flow 0 has the empty cut); nullopt otherwise: not requested, - // not Global, a solve stopped by its cap, or a zero call (nothing is solved). + // Global, when bottleneck is requested: positive-view edges leaving the set reachable from + // the real sources through residual arcs, including reverse arcs that cancel flow. A maximal + // solve returns this minimum cut; an empty roster returns an empty cut for its maximal flow + // of 0. nullopt if not requested, not Global, stopped by the cap, or a zero call (no solve). std::optional> min_cut {}; }; -// The one placement engine (APPROACH 9). A policy binds to its ledger at construction and is the -// only way flows enter it: place() runs the transaction of 9.2 and commits one atomic exchange; -// remove() reverts every flow of the policy, retires the ids and opens a new epoch; destroying a -// policy without remove() leaves its records in the ledger as orphans. +// A policy binds to its ledger at construction. place() validates, then Global re-solves what +// it already delivers plus the request; other modes create, reroute, fill and grow flows per roster source using +// one working copy of unused capacity. All changes commit together, or none do. +// remove() releases every policy flow, retires their ids and starts a new epoch: the period +// since construction or the last remove() over which requests and flows accumulate. Destroying +// a policy without remove() leaves its records in the ledger as orphans. // // Validation (std::invalid_argument, before anything binds): the endpoint sets (non-empty, ids // in range, disjoint), a foreign Eligibility or DagCache, the configuration (enumeration values; // Lossy with MaxFlow, Global or CapacityAware; Global without MaxFlow; Weighted with Headroom; -// flow_count >= 1; max_flow_count >= flow_count; min_flow_bandwidth >= 0; the ceiling; the -// diminishing guard; Pinned: single endpoints, a non-empty bundle list of this graph without -// duplicates, every bundle with a src -> dst walk before pruning). place() validates the -// request ([0, 2^62) units or +inf; a finite request after +inf in the epoch is rejected, also -// 0) before any change. +// flow_count >= 1; max_flow_count >= flow_count; min_flow_bandwidth in [0, 2^62) units; the +// ceiling; the diminishing guard; Pinned: single endpoints, a non-empty bundle list of this graph +// without null or duplicate bundles, every bundle with a src -> dst walk before pruning). The order between +// these three groups of checks is not specified: when defects of two groups meet, the one +// reported may differ between this constructor and a language binding. place() validates the request ([0, 2^62) units or +inf; a +// finite request after +inf in the epoch is rejected, also 0) before any change. A solve of the +// flow core (Global, or a MaxFlow flow) whose network exceeds the core's int32 numbering throws +// std::invalid_argument, and the call changes nothing: more than INT32_MAX - 2 nodes, or more than +// 2^30 - 1 edges, sources and destinations together (Global counts the graph's nodes, the +// eligible edges, the roster and the eligible destinations; a MaxFlow flow the nodes of its +// member edges and endpoints, its member edges, its source and its destinations). // // Lifetime: the ledger, the Eligibility and the DagCache outlive the policy (the policy holds // pointers to them). The roster (the eligible sources that statically reach an allowed @@ -156,7 +186,7 @@ class FlowPolicy { private: friend struct detail::PolicyAccess; // Construction inside another public call (an evaluator): a supplied frozen cache's hits are - // recorded in that call's tally (NOTES R27). + // recorded in that call's tally. FlowPolicy(FlowLedger& ledger, std::span sources, std::span destinations, FlowPolicyConfig config, const Eligibility* elig, DagCache* cache, detail::FrozenTally& tally); std::unique_ptr engine_; diff --git a/include/netgraph/core/flow_record.hpp b/include/netgraph/core/flow_record.hpp index b3b8c3f..1f1440e 100644 --- a/include/netgraph/core/flow_record.hpp +++ b/include/netgraph/core/flow_record.hpp @@ -1,4 +1,4 @@ -/* Flow identities, the 128-bit counter and the ledger records (APPROACH 8). */ +/* Flow identities, the 128-bit counter and the ledger records. */ #pragma once #include @@ -17,7 +17,8 @@ namespace netgraph::core { using FlowId = std::pair; // A cumulative counter (offered, per-edge drops): a sum over calls, the 128-bit value -// hi * 2^64 + lo, always below 2^127 (APPROACH 2.1, 2.3). +// hi * 2^64 + lo, always below 2^127. Such sums over many calls may exceed 64 bits; the +// Python binding returns them as exact int. struct Counter128 { std::uint64_t hi {0}; std::uint64_t lo {0}; @@ -35,11 +36,11 @@ struct Counter128 { } }; -// One flow of the ledger, as a snapshot (records(), record(id)). `bundle` is the flow's route -// structure (its structural identity); `route_cost` the cached minimum walk cost from the -// sources to the destinations over that bundle (kUnreachable when it has no such walk); -// `offered` exists on Lossy records only (the injection basis); `edges` and `drops` list -// positive values in ascending EdgeId; per-call and cumulative drop totals are derived. +// A snapshot of one ledger flow, returned by records() or record(id). `bundle` is its route +// structure; `route_cost` is the minimum walk cost from its sources to its destinations over +// that bundle, or kUnreachable if none exists. Lossy records alone have `offered`: the cumulative +// amount submitted to this flow before any drops. `edges` and `drops` contain positive values +// in ascending EdgeId; per-call and cumulative drop totals are derived. struct FlowRecord { FlowId flow_id {}; std::int64_t owner {0}; diff --git a/include/netgraph/core/graph.hpp b/include/netgraph/core/graph.hpp index c261fcc..1b2f907 100644 --- a/include/netgraph/core/graph.hpp +++ b/include/netgraph/core/graph.hpp @@ -1,4 +1,5 @@ -/* The one graph type: an immutable directed multigraph on integer units (APPROACH 3). */ +/* An immutable directed graph that allows several edges between the same ordered node pair. + Capacities use integer units. */ #pragma once #include @@ -14,37 +15,40 @@ namespace detail { struct GraphAccess; } // namespace detail -// Converts a physical value to units of the resolution q, rounding half up. The only way a -// physical number enters Core. Throws std::invalid_argument for NaN, infinity, a negative value, -// a result at or above the domain bound 2^62, or a resolution that is not a power of two in -// [2^-32, 2^32]. +// Convert a physical value to units of resolution q (the physical size of one unit). +// Round to the nearest integer, with exact halves rounded up. This is the only way a physical +// value enters Core. Throw std::invalid_argument for NaN, infinity, a negative value, a result +// >= 2^62, or a resolution that is not a power of two in [2^-32, 2^32]. [[nodiscard]] Amount to_units(double x, double resolution); // Units to a physical double (x * q): exact below 2^53 units. [[nodiscard]] double as_float(Amount x, double resolution); -// Edges are given in input order. The public EdgeId of an edge is its rank in the stable order -// (src, cost, dst, input): the out-edges of u are exactly the ids [row_offsets()[u], -// row_offsets()[u + 1]) in traversal order (cost, dst, input), and input_index()[e] is the input -// position of e. Every other per-edge array is in id order. The reverse CSR lists the in-edges -// of v in (cost, src, input) order. +// Edges arrive in input order. Their public EdgeIds are their positions after sorting by +// (src, cost, dst, input). Node u's out-edges are [row_offsets()[u], row_offsets()[u + 1]), +// ordered by (cost, dst, input); input_index()[e] gives e's input position. All other per-edge +// arrays use EdgeId order, except in_adj_edge_index() (reverse-CSR slot order). CSR (compressed +// sparse row) stores each node's edges in a flat array and uses offsets to mark its range. +// Reverse CSR lists v's in-edges in (cost, src, input) order. // // The constructor validates in this order and reports the first defect (std::invalid_argument): // the resolution (a power of two in [2^-32, 2^32]); the unit values (every capacity in // [0, 2^62)); the array shapes (num_nodes >= 0, equal lengths, at most INT32_MAX edges); zero -// or negative costs (costs are positive, rule 28); the remaining bounds (endpoints in -// [0, num_nodes), then the sum of all capacities and the sum of all costs below 2^62, so no path -// cost overflows int64). This is APPROACH 3's order (revision 25); a language binding validates -// in the same order before narrowing any value, so the reported defect does not depend on the -// representation of the input. +// or negative costs (costs are positive); the remaining bounds (endpoints in +// [0, num_nodes), then the capacity and cost sums below 2^62, so no path cost overflows int64: +// both are added up edge by edge in input order, and the first edge at which a running sum +// reaches 2^62 reports that sum, the capacity sum before the cost sum at the same edge). The +// order is part of the interface: a language binding validates in the same order before +// narrowing any value, so the reported defect does not depend on the representation of the +// input. // -// Construction (APPROACH 3): stable counting passes by dst, cost rank and src give the id order, -// two more by cost rank and dst the reverse CSR; when N K < E, combined keys do it in two forward -// passes (dst, then (src, rank)) and one reverse pass ((dst, rank)). An edgeless graph is its -// validation and two zero-filled offset arrays. The cost ranks come from the distinct costs -// collected in a hash table while there are at most 65,536 of them and a cumulative probe budget -// of 8E + 8 x 65,536 table slots lasts, else from a sort of all costs; both give the same ranks. -// Expected linear in E + N for a bounded number of distinct costs, O(N + E log(E + 1)) at worst. -// Retained: 36 bytes per edge plus 8 (N + 1) bytes of offsets. +// Construction uses N nodes, E edges and K distinct costs. A cost's rank is its position among +// the sorted distinct costs. Stable counting passes group by each key without changing ties: +// dst, cost rank, src give EdgeId order; cost rank, dst give reverse CSR. When N K < E, use +// dst then (src, rank) forward, and (dst, rank) reverse. With no edges, only validation and two +// zero-filled offset arrays are needed. Rank costs through a hash table up to 65,536 distinct +// costs and 8E + 8 x 65,536 examined slots; otherwise sort all costs. Both give the same ranks. +// Expected O(E + N) for bounded K; O(N + E log(E + 1)) worst case. +// Retained arrays: 36 bytes per edge plus 8 (N + 1) bytes of offsets. // // A copy is a new graph: same ids and input map, a new uid. Uids come from one process-wide // 64-bit counter and are never reused. A move transfers every array and the uid without @@ -75,7 +79,8 @@ class Graph { [[nodiscard]] std::span cost() const noexcept { return cost_; } [[nodiscard]] std::span edge_src() const noexcept { return src_; } [[nodiscard]] std::span edge_dst() const noexcept { return dst_; } - // Full scans at construction; 0 on a graph without edges. + // Maximum edge cost and greatest common divisor of all edge costs, computed by full scans + // at construction; both are 0 on a graph without edges. [[nodiscard]] Cost max_cost() const noexcept { return max_cost_; } [[nodiscard]] Cost cost_gcd() const noexcept { return cost_gcd_; } // id -> input position. The inverse permutation is the caller's (one O(E) scatter). @@ -90,7 +95,7 @@ class Graph { // Static eligibility from excluded ids (duplicates and unsorted lists accepted, out-of-range // ids rejected). Empty lists mean all allowed (uid 0). The one public construction of an - // Eligibility (eligibility.hpp). + // Eligibility from excluded ids (Eligibility::intersect combines two values). [[nodiscard]] Eligibility eligibility(std::span excluded_nodes = {}, std::span excluded_edges = {}) const; diff --git a/include/netgraph/core/options.hpp b/include/netgraph/core/options.hpp index 7fae6bc..6a9a01d 100644 --- a/include/netgraph/core/options.hpp +++ b/include/netgraph/core/options.hpp @@ -1,6 +1,6 @@ -/* The shared small option types: the selection axes of the searches and the policies (APPROACH 6) - and the placement kinds of the kernels (APPROACH 7). Component configurations and result - statuses stay with their component. */ +/* The shared small option types: the selection axes of the searches and the policies and the + placement kinds of the kernels. Component configurations and result statuses stay with their + component. */ #pragma once #include @@ -9,13 +9,13 @@ namespace netgraph::core { -// The orientation of a search: Forward from the roots, or Reverse (rooted at the roots over -// reversed arcs; the DAG it returns is forward-oriented, toward the roots). +// Forward follows edges from the roots; Reverse follows them backwards toward the roots. +// The returned DAG (a directed graph with no cycles) keeps the edges' original orientation. enum class Direction { Forward, Reverse }; -// One cost ceiling knob (APPROACH 6): an Absolute cost budget (inclusive, >= 0), or a Stretch -// factor (in [1, inf)) of the static shortest cost under the given Eligibility, read as the exact -// dyadic rational it is and compared without rounding. +// Inclusive cost ceiling: Absolute gives a budget >= 0; Stretch multiplies the static shortest +// cost under the given Eligibility by a factor in [1, inf). Use the double's exact stored +// value, a dyadic rational (an integer divided by a power of two), with no comparison rounding. struct Absolute { Cost cost {0}; friend bool operator==(const Absolute&, const Absolute&) = default; @@ -26,26 +26,27 @@ struct Stretch { }; using CostCeiling = std::variant; -// How ties between equal-cost candidates are broken, at both tie levels: the member chosen -// among parallel edges of one (u, v) pair and the single path chosen among equal-cost walks. -// Deterministic ignores capacities entirely (the lowest EdgeId wins); PreferHigherPriority -// prefers the higher `priority` value first (then the lowest EdgeId). +// Break equal-cost ties first between parallel edges, then between walks. +// For one (u, v) pair, Deterministic picks the lowest EdgeId; PreferHigherPriority picks the +// higher priority, then lowest EdgeId. For a single path (multipath = false), +// PreferHigherPriority first maximises the smallest edge priority along the walk (bottleneck); +// Deterministic ignores capacities. Both then prefer fewer hops, then the lexicographically +// smallest EdgeId sequence: compare at the first differing edge. Choose parallel members +// first (multi_edge = false), then choose the walk among the remaining members. enum class EdgeTieBreak { Deterministic, PreferHigherPriority }; -// The per-edge value read by PreferHigherPriority and, for the Weighted kernel, its membership -// and weights: the residual units on the view of the search (Residual), the static capacity -// units (Capacity), or the exact rational residual / capacity (Headroom; rejected with -// Weighted). A search given no residual reads static capacities for every priority. +// Per-edge priority for PreferHigherPriority and for Weighted membership and weights: +// Residual is unused capacity in units on the search's view; Capacity is static capacity in +// units; Headroom is the exact ratio residual / capacity (rejected with Weighted). +// Without a supplied residual, a search uses static capacity as the value for every priority. enum class EdgePriority { Residual, Capacity, Headroom }; -// The selection of a search: `multipath` keeps every equal-cost walk (the shortest-path DAG) -// instead of the one canonical single path; `multi_edge` keeps every eligible parallel member -// of a (u, v) pair instead of the one chosen by (minimum cost, priority under -// PreferHigherPriority, lowest EdgeId). tie_break and priority have no default (APPROACH 9: an -// outer axis is explicit); the two flags default to the plain form of their mechanism. The -// constructor rejects a tie_break or priority outside its enumeration (std::invalid_argument -// naming the field; rule 13); the fields stay assignable, so every entry that reads a selection -// checks it again. +// `multipath` keeps all equal-cost walks as a shortest-path DAG; false selects the canonical +// single path. `multi_edge` keeps all eligible parallel edges between each (u, v) pair; false +// keeps one by (minimum cost, higher priority under PreferHigherPriority, lowest EdgeId). +// Both flags default to true. tie_break and priority are required. Invalid enum values throw +// std::invalid_argument naming the field. Fields remain assignable, so every entry point that +// uses a selection validates it again. struct PathSelection { PathSelection(EdgeTieBreak tie_break_, EdgePriority priority_, bool multipath_ = true, bool multi_edge_ = true); EdgeTieBreak tie_break; @@ -55,10 +56,13 @@ struct PathSelection { friend bool operator==(const PathSelection&, const PathSelection&) = default; }; -// The kernel that computes one flow's pushes on its DAG: MaxFlow (the flow core of the -// minimum-cost flow restricted to the flow's split graph, a replacement on V = W + own), -// Weighted (hop-by-hop WCMP: highest-averages apportionment over frozen weights at every node) -// and EqualBalanced (ECMP: round robin at every node). +// A placement kernel assigns one flow's amounts to its usable route edges (its split graph). +// MaxFlow replaces its allocation with a minimum-cost flow on the split graph over +// V = W + own, where W is unused capacity and own is this flow's current allocation. Weighted +// splits at each node using weights fixed at creation: give each next unit to the edge with +// largest w / (a + 1), where w is its weight and a its share so far; ties use lower EdgeId. +// EqualBalanced splits equally at each node, giving leftover units to edges in ascending +// EdgeId order. enum class FlowPlacement { MaxFlow, Weighted, EqualBalanced }; // Lossless admits only what fits; Lossy forwards hop by hop without admission control and diff --git a/include/netgraph/core/pred_dag.hpp b/include/netgraph/core/pred_dag.hpp index 233f510..d887333 100644 --- a/include/netgraph/core/pred_dag.hpp +++ b/include/netgraph/core/pred_dag.hpp @@ -1,4 +1,4 @@ -/* Route structures: the immutable predecessor DAG and the explicit Path (APPROACH 5). */ +/* Route structures: the immutable predecessor DAG and the explicit Path. */ #pragma once #include @@ -15,14 +15,14 @@ struct PredDAGBuilder; struct PathBuilder; } // namespace detail -// An immutable, graph-bound DAG in predecessor CSR form: the predecessor entries of node v are -// parents()[k] and via_edges()[k] for k in [parent_offsets()[v], parent_offsets()[v + 1]); the -// entry (u, e) means the edge e = u -> v belongs to the DAG. Within a node the entries are in -// strictly ascending via EdgeId, so a DAG value has one canonical form. Equality is structural -// (same graph, same CSR). Every API that takes a route structure takes a PredDAG; a DAG of -// another graph is rejected wherever it is used. +// An immutable predecessor DAG: a directed graph with no cycles, recording each node's +// incoming edges. It belongs to one graph. CSR (compressed sparse row) groups entries in flat +// arrays: node v uses parents()[k] and via_edges()[k] for +// k in [parent_offsets()[v], parent_offsets()[v + 1]); (u, e) means edge e = u -> v is included. +// Entries per node have strictly ascending via EdgeId, giving one canonical form. Equality +// means the same graph and CSR. Route APIs take PredDAG and reject one from another graph. // -// Storage is sparse (APPROACH 8, revision 27): the entries are kept per member node (the nodes +// Storage is sparse: the entries are kept per member node (the nodes // with at least one entry, ascending, and their offsets), never as an N + 1 offsets array, so a // flow's bundle costs O(its entries) whatever the graph's size. parent_offsets() presents the // from_csr shape on demand (a fresh N + 1 array); parents() and via_edges() are the stored diff --git a/include/netgraph/core/search.hpp b/include/netgraph/core/search.hpp index d1dd2c8..42ed9a7 100644 --- a/include/netgraph/core/search.hpp +++ b/include/netgraph/core/search.hpp @@ -1,6 +1,6 @@ -/* The public search (APPROACH 5 and 6): spf from a set of roots, forward or reverse, with an - optional set of targets (anycast); ksp, the K shortest paths in the canonical order; and - resolve_to_paths, a DAG unfolded into explicit paths. */ +/* The public search: spf from a set of roots, forward or reverse, + with an optional set of targets (anycast); ksp, the K shortest paths in the canonical order; + and resolve_to_paths, a DAG unfolded into explicit paths. */ #pragma once #include @@ -16,20 +16,21 @@ namespace netgraph::core { -// The frontier queue of a search (C++ only; the results never depend on it). Auto takes the -// monotone bucket queue when the graph's costs fit max_cost / max(1, cost_gcd) + 1 <= 65,536 -// buckets, else the binary heap; Heap always takes the heap. Both pop in (distance, node) order -// and give identical results. +// The queue holds nodes waiting for their shortest distance to be finalised (C++ only). +// Auto groups them into cyclic distance buckets if max_cost / max(1, cost_gcd) + 1 <= 65,536; +// cost_gcd is the greatest common divisor of edge costs. Otherwise use a binary heap, which +// keeps the smallest key first; Heap always uses it. Both pop in (distance, node) order and +// give identical results. The bucket limit bounds bucket storage. enum class SpfQueue { Auto, Heap }; -// The options of spf (APPROACH 6). direction and selection are explicit; targets absent is a -// full search (every reachable node settled), present a search that stops at the nearest -// targets (anycast). A supplied residual (int64[E] in EdgeId order, each entry in -// [0, capacity_units]) is borrowed for the call; it adds residual gating at min_residual units -// and supplies the residual values of the selected priority. min_residual (an integer >= 1) gates -// a supplied residual only: static eligibility (the Eligibility, capacity >= 1 unit) is -// unconditional. The constructor rejects a direction outside its enumeration -// (std::invalid_argument naming the field); the fields stay assignable, so spf checks them again. +// spf requires direction and selection. Without targets, finalise every reachable node's +// shortest distance; with targets, stop at the nearest targets (anycast). +// A supplied residual gives unused capacity: int64[E] in EdgeId order, each in +// [0, capacity_units], borrowed for this call. Only edges with residual >= min_residual are +// usable, and these values also supply residual-based priorities. min_residual must be >= 1 +// and applies only when residual is supplied. Eligibility and capacity >= 1 unit always apply. +// Invalid direction enum values throw std::invalid_argument naming the field. Fields remain +// assignable, so spf validates them again. struct SpfOptions { SpfOptions(Direction direction_, PathSelection selection_); Direction direction; @@ -63,13 +64,13 @@ struct SpfResult { [[nodiscard]] SpfResult spf(const Graph& g, std::span roots, const SpfOptions& opts, SpfQueue queue = SpfQueue::Auto); -// The options of ksp (APPROACH 6, rule 10). k (>= 1) and selection are explicit; the selection's -// multi_edge is the member rule (Expand keeps every eligible parallel member, Collapse one per -// (u, v) pair by select_adjacency) and multipath is ignored. max_path_cost: an inclusive ceiling, -// Absolute(c) (c >= 0) or Stretch(f) (f finite in [1, inf), read as the exact dyadic rational it -// is) times the static shortest cost of the member graph under the Eligibility, residual ignored. -// A supplied residual (validated as for spf, borrowed for the call) gates at 1 unit and supplies -// the priority values. The fields stay assignable; ksp checks them. +// ksp requires k >= 1 and a selection. multi_edge = true keeps all eligible parallel edges; +// false keeps one per (u, v) pair by select_adjacency. multipath is ignored. +// max_path_cost is inclusive: Absolute(c), c >= 0, or Stretch(f), finite f in [1, inf), times +// the static shortest cost under the Eligibility, ignoring residual capacity. f is compared +// exactly, as for CostCeiling. +// A residual is validated as for spf and borrowed for the call; edges need at least 1 unused +// unit, and it supplies priority values. Fields remain assignable, so ksp validates them. struct KspOptions { KspOptions(std::int64_t k_, PathSelection selection_); std::int64_t k; @@ -79,7 +80,7 @@ struct KspOptions { const Eligibility* elig {nullptr}; }; -// The K shortest simple src -> dst paths of the member graph in increasing (cost, -bottleneck +// The K shortest src -> dst paths with no repeated node, ordered by (cost, -bottleneck // under PreferHigherPriority, hops, edge-id sequence), the bottleneck being the minimum priority // value along a path: the minimal prefix of that order, truncated at k and at the ceiling. Each // path is a Path of single-member hops with its cost. Validates in this order before any work @@ -88,10 +89,11 @@ struct KspOptions { // Stretch ceiling, or no path gives an empty list. [[nodiscard]] std::vector> ksp(const Graph& g, NodeId src, NodeId dst, const KspOptions& opts); -// The src -> dst walks of a DAG (APPROACH 5) in increasing (cost, hops, edge-id sequence): a hop's +// The src -> dst walks of a DAG in increasing (cost, hops, edge-id sequence): a hop's // cost is its cheapest member's, and a grouped hop is represented in the key by its lowest member // id. split_parallel_edges = false keeps every parallel member of the DAG's (u, v) pair in one hop -// (members ascending); true gives one path per member combination, in the same order. max_paths +// (members ascending); true gives one path per member combination, sorted by that path's own +// (cost, hops, edge-id sequence). max_paths // (>= 1) truncates the sorted list; absent, every walk. Validates (std::invalid_argument): a DAG of // another graph, src and dst in range, src == dst, max_paths >= 1. An endpoint outside the DAG, or // no walk, gives an empty list. diff --git a/include/netgraph/core/types.hpp b/include/netgraph/core/types.hpp index 916adef..e518372 100644 --- a/include/netgraph/core/types.hpp +++ b/include/netgraph/core/types.hpp @@ -1,4 +1,4 @@ -/* Core type aliases and the bounds of the units domain (APPROACH 2.1). */ +/* Core type aliases and the bounds of the units domain. */ #pragma once #include @@ -7,11 +7,12 @@ namespace netgraph::core { // Node and edge identifiers are signed 32-bit integers. An EdgeId is the rank of the edge in -// the stable order (src, cost, dst, input) of its graph (layout B'); the input position of an +// the stable order (src, cost, dst, input) of its graph; the input position of an // edge is Graph::input_index()[e]. using NodeId = std::int32_t; using EdgeId = std::int32_t; -// Edge and path costs: positive integers, never converted. +// Costs: integers, never converted. An edge cost is at least 1, so a path's cost is positive; a +// search's distance is 0 at its roots, and kUnreachable (below) marks no path. using Cost = std::int64_t; // An amount: an integer number of units of the graph's resolution. Capacities, views, flows and // requests are amounts; there is no finer grid. @@ -21,8 +22,10 @@ using Amount = std::int64_t; // all edge costs stay below 2^62. inline constexpr std::int64_t kDomainLimit = std::int64_t{1} << 62; -// The path-cost sentinel of an unreachable node or an empty route (None in Python). Costs are -// positive and their sum is below 2^62, so no path cost reaches it. +// The path-cost sentinel of an unreachable node or an empty route. Python keeps this value itself +// in the integer distance array SpfResult.costs and shows a single unreachable cost +// (SpfResult.target_cost, FlowRecord.route_cost) as None. Costs are positive and their sum is +// below 2^62, so no path cost reaches it. inline constexpr Cost kUnreachable = std::numeric_limits::max(); } // namespace netgraph::core diff --git a/pyproject.toml b/pyproject.toml index 472a411..a5625e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,8 @@ [build-system] -requires = ["scikit-build-core>=0.10", "pybind11>=3", "numpy>=1.23.2"] +# scikit-build-core 1.0 introduced the "explicit" sdist file selection used below. pybind11 is +# pinned: the wheel ships its licence notice (third_party/pybind11/LICENSE, this version's) and +# CMakeLists.txt requires the same version. NumPy is a run-time dependency only. +requires = ["scikit-build-core>=1.0", "pybind11==3.1.0"] build-backend = "scikit_build_core.build" [project] @@ -9,6 +12,8 @@ description = "C++ implementation of graph algorithms for network flow analysis readme = "README.md" requires-python = ">=3.11" license = "MIT" +# The wheel contains compiled pybind11 code, whose BSD-3-Clause licence asks for its notice. +license-files = ["LICENSE", "third_party/pybind11/LICENSE"] authors = [{ name = "Project Contributors" }] classifiers = [ "Development Status :: 3 - Alpha", @@ -31,11 +36,11 @@ dependencies = ["numpy>=1.23.2"] [project.optional-dependencies] dev = [ "pytest>=8", - "pytest-cov", - "pytest-benchmark", - "gcovr>=7", "ruff==0.11.13", "pyright==1.1.401", + # Generates python/netgraph_core/__init__.pyi (bash dev/stubs/run.sh generate); a new + # version can change the generated text, so it is pinned and updated deliberately. + "pybind11-stubgen==3.0.0", "pre-commit", "build", "twine", @@ -50,28 +55,77 @@ Homepage = "https://github.com/networmix/NetGraph-Core" wheel.expand-macos-universal-tags = true build-dir = "build/{wheel_tag}" cmake.version = ">=3.23" -# Developer tooling and research records are not part of the distribution. -sdist.exclude = ["dev/"] -# Include Python sources from the "python/" directory +# The sdist holds exactly the files listed here ("explicit": nothing outside them is taken, so a +# local file elsewhere in the checkout never ships): the build's sources, the package, the tests +# with their data, the documents and the licence notices. Left out: the developer tooling (dev/), +# the entrypoints that only run it (the Makefile, the CI workflows, the pre-commit +# configuration), the agent/workspace configuration, and the frozen reference that regenerates +# tests/data/policy_fixtures.json (tests/reference/, see its README; no test reads it). Release +# CI runs the same commit's tests from its checkout against an installation built from the +# sdist. This mode does not read .gitignore, so the local by-products it names that appear +# inside the listed directories (Python caches, compiled modules, Finder files, the backup and +# reject files of `patch`) are excluded here. The wheel takes its package files +# (python/netgraph_core) by the same rule. +# dev/stubs/check_contents.py repeats the list and refuses any other file, and any file git +# ignores. +sdist.inclusion-mode = "explicit" +sdist.include = [ + "/.coveragerc", + "/.gitignore", + "/CHANGELOG.md", + "/CMakeLists.txt", + "/CONTRIBUTING.md", + "/LICENSE", + "/README.md", + "/pyproject.toml", + "/bindings/", + "/docs/", + "/include/", + "/python/", + "/src/", + "/tests/", + "/third_party/", +] +sdist.exclude = [ + "/tests/reference/", + "__pycache__/", + "*.py[codz]", + "*.so", + ".DS_Store", + "*.orig", + "*.rej", +] +# The package directory ships whole: __init__.py, the generated type stub __init__.pyi and the +# py.typed marker (dev/stubs/run.sh check lists the wheel and the sdist and requires them). wheel.packages = ["python/netgraph_core"] +# Every project option is stated for every build: the build directory above is shared, CMake keeps +# an option's cached value when a later configure does not name it, and so a coverage, native or +# hooks setting of one build would otherwise leak into the next. A variant overrides one value on +# the command line (`pip install -C cmake.define.NETGRAPH_CORE_NATIVE=ON ...`, see the Makefile). +[tool.scikit-build.cmake.define] +NETGRAPH_CORE_PRIVATE_HOOKS = "OFF" +NETGRAPH_CORE_BUILD_TESTS = "OFF" +NETGRAPH_CORE_COVERAGE = "OFF" +NETGRAPH_CORE_SANITIZE = "OFF" +NETGRAPH_CORE_TSAN = "OFF" +NETGRAPH_CORE_NATIVE = "OFF" + [[tool.scikit-build.overrides]] # The editable development build carries the private test hooks (CMake option -# NETGRAPH_CORE_PRIVATE_HOOKS); a wheel or sdist build does not. +# NETGRAPH_CORE_PRIVATE_HOOKS); a wheel or sdist build does not. "append": the override changes +# this one value and keeps the other explicit defaults above (it would otherwise replace them). if.state = "^editable$" +inherit.cmake.define = "append" cmake.define.NETGRAPH_CORE_PRIVATE_HOOKS = "ON" [tool.pytest.ini_options] addopts = "-q" -# pytest without arguments collects the repository tests only, not saved evidence under dev/. testpaths = ["tests"] -markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"] [tool.ruff] line-length = 88 indent-width = 4 -# Recorded reviewer artefacts are kept verbatim. -extend-exclude = ["dev/reviews", "dev/design/2026-10-streamline/review", "dev/design/2026-10-streamline/review-*"] [tool.ruff.lint] select = ["E4", "E7", "E9", "F", "B", "I"] @@ -98,6 +152,3 @@ exclude = [ "**/*.egg-info/**", ] reportMissingTypeStubs = "warning" -reportUnknownMemberType = "none" -reportUnknownVariableType = "none" -reportUnknownArgumentType = "none" diff --git a/python/netgraph_core/__init__.py b/python/netgraph_core/__init__.py index 8cca271..dedfc25 100644 --- a/python/netgraph_core/__init__.py +++ b/python/netgraph_core/__init__.py @@ -1,26 +1,26 @@ -"""Runtime package surface for :mod:`netgraph_core`. +"""NetGraph-Core: graph, flow and placement models on exact integer units. -All public symbols are re-exported from the compiled ``_netgraph_core`` module so -downstream users import everything from a single place. Typing shims supplied in -``_docs.py`` keep editors happy without affecting runtime behavior. +Every public name is re-exported from the compiled ``_netgraph_core`` module, so users +import everything from here. Type checkers read the generated stub ``__init__.pyi`` next +to this file (``py.typed`` marks the package as typed). -The package carries the graph on integer units (``Graph``, ``Eligibility`` -with ``intersect``, ``to_units``, ``as_float``), the route structures (``PredDAG``, -``Path``), the selection and placement axes (``PathSelection``, ``EdgeTieBreak``, -``EdgePriority``, ``FlowPlacement``, ``Admission``), the integer ledger (``FlowLedger`` -with its ``FlowRecord`` snapshots), the policy engine (``FlowPolicy`` with -``FlowPolicyConfig`` over the routing variants ``CostOnly``, ``CapacityAware``, -``Global``, ``Pinned``; ``PlacementResult``, ``OutputOptions``, ``Termination``), the -``DagCache`` (building and frozen modes, ``DagCacheMiss``) and the evaluators (``capacity``, ``batch_capacity``, -``sensitivity``) and the public search (``spf`` with ``Direction``, ``SpfOptions``, -``SpfResult`` and the ``UNREACHABLE`` sentinel; ``ksp`` with ``KspOptions``; ``resolve_to_paths``). ``_netgraph_core`` also holds the private -entry of the flow core, which is never re-exported here. -""" +- The graph on integer units: ``Graph``, ``Eligibility``, ``to_units``, ``as_float``. +- Route structures: ``PredDAG``, ``Path``. +- Route selection and placement settings: ``PathSelection``, ``EdgeTieBreak``, + ``EdgePriority``, ``FlowPlacement``, ``Admission``. +- The ledger of placed traffic: ``FlowLedger`` and its ``FlowRecord`` snapshots. +- The policy engine: ``FlowPolicy``, ``FlowPolicyConfig``, the routings ``CostOnly``, + ``CapacityAware``, ``Global``, ``Pinned``, and ``PlacementResult``, ``OutputOptions``, + ``Termination``. +- The shortest-path cache: ``DagCache`` (building and frozen modes), ``DagCacheMiss``. +- The evaluators: ``capacity``, ``batch_capacity``, ``sensitivity``. +- The searches: ``spf`` (``Direction``, ``SpfOptions``, ``SpfResult``, ``UNREACHABLE``), + ``ksp`` (``KspOptions``), ``resolve_to_paths``. -# pyright: reportMissingImports=false +``_netgraph_core`` also holds a private entry of the flow core, never re-exported here. +""" -from importlib.metadata import version -from typing import TYPE_CHECKING +from importlib.metadata import version as _version from _netgraph_core import ( MAX_FLOWS_PER_CALL, @@ -55,7 +55,6 @@ SensitivityEntry, SensitivityOptions, SensitivityResult, - SensitivityStatus, SpfOptions, SpfResult, Stretch, @@ -70,61 +69,7 @@ to_units, ) -__version__ = version("netgraph-core") - -# Provide richer type information for editors/type-checkers without affecting runtime. -# Every name here must also appear in the runtime import above; the aliases only -# change what a type checker sees. Keep this list in sync with _docs.py, and keep -# _docs.py in sync with the bindings -- a wrong stub is worse than a missing one, -# because it turns "unchecked" into "confidently wrong". -if TYPE_CHECKING: # pragma: no cover - typing-only - from ._docs import ( # noqa: I001 - MAX_FLOWS_PER_CALL as MAX_FLOWS_PER_CALL, - UNREACHABLE as UNREACHABLE, - Absolute as Absolute, - Admission as Admission, - CapacityAware as CapacityAware, - CapacityOptions as CapacityOptions, - CostOnly as CostOnly, - DagCache as DagCache, - DagCacheMiss as DagCacheMiss, - DagCacheStats as DagCacheStats, - DiminishingReturns as DiminishingReturns, - Direction as Direction, - EdgePriority as EdgePriority, - EdgeTieBreak as EdgeTieBreak, - Eligibility as Eligibility, - FlowLedger as FlowLedger, - FlowPlacement as FlowPlacement, - FlowPolicy as FlowPolicy, - FlowPolicyConfig as FlowPolicyConfig, - FlowRecord as FlowRecord, - Global as Global, - Graph as Graph, - KspOptions as KspOptions, - OutputOptions as OutputOptions, - Path as Path, - PathSelection as PathSelection, - Pinned as Pinned, - PlacementResult as PlacementResult, - PredDAG as PredDAG, - SensitivityEntry as SensitivityEntry, - SensitivityOptions as SensitivityOptions, - SensitivityResult as SensitivityResult, - SensitivityStatus as SensitivityStatus, - SpfOptions as SpfOptions, - SpfResult as SpfResult, - Stretch as Stretch, - Termination as Termination, - as_float as as_float, - batch_capacity as batch_capacity, - capacity as capacity, - ksp as ksp, - resolve_to_paths as resolve_to_paths, - sensitivity as sensitivity, - spf as spf, - to_units as to_units, - ) +__version__ = _version("netgraph-core") __all__ = [ "__version__", @@ -159,7 +104,6 @@ "DagCacheStats", "CapacityOptions", "SensitivityOptions", - "SensitivityStatus", "SensitivityEntry", "SensitivityResult", "capacity", diff --git a/python/netgraph_core/__init__.pyi b/python/netgraph_core/__init__.pyi new file mode 100644 index 0000000..2c1d290 --- /dev/null +++ b/python/netgraph_core/__init__.pyi @@ -0,0 +1,1789 @@ +# Generated from the compiled module; do not edit. +"""NetGraph-Core: graph, flow and placement models on exact integer units. + +Every public name is re-exported from the compiled ``_netgraph_core`` module, so users +import everything from here. Type checkers read the generated stub ``__init__.pyi`` next +to this file (``py.typed`` marks the package as typed). + +- The graph on integer units: ``Graph``, ``Eligibility``, ``to_units``, ``as_float``. +- Route structures: ``PredDAG``, ``Path``. +- Route selection and placement settings: ``PathSelection``, ``EdgeTieBreak``, + ``EdgePriority``, ``FlowPlacement``, ``Admission``. +- The ledger of placed traffic: ``FlowLedger`` and its ``FlowRecord`` snapshots. +- The policy engine: ``FlowPolicy``, ``FlowPolicyConfig``, the routings ``CostOnly``, + ``CapacityAware``, ``Global``, ``Pinned``, and ``PlacementResult``, ``OutputOptions``, + ``Termination``. +- The shortest-path cache: ``DagCache`` (building and frozen modes), ``DagCacheMiss``. +- The evaluators: ``capacity``, ``batch_capacity``, ``sensitivity``. +- The searches: ``spf`` (``Direction``, ``SpfOptions``, ``SpfResult``, ``UNREACHABLE``), + ``ksp`` (``KspOptions``), ``resolve_to_paths``. + +``_netgraph_core`` also holds a private entry of the flow core, never re-exported here.""" + +from __future__ import annotations + +import collections.abc +import typing + +import numpy +import numpy.typing + +__all__: list[str] = [ + "__version__", + "Graph", + "Eligibility", + "to_units", + "as_float", + "PredDAG", + "Path", + "EdgeTieBreak", + "EdgePriority", + "PathSelection", + "FlowPlacement", + "Admission", + "FlowLedger", + "FlowRecord", + "Absolute", + "Stretch", + "DiminishingReturns", + "CostOnly", + "CapacityAware", + "Global", + "Pinned", + "FlowPolicyConfig", + "OutputOptions", + "Termination", + "PlacementResult", + "FlowPolicy", + "MAX_FLOWS_PER_CALL", + "DagCache", + "DagCacheMiss", + "DagCacheStats", + "CapacityOptions", + "SensitivityOptions", + "SensitivityEntry", + "SensitivityResult", + "capacity", + "batch_capacity", + "sensitivity", + "Direction", + "SpfOptions", + "SpfResult", + "UNREACHABLE", + "spf", + "KspOptions", + "ksp", + "resolve_to_paths", +] +__version__: str + +class FlowPlacement: + """ + How one flow spreads over its route (its kernel). MAX_FLOW: the minimum-cost flow core, re-solved + on the flow's route over the free capacity plus the flow's own allocation (it may move its own + earlier placement, never another flow's). WEIGHTED (WCMP, weighted-cost multipath): every node + splits in proportion to weights fixed when the flow is created (highest averages: whole-unit + shares as close to proportional as possible). EQUAL_BALANCED (ECMP, equal-cost multipath): every + node splits equally, by round robin. + + Members: + + MAX_FLOW + + WEIGHTED + + EQUAL_BALANCED + """ + + EQUAL_BALANCED: typing.ClassVar[FlowPlacement] + MAX_FLOW: typing.ClassVar[FlowPlacement] + WEIGHTED: typing.ClassVar[FlowPlacement] + __members__: typing.ClassVar[dict[str, FlowPlacement]] + + @typing.overload + def __eq__(self, other: FlowPlacement, /) -> bool: ... + @typing.overload + def __eq__(self, other: typing.Any, /) -> bool: ... + def __getstate__(self) -> int: ... + def __hash__(self) -> int: ... + def __index__(self) -> int: ... + def __init__(self, value: typing.SupportsInt | typing.SupportsIndex) -> None: ... + def __int__(self) -> int: ... + @typing.overload + def __ne__(self, other: FlowPlacement, /) -> bool: ... + @typing.overload + def __ne__(self, other: typing.Any, /) -> bool: ... + def __repr__(self) -> str: ... + def __setstate__( + self, state: typing.SupportsInt | typing.SupportsIndex, / + ) -> None: ... + def __str__(self) -> str: ... + @property + def name(self) -> str: ... + @property + def value(self) -> int: ... + +class Admission: + """ + LOSSLESS admits only what fits; LOSSY forwards hop by hop without admission control and + drops what does not fit on each edge (EQUAL_BALANCED and WEIGHTED only). + + Members: + + LOSSLESS + + LOSSY + """ + + LOSSLESS: typing.ClassVar[Admission] + LOSSY: typing.ClassVar[Admission] + __members__: typing.ClassVar[dict[str, Admission]] + + @typing.overload + def __eq__(self, other: Admission, /) -> bool: ... + @typing.overload + def __eq__(self, other: typing.Any, /) -> bool: ... + def __getstate__(self) -> int: ... + def __hash__(self) -> int: ... + def __index__(self) -> int: ... + def __init__(self, value: typing.SupportsInt | typing.SupportsIndex) -> None: ... + def __int__(self) -> int: ... + @typing.overload + def __ne__(self, other: Admission, /) -> bool: ... + @typing.overload + def __ne__(self, other: typing.Any, /) -> bool: ... + def __repr__(self) -> str: ... + def __setstate__( + self, state: typing.SupportsInt | typing.SupportsIndex, / + ) -> None: ... + def __str__(self) -> str: ... + @property + def name(self) -> str: ... + @property + def value(self) -> int: ... + +class EdgeTieBreak: + """ + How a search breaks cost ties. Between parallel members of one (u, v) pair + (multi_edge = False): DETERMINISTIC keeps the lowest EdgeId, PREFER_HIGHER_PRIORITY the + higher priority value, then the lowest EdgeId. Between equal-cost walks (multipath = + False): PREFER_HIGHER_PRIORITY first takes the highest bottleneck (the smallest priority + value along the walk), DETERMINISTIC skips that step; then the fewest hops, then the + lexicographically smallest EdgeId sequence. + + Members: + + DETERMINISTIC + + PREFER_HIGHER_PRIORITY + """ + + DETERMINISTIC: typing.ClassVar[EdgeTieBreak] + PREFER_HIGHER_PRIORITY: typing.ClassVar[EdgeTieBreak] + __members__: typing.ClassVar[dict[str, EdgeTieBreak]] + + @typing.overload + def __eq__(self, other: EdgeTieBreak, /) -> bool: ... + @typing.overload + def __eq__(self, other: typing.Any, /) -> bool: ... + def __getstate__(self) -> int: ... + def __hash__(self) -> int: ... + def __index__(self) -> int: ... + def __init__(self, value: typing.SupportsInt | typing.SupportsIndex) -> None: ... + def __int__(self) -> int: ... + @typing.overload + def __ne__(self, other: EdgeTieBreak, /) -> bool: ... + @typing.overload + def __ne__(self, other: typing.Any, /) -> bool: ... + def __repr__(self) -> str: ... + def __setstate__( + self, state: typing.SupportsInt | typing.SupportsIndex, / + ) -> None: ... + def __str__(self) -> str: ... + @property + def name(self) -> str: ... + @property + def value(self) -> int: ... + +class EdgePriority: + """ + The per-edge value PREFER_HIGHER_PRIORITY reads and, with WEIGHTED, the value that decides which + edges a flow uses and their weights: RESIDUAL (residual units), CAPACITY (static capacity units), + HEADROOM (residual / capacity, compared exactly as a fraction; rejected with WEIGHTED). + + Members: + + RESIDUAL + + CAPACITY + + HEADROOM + """ + + CAPACITY: typing.ClassVar[EdgePriority] + HEADROOM: typing.ClassVar[EdgePriority] + RESIDUAL: typing.ClassVar[EdgePriority] + __members__: typing.ClassVar[dict[str, EdgePriority]] + + @typing.overload + def __eq__(self, other: EdgePriority, /) -> bool: ... + @typing.overload + def __eq__(self, other: typing.Any, /) -> bool: ... + def __getstate__(self) -> int: ... + def __hash__(self) -> int: ... + def __index__(self) -> int: ... + def __init__(self, value: typing.SupportsInt | typing.SupportsIndex) -> None: ... + def __int__(self) -> int: ... + @typing.overload + def __ne__(self, other: EdgePriority, /) -> bool: ... + @typing.overload + def __ne__(self, other: typing.Any, /) -> bool: ... + def __repr__(self) -> str: ... + def __setstate__( + self, state: typing.SupportsInt | typing.SupportsIndex, / + ) -> None: ... + def __str__(self) -> str: ... + @property + def name(self) -> str: ... + @property + def value(self) -> int: ... + +class Direction: + """ + The orientation of a search: FORWARD from the roots along the edges, or REVERSE toward the roots + against the edges (the DAG it returns keeps the edges' own direction, so its walks lead to the + roots). + + Members: + + FORWARD + + REVERSE + """ + + FORWARD: typing.ClassVar[Direction] + REVERSE: typing.ClassVar[Direction] + __members__: typing.ClassVar[dict[str, Direction]] + + @typing.overload + def __eq__(self, other: Direction, /) -> bool: ... + @typing.overload + def __eq__(self, other: typing.Any, /) -> bool: ... + def __getstate__(self) -> int: ... + def __hash__(self) -> int: ... + def __index__(self) -> int: ... + def __init__(self, value: typing.SupportsInt | typing.SupportsIndex) -> None: ... + def __int__(self) -> int: ... + @typing.overload + def __ne__(self, other: Direction, /) -> bool: ... + @typing.overload + def __ne__(self, other: typing.Any, /) -> bool: ... + def __repr__(self) -> str: ... + def __setstate__( + self, state: typing.SupportsInt | typing.SupportsIndex, / + ) -> None: ... + def __str__(self) -> str: ... + @property + def name(self) -> str: ... + @property + def value(self) -> int: ... + +class PathSelection: + """ + The selection of a search: tie_break and priority are explicit; multipath = True keeps + every equal-cost walk (the DAG), False the one canonical single path; multi_edge = True + keeps every eligible parallel member, False one per (u, v) pair. A tie_break or priority + outside its enumeration raises ValueError. + """ + + def __eq__(self, arg0: typing.Any) -> bool: ... + def __hash__(self) -> int: ... + def __init__( + self, + tie_break: EdgeTieBreak, + priority: EdgePriority, + multipath: bool = True, + multi_edge: bool = True, + ) -> None: ... + def __repr__(self) -> str: ... + @property + def multi_edge(self) -> bool: + """ + True: every eligible parallel member; False: one member per (u, v) pair + """ + + @property + def multipath(self) -> bool: + """ + True: every equal-cost walk (a DAG); False: the canonical single path + """ + + @property + def priority(self) -> EdgePriority: + """ + the EdgePriority + """ + + @property + def tie_break(self) -> EdgeTieBreak: + """ + the EdgeTieBreak + """ + +class Eligibility: + """ + Which nodes and edges of one graph may be used: one flag per node and per edge, fixed when built + (it does not depend on traffic). Built by Graph.eligibility(excluded_nodes, excluded_edges) and + Eligibility.intersect. + """ + + def __init__(self, _no_public_constructor: typing.Never, /) -> None: ... + @staticmethod + def intersect(a: Eligibility, b: Eligibility) -> Eligibility: + """ + The intersection of two values of one graph (ValueError for values of different graphs): + an operand itself when the result equals it (a first, then b), otherwise a new value with a + fresh uid; equal intersections built separately get distinct uids. + """ + + def edge_allowed(self) -> numpy.typing.NDArray[numpy.bool_]: + """ + bool[E] read-only view (not excluded and both endpoints allowed) + """ + + def graph_uid(self) -> int: + """ + uid of the graph this value belongs to + """ + + def node_allowed(self) -> numpy.typing.NDArray[numpy.bool_]: + """ + bool[N] read-only view + """ + + def uid(self) -> int: + """ + 0 for the all-allowed value, a fresh uid otherwise + """ + +class Graph: + """ + Immutable directed multigraph on integer units (parallel edges and self-loops allowed). Edges are + given in input order and numbered by sorting on (src, cost, dst), ties kept in input order: that + number is the public EdgeId. input_index()[e] is the input position of edge e. Per-edge values + use EdgeId order; in_adj_edge_index() instead lists EdgeIds in incoming-slot order. Costs are + positive integers; capacities are int64 units of the explicit resolution q (a power of two in + [2^-32, 2^32]); physical capacities enter through to_units. + """ + + def __init__( + self, + num_nodes: typing.SupportsIndex, + src: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + dst: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + capacity_units: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + cost: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + resolution: typing.SupportsFloat | typing.SupportsIndex, + ) -> None: + """ + Validates, in order, and reports the first defect as ValueError: the resolution (a power of + two in [2^-32, 2^32]), the unit values (every capacity in [0, 2^62)), the array shapes, + zero or negative costs, the remaining bounds (endpoints in range, then the + capacity and cost sums below 2^62). Integers are read without narrowing, so the error + does not depend on how the input is represented. + """ + + def capacity_units(self) -> numpy.typing.NDArray[numpy.int64]: + """ + int64[E] capacities in units, id order (read-only view) + """ + + def cost(self) -> numpy.typing.NDArray[numpy.int64]: + """ + int64[E] costs, id order (read-only view) + """ + + def cost_gcd(self) -> int: + """ + gcd of the edge costs (0 without edges) + """ + + def edge_dst(self) -> numpy.typing.NDArray[numpy.int32]: + """ + int32[E] destination node per edge (read-only view) + """ + + def edge_src(self) -> numpy.typing.NDArray[numpy.int32]: + """ + int32[E] source node per edge (read-only view) + """ + + def eligibility( + self, + excluded_nodes: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] = (), + excluded_edges: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] = (), + ) -> Eligibility: + """ + Static eligibility from excluded node ids and EdgeIds (duplicates and unsorted lists + accepted, out-of-range ids rejected); an edge is allowed when it is not excluded and + both endpoints are allowed. Empty lists mean all allowed (uid 0). + """ + + def in_adj_edge_index(self) -> numpy.typing.NDArray[numpy.int32]: + """ + int32[E]: the in-edges of v in (cost, src, input) order, as EdgeIds + """ + + def in_row_offsets(self) -> numpy.typing.NDArray[numpy.int32]: + """ + int32[N + 1]: the in-edges of v are in_adj_edge_index()[in_row_offsets[v] : in_row_offsets[v + 1]] + """ + + def input_index(self) -> numpy.typing.NDArray[numpy.int32]: + """ + int32[E]: EdgeId -> input position (read-only view); build the inverse yourself if needed + """ + + def max_cost(self) -> int: + """ + largest edge cost (0 without edges) + """ + + def num_edges(self) -> int: + """ + E: EdgeIds are 0 .. E - 1 + """ + + def num_nodes(self) -> int: + """ + N: node ids are 0 .. N - 1 + """ + + def resolution(self) -> float: + """ + the resolution q (units of capacity) + """ + + def row_offsets(self) -> numpy.typing.NDArray[numpy.int32]: + """ + int32[N + 1]: the out-edges of u are the ids [row_offsets[u], row_offsets[u + 1]) + """ + + def uid(self) -> int: + """ + process-unique id (never reused) + """ + +class PredDAG: + """ + Immutable route structure of one graph, a predecessor DAG (directed, no cycle): for each node v, + the edges that reach v on the route and the node each comes from (its parent). The entries of v + are parents()[k], via_edges()[k] for k in [parent_offsets()[v], parent_offsets()[v + 1]), in + strictly ascending EdgeId, so two DAGs of the same graph with the same entries have equal arrays + and compare equal; DAGs of different graphs never compare equal. + parents() and via_edges() are read-only views that keep the DAG alive; parent_offsets() is a new + read-only array on each call (the DAG stores only the nodes that have entries). Built by + PredDAG.from_csr, Path.to_dag(), the searches and the policies (FlowRecord.bundle). + """ + + def __init__(self, _no_public_constructor: typing.Never, /) -> None: ... + @staticmethod + def from_csr( + graph: Graph, + offsets: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + parents: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + via: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + ) -> PredDAG: + """ + Validates, in order, before any traversal (ValueError on the first defect): offsets of + length N + 1, offsets[0] == 0, monotone, offsets[N] == len(parents) == len(via); then every + parent and via in range, every via joining (parent, child), no duplicate (child, via), + acyclic; within a node the entries in ascending via EdgeId. + """ + + def __eq__(self, arg0: typing.Any) -> bool: ... + def __hash__(self) -> int: ... + def __repr__(self) -> str: ... + def graph_uid(self) -> int: + """ + uid of the graph this DAG belongs to + """ + + def parent_offsets(self) -> numpy.typing.NDArray[numpy.int32]: + """ + int32[N + 1]: the entries of node v are [parent_offsets[v], parent_offsets[v + 1]); a new read-only array per call + """ + + def parents(self) -> numpy.typing.NDArray[numpy.int32]: + """ + int32 parent NodeId per entry (read-only view) + """ + + def via_edges(self) -> numpy.typing.NDArray[numpy.int32]: + """ + int32 via EdgeId per entry (read-only view) + """ + +class Path: + """ + Immutable route of one graph, hop by hop: each hop lists one or more parallel edges (members) + from the same u to the same v, in the caller's order. cost = the sum over hops of the cost of + each hop's cheapest member. + """ + + def __eq__(self, arg0: typing.Any) -> bool: ... + def __hash__(self) -> int: ... + def __init__( + self, + graph: Graph, + hops: collections.abc.Sequence[ + collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] + ] + | numpy.typing.NDArray[numpy.integer], + ) -> None: + """ + Validates, in order (ValueError on the first defect): every member id in range, no duplicate + member, a non-empty hop list, every hop non-empty, the members of a hop sharing (u, v), + contiguous hops, no repeated node. + """ + + def __repr__(self) -> str: ... + def cost(self) -> int: + """ + sum over hops of the minimum member cost + """ + + def dst(self) -> int: + """ + the last node of the walk + """ + + def graph_uid(self) -> int: + """ + uid of the graph this path belongs to + """ + + def hops(self) -> list[list[int]]: + """ + the hops as lists of EdgeIds (the caller's member order) + """ + + def src(self) -> int: + """ + the first node of the walk + """ + + def to_dag(self) -> PredDAG: + """ + the equivalent PredDAG (every member of every hop) + """ + +class FlowRecord: + """ + One flow of a ledger, as a snapshot: flow_id (ledger uid, id), owner (the policy's id), + sources, destinations, placement, admission, bundle (PredDAG: the flow's route), + route_cost (its minimum walk cost; None when empty), offered (Lossy only, else None), + delivered, edges [(EdgeId, units)] and drops [(EdgeId, units)] (cumulative, ascending + EdgeId). offered and the drops units are exact Python ints (they may exceed 64 bits). + """ + + def __init__(self, _no_public_constructor: typing.Never, /) -> None: ... + def __repr__(self) -> str: ... + @property + def admission(self) -> Admission: + """ + LOSSLESS or LOSSY + """ + + @property + def bundle(self) -> PredDAG: + """ + the flow's route (an immutable PredDAG shared with the ledger) + """ + + @property + def delivered(self) -> int: + """ + the units the flow delivers to its destinations (its total) + """ + + @property + def destinations(self) -> list[int]: + """ + the flow's destination node ids, ascending + """ + + @property + def drops(self) -> list[tuple[int, int]]: + """ + Lossy: the units dropped per edge [(EdgeId, units)], summed over calls, positive entries in + ascending EdgeId (exact ints); empty for a Lossless flow + """ + + @property + def edges(self) -> list[tuple[int, int]]: + """ + the flow's allocation [(EdgeId, units)], positive entries in ascending EdgeId + """ + + @property + def flow_id(self) -> tuple[int, int]: + """ + (ledger uid, id): ids are assigned in creation order and never reused in the ledger + """ + + @property + def offered(self) -> int | None: + """ + Lossy: the units offered to the flow, summed over calls (an exact int, may exceed 64 bits); + None for a Lossless flow + """ + + @property + def owner(self) -> int: + """ + the ledger-local id of the policy that created the flow + """ + + @property + def placement(self) -> FlowPlacement: + """ + the FlowPlacement (kernel) that spreads the flow over its route + """ + + @property + def route_cost(self) -> int | None: + """ + the cost of the cheapest walk of the bundle from the sources to the destinations; None when + the bundle has no such walk + """ + + @property + def sources(self) -> list[int]: + """ + the flow's source node ids, ascending + """ + +class FlowLedger: + """ + The integer ledger of one graph: a baseline residual in units (replaced only by reset()) and the + records of the flows placed on it by policies (the ledger has no public placement or removal). + allocated() is the sum of every record's edges and residual() = baseline - allocated(), both + int64[E] snapshots. reset() and copy() are rejected while a policy is bound (a FlowPolicy built + on this ledger still exists). + """ + + def __copy__(self) -> FlowLedger: ... + def __deepcopy__(self, memo: typing.Any) -> FlowLedger: ... + def __init__( + self, + graph: Graph, + residual: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] + | None = None, + ) -> None: + """ + residual: the baseline as integer units, one entry per edge in [0, capacity_units[e]] (a + physical baseline goes through to_units first); None: the capacities. ValueError otherwise. + """ + + def allocated(self) -> numpy.typing.NDArray[numpy.int64]: + """ + int64[E] snapshot: the sum of every record's edges + """ + + def bottleneck( + self, + sources: typing.SupportsIndex + | collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + elig: Eligibility | None = None, + ) -> list[int]: + """ + Saturation report: the eligible edges with capacity >= 1 unit that leave the set of nodes the + allowed sources can reach (following eligible edges forward with residual >= 1 unit), in + ascending EdgeId. It ignores the destinations, so it is not a source-destination cut. + """ + + def capacity_units(self) -> numpy.typing.NDArray[numpy.int64]: + """ + int64[E] the graph's capacities in units (read-only view) + """ + + def copy(self) -> FlowLedger: + """ + a new ledger (new uid) with the same baseline and records, ids kept under the new uid; + ValueError while a policy is bound + """ + + def graph(self) -> Graph: + """ + the ledger's graph + """ + + def record( + self, flow_id: tuple[typing.SupportsIndex, typing.SupportsIndex] + ) -> FlowRecord: + """ + one record by FlowId (ValueError for a FlowId of another ledger, a retired or an unknown id) + """ + + def records(self) -> list[FlowRecord]: + """ + live records as snapshots in FlowId order: those of bound policies and orphans (records left by a + policy destroyed without remove()) + """ + + def reset( + self, + residual: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] + | None = None, + ) -> None: + """ + A new baseline (None: the capacities); orphaned records and their allocations are cleared + and their ids retired (ids are never reused). ValueError while a policy is bound. + """ + + def residual(self) -> numpy.typing.NDArray[numpy.int64]: + """ + int64[E] snapshot: baseline - allocated + """ + + def uid(self) -> int: + """ + process-unique ledger uid (the first half of every FlowId) + """ + +class Absolute: + """ + Cost ceiling: an absolute, inclusive path-cost budget (cost >= 0). + """ + + def __eq__(self, arg0: typing.Any) -> bool: ... + def __hash__(self) -> int: ... + def __init__(self, cost: typing.SupportsIndex) -> None: + """ + Absolute(cost): a 64-bit integer budget in cost units (checked >= 0 where it is used) + """ + + def __repr__(self) -> str: ... + @property + def cost(self) -> int: + """ + the budget: a path qualifies when its cost is at most this + """ + +class Stretch: + """ + Cost ceiling: a route qualifies when its cost is at most factor times the static shortest cost + under the Eligibility; factor is in [1, inf), and the float is compared exactly, without rounding. + """ + + def __eq__(self, arg0: typing.Any) -> bool: ... + def __hash__(self) -> int: ... + def __init__(self, factor: float | numpy.floating | typing.SupportsIndex) -> None: + """ + Stretch(factor): a number, checked finite and >= 1 where it is used; a path qualifies when its + cost is at most factor times the static shortest cost. Here, an integer beyond the largest finite + float raises ValueError. + """ + + def __repr__(self) -> str: ... + @property + def factor(self) -> float: + """ + the factor as a float + """ + +class DiminishingReturns: + """ + The diminishing-returns guard of CapacityAware growth: stop once at least `window` + growth steps were accepted in the call and the last `window` gains sum below + `fraction` times the source's (finite) offer. window >= 1, fraction in (0, 1). + """ + + def __init__( + self, + window: typing.SupportsIndex, + fraction: float | numpy.floating | typing.SupportsIndex, + ) -> None: + """ + DiminishingReturns(window, fraction): a 32-bit integer and a number, checked (window >= 1, + fraction in (0, 1)) when a policy is built with them. Here, a window outside 32 bits or a fraction + that is an integer beyond the largest finite float raises ValueError. + """ + + def __repr__(self) -> str: ... + @property + def fraction(self) -> float: + """ + the share of the source's offer those steps must gain together to continue + """ + + @property + def window(self) -> int: + """ + the number of recent accepted growth steps compared + """ + +class CostOnly: + """ + Routing like IGP forwarding (OSPF, IS-IS routers): one flow per roster source on the static + shortest-path DAG (read from the DagCache; the selection is applied when the flow is created), + never refreshed, grown or reoptimised. + """ + + def __init__(self) -> None: + """ + CostOnly routing (no parameters) + """ + + def __repr__(self) -> str: ... + +class CapacityAware: + """ + Routing like distributed CSPF tunnels (each tunnel head picks its own shortest path among edges + with enough free capacity). Each source creates flow_count flows, one search at a time, on edges + with residual >= max(1, min_flow_bandwidth) units and within max_path_cost; while its offer is + unmet it adds flows up to max_flow_count (None: no ceiling). With reoptimize, a flow moves to a + new route only when it keeps its whole amount there. `diminishing` (None: off) stops adding flows + once the recent ones gain too little. + """ + + def __init__( + self, + *, + flow_count: typing.SupportsIndex = 1, + max_flow_count: typing.SupportsIndex | None = None, + max_path_cost: Absolute | Stretch | None = None, + min_flow_bandwidth: typing.SupportsIndex = 0, + reoptimize: bool = False, + diminishing: DiminishingReturns | None = None, + ) -> None: + """ + The fields are checked when a policy is built with them (flow_count >= 1, max_flow_count >= + flow_count, min_flow_bandwidth in [0, 2^62) units, the ceiling, the guard). Here, an integer field + of the wrong type or outside its integer type (32-bit flow_count and max_flow_count, 64-bit + min_flow_bandwidth) raises ValueError; a ceiling or guard of the wrong class, a reoptimize that is + not a bool, a number or None (a number or None counts by its truth value), and an unknown keyword + raise TypeError. + """ + + def __repr__(self) -> str: ... + @property + def diminishing(self) -> DiminishingReturns | None: + """ + the growth guard; None: off + """ + + @property + def flow_count(self) -> int: + """ + the flows a source creates on its first positive request + """ + + @property + def max_flow_count(self) -> int | None: + """ + the most flows growth may reach per source; None: no ceiling + """ + + @property + def max_path_cost(self) -> Absolute | Stretch | None: + """ + the inclusive route cost ceiling (Absolute or Stretch); None: no ceiling + """ + + @property + def min_flow_bandwidth(self) -> int: + """ + units in [0, 2^62): every search keeps only edges with at least max(1, this) residual units + """ + + @property + def reoptimize(self) -> bool: + """ + True: a retained flow moves to a newly found route when it keeps its whole amount there + """ + +class Global: + """ + Routing: an ideal central controller that sees the whole network. Every positive call re-solves + the minimum-cost flow of what the policy already delivers plus this call's request + (placed_demand() + volume; what an earlier call could not place is not requested again) over + the eligible graph, on the free capacity plus its own allocation (it may move its own traffic, + never another's). Placement MAX_FLOW only. A positive call on a network beyond the flow core's + int32 numbering raises ValueError (see FlowPolicy.place). + """ + + def __init__(self) -> None: + """ + Global routing (no parameters) + """ + + def __repr__(self) -> str: ... + +class Pinned: + """ + Routing on routes the caller gives: one flow per usable bundle (a PredDAG with at least one + src -> dst walk over eligible edges of at least 1 unit of capacity), in the given order; one + source and one destination. A Path converts with to_dag(). The list is copied at construction. + """ + + def __init__(self, bundles: collections.abc.Sequence[PredDAG]) -> None: + """ + Pinned(bundles): a sequence of PredDAG of one graph, copied; the policy checks them when it is + built (non-empty, no duplicates, each with a src -> dst walk) + """ + + def __repr__(self) -> str: ... + @property + def bundles(self) -> list[PredDAG]: + """ + the bundles in supply order (a new list of the shared, immutable PredDAG values) + """ + +class FlowPolicyConfig: + """ + The four explicit axes (independent settings) of a policy: routing (CostOnly, CapacityAware, + Global or Pinned), placement (FlowPlacement), admission (Admission) and selection + (PathSelection). A placement or admission outside its enumeration raises ValueError. + """ + + def __init__( + self, + *, + routing: CostOnly | CapacityAware | Global | Pinned, + placement: FlowPlacement, + admission: Admission, + selection: PathSelection, + ) -> None: ... + def __repr__(self) -> str: ... + @property + def admission(self) -> Admission: + """ + LOSSLESS or LOSSY + """ + + @property + def placement(self) -> FlowPlacement: + """ + how every flow spreads over its route (FlowPlacement) + """ + + @property + def routing(self) -> CostOnly | CapacityAware | Global | Pinned: + """ + the routing mode: CostOnly, CapacityAware, Global or Pinned + """ + + @property + def selection(self) -> PathSelection: + """ + the PathSelection the routing's searches read + """ + +class OutputOptions: + """ + Optional outputs of a placement (all false by default; absent outputs are None): + edge_flows (the policy's own allocation per edge after the call), residual and + bottleneck (the ledger's after the call); on Global, bottleneck adds min_cut. + """ + + def __init__( + self, + *, + edge_flows: bool = False, + residual: bool = False, + bottleneck: bool = False, + ) -> None: ... + @property + def bottleneck(self) -> bool: + """ + request PlacementResult.bottleneck (and min_cut on Global) + """ + + @property + def edge_flows(self) -> bool: + """ + request PlacementResult.edge_flows + """ + + @property + def residual(self) -> bool: + """ + request PlacementResult.residual + """ + +class Termination: + """ + How a call ended, per source (local_outcomes) and for the whole call (termination). + A source's offer is served when it is 0, when (LOSSLESS) the delivery equals it, or when (LOSSY, + finite offer) all of it was forwarded, drops included. A source's outcome is the first that + applies: + COMPLETE: served. + LIMIT_FLOWS: creating or adding flows was stopped by MAX_FLOWS_PER_CALL, or adding flows by a + CapacityAware max_flow_count above flow_count. + LIMIT_DIMINISHING: the diminishing guard stopped adding flows. + NO_ROUTE: the source has no flows after the call (Global: the maximum flow is 0 and nothing + was ever placed). + EXHAUSTED: any other unserved case (every flow full and no new route helps, a full fixed-size + set of flows, Global at the maximum flow, a LOSSY infinite offer). + Global and Pinned act as one source. The call's termination is COMPLETE for a request of 0 or + when every source is served, NO_ROUTE for a positive request with an empty roster, else the + highest outcome of the unserved sources in the order LIMIT_FLOWS > LIMIT_DIMINISHING > + EXHAUSTED > NO_ROUTE. + + Members: + + COMPLETE + + LIMIT_FLOWS + + LIMIT_DIMINISHING + + NO_ROUTE + + EXHAUSTED + """ + + COMPLETE: typing.ClassVar[Termination] + EXHAUSTED: typing.ClassVar[Termination] + LIMIT_DIMINISHING: typing.ClassVar[Termination] + LIMIT_FLOWS: typing.ClassVar[Termination] + NO_ROUTE: typing.ClassVar[Termination] + __members__: typing.ClassVar[dict[str, Termination]] + + @typing.overload + def __eq__(self, other: Termination, /) -> bool: ... + @typing.overload + def __eq__(self, other: typing.Any, /) -> bool: ... + def __getstate__(self) -> int: ... + def __hash__(self) -> int: ... + def __index__(self) -> int: ... + def __init__(self, value: typing.SupportsInt | typing.SupportsIndex) -> None: ... + def __int__(self) -> int: ... + @typing.overload + def __ne__(self, other: Termination, /) -> bool: ... + @typing.overload + def __ne__(self, other: typing.Any, /) -> bool: ... + def __repr__(self) -> str: ... + def __setstate__( + self, state: typing.SupportsInt | typing.SupportsIndex, / + ) -> None: ... + def __str__(self) -> str: ... + @property + def name(self) -> str: ... + @property + def value(self) -> int: ... + +class PlacementResult: + """ + One placement call: placed (units), remaining (request - placed; float('inf') for an infinite + request), termination, local_outcomes [(source, Termination)] per roster source, routes_changed, + weights_changed, and this call's drops [(EdgeId, units)]. cost_distribution: Global only, the + marginal cost curve [(cost, units)] of the policy's flow so far, else None: in ascending cost, + delivering `units` more raised the minimum total flow cost by `cost` per unit (more delivery can + reroute earlier flow, so these are not the costs of the final paths); a zero call leaves it + unchanged. Optional outputs, None unless requested: + edge_flows and residual (int64[E]), bottleneck, and min_cut (Global with bottleneck; None when the + solve did not reach the maximum flow). + """ + + def __init__(self, _no_public_constructor: typing.Never, /) -> None: ... + def __repr__(self) -> str: ... + @property + def bottleneck(self) -> list[int] | None: + """ + if requested: the ledger's saturation report after the call (FlowLedger.bottleneck of the + policy's sources and Eligibility), else None + """ + + @property + def cost_distribution(self) -> list[tuple[int, int]] | None: + """ + Global: the marginal cost curve [(cost, units)] of the policy's cumulative flow (a zero + call reports it unchanged); None for the other routing modes + """ + + @property + def drops(self) -> list[tuple[int, int]]: + """ + this call's transit drops [(EdgeId, units)], ascending EdgeId (empty unless Lossy; records + keep the cumulative drops) + """ + + @property + def edge_flows(self) -> numpy.typing.NDArray[numpy.int64] | None: + """ + if requested: the policy's own allocation per edge after the call (int64[E], EdgeId order), + else None + """ + + @property + def local_outcomes(self) -> list[tuple[int, Termination]]: + """ + [(source, Termination)] for every roster source, ascending node id + """ + + @property + def min_cut(self) -> list[int] | None: + """ + Global with bottleneck requested: a minimum cut (edges whose removal separates the sources from + the destinations, with the smallest total capacity) of the policy's view (free capacity plus its + own allocation), when the solve reached the maximum flow; None otherwise (also on a zero call) + """ + + @property + def placed(self) -> int: + """ + the units placed by this call + """ + + @property + def remaining(self) -> int | float: + """ + the request minus placed, in units (an amount, not a status); float('inf') for an infinite request + """ + + @property + def residual(self) -> numpy.typing.NDArray[numpy.int64] | None: + """ + if requested: the ledger's residual after the call (int64[E], EdgeId order), else None + """ + + @property + def routes_changed(self) -> bool: + """ + True when this call committed a route change: a flow created (also at zero delivery), rerouted + or added, or a change in the edges a Global source's share uses; a candidate equal to the + current route is not a change + """ + + @property + def termination(self) -> Termination: + """ + the call's outcome (Termination) + """ + + @property + def weights_changed(self) -> bool: + """ + True when this call committed new weights for a rerouted WEIGHTED flow + """ + +class DagCacheStats: + """ + DagCache statistics (atomic per field, not a consistent snapshot): hits, misses, + entries, bytes (the entries' retained payload), live_values (values alive anywhere). + """ + + def __init__(self, _no_public_constructor: typing.Never, /) -> None: ... + @property + def bytes(self) -> int: + """ + the retained payload of the entries (their DAGs' stored arrays) + """ + + @property + def entries(self) -> int: + """ + the entries held + """ + + @property + def hits(self) -> int: + """ + lookups answered from an entry (they persist across clear()) + """ + + @property + def live_values(self) -> int: + """ + the cached values alive anywhere, entries included + """ + + @property + def misses(self) -> int: + """ + lookups without an entry: a building cache searched, a frozen one raised DagCacheMiss + """ + +class DagCacheMiss(Exception): + """ + A lookup in a frozen DagCache whose key is absent: a dedicated exception (not a + RuntimeError or ValueError); the evaluators propagate it unchanged. + """ + +class DagCache: + """ + A cache of static shortest-path DAGs (from costs and the Eligibility only) for one graph, keyed by + (the root set, the direction, the Eligibility uid). It stores the full DAG and applies each + caller's selection afterwards, so the selection is never cached. CostOnly policies and the + evaluators read it. + Building mode (a new cache): one writer, no concurrent readers; a miss searches and inserts; + clear() empties it (the hit and miss counters persist), and values already handed out stay valid. + Frozen mode (freeze()): read-only and shareable by every worker; a miss counts one miss and raises + DagCacheMiss; clear() raises ValueError; thaw() returns to building once the caller has joined + every reader. + When a public call fails: a building cache passed to it keeps the complete entries and statistics + of the lookups that succeeded before the failure (never a partial entry; a kept entry never + changes a result); a frozen cache's statistics stay unchanged, except one miss when the call + raised DagCacheMiss. A frozen cache counts its hits only when the call succeeds, Python result + included. + """ + + def __init__(self, graph: Graph) -> None: + """ + An empty cache in building mode, bound to graph (by its uid). + """ + + def clear(self) -> None: + """ + empty a building cache; ValueError when frozen + """ + + def freeze(self) -> None: + """ + enter frozen mode (no effect when frozen) + """ + + def graph_uid(self) -> int: + """ + uid of the graph this cache is bound to + """ + + def stats(self) -> DagCacheStats: + """ + the current DagCacheStats (each field read atomically) + """ + + def thaw(self) -> None: + """ + return to building mode (no effect when building); join every reader first + """ + +class FlowPolicy: + """ + The placement engine for one demand, bound to its ledger at construction. place(volume, outputs) + is all-or-nothing: a failed call raises and leaves the ledger and the policy as they were (with + the two limits in the module docstring). remove() takes every flow of the policy back out and + starts a new epoch (the request total starts again from 0). A policy dropped without remove() + leaves its records in the ledger as orphans: readable, never changed again, cleared by + FlowLedger.reset(). sources and destinations are one NodeId or a sequence; the two sets must not + overlap. ValueError for invalid endpoints, configurations and requests (non-integer node ids and + requests included) and for objects of another graph, ledger or policy; TypeError for a ledger, + config, elig, cache or outputs of the wrong class (a ledger of None: ValueError) and for an + unknown keyword. + """ + + def __init__( + self, + ledger: FlowLedger, + sources: typing.SupportsIndex + | collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + destinations: typing.SupportsIndex + | collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + config: FlowPolicyConfig, + elig: Eligibility | None = None, + cache: DagCache | None = None, + ) -> None: + """ + Bind a policy to ledger. elig (None: everything allowed) and cache (None: none; only CostOnly + routing reads it) belong to the ledger's graph; the policy keeps the ledger, elig and cache + alive. The roster (the sources that statically reach an allowed destination) and, for Pinned, + the usable bundles are fixed here. Everything is validated before the policy binds. + """ + + def place( + self, + volume: typing.SupportsIndex | float | numpy.floating, + outputs: OutputOptions | None = None, + ) -> PlacementResult: + """ + Place volume (int units in [0, 2^62), or float('inf')) all or nothing -> PlacementResult. + Requests add up within an epoch (from construction or the last remove()); after float('inf') any + finite request is rejected, 0 included. A request of 0 places nothing. outputs (OutputOptions; + None: none) requests edge_flows, residual and bottleneck. Everything is validated before any + change; a failed call raises with the ledger and the policy unchanged. A solve of the flow core + (Global, or a MAX_FLOW flow) on a network beyond its int32 numbering raises ValueError: more than + INT32_MAX - 2 nodes, or more than 2^30 - 1 edges, sources and destinations together. + """ + + def placed_demand(self) -> int: + """ + the sum of delivered over the policy's live records + """ + + def record( + self, flow_id: tuple[typing.SupportsIndex, typing.SupportsIndex] + ) -> FlowRecord: + """ + one of the policy's live records by FlowId (ValueError for a FlowId of another ledger or policy, a + retired or an unknown id) + """ + + def records(self) -> list[FlowRecord]: + """ + the policy's live records as snapshots in FlowId order + """ + + def remove(self) -> None: + """ + revert every flow of the policy, retire the ids, open a new epoch + """ + + def usable_bundles(self) -> int: + """ + Pinned: the bundles with a src -> dst walk over eligible edges of at least 1 unit of capacity (0 + for the other modes) + """ + + @property + def config(self) -> FlowPolicyConfig: + """ + the FlowPolicyConfig the policy was built with + """ + +class CapacityOptions: + """ + The configuration, the static Eligibility (None: all allowed), the DagCache (None: + private per worker) and the optional outputs of capacity() and batch_capacity(). + """ + + def __init__( + self, + config: FlowPolicyConfig, + *, + elig: Eligibility | None = None, + cache: DagCache | None = None, + outputs: OutputOptions | None = None, + ) -> None: ... + @property + def cache(self) -> DagCache | None: + """ + the supplied DagCache; None: a private cache per worker + """ + + @property + def config(self) -> FlowPolicyConfig: + """ + the FlowPolicyConfig of every evaluated policy + """ + + @property + def elig(self) -> Eligibility | None: + """ + the static Eligibility; None: everything allowed + """ + + @property + def outputs(self) -> OutputOptions: + """ + the OutputOptions of every result + """ + +class SensitivityOptions: + """ + The configuration, Eligibility, DagCache and baseline outputs of sensitivity(). + """ + + def __init__( + self, + config: FlowPolicyConfig, + *, + elig: Eligibility | None = None, + cache: DagCache | None = None, + outputs: OutputOptions | None = None, + ) -> None: ... + @property + def cache(self) -> DagCache | None: + """ + the supplied DagCache; None: a private cache per worker + """ + + @property + def config(self) -> FlowPolicyConfig: + """ + the FlowPolicyConfig of the baseline and every deletion + """ + + @property + def elig(self) -> Eligibility | None: + """ + the static Eligibility; None: everything allowed + """ + + @property + def outputs(self) -> OutputOptions: + """ + the OutputOptions of the baseline result + """ + +class SensitivityEntry: + """ + One candidate edge: edge and loss (int units: the baseline minus the capacity without the + edge). + """ + + def __init__(self, _no_public_constructor: typing.Never, /) -> None: ... + @property + def edge(self) -> int: ... + @property + def loss(self) -> int: ... + +class SensitivityResult: + """ + baseline (the PlacementResult of capacity()) and entries (ascending EdgeId; an + absent edge has loss 0). + """ + + def __init__(self, _no_public_constructor: typing.Never, /) -> None: ... + @property + def baseline(self) -> PlacementResult: ... + @property + def entries(self) -> list[SensitivityEntry]: ... + +class KspOptions: + """ + The options of ksp: k (>= 1) and selection are explicit (multi_edge = True lists each parallel + edge as a separate path, False keeps one per (u, v) pair as EdgeTieBreak describes; multipath is + ignored); max_path_cost (None, Absolute(c) with c >= 0, or Stretch(f) with f finite in [1, inf) + times the static shortest cost under the Eligibility, residual ignored; inclusive); residual + (int64 units per EdgeId, each in [0, capacity_units]: edges with less than 1 unit are skipped, and + the values feed the priority); elig (None: everything allowed). The fields are validated by ksp, + in the order its docstring gives. + """ + + def __init__( + self, + k: typing.SupportsIndex, + selection: PathSelection, + *, + max_path_cost: Absolute | Stretch | None = None, + residual: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] + | None = None, + elig: Eligibility | None = None, + ) -> None: ... + @property + def elig(self) -> Eligibility | None: + """ + the static Eligibility; None: everything allowed + """ + + @property + def k(self) -> typing.SupportsIndex: + """ + the number of paths as given (validated by ksp: an integer >= 1) + """ + + @property + def max_path_cost(self) -> Absolute | Stretch | None: + """ + the inclusive cost ceiling as given: None, Absolute or Stretch (validated by ksp) + """ + + @property + def residual( + self, + ) -> ( + collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] + | None + ): + """ + the residual units per EdgeId as given (validated by ksp); None: static capacities, no + residual gating (edges below 1 unit of capacity are still skipped) + """ + + @property + def selection(self) -> PathSelection: + """ + the PathSelection (multipath is ignored) + """ + +class SpfOptions: + """ + The options of spf: direction and selection are explicit; targets (one NodeId or a sequence; + None: a full search) stops the search at the nearest targets (anycast: any nearest target will + do); residual (int64 units per EdgeId, each in [0, capacity_units]) makes the search skip edges + with fewer than min_residual residual units (an integer >= 1; without a residual it has no + effect) and supplies the priority values; elig (None: everything allowed). A direction outside its + enumeration raises ValueError here; the other fields are validated by spf, in the order its + docstring gives. + """ + + def __init__( + self, + direction: Direction, + selection: PathSelection, + *, + targets: typing.SupportsIndex + | collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] + | None = None, + residual: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] + | None = None, + min_residual: typing.SupportsIndex = 1, + elig: Eligibility | None = None, + ) -> None: ... + @property + def direction(self) -> Direction: + """ + FORWARD or REVERSE + """ + + @property + def elig(self) -> Eligibility | None: + """ + the static Eligibility; None: everything allowed + """ + + @property + def min_residual(self) -> typing.SupportsIndex: + """ + the gating threshold in units as given (validated by spf: an integer >= 1) + """ + + @property + def residual( + self, + ) -> ( + collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] + | None + ): + """ + the residual units per EdgeId as given (validated by spf); None: static capacities, no + residual gating (edges below 1 unit of capacity are still skipped) + """ + + @property + def selection(self) -> PathSelection: + """ + the PathSelection the search reads + """ + + @property + def targets( + self, + ) -> ( + typing.SupportsIndex + | collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] + | None + ): + """ + the targets as given (validated by spf); None: a full search + """ + +class SpfResult: + """ + The result of spf: costs (full searches: a read-only int64 array, UNREACHABLE for the nodes not + reached; None with targets), dag (PredDAG of the tight edges, the edges on some shortest walk, + one per (u, v) pair with multi_edge = False, each in its own direction; with targets only the + walks between the roots and a nearest target, for REVERSE from the target to a root; for + multipath = False the canonical single path) and + target_cost (with targets: the nearest targets' cost, None when no target is reached; None for a + full search). + """ + + def __init__(self, _no_public_constructor: typing.Never, /) -> None: ... + def __repr__(self) -> str: ... + @property + def costs(self) -> numpy.typing.NDArray[numpy.int64] | None: + """ + full search: each node's distance from the roots for FORWARD, or to the roots for REVERSE + (read-only int64[N], UNREACHABLE for nodes not reached); None with targets + """ + + @property + def dag(self) -> PredDAG: + """ + the PredDAG of the result, in the edges' original orientation + """ + + @property + def target_cost(self) -> int | None: + """ + with targets: the cost of the nearest targets, None when no target is reached; None for a + full search + """ + +@typing.overload +def as_float( + x: collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + resolution: typing.SupportsFloat | typing.SupportsIndex, +) -> numpy.typing.NDArray[numpy.float64]: + """ + Units to physical value(s): x * q, correctly rounded (exact below 2^53 units). An int, of + any size, gives a float, an integer array a float64 array; a result beyond the finite + doubles raises ValueError; q must be a power of two in [2^-32, 2^32]. + """ + +@typing.overload +def as_float( + x: typing.SupportsIndex, resolution: typing.SupportsFloat | typing.SupportsIndex +) -> float: ... +def batch_capacity( + graph: Graph, + pairs: collections.abc.Sequence[ + collections.abc.Sequence[ + typing.SupportsIndex + | collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer] + ] + | numpy.typing.NDArray[numpy.integer] + ] + | numpy.typing.NDArray[numpy.integer], + opts: CapacityOptions, + per_pair_elig: collections.abc.Sequence[Eligibility | None] | None = None, + workers: typing.SupportsIndex | None = None, +) -> list[PlacementResult]: + """ + capacity() for each (sources, destinations) pair, results in pair order. Pair i uses opts.elig + intersected with per_pair_elig[i] (None entries allowed; a given sequence has one entry per pair, + also when empty). workers >= 1; None: NGRAPH_CORE_BATCH_THREADS or 1, and 1 when a cache is + supplied. Everything is validated before a worker starts, the arguments first, then each pair + completely in pair order, so the error of the lowest failing pair is raised. Pinned is rejected; + an empty batch returns []. + Caches: a supplied building cache needs workers == 1; a frozen cache is shared by every worker + (pass workers explicitly), and its DagCacheMiss reaches the caller unchanged (the lowest failing + pair's). An intersection that differs from both operands gets a new Eligibility uid, which a + frozen cache cannot hold: pass the combined per-pair values with elig None, or a common value + that contains them. + A failed call raises; a supplied DagCache then behaves as the DagCache docstring states. + """ + +def capacity( + graph: Graph, + sources: typing.SupportsIndex + | collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + destinations: typing.SupportsIndex + | collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + opts: CapacityOptions, +) -> PlacementResult: + """ + place(+inf) of a FlowPolicy with opts.config on a fresh ledger of the graph -> PlacementResult. + The exact maximum flow is the Global + MAX_FLOW configuration. A supplied frozen cache raises + DagCacheMiss for an absent key, unchanged. + A failed call raises; a supplied DagCache then behaves as the DagCache docstring states. + """ + +def ksp( + graph: Graph, src: typing.SupportsIndex, dst: typing.SupportsIndex, opts: KspOptions +) -> list[tuple[int, Path]]: + """ + The k shortest simple src -> dst paths over the eligible edges of at least 1 unit, as the options + further limit them -> [(cost, Path)], each hop a single edge. Order: lowest cost; then, under + PREFER_HIGHER_PRIORITY only, highest bottleneck (the smallest priority value along the path); then + fewest hops; then the smallest EdgeId sequence. The result is the first k paths of that order + within the inclusive cost ceiling. Validated in this order before any work (ValueError): src and + dst in range, src == dst, k >= 1, the ceiling, the residual (length, then every entry), a foreign + Eligibility. An excluded endpoint, no static route under Stretch, or no path gives []. + """ + +def resolve_to_paths( + graph: Graph, + dag: PredDAG, + src: typing.SupportsIndex, + dst: typing.SupportsIndex, + split_parallel_edges: bool = False, + max_paths: typing.SupportsIndex | None = None, +) -> list[Path]: + """ + The src -> dst walks of a DAG -> [Path], in increasing (cost, hops, edge-id sequence): + a hop costs its cheapest member and is keyed by its lowest member id. split_parallel_edges = False + keeps every parallel member of a (u, v) pair in one hop (members ascending); True gives one path + per member combination, sorted by that path's own (cost, hops, edge-id sequence). max_paths + (>= 1) truncates; None lists every walk. + ValueError: a DAG of another graph, src or dst out of range, src == dst, max_paths < 1. An + endpoint outside the DAG, or no walk, gives []. + """ + +def sensitivity( + graph: Graph, + sources: typing.SupportsIndex + | collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + destinations: typing.SupportsIndex + | collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + opts: SensitivityOptions, + workers: typing.SupportsIndex | None = None, +) -> SensitivityResult: + """ + The baseline capacity() and one SensitivityEntry per candidate edge. Candidates: with Global, the + edges that carry flow in the baseline (removing any other edge cannot lower a maximum flow); with + the other routings, every allowed edge of at least 1 unit. loss = baseline.placed minus the placed + of capacity() without the edge, in signed units, never thresholded. Two shortcuts give exactly + that value: Global repairs a copy of the baseline's solve instead of solving again, and CostOnly + gives loss 0 without placing to an edge outside the static shortest-path support reachable from + the roster sources (no route can use it). workers as batch_capacity + (NGRAPH_CORE_SENSITIVITY_THREADS). Only CostOnly reads the cache, and each placed deletion uses a + new Eligibility uid, so a frozen cache raises DagCacheMiss there. + A failed deletion fails the call (the lowest failing candidate's exception, no partial result); a + supplied DagCache then behaves as the DagCache docstring states. + """ + +def spf( + graph: Graph, + roots: typing.SupportsIndex + | collections.abc.Sequence[typing.SupportsIndex] + | numpy.typing.NDArray[numpy.integer], + opts: SpfOptions, +) -> SpfResult: + """ + Shortest paths from the roots for FORWARD, or to the roots for REVERSE (one NodeId or a sequence; + every root at distance 0), over the eligible edges of at least 1 unit of capacity (and of residual + >= min_residual with a residual) -> SpfResult. Without targets every reachable node gets its + distance: costs and the full DAG of tight edges (the edges on some shortest walk; one per (u, v) + pair with multi_edge = False). With targets the search stops at the nearest targets' cost: + target_cost and the DAG of the walks between the roots and a nearest target (for REVERSE, from + the target to a root, in the edges' own direction); + with multipath = False, instead the canonical single path, the walk minimising (cost, -bottleneck + under PREFER_HIGHER_PRIORITY, hops, EdgeId sequence). Validated in this order before any work + (ValueError): the roots (non-empty, ids in range), the targets (the same), a node in both sets, + multipath = False without targets, the residual (length, then every entry in [0, capacity_units]), + min_residual >= 1, a foreign Eligibility. Excluded roots and targets are skipped; all roots + excluded gives an empty result. + """ + +@typing.overload +def to_units( + x: float | numpy.floating | numpy.integer, + resolution: typing.SupportsFloat | typing.SupportsIndex, +) -> int: + """ + Physical value(s) to units of the resolution q, rounding half up: an int for a scalar, an + int64 array for an array. NaN, infinity, negative values and results at or above 2^62 + units raise ValueError; q must be a power of two in [2^-32, 2^32]. + """ + +@typing.overload +def to_units( + x: collections.abc.Sequence[float | numpy.floating | numpy.integer] + | numpy.typing.NDArray[numpy.floating | numpy.integer], + resolution: typing.SupportsFloat | typing.SupportsIndex, +) -> numpy.typing.NDArray[numpy.int64]: ... + +MAX_FLOWS_PER_CALL: int = 10000 +UNREACHABLE: int = 9223372036854775807 diff --git a/python/netgraph_core/_docs.py b/python/netgraph_core/_docs.py deleted file mode 100644 index 3a6e8e2..0000000 --- a/python/netgraph_core/_docs.py +++ /dev/null @@ -1,1191 +0,0 @@ -"""Typing and docstrings for netgraph_core public API. - -This module is intentionally light; runtime implementations live in the -compiled extension. The goal is to provide type hints and help text. - -Every public call is all-or-nothing up to its caller (APPROACH 13), with two limits: (1) the -pybind11 object machinery is outside the guarantee: constructing a bound object from Python, -or reading an attribute or method value converted into a new bound object or container outside -the staged calls, allocates through pybind11's ``make_new_instance``, which does not check the -result of ``tp_alloc``, so a Python allocation failure at that point crashes the process, with -Core state unchanged; (2) a supplied building DagCache is a memo: it keeps the complete entries -and statistics of the lookups that succeeded before a later failure of the call. A frozen -DagCache's statistics are part of the guarantee: a failed call changes none of them, except one -miss when it raises ``DagCacheMiss``. - -This hand-written shim is the package's type declaration until Phase 5, which replaces it by -generated ``.pyi`` stubs (pybind11-stubgen, APPROACH 13) with their override file and drift check. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, ClassVar, Sequence, overload - -if TYPE_CHECKING: # only for typing; runtime comes from extension - import numpy as np # type: ignore[reportMissingImports] - # Use forward references (strings) below; avoid importing from the package - # to prevent circular imports during type checking. - - -@overload -def to_units(x: float, resolution: float) -> int: ... -@overload -def to_units(x: "Sequence[float] | np.ndarray", resolution: float) -> "np.ndarray": ... -def to_units(x, resolution): # type: ignore[no-untyped-def] - """Convert physical value(s) to integer units of the resolution, rounding half up. - - The only way physical numbers enter Core: ``to_units(x, q) = floor(x / q + 1/2)``. - - Args: - x: A physical float, or a 1-D array of them. - resolution: The unit ``q``, a power of two in ``[2**-32, 2**32]``. - - Returns: - An ``int`` for a scalar, an ``int64`` array for an array. - - Raises: - ValueError: an invalid resolution (checked first, also for an empty array); - NaN, infinity or a negative value (no unit value); a result at or above - ``2**62`` units (the domain bound). - """ - ... - - -@overload -def as_float(x: int, resolution: float) -> float: ... -@overload -def as_float(x: "Sequence[int] | np.ndarray", resolution: float) -> "np.ndarray": ... -def as_float(x, resolution): # type: ignore[no-untyped-def] - """Convert integer units to physical value(s): ``x * q``, correctly rounded. - - Exact below ``2**53`` units. Returns a ``float`` for an int of any size and a - ``float64`` array for an integer array or sequence. The resolution is validated first - (also for an empty array); a result beyond the finite doubles raises ValueError. - """ - ... - - -class Eligibility: - """Immutable, graph-bound static eligibility: dense node and edge flags. - - Built by ``Graph.eligibility(excluded_nodes, excluded_edges)``. An - edge is allowed when it is not excluded and both of its endpoints are allowed. - Passing it to a call of another graph raises ValueError. - """ - - def graph_uid(self) -> int: - """uid of the graph this value belongs to.""" - ... - - def uid(self) -> int: - """0 for the all-allowed value, a fresh uid otherwise.""" - ... - - def node_allowed(self) -> "np.ndarray": - """bool[N] read-only view.""" - ... - - def edge_allowed(self) -> "np.ndarray": - """bool[E] read-only view.""" - ... - - @staticmethod - def intersect(a: "Eligibility", b: "Eligibility") -> "Eligibility": - """The intersection of two values of one graph (ValueError for different graphs). - - Returns an operand itself when the result equals it (``a`` first, then ``b``), - otherwise a new value with a fresh uid; equal intersections built separately get - distinct uids, so a consumer builds each state once and reuses it. - """ - ... - - -class Graph: - """Immutable directed multigraph on integer units (layout B'). - - Edges are given in input order. The public EdgeId of an edge is its rank in the - stable order ``(src, cost, dst, input)``: the out-edges of node ``u`` are the ids - ``[row_offsets()[u], row_offsets()[u + 1])``, and ``input_index()[e]`` is the input - position of edge ``e`` (the inverse permutation is the caller's). Every other - per-edge array is in id order. Accessors return read-only NumPy views that keep the - graph alive. - - Construction uses stable counting passes, not comparison sorts: expected time is - linear in ``E + N`` while the number of distinct costs is bounded, and at most - ``O(N + E log(E + 1))`` in the worst case (more than 65,536 distinct costs, or - costs that collide too often in the hash table, fall back to one sort of all - costs). A graph without edges costs only validation and its two offset arrays. The - graph keeps ``36 E + 8 (N + 1)`` bytes of arrays: the eight views plus an internal - copy of each reverse slot's source node, which the reverse search reads. - """ - - def __init__( - self, - num_nodes: int, - src: "Sequence[int] | np.ndarray", - dst: "Sequence[int] | np.ndarray", - capacity_units: "Sequence[int] | np.ndarray", - cost: "Sequence[int] | np.ndarray", - resolution: float, - ) -> None: - """Build a graph from parallel edge arrays in input order. - - Args: - num_nodes: Node count; every src/dst must lie in ``[0, num_nodes)``. - src: Source node ids (integers). - dst: Destination node ids (integers). - capacity_units: Capacities as integer units of ``resolution``, each in - ``[0, 2**62)``; physical capacities go through ``to_units`` first. - cost: Positive integer costs (``cost >= 1``). - resolution: The unit ``q``, a power of two in ``[2**-32, 2**32]``. - - Raises: - ValueError: On the first defect, in this order: the resolution, the unit - values, the array shapes, zero or negative costs, the remaining bounds - (endpoints in range, then the capacity and cost sums below ``2**62``). - Integers are read without narrowing, so the reported defect does not - depend on how the input is represented; floats or bools in an integer - array are rejected, never truncated. - """ - ... - - def uid(self) -> int: - """Process-unique id, never reused.""" - ... - - def resolution(self) -> float: ... - def num_nodes(self) -> int: ... - def num_edges(self) -> int: ... - def max_cost(self) -> int: - """Largest edge cost (0 for a graph without edges).""" - ... - - def cost_gcd(self) -> int: - """Greatest common divisor of all edge costs (0 for a graph without edges).""" - ... - - def capacity_units(self) -> "np.ndarray": - """int64[E] capacities in units, id order.""" - ... - - def cost(self) -> "np.ndarray": - """int64[E] costs, id order.""" - ... - - def edge_src(self) -> "np.ndarray": - """int32[E] source node per edge.""" - ... - - def edge_dst(self) -> "np.ndarray": - """int32[E] destination node per edge.""" - ... - - def input_index(self) -> "np.ndarray": - """int32[E]: id -> input position.""" - ... - - def row_offsets(self) -> "np.ndarray": - """int32[N + 1] forward CSR offsets (id ranges of the out-edges).""" - ... - - def in_row_offsets(self) -> "np.ndarray": - """int32[N + 1] reverse CSR offsets.""" - ... - - def in_adj_edge_index(self) -> "np.ndarray": - """int32[E] in-edges per node in (cost, src, input) order, as EdgeIds.""" - ... - - def eligibility( - self, - excluded_nodes: "Sequence[int] | np.ndarray" = (), - excluded_edges: "Sequence[int] | np.ndarray" = (), - ) -> Eligibility: - """Static eligibility from excluded node ids and EdgeIds. - - Duplicates and unsorted lists are accepted; out-of-range ids raise ValueError. - Empty lists mean all allowed (uid 0). - """ - ... - - -class EdgeTieBreak: - """Ties between equal-cost candidates, at both tie levels (parallel members and walks; - APPROACH 6). - - ``DETERMINISTIC`` takes the lowest EdgeId and ignores capacities; - ``PREFER_HIGHER_PRIORITY`` prefers the higher ``priority`` value first. - """ - - DETERMINISTIC: ClassVar["EdgeTieBreak"] - PREFER_HIGHER_PRIORITY: ClassVar["EdgeTieBreak"] - __members__: ClassVar[dict[str, "EdgeTieBreak"]] - - def __init__(self, value: int) -> None: ... - def __int__(self) -> int: ... - def __index__(self) -> int: ... - @property - def name(self) -> str: ... - @property - def value(self) -> int: ... - - -class EdgePriority: - """The per-edge value ``PREFER_HIGHER_PRIORITY`` reads, and the Weighted kernel's - membership and weights (APPROACH 6, 7): ``RESIDUAL`` (residual units), ``CAPACITY`` (static capacity - units), ``HEADROOM`` (the exact rational residual / capacity; rejected with WEIGHTED). - A search given no residual reads static capacities for every priority.""" - - RESIDUAL: ClassVar["EdgePriority"] - CAPACITY: ClassVar["EdgePriority"] - HEADROOM: ClassVar["EdgePriority"] - __members__: ClassVar[dict[str, "EdgePriority"]] - - def __init__(self, value: int) -> None: ... - def __int__(self) -> int: ... - def __index__(self) -> int: ... - @property - def name(self) -> str: ... - @property - def value(self) -> int: ... - - -class Direction: - """The orientation of a search (APPROACH 6): ``FORWARD`` from the roots, or ``REVERSE`` - rooted at the roots over reversed arcs. A reverse search returns the forward-oriented DAG - of the walks toward its roots.""" - - FORWARD: ClassVar["Direction"] - REVERSE: ClassVar["Direction"] - __members__: ClassVar[dict[str, "Direction"]] - - def __init__(self, value: int) -> None: ... - def __int__(self) -> int: ... - def __index__(self) -> int: ... - @property - def name(self) -> str: ... - @property - def value(self) -> int: ... - - -class FlowPlacement: - """The kernel of one flow (APPROACH 7). - - ``MAX_FLOW``: the minimum-cost flow core on the flow's split graph, a replacement on - ``V = W + own`` (its own allocation cancellable, other owners never). ``WEIGHTED``: - hop-by-hop WCMP, highest-averages apportionment over weights frozen at creation. - ``EQUAL_BALANCED``: ECMP, round robin at every node. - """ - - MAX_FLOW: ClassVar["FlowPlacement"] - WEIGHTED: ClassVar["FlowPlacement"] - EQUAL_BALANCED: ClassVar["FlowPlacement"] - __members__: ClassVar[dict[str, "FlowPlacement"]] - - def __init__(self, value: int) -> None: ... - def __int__(self) -> int: ... - def __index__(self) -> int: ... - @property - def name(self) -> str: ... - @property - def value(self) -> int: ... - - -class Admission: - """``LOSSLESS`` admits only what fits; ``LOSSY`` forwards hop by hop without admission - control and drops what does not fit on each edge (EQUAL_BALANCED and WEIGHTED only).""" - - LOSSLESS: ClassVar["Admission"] - LOSSY: ClassVar["Admission"] - __members__: ClassVar[dict[str, "Admission"]] - - def __init__(self, value: int) -> None: ... - def __int__(self) -> int: ... - def __index__(self) -> int: ... - @property - def name(self) -> str: ... - @property - def value(self) -> int: ... - - -class PathSelection: - """The selection of a search (APPROACH 6). - - ``tie_break`` and ``priority`` are explicit; ``multipath = True`` keeps every equal-cost - walk (the DAG), ``False`` the one canonical single path; ``multi_edge = True`` keeps every - eligible parallel member, ``False`` one per ``(u, v)`` pair (minimum cost, then the - priority under PREFER_HIGHER_PRIORITY, then the lowest EdgeId). Immutable value; a - ``tie_break`` or ``priority`` outside its enumeration raises ValueError. - """ - - def __init__( - self, - tie_break: EdgeTieBreak, - priority: EdgePriority, - multipath: bool = True, - multi_edge: bool = True, - ) -> None: ... - @property - def tie_break(self) -> EdgeTieBreak: ... - @property - def priority(self) -> EdgePriority: ... - @property - def multipath(self) -> bool: ... - @property - def multi_edge(self) -> bool: ... - - -class PredDAG: - """Immutable, graph-bound predecessor DAG (APPROACH 5). - - The entries of node ``v`` are ``parents()[k]``, ``via_edges()[k]`` for ``k`` in - ``[parent_offsets()[v], parent_offsets()[v + 1])``: the edge ``via`` joins - ``parent -> v``. Within a node the entries are in strictly ascending via EdgeId (one - canonical form). Equality is structural. ``parents()`` and ``via_edges()`` are read-only - NumPy views that keep the DAG alive; ``parent_offsets()`` is a fresh read-only array that - owns its data, built on demand (the DAG is stored over its member nodes only). A DAG of - another graph is rejected wherever it is used. - """ - - @staticmethod - def from_csr( - graph: Graph, - offsets: "Sequence[int] | np.ndarray", - parents: "Sequence[int] | np.ndarray", - via: "Sequence[int] | np.ndarray", - ) -> "PredDAG": - """Build a DAG from its CSR, validated before any traversal. - - Raises: - ValueError: On the first defect, in this order: offsets of length N + 1, - ``offsets[0] == 0``, monotone, ``offsets[N] == len(parents) == len(via)``; - every parent and via in range, every via joining ``(parent, child)``, no - duplicate ``(child, via)``, acyclic; within a node the entries in ascending - via EdgeId. - """ - ... - - def graph_uid(self) -> int: - """uid of the graph this DAG belongs to.""" - ... - - def parent_offsets(self) -> "np.ndarray": - """int32[N + 1] CSR offsets, a fresh read-only array built on demand.""" - ... - - def parents(self) -> "np.ndarray": - """int32 parent NodeId per entry.""" - ... - - def via_edges(self) -> "np.ndarray": - """int32 via EdgeId per entry.""" - ... - - -class Path: - """Immutable, graph-bound explicit walk (APPROACH 5). - - ``hops`` lists, per hop, one or more parallel members of one ``(u, v)`` pair (the - caller's member order is kept); ``cost`` is the sum over hops of the minimum member - cost. ``to_dag()`` gives the equivalent ``PredDAG``. - """ - - def __init__(self, graph: Graph, hops: "Sequence[Sequence[int]]") -> None: - """Validate and build the walk. - - Raises: - ValueError: On the first defect, in this order: every member id in range, - no duplicate member, a non-empty hop list, every hop non-empty, the - members of a hop sharing ``(u, v)``, contiguous hops, no repeated node. - """ - ... - - def graph_uid(self) -> int: ... - def hops(self) -> list[list[int]]: ... - def src(self) -> int: ... - def dst(self) -> int: ... - def cost(self) -> int: ... - def to_dag(self) -> PredDAG: ... - - -class FlowRecord: - """One flow of a ledger, as a snapshot (APPROACH 8). - - ``flow_id`` is ``(ledger uid, id)``; ``owner`` the id of the policy that placed it; - ``bundle`` the flow's route; ``route_cost`` its minimum walk cost (``None`` for an empty - route); ``offered`` exists on Lossy records only (``None`` otherwise); ``edges`` and - ``drops`` are ``(EdgeId, units)`` pairs in ascending EdgeId (drops cumulative over - calls). Counters are exact ints and may exceed 64 bits. - """ - - @property - def flow_id(self) -> tuple[int, int]: ... - @property - def owner(self) -> int: ... - @property - def sources(self) -> list[int]: ... - @property - def destinations(self) -> list[int]: ... - @property - def placement(self) -> FlowPlacement: ... - @property - def admission(self) -> Admission: ... - @property - def bundle(self) -> PredDAG: ... - @property - def route_cost(self) -> int | None: ... - @property - def offered(self) -> int | None: ... - @property - def delivered(self) -> int: ... - @property - def edges(self) -> list[tuple[int, int]]: ... - @property - def drops(self) -> list[tuple[int, int]]: ... - - -class FlowLedger: - """The integer ledger of one graph (APPROACH 8). - - An immutable baseline residual in units and the records of the flows placed on it by - policies; the ledger has no public placement, removal or reservation. ``allocated()`` - is the sum of every record's edges and ``residual() = baseline - allocated()``, both - ``int64[E]`` snapshots in EdgeId order. ``reset()`` and ``copy()`` are rejected while a - policy is bound. A policy destroyed without ``remove()`` leaves its records as - orphans (readable, never changed again) until ``reset()``. - """ - - def __init__( - self, - graph: Graph, - residual: "Sequence[int] | np.ndarray | None" = None, - ) -> None: - """Bind a ledger to ``graph``. - - Args: - graph: The graph (kept alive by the ledger). - residual: The baseline as integer units, one entry per edge in - ``[0, capacity_units[e]]`` (a physical baseline goes through ``to_units``); - ``None``: the capacities. - - Raises: - ValueError: a baseline of the wrong length, a non-integer entry, or an entry - outside ``[0, capacity_units[e]]``. - """ - ... - - def uid(self) -> int: - """Process-unique ledger uid (the first half of every FlowId).""" - ... - - def graph(self) -> Graph: ... - def capacity_units(self) -> "np.ndarray": - """int64[E] the graph's capacities in units (read-only view).""" - ... - - def residual(self) -> "np.ndarray": - """int64[E] snapshot: baseline - allocated.""" - ... - - def allocated(self) -> "np.ndarray": - """int64[E] snapshot: the sum of every record's edges.""" - ... - - def records(self) -> list[FlowRecord]: - """Live records (bound and orphaned) as snapshots in FlowId order.""" - ... - - def record(self, flow_id: tuple[int, int]) -> FlowRecord: - """One record; ValueError for a FlowId of another ledger, a retired or an unknown id.""" - ... - - def bottleneck( - self, sources: "int | Sequence[int]", elig: Eligibility | None = None - ) -> list[int]: - """Saturation diagnostic, ascending EdgeId (not a cut). - - The edges (statically eligible, capacity >= 1 unit) leaving the set reachable - from the allowed sources over eligible forward arcs with residual >= 1 unit. - """ - ... - - def reset(self, residual: "Sequence[int] | np.ndarray | None" = None) -> None: - """A new baseline (``None``: the capacities); orphans cleared, ids retired. - - Raises: - ValueError: while a policy is bound; an invalid baseline. - """ - ... - - def copy(self) -> "FlowLedger": - """A new ledger (new uid) with the same baseline and records (ids kept under the - new uid); ValueError while a policy is bound.""" - ... - - -# ---------------------------------------------------------------------------- policies - -MAX_FLOWS_PER_CALL: int -"""The call-wide CapacityAware creation cap, 10,000: the flows one ``place`` call may create -(creation and accepted growth, across sources) before it stops with ``LIMIT_FLOWS``. An engine -constant (C++ ``kMaxFlowsPerCall``); not a limit on any mode's existing flow count.""" - - -class Absolute: - """CostCeiling: an absolute, inclusive path-cost budget (``cost >= 0``).""" - - def __init__(self, cost: int) -> None: ... - @property - def cost(self) -> int: ... - - -class Stretch: - """CostCeiling: a factor in ``[1, inf)`` of the static shortest cost under the policy's - Eligibility, read as the exact dyadic rational it is (compared without rounding).""" - - def __init__(self, factor: float) -> None: ... - @property - def factor(self) -> float: ... - - -class DiminishingReturns: - """The diminishing-returns guard of CapacityAware growth. - - Growth stops once at least ``window`` steps were accepted in the call and the last - ``window`` gains sum below ``fraction`` times the source's (finite) offer - (``limit_diminishing``). ``window >= 1``, ``fraction`` in ``(0, 1)``. - """ - - def __init__(self, window: int, fraction: float) -> None: ... - @property - def window(self) -> int: ... - @property - def fraction(self) -> float: ... - - -class CostOnly: - """Routing: IGP forwarding. One flow per roster source on the static shortest-path DAG - (the DagCache value of the destination set; selection projected at creation on the - working residual), never refreshed, grown or reoptimised.""" - - def __init__(self) -> None: ... - - -class CapacityAware: - """Routing: distributed CSPF tunnels (APPROACH 9.1, 9.2). - - ``flow_count`` flows are created by the canonical search of section 6 on the selection - view (residual >= ``max(1, min_flow_bandwidth)`` units, the ``max_path_cost`` ceiling), - then grown up to ``max_flow_count`` (``None``: no ceiling); ``reoptimize`` reroutes - retained flows (adopted only when the candidate delivers the flow's amount); - ``diminishing`` guards growth (``None``: off). Fields are keyword-only. - """ - - def __init__( - self, - *, - flow_count: int = 1, - max_flow_count: int | None = None, - max_path_cost: "Absolute | Stretch | None" = None, - min_flow_bandwidth: int = 0, - reoptimize: bool = False, - diminishing: DiminishingReturns | None = None, - ) -> None: ... - @property - def flow_count(self) -> int: ... - @property - def max_flow_count(self) -> int | None: ... - @property - def max_path_cost(self) -> "Absolute | Stretch | None": ... - @property - def min_flow_bandwidth(self) -> int: ... - @property - def reoptimize(self) -> bool: ... - @property - def diminishing(self) -> DiminishingReturns | None: ... - - -class Global: - """Routing: the omniscient controller. Every call re-solves the minimum-cost flow of the - cumulative value over the whole eligible graph on ``V = W + own`` (placement MaxFlow - only); records are the canonical per-source decomposition.""" - - def __init__(self) -> None: ... - - -class Pinned: - """Routing: explicit route structures, one flow per usable bundle in supply order - (single endpoints). A ``Path`` converts with ``to_dag()``.""" - - def __init__(self, bundles: "Sequence[PredDAG]") -> None: ... - @property - def bundles(self) -> list[PredDAG]: ... - - -class FlowPolicyConfig: - """The four explicit axes of a policy (keyword-only, no defaults). - - ``Global`` and ``Pinned`` read ``selection.priority`` only for the Weighted kernel and - ignore the rest; ``CostOnly`` and ``CapacityAware`` read every field. A ``placement`` or - ``admission`` outside its enumeration raises ValueError; a routing of another type, - TypeError. - """ - - def __init__( - self, - *, - routing: "CostOnly | CapacityAware | Global | Pinned", - placement: FlowPlacement, - admission: Admission, - selection: PathSelection, - ) -> None: ... - @property - def routing(self) -> "CostOnly | CapacityAware | Global | Pinned": ... - @property - def placement(self) -> FlowPlacement: ... - @property - def admission(self) -> Admission: ... - @property - def selection(self) -> PathSelection: ... - - -class OutputOptions: - """Optional outputs of a placement (absent outputs are ``None``): ``edge_flows`` (the - policy's own allocation per edge after the call), ``residual`` and ``bottleneck`` (the - ledger's after the call); on Global, ``bottleneck`` adds ``min_cut``.""" - - def __init__( - self, - *, - edge_flows: bool = False, - residual: bool = False, - bottleneck: bool = False, - ) -> None: ... - @property - def edge_flows(self) -> bool: ... - @property - def residual(self) -> bool: ... - @property - def bottleneck(self) -> bool: ... - - -class Termination: - """A source's local outcome and a call's termination (APPROACH 9.4). - - The reduction over the sources whose offer is unserved: none ``COMPLETE``, else the - highest of ``LIMIT_FLOWS > LIMIT_DIMINISHING > EXHAUSTED > NO_ROUTE``. - """ - - COMPLETE: ClassVar["Termination"] - LIMIT_FLOWS: ClassVar["Termination"] - LIMIT_DIMINISHING: ClassVar["Termination"] - NO_ROUTE: ClassVar["Termination"] - EXHAUSTED: ClassVar["Termination"] - __members__: ClassVar[dict[str, "Termination"]] - - def __init__(self, value: int) -> None: ... - def __int__(self) -> int: ... - def __index__(self) -> int: ... - @property - def name(self) -> str: ... - @property - def value(self) -> int: ... - - -class PlacementResult: - """One placement call (the deltas of this call). - - ``placed`` and ``remaining = request - placed`` (``float('inf')`` for ``+inf``) in - units; ``termination`` and ``local_outcomes`` (one ``(source, Termination)`` per roster - source, ascending); ``routes_changed`` and ``weights_changed`` (committed events of the - call, the flag table of 9.4); ``drops`` (this call's transit drops); Global adds - ``cost_distribution``, the marginal curve of the policy's cumulative flow (its amounts sum - to ``placed_demand()``; a zero call reports it unchanged, ``[]`` before the first - placement and after ``remove()``). ``edge_flows``, ``residual``, ``bottleneck`` and - (Global) ``min_cut`` exist when requested through ``OutputOptions``; ``min_cut`` is ``None`` - when the solve was not maximal on its view, and on a zero call (nothing is solved). - """ - - @property - def placed(self) -> int: ... - @property - def remaining(self) -> "int | float": ... - @property - def termination(self) -> Termination: ... - @property - def local_outcomes(self) -> list[tuple[int, Termination]]: ... - @property - def routes_changed(self) -> bool: ... - @property - def weights_changed(self) -> bool: ... - @property - def drops(self) -> list[tuple[int, int]]: ... - @property - def cost_distribution(self) -> list[tuple[int, int]] | None: ... - @property - def edge_flows(self) -> "np.ndarray | None": ... - @property - def residual(self) -> "np.ndarray | None": ... - @property - def bottleneck(self) -> list[int] | None: ... - @property - def min_cut(self) -> list[int] | None: ... - - -class DagCacheStats: - """DagCache statistics, atomic per field (not a consistent snapshot): ``hits``, - ``misses``, ``entries``, ``bytes`` (the entries' retained payload) and - ``live_values`` (values alive anywhere, entries included).""" - - @property - def hits(self) -> int: ... - @property - def misses(self) -> int: ... - @property - def entries(self) -> int: ... - @property - def bytes(self) -> int: ... - @property - def live_values(self) -> int: ... - - -class DagCacheMiss(Exception): - """A lookup in a frozen DagCache whose key is absent (APPROACH 10). - - A dedicated exception (neither a RuntimeError nor a ValueError): every evaluator - propagates it unchanged, never as a sensitivity status. A frozen cache never searches, - so a miss means the caller did not prepopulate that (roots, direction, Eligibility uid). - """ - - -class DagCache: - """A cache of static shortest-path supports bound to one graph (APPROACH 10). - - Keyed ``(roots as a set, direction, Eligibility uid)``; the value is the full static - support (``multipath``, ``multi_edge``, no residual) as an immutable shared value, valid - after ``clear()`` and after the cache is gone. Selection is projected at creation, never - cached. - - Building mode (a new cache): one writer, no concurrent readers; a miss searches and - inserts; a supplied building cache needs ``workers == 1``. Frozen mode (``freeze()``): - read-only, shareable by every worker; a miss counts one miss and raises - ``DagCacheMiss``; ``clear()`` raises ValueError; ``freeze()`` again has no effect; - ``thaw()`` returns to building mode once the caller has joined every reader. A per-pair - or per-candidate Eligibility built inside a batch or a sensitivity has a new uid, so - its key is absent from a frozen cache unless the intersection returned a prepopulated - operand. - - A supplied building cache is a memo (APPROACH 13, limit 2): a public call that fails - keeps the complete entries and statistics of the lookups that succeeded before; it never - holds a partial entry, held values stay valid, and a retained entry never changes a - result. A frozen cache's statistics follow the public call that reads it (a policy - constructor, an evaluator): its hits count when the call succeeds, its Python result - included; a failed call changes no statistic, except that a call raising - ``DagCacheMiss`` counts one miss. - """ - - def __init__(self, graph: Graph) -> None: ... - def graph_uid(self) -> int: - """uid of the graph this cache is bound to.""" - ... - - def freeze(self) -> None: - """Enter frozen mode (no effect when already frozen).""" - ... - - def thaw(self) -> None: - """Return to building mode (no effect when building); join every reader first.""" - ... - - def clear(self) -> None: - """Empty a building cache (the hit and miss counters persist; held values stay - valid); ValueError when frozen.""" - ... - - def stats(self) -> DagCacheStats: ... - - -class FlowPolicy: - """The one placement engine (APPROACH 9), bound to its ledger at construction. - - ``place(volume, outputs)`` runs the transaction of 9.2 (per source for CostOnly and - CapacityAware: creation, reoptimisation, amounts, growth on the shared working - residual; Global: the cumulative minimum-cost flow on ``V = W + own``) and commits one - atomic exchange: a failed call raises and leaves the ledger and the policy as they were, - a failed allocation of its Python result included (the result object exists before - anything is published). Two limits (APPROACH 13): (1) the pybind11 object machinery is - outside the guarantee: constructing a bound object from Python, or reading an attribute - or method value converted into a new bound object or container outside the staged calls, - allocates through pybind11's ``make_new_instance``, which does not check ``tp_alloc``, so a - Python allocation failure there crashes the process, with Core state unchanged; (2) a - supplied building DagCache is a memo: it keeps the complete entries and statistics of the - lookups that succeeded before a later failure (of this call or of the construction); - never a partial entry, and a retained entry never changes a result. ``remove()`` reverts every - flow, retires the ids and opens a new epoch. Dropping the policy without ``remove()`` - leaves its records as orphans. Keeps the ledger, the Eligibility and the cache alive. - - Raises: - ValueError: invalid endpoints (empty, out of range, overlapping sets), configurations - (Lossy with MaxFlow, Global or CapacityAware; Global without MaxFlow; Weighted - with Headroom; counts, ceilings, guards; Pinned bundles: empty, duplicate, - foreign, without a src -> dst walk; Pinned with endpoint sets), foreign objects, - and requests (outside ``[0, 2**62)`` units, a finite request after ``+inf``). - TypeError: arguments of the wrong type. - """ - - def __init__( - self, - ledger: FlowLedger, - sources: "int | Sequence[int]", - destinations: "int | Sequence[int]", - config: FlowPolicyConfig, - elig: Eligibility | None = None, - cache: DagCache | None = None, - ) -> None: ... - def place( - self, volume: "int | float", outputs: OutputOptions | None = None - ) -> PlacementResult: - """Place ``volume`` units (an int in ``[0, 2**62)``) or ``float('inf')``.""" - ... - - def remove(self) -> None: ... - def placed_demand(self) -> int: - """The sum of delivered over the policy's live records.""" - ... - - def records(self) -> list[FlowRecord]: - """The policy's live records as snapshots in FlowId order.""" - ... - - def record(self, flow_id: tuple[int, int]) -> FlowRecord: - """One of the policy's live records; ValueError for a FlowId of another ledger or - policy, a retired or an unknown id.""" - ... - - def usable_bundles(self) -> int: - """Pinned: the bundles with a src -> dst walk after static pruning (else 0).""" - ... - - @property - def config(self) -> FlowPolicyConfig: - """The configuration (a copy taken at construction).""" - ... - - -class CapacityOptions: - """The configuration, the static Eligibility (``None``: all allowed), the DagCache - (``None``: a private cache per worker) and the outputs of ``capacity()`` and - ``batch_capacity()``.""" - - def __init__( - self, - config: FlowPolicyConfig, - *, - elig: Eligibility | None = None, - cache: DagCache | None = None, - outputs: OutputOptions | None = None, - ) -> None: ... - @property - def config(self) -> FlowPolicyConfig: ... - @property - def elig(self) -> Eligibility | None: ... - @property - def cache(self) -> DagCache | None: ... - @property - def outputs(self) -> OutputOptions: ... - - -class SensitivityOptions: - """The configuration, Eligibility, DagCache and baseline outputs of ``sensitivity()``.""" - - def __init__( - self, - config: FlowPolicyConfig, - *, - elig: Eligibility | None = None, - cache: DagCache | None = None, - outputs: OutputOptions | None = None, - ) -> None: ... - @property - def config(self) -> FlowPolicyConfig: ... - @property - def elig(self) -> Eligibility | None: ... - @property - def cache(self) -> DagCache | None: ... - @property - def outputs(self) -> OutputOptions: ... - - -class SensitivityStatus: - """``EVALUATED`` (loss = baseline minus the capacity without the edge) or - ``UNEVALUABLE`` (the deletion raised a model exception: ``loss`` is ``None`` and - ``reason`` the message).""" - - EVALUATED: ClassVar["SensitivityStatus"] - UNEVALUABLE: ClassVar["SensitivityStatus"] - __members__: ClassVar[dict[str, "SensitivityStatus"]] - - def __init__(self, value: int) -> None: ... - def __int__(self) -> int: ... - def __index__(self) -> int: ... - @property - def name(self) -> str: ... - @property - def value(self) -> int: ... - - -class SensitivityEntry: - """One candidate edge: ``edge``, ``loss`` (signed units, never thresholded; ``None`` - when unevaluable), ``status``, ``reason`` (``None`` unless unevaluable).""" - - @property - def edge(self) -> int: ... - @property - def loss(self) -> int | None: ... - @property - def status(self) -> SensitivityStatus: ... - @property - def reason(self) -> str | None: ... - - -class SensitivityResult: - """``baseline`` (the PlacementResult of ``capacity()``) and ``entries`` (ascending - EdgeId; an absent edge has loss 0).""" - - @property - def baseline(self) -> PlacementResult: ... - @property - def entries(self) -> list[SensitivityEntry]: ... - - -def capacity( - graph: Graph, - sources: "int | Sequence[int]", - destinations: "int | Sequence[int]", - opts: CapacityOptions, -) -> PlacementResult: - """``place(+inf)`` of a FlowPolicy with ``opts.config`` on a fresh ledger of the graph. - - The exact maximum flow is the Global + MaxFlow configuration (the minimum-cost maximum - flow; ``cost_distribution`` its marginal curve). A supplied frozen cache raises - ``DagCacheMiss`` for an absent key, unchanged. A failed call raises and returns no - result; a supplied building cache keeps the entries and statistics of the lookups that - succeeded before the failure (a memo: APPROACH 13, limit 2); a supplied frozen - cache's statistics are unchanged, except one miss when the call raises ``DagCacheMiss``. - """ - ... - - -def batch_capacity( - graph: Graph, - pairs: "Sequence[tuple[int | Sequence[int], int | Sequence[int]]]", - opts: CapacityOptions, - per_pair_elig: "Sequence[Eligibility | None] | None" = None, - workers: int | None = None, -) -> list[PlacementResult]: - """``capacity()`` per ``(sources, destinations)`` pair, in pair order. - - ``opts.elig`` is intersected with ``per_pair_elig[i]`` (``None`` entries allowed; a given - sequence has one entry per pair, also when it is empty). Everything is validated before a - worker starts: the arguments first (workers, options, the configuration: Pinned is - rejected; the ``per_pair_elig`` length), then each pair completely in pair order (its - endpoints, their overlap, its Eligibility), so the error of the lowest failing pair index - is raised; an empty batch returns ``[]``. ``workers``: an explicit int >= 1 is honoured; ``None`` takes - ``NGRAPH_CORE_BATCH_THREADS`` (or 1), and 1 when a cache is supplied. A supplied - building cache is rejected unless ``workers == 1``; a frozen cache is shared by every - worker (pass ``workers`` explicitly), and its ``DagCacheMiss`` for an absent key reaches - the caller unchanged (the lowest failing pair index's). A per-pair intersection that - differs from both operands has a new uid, absent from a frozen cache: pass already - effective per-pair values with ``opts.elig`` ``None``, or a common value containing them, - so the intersection returns the prepopulated operand. A failed call raises and returns - no result; a supplied building cache keeps the entries and statistics of the lookups - that succeeded before the failure (a memo: APPROACH 13, limit 2); a supplied frozen - cache's statistics are unchanged, except one miss when the call raises ``DagCacheMiss``. - """ - ... - - -def sensitivity( - graph: Graph, - sources: "int | Sequence[int]", - destinations: "int | Sequence[int]", - opts: SensitivityOptions, - workers: int | None = None, -) -> SensitivityResult: - """The baseline ``capacity()`` and one deletion per candidate edge. - - Candidates: under Global the baseline's positive-flow edges; under every other model - every edge ``opts.elig`` allows with at least 1 unit of static capacity. A loss is the - baseline's placed minus ``capacity()`` with the edge deleted (``opts.elig`` minus the - edge, the same configuration and cache), in signed units, never thresholded. Global - evaluates it by the flow core's deletion re-solve from the baseline solve's final state - (that value exactly; a failure there raises); CostOnly gives an edge outside every roster - source's reachable part of the static support loss 0 without a placement, a - result-equivalent predicate; every other deletion is placed on a fresh ledger, where a - model exception makes the entry ``UNEVALUABLE`` with the message as reason. ``workers`` as ``batch_capacity`` (``NGRAPH_CORE_SENSITIVITY_THREADS``). - The cache: a supplied building cache needs ``workers == 1``, a frozen one is shared by - every worker; CostOnly is the one model reading the cache, and its deletion looks up a - new Eligibility (a new uid), so with a frozen cache it raises ``DagCacheMiss`` - (unchanged, never ``UNEVALUABLE``); the other models read no cache. - A failed call raises and returns no result; a supplied building cache keeps the - entries and statistics of the lookups that succeeded before the failure (a memo: - APPROACH 13, limit 2); a supplied frozen cache's statistics are unchanged, except one - miss when the call raises ``DagCacheMiss``. - """ - ... - - -# ---------------------------------------------------------------- the public search (APPROACH 6) - -UNREACHABLE: int -"""The path-cost sentinel of an unreachable node (``2**63 - 1``) in ``SpfResult.costs``; the -result accessors that return a single cost give ``None`` instead.""" - - -class SpfOptions: - """The options of ``spf`` (APPROACH 6). - - ``direction`` and ``selection`` are explicit. ``targets`` (one NodeId or a sequence; - ``None``: a full search) stops the search at the nearest targets (anycast). ``residual`` - (int64 units per EdgeId, each in ``[0, capacity_units]``) adds residual gating at - ``min_residual`` units and supplies the priority values of the selection; ``min_residual`` - (an integer ``>= 1``) gates a supplied residual only (static eligibility, the Eligibility - and capacity ``>= 1`` unit, is unconditional). ``elig``: ``None`` allows everything. - A direction outside its enumeration raises ValueError here; every other field is - validated by ``spf``, in the contract's order. - """ - - def __init__( - self, - direction: Direction, - selection: PathSelection, - *, - targets: "int | Sequence[int] | None" = None, - residual: "Sequence[int] | np.ndarray | None" = None, - min_residual: int = 1, - elig: Eligibility | None = None, - ) -> None: ... - @property - def direction(self) -> Direction: ... - @property - def selection(self) -> PathSelection: ... - @property - def targets(self) -> "int | Sequence[int] | None": ... - @property - def residual(self) -> "Sequence[int] | np.ndarray | None": ... - @property - def min_residual(self) -> int: ... - @property - def elig(self) -> Eligibility | None: ... - - -class SpfResult: - """The result of ``spf`` (APPROACH 6). - - ``costs``: full searches only, a read-only int64 array with one entry per node and - ``UNREACHABLE`` for the nodes the search did not reach (``None`` with targets). - ``dag``: the unique DAG of the member graph's edges that are tight in the traversal - direction, returned in their original orientation ``u -> v`` (``d`` the search's distance: - ``d(u) + cost = d(v)`` for a forward search, ``d(v) + cost = d(u)`` for a reverse one); - with targets, pruned to the walks between the roots and a nearest target (forward: from a - root to the target; reverse: from the target to a root), or the canonical single path for - ``multipath = False``. ``target_cost``: with targets, the nearest targets' cost, ``None`` - when no target is reached (and ``None`` for a full search). - """ - - @property - def costs(self) -> "np.ndarray | None": ... - @property - def dag(self) -> PredDAG: ... - @property - def target_cost(self) -> int | None: ... - - -def spf(graph: Graph, roots: "int | Sequence[int]", opts: SpfOptions) -> SpfResult: - """Shortest paths from the roots (every root at distance 0), forward or reverse (APPROACH 6). - - The member graph is every statically eligible edge of at least 1 unit (and, with a - residual, at least ``min_residual`` residual units); ``multi_edge = False`` keeps one - member per ``(u, v)`` pair (minimum cost, then the higher priority under - PREFER_HIGHER_PRIORITY, then the lowest EdgeId). Without targets every reachable node is - settled (``costs`` and the full DAG). With targets the search stops at the nearest - targets' cost (``target_cost``) and the DAG keeps only the walks between the roots and a - nearest target (for a reverse search, from the target to a root); with ``multipath = False`` it is the canonical single path, the walk minimising - ``(cost, -bottleneck under PREFER_HIGHER_PRIORITY, hops, edge-id sequence)`` (the same - walk from both directions with the roles swapped). Validated in this order before any - work (ValueError): the roots (non-empty, ids in range), the targets (the same), a vertex - in both sets, ``multipath = False`` without targets, the residual (its length, then every - entry in ``[0, capacity_units]``), ``min_residual >= 1``, a foreign Eligibility. A root or - target excluded by the Eligibility is skipped; all roots excluded gives an empty result. - """ - ... - - -class KspOptions: - """The options of ``ksp`` (APPROACH 6, rule 10). - - ``k`` (an integer ``>= 1``) and ``selection`` are explicit; the selection's ``multi_edge`` - is the member rule (Expand keeps every eligible parallel member, Collapse one per - ``(u, v)`` pair by ``select_adjacency``) and ``multipath`` is ignored. ``max_path_cost``: - ``None``, ``Absolute(c)`` (``c >= 0``) or ``Stretch(f)`` (``f`` finite in ``[1, inf)``, the - exact dyadic rational it is, times the static shortest cost of the member graph under the - Eligibility, residual ignored); the ceiling is inclusive. ``residual`` (int64 units per - EdgeId, each in ``[0, capacity_units]``) gates at 1 unit and supplies the priority values. - Every field is validated by ``ksp``, in the contract's order. - """ - - def __init__( - self, - k: int, - selection: PathSelection, - *, - max_path_cost: "Absolute | Stretch | None" = None, - residual: "Sequence[int] | np.ndarray | None" = None, - elig: Eligibility | None = None, - ) -> None: ... - @property - def k(self) -> int: ... - @property - def selection(self) -> PathSelection: ... - @property - def max_path_cost(self) -> "Absolute | Stretch | None": ... - @property - def residual(self) -> "Sequence[int] | np.ndarray | None": ... - @property - def elig(self) -> Eligibility | None: ... - - -def ksp(graph: Graph, src: int, dst: int, opts: KspOptions) -> "list[tuple[int, Path]]": - """The K shortest simple ``src -> dst`` paths of the member graph (APPROACH 6, rule 10). - - In increasing ``(cost, -bottleneck under PREFER_HIGHER_PRIORITY, hops, edge-id sequence)``, - the bottleneck being the minimum priority value along a path: the minimal prefix of that - order, truncated at ``k`` and at the inclusive ceiling. Each Path has single-member hops. - Built by Yen deviations whose first path and spur paths are the canonical single path of - ``spf`` (the spur's bottleneck target capped by the root prefix's bottleneck), so ``k = 1`` - gives the single path of a forward or reverse ``spf``. Validated in this order before any - work (ValueError): ``src`` and ``dst`` in range, ``src == dst``, ``k >= 1``, the ceiling, the - residual (its length, then every entry), a foreign Eligibility. An excluded endpoint, no - static route under ``Stretch``, or no path gives ``[]``. - """ - ... - - -def resolve_to_paths( - graph: Graph, - dag: PredDAG, - src: int, - dst: int, - split_parallel_edges: bool = False, - max_paths: int | None = None, -) -> "list[Path]": - """The ``src -> dst`` walks of a DAG (APPROACH 5) in increasing ``(cost, hops, edge-id - sequence)``: a hop costs its cheapest member and is keyed by its lowest member id. - - ``split_parallel_edges = False`` keeps every parallel member of a ``(u, v)`` pair in one - hop (members ascending); ``True`` gives one path per member combination, in the same - order. ``max_paths`` (``>= 1``) truncates the sorted list; ``None`` lists every walk. - ValueError: a DAG of another graph, ``src`` or ``dst`` out of range, ``src == dst``, - ``max_paths < 1``. An endpoint outside the DAG, or no walk, gives ``[]``. - """ - ... diff --git a/src/dag_cache.cpp b/src/dag_cache.cpp index e409516..7f96540 100644 --- a/src/dag_cache.cpp +++ b/src/dag_cache.cpp @@ -1,5 +1,5 @@ /* - The DagCache of APPROACH 10: a map from (roots, direction, Eligibility uid) to the full static + The DagCache: a map from (roots, direction, Eligibility uid) to the full static support of that search, with atomic statistics; building mode (a miss searches and inserts) and frozen mode (read-only: concurrent hits read the map and bump atomic counters, a miss throws). */ @@ -44,7 +44,7 @@ std::shared_ptr DagCache::lookup(const Graph& g, std::span DagCache::lookup_in_call(const Graph& g, std::span roots, Direction direction, const Eligibility* elig, detail::FrozenTally* tally) { - // Inside a public call a frozen cache's statistics wait for the call's boundary (NOTES R27). + // Inside a public call a frozen cache's statistics wait for the call's boundary. const bool staged = frozen_ && tally != nullptr && tally->cache() == this; // Invalid queries throw before anything is counted. if (g.uid() != graph_uid_) throw std::invalid_argument("DagCache: the graph is not the cache's graph (foreign graph)"); diff --git a/src/eligibility.cpp b/src/eligibility.cpp index 25e1765..8c9fd00 100644 --- a/src/eligibility.cpp +++ b/src/eligibility.cpp @@ -1,8 +1,8 @@ /* - Eligibility: the static node and edge flags of one graph (APPROACH 4), and its one public - construction Graph::eligibility(excluded_nodes, excluded_edges). An excluded node excludes its - incident edges (its out-edge id range and its reverse-CSR in-edges); intersect keeps an - operand, and its uid, when the result equals it. + Eligibility: the static node and edge flags of one graph, its one public construction from + excluded ids, Graph::eligibility(excluded_nodes, excluded_edges), and intersect. An excluded + node excludes its incident edges (its out-edge id range and its reverse-CSR in-edges); + intersect keeps an operand, and its uid, when the result equals it. */ #include "netgraph/core/eligibility.hpp" diff --git a/src/evaluators.cpp b/src/evaluators.cpp index 214e066..21b77ba 100644 --- a/src/evaluators.cpp +++ b/src/evaluators.cpp @@ -1,10 +1,10 @@ /* - The evaluators of APPROACH 11: capacity() is place(+inf) of a FlowPolicy on a fresh ledger; - batch_capacity() runs it per pair and sensitivity() per deleted candidate edge, on `workers` - threads (results in input order; the lowest index's exception wins). Sensitivity evaluates a - Global deletion by the flow core's deletion re-solve from the baseline solve's final state - (APPROACH 2.2) and gives a CostOnly edge outside the baseline's routes loss 0 without a - placement (a result-equivalent predicate); every other deletion is placed on a fresh ledger. + capacity() is place(+inf) on a fresh ledger. batch_capacity() runs it per pair and + sensitivity() per candidate deletion on `workers` threads; results follow input order and + the lowest failing index supplies the exception. Global repairs the baseline flow after + each deletion. CostOnly skips an edge that no roster source's reachable part of the static + shortest-path support contains: removing it changes no route or amount. Other deletions use + fresh ledgers. */ #include "netgraph/core/evaluators.hpp" @@ -26,7 +26,6 @@ #include "netgraph/core/detail/flow_core.hpp" #include "netgraph/core/detail/policy_engine.hpp" #include "netgraph/core/detail/pred_dag_builder.hpp" -#include "netgraph/core/detail/private_hooks.hpp" namespace netgraph::core { @@ -119,13 +118,16 @@ void run_indexed(std::size_t n, std::int32_t workers, const Graph& g, DagCache* } } -// CostOnly sensitivity: the edges of the baseline's routes. A source's route is read from the -// part of the full static support reachable from it (projected by the selection on the values -// of those edges; on a fresh ledger the creation view is the capacity). Deleting an edge -// outside every such part leaves every roster source's distance and reachable support as they -// were (distances only grow, and no shortest path of a reachable node used the edge), so every -// route, and the placement over the same capacities, is unchanged: the loss is exactly 0 and -// the deletion is not re-evaluated. +// CostOnly sensitivity: the edges of the full static support (every edge on a static shortest +// path to the destination set) reachable from the roster sources through support edges. This is +// not only the baseline's selected routes. A source's route is read from the part of this set +// reachable from it, then selected on the priority values of the working residual when that +// source's flow is created; earlier creations may already have used capacity, even on a fresh +// ledger. Deleting an edge outside the set leaves every reachable node's distance and support +// edges as they were (distances only grow, and no shortest path of a reachable node used the +// edge). So, creation by creation in their order, each sees the same reachable support and the +// same residual, and every route and placement is unchanged: the loss is exactly 0 and the +// deletion is not re-evaluated. std::vector reachable_support(const Graph& g, const detail::PolicyEngine& pe) { const auto num_edges = static_cast(g.num_edges()); std::vector in_support(num_edges, 0); @@ -271,33 +273,19 @@ SensitivityResult sensitivity_in_call(const Graph& g, std::span so const EdgeId e = candidates[i]; auto& entry = out.entries[i]; entry.edge = e; + // A failed deletion fails the call: run_indexed raises the lowest failing candidate's exception. if (global) { - // An exact deletion on valid inputs always has a feasible optimum: a failure propagates. entry.loss = base - resolvers[t].resolve(e).value; - entry.status = SensitivityStatus::Evaluated; return; } - try { -#if NETGRAPH_CORE_PRIVATE_HOOKS - if (detail::sensitivity_fail_edge().load(std::memory_order_relaxed) == e) { - throw std::runtime_error("injected model exception for the deletion of edge " + std::to_string(e) + - " (test hook)"); - } -#endif - if (cost_only && on_route[static_cast(e)] == 0) { - entry.loss = 0; - } else { - const EdgeId one[1] = {e}; - Eligibility deleted = g.eligibility({}, one); - if (opts.elig != nullptr) deleted = Eligibility::intersect(*opts.elig, deleted); - const auto r = evaluate(g, sources, destinations, opts.cfg, &deleted, &cache, OutputOptions {}, tally); - entry.loss = base - r.placed; - } - entry.status = SensitivityStatus::Evaluated; - } catch (const std::runtime_error& ex) { - entry.loss = std::nullopt; - entry.status = SensitivityStatus::Unevaluable; - entry.reason = ex.what(); + if (cost_only && on_route[static_cast(e)] == 0) { + entry.loss = 0; + } else { + const EdgeId one[1] = {e}; + Eligibility deleted = g.eligibility({}, one); + if (opts.elig != nullptr) deleted = Eligibility::intersect(*opts.elig, deleted); + const auto r = evaluate(g, sources, destinations, opts.cfg, &deleted, &cache, OutputOptions {}, tally); + entry.loss = base - r.placed; } if (&cache != opts.cache) cache.clear(); }); @@ -315,7 +303,7 @@ std::int32_t default_workers(bool sensitivity) noexcept { } // namespace detail -// Each evaluator is one public call on a supplied frozen cache's statistics (NOTES R27). +// Each evaluator is one public call on a supplied frozen cache's statistics. PlacementResult capacity(const Graph& g, std::span sources, std::span destinations, const CapacityOptions& opts) { return detail::frozen_call(opts.cache, [&](detail::FrozenTally& tally) { diff --git a/src/flow_core.cpp b/src/flow_core.cpp index 462f4b5..0c8217e 100644 --- a/src/flow_core.cpp +++ b/src/flow_core.cpp @@ -1,52 +1,66 @@ /* - The one integer minimum-cost flow core (APPROACH 2.2). - - One objective on every view: minimum cost for the maximum value attainable under the value - cap. Successive shortest paths with potentials and blocking flows. Each tier runs Dijkstra on - reduced costs cost(e) + pi(u) - pi(v) to completion over the residual network reachable from - the virtual source s* (through the virtual sink t* and onward), adds the distances to the - potentials of the vertices it settled, then drains the zero-reduced-cost admissible subgraph - by Dinic phases (BFS levels, DFS with current-arc pointers) until no admissible augmenting - path remains or the cap is reached; then the next tier. Positive original costs do not - exclude zero-reduced-cost residual cycles, hence the repeated phases. The solve ends when a - Dijkstra does not reach t* or the cap is reached; there is no completion phase. - - Arc identity: every eligible edge is its own arc pair (forward with the view capacity, - reverse with the flow). At a node the forward arcs of its out-edges and the reverse arcs of - its in-edges are visited in one sequence by ascending EdgeId (a self-loop's forward arc before - its reverse arc); at a real endpoint its virtual arc comes first; s* visits its arcs by - ascending source NodeId and t* by ascending destination NodeId. Virtual arcs cost 0 and carry - 2^63 - 2 units, above every attainable value. Every solve starts from zero flow, so the zero - potentials are valid (all costs are positive). - - The deletion re-solve (the exact sensitivity model, APPROACH 11) continues from a solve's - final state instead. A solve asked to capture its state also records, as a shadow that - changes none of its searches, the working potentials: the running sum of the tier maxima D_k - and, per settled vertex, that sum at its last settlement; the working potential is the - published one plus the difference (a never-settled vertex: the whole sum, from numeric zero). - They equal the update of every vertex after every Dijkstra, the reached ones by their distance - and the others by D_k, so the reduced cost is non-negative on every positive residual arc, - real or virtual. The re-solve deletes a real arc e = (u, v) with flow f_e > 0 from a copy of - the state and runs three fixed-origin repairs, each a sequence of tiers as above from its - origin to its target, with that all-vertex update before the Dinic phases: u -> v for f_e, - moving m; when r = f_e - m > 0, u -> s* for r, whose first tier reuses repair 1's failed - search (nothing changed since), then t* -> v for r. The result is the minimum-cost flow of the - maximum value placed - r on the view without e, certified by the working potentials and the - real sources' residual closure; at most m + 2 r Dijkstra runs. + The one integer minimum-cost flow core. + + Find the maximum flow allowed by the cap at minimum cost. The residual network records how + much flow a forward arc can add and how much its reverse arc can cancel. A potential pi is + a vertex cost offset; the reduced cost of u -> v is cost(e) + pi(u) - pi(v). These offsets + make searchable arc costs non-negative without changing which path between two endpoints + is cheapest. Each tier runs Dijkstra, which finalises the nearest pending vertex first, + over all residual vertices reachable from virtual source s*, through sink t* and onward. + Add the final distances to settled vertices' potentials. Arcs on shortest paths now have + zero reduced cost; those with positive residual capacity form the admissible subgraph. + Dinic phases push flow through it: breadth-first search (BFS) assigns minimum-hop levels; + depth-first search (DFS) follows level-increasing paths, keeping each node's next arc to try. + A blocking flow fills these paths until none can take more. Repeat phases until no admissible + augmenting path (a residual source-to-sink path) remains, or the cap is reached. Repetition is + needed because a phase's level graph keeps only the admissible paths with the fewest arcs. + Start the next tier; stop when Dijkstra cannot reach t* or the cap is reached. No completion + phase follows. + + Arc identity: each eligible edge has a forward arc with the view capacity and a reverse arc + with its flow. At each node, visit forward out-arcs and reverse in-arcs together by ascending + EdgeId; for a self-loop visit forward before reverse. Virtual vertices s* and t* connect the + real source and destination sets. A real endpoint's virtual arc comes first; s* visits + sources and t* visits destinations by ascending NodeId. Virtual arcs cost 0 and carry + 2^63 - 2 units, above every attainable value. Starting from zero flow makes zero potentials + valid: usable real arcs have positive costs and virtual arcs cost 0. + + Deletion re-solves start from a captured final state. During capture, track working + potentials without changing the original searches. Keep the sum of tier maxima D_k and + each settled vertex's last such sum; D_k is that search's largest finite distance. + Working potential = published potential + tier maxima accumulated since its last settlement. + For a never-settled vertex, start at zero and add the whole sum. This equals updating every + vertex after every Dijkstra: reached vertices by their distance, the others by D_k. + Thus every positive residual arc, real or virtual, has non-negative reduced cost. + Delete e = (u, v), with flow f_e > 0, from a copy and run three repairs, each with a fixed + origin and target and that all-vertex update before Dinic phases. First move up to f_e from + u to v; call the amount moved m. If r = f_e - m > 0, move r from u to s*, then from t* to v. + The u -> s* repair reuses the first repair's failed search because nothing has changed. + The result has maximum value placed - r and minimum cost on the view without e. Working + potentials certify its cost; the real sources' full residual closure certifies its value. + There are at most m + 2 r Dijkstra runs. Dijkstra's queue is a radix heap: keys pop in increasing order and entries with equal keys pop in a fixed order (last pushed first within the lowest bucket), so the settle order, and with it every counter, is a function of the input alone. Equal-key order affects only the counters, never a distance, a potential or an augmenting path. The solve runs to the cap or to - disconnection; there is no clamp of the cap (APPROACH 2.2, revision 25). - - Arithmetic. A solve from zero stays in int64: every true residual distance is the cost of a - simple residual path, below the cost sum bound 2^62; potentials are such distances; a path's - bottleneck contains a real arc (sources and destinations are disjoint), so every amount is - below 2^62. The deletion mode's working potentials, distance keys, tentative distances and - sums of tier maxima are signed 128-bit (I128; reachability kept apart from the value by a - stamp): they stay within (n + 1) C at capture and (22 n + 15) C across the repairs for n - vertices and the cost sum C < 2^62, and its heap takes 128-bit keys. Amounts stay int64. + disconnection; there is no clamp of the cap. + + Arithmetic. A solve from zero stays in int64: each true residual distance is the cost of a + simple path (no repeated vertex), below the cost sum bound 2^62; potentials are such distances. + A path's bottleneck is its smallest residual capacity. It includes a real arc because sources + and destinations are disjoint, so every amount is below 2^62. The deletion mode's working + potentials, distance keys, tentative distances and sums of tier maxima are signed 128-bit + (I128; reachability kept apart from the value by a stamp): they stay within (n + 1) C at + capture and (22 n + 15) C across the repairs for n vertices and the cost sum C < 2^62, and its + heap takes 128-bit keys. Amounts stay int64. + + Size. Vertex ids, arc slots and slot offsets are int32. A network of N real vertices has + N + 2 vertices, and every edge and every virtual arc has two slots (forward and reverse), so + N <= INT32_MAX - 2 and edges + sources + destinations <= 2^30 - 1 (twice that is the largest + even int32). network_vertices() refuses a larger network; both ways of sizing the solver (a + solve from zero, a captured state's re-solves) take their vertex count from it before anything + else, so every vertex id, slot and offset fits. */ #include "netgraph/core/detail/flow_core.hpp" @@ -58,6 +72,7 @@ #include #include +#include "netgraph/core/detail/edge_amounts.hpp" #include "netgraph/core/detail/endpoints.hpp" #include "netgraph/core/detail/node_map.hpp" @@ -69,7 +84,8 @@ constexpr Cost kInfDist = std::numeric_limits::max(); constexpr Amount kUncapped = std::numeric_limits::max(); // The bucket of a key: the number of the highest bit in which it differs from the last popped -// key (keys are non-negative). +// key (keys are non-negative). A 128-bit key differs in its high word from 2^64 on, which the +// deletion mode's bound (22 n + 15) C allows (see Arithmetic above). std::size_t bucket_of(Cost key, Cost last) noexcept { return static_cast(std::bit_width(static_cast(key) ^ static_cast(last))); } @@ -123,27 +139,51 @@ inline constexpr bool kWide = std::is_same_v; } // namespace +std::int32_t network_vertices(std::int32_t num_nodes, std::size_t num_edges, std::size_t num_sources, + std::size_t num_destinations) { + constexpr std::int32_t kMax = std::numeric_limits::max(); + if (num_nodes > kMax - 2) { + throw std::invalid_argument( + "flow core: the number of nodes exceeds INT32_MAX - 2 (a solve adds a virtual source and sink)"); + } + // Each term is compared with what the earlier ones leave, so no sum of counts can wrap. + constexpr auto kPairs = static_cast(kMax / 2); // 2^30 - 1 + if (num_edges > kPairs || num_sources > kPairs - num_edges || num_destinations > kPairs - num_edges - num_sources) { + throw std::invalid_argument( + "flow core: the edges, sources and destinations of a solve number more than 2^30 - 1 together (each " + "takes two int32 arc slots)"); + } + return num_nodes + 2; +} + // The residual network of one solve (Network: real vertices 0..N-1, s* = N, t* = N + 1) with // its flow, potentials and scratch; also the working storage of a deletion re-solve. class FlowSolver { public: // One reusable instance per thread (`local()`) for the solves from zero: build() resizes every - // buffer for the input, keeping capacity across calls, so repeated solves do not allocate - // once warm. Not reentrant (a solve never starts another). A deletion resolver owns its own. + // buffer for the input, keeping capacity across calls, so the solver's own buffers are not + // reallocated once warm (each solve still allocates its outputs). Not reentrant (a solve never + // starts another). A deletion resolver owns its own. static FlowSolver& local() { thread_local FlowSolver solver; return solver; } + FlowSolver() = default; + // Never copied or moved: net_ may point at own_. + FlowSolver(const FlowSolver&) = delete; + FlowSolver& operator=(const FlowSolver&) = delete; + // The input part of the network (num_nodes, the edges, the endpoint sets) is the caller's to // fill; build() adds the arcs. - Network& network() noexcept { return net_; } + Network& network() noexcept { return own_; } // The arcs of the input, zero flow and zero potentials. `shadow`: record the working // potentials for a capture. void build(bool shadow) { - auto& in = net_; - size(in.num_nodes); + auto& in = own_; + size(in); + net_ = &own_; const auto n = static_cast(n_); in.off.assign(n + 1, 0); auto deg = [&](std::int32_t v) { ++in.off[static_cast(v) + 1]; }; @@ -204,11 +244,13 @@ class FlowSolver { } } - // A captured state's network, as the working storage of its deletion re-solves. + // A captured state's network, read in place by its deletion re-solves (never written; the + // caller keeps it alive while this solver uses it). The residual, the potentials and the + // scratch stay this solver's own. void load(const Network& net) { - net_ = net; - size(net_.num_nodes); - res_.resize(net_.arc_head.size()); + size(net); + net_ = &net; + res_.resize(net_->arc_head.size()); wpi_.resize(static_cast(n_)); wdist_.resize(static_cast(n_)); wseen_.assign(static_cast(n_), 0); @@ -252,11 +294,11 @@ class FlowSolver { } out.placed = total; - out.flows.resize(net_.edges.size()); - for (std::size_t k = 0; k < net_.edges.size(); ++k) out.flows[k] = flow_of(k); + out.flows.resize(net_->edges.size()); + for (std::size_t k = 0; k < net_->edges.size(); ++k) out.flows[k] = flow_of(k); if (closure && close_sources()) out.cut = cut_edges(-1); if (potentials) { - out.potentials.resize(static_cast(net_.num_nodes)); + out.potentials.resize(static_cast(net_->num_nodes)); for (std::size_t v = 0; v < out.potentials.size(); ++v) { out.potentials[v] = ever_[v] != 0 ? pi_[v] : kNeverReached; } @@ -268,7 +310,7 @@ class FlowSolver { void capture(DeletionCapture& req, Amount placed, const FlowCoreCounters& counters) { DeletionState state; state.graph_ = req.graph; - state.net_ = std::make_shared(net_); + state.net_ = std::make_shared(*net_); state.res_ = res_; state.pi_.resize(static_cast(n_)); for (std::size_t v = 0; v < state.pi_.size(); ++v) state.pi_[v] = I128 {pi_[v]} + (tier_sum_ - checkpoint_[v]); @@ -291,13 +333,13 @@ class FlowSolver { std::copy(state.potentials().begin(), state.potentials().end(), wpi_.begin()); DeletionResult out; auto& c = out.counters; - const auto fwd = static_cast(net_.edge_slot[k]); - const auto bwd = static_cast(net_.rev[fwd]); + const auto fwd = static_cast(net_->edge_slot[k]); + const auto bwd = static_cast(net_->rev[fwd]); const Amount f = res_[bwd]; - const std::int32_t u = net_.tail[k]; - const std::int32_t v = net_.head[k]; - // The deletion: a pseudo-flow with an excess of f at u and a deficit of f at v, its residual - // the state's without e's two arcs. + const std::int32_t u = net_->tail[k]; + const std::int32_t v = net_->head[k]; + // Removing e leaves f extra units at u and f missing units at v; conservation is broken + // until repairs reconnect or cancel them. Remove both residual arcs of e. res_[fwd] = 0; res_[bwd] = 0; emit(0, false, 0, c); @@ -321,19 +363,21 @@ class FlowSolver { } out.value = state.value() - r; out.potentials = wpi_; - for (std::int32_t j : cut_edges(static_cast(k))) out.cut.push_back(net_.edges[static_cast(j)]); + for (std::int32_t j : cut_edges(static_cast(k))) out.cut.push_back(net_->edges[static_cast(j)]); out.edge_flows.assign(static_cast(state.graph().num_edges()), 0); - for (std::size_t j = 0; j < net_.edges.size(); ++j) { - out.edge_flows[static_cast(net_.edges[j])] = j == k ? 0 : flow_of(j); + for (std::size_t j = 0; j < net_->edges.size(); ++j) { + out.edge_flows[static_cast(net_->edges[j])] = j == k ? 0 : flow_of(j); } return out; } private: - void size(std::int32_t num_nodes) { - n_ = num_nodes + 2; - s_ = num_nodes; - t_ = num_nodes + 1; + // The vertex count and the virtual vertices of `net` (refused beyond the limits of + // network_vertices(), before any change), then the scratch of that many vertices. + void size(const Network& net) { + n_ = network_vertices(net.num_nodes, net.edges.size(), net.sources.size(), net.destinations.size()); + s_ = n_ - 2; + t_ = n_ - 1; const auto n = static_cast(n_); level_.resize(n); reach_.assign(n, 0); @@ -343,7 +387,7 @@ class FlowSolver { // The flow of input edge k: the residual of its reverse slot. [[nodiscard]] Amount flow_of(std::size_t k) const { - return res_[static_cast(net_.rev[static_cast(net_.edge_slot[k])])]; + return res_[static_cast(net_->rev[static_cast(net_->edge_slot[k])])]; } template @@ -388,8 +432,15 @@ class FlowSolver { auto& pi = potentials

(); auto& dist = distances

(); auto& queue = heap

(); + // The network is never resized during a search: raw pointers keep the compiler from + // reloading net_ and the vector bases around every store into the solver's own buffers. + const std::int32_t* const off = net_->off.data(); + const std::int32_t* const arc_head = net_->arc_head.data(); + const Cost* const arc_cost = net_->arc_cost.data(); ++c.dijkstra_runs; if constexpr (kWide

) { + // Wraparound after 2^32 wide searches since load(): one resolver's searches over all its + // deletions, which no stated limit keeps below 2^32. if (++wepoch_ == 0) { std::fill(wseen_.begin(), wseen_.end(), 0); wepoch_ = 1; @@ -411,13 +462,13 @@ class FlowSolver { settled_.push_back(ui); ++c.settled; const P base = du + pi[u]; - const auto end = net_.off[u + 1]; - c.arc_scans += end - net_.off[u]; - for (auto p = net_.off[u]; p < end; ++p) { + const auto end = off[u + 1]; + c.arc_scans += end - off[u]; + for (auto p = off[u]; p < end; ++p) { const auto a = static_cast(p); if (res_[a] <= 0) continue; - const auto v = static_cast(net_.arc_head[a]); - const P nd = base + net_.arc_cost[a] - pi[v]; + const auto v = static_cast(arc_head[a]); + const P nd = base + arc_cost[a] - pi[v]; if constexpr (kWide

) { if (wseen_[v] != wepoch_ || nd < dist[v]) { wseen_[v] = wepoch_; @@ -465,16 +516,17 @@ class FlowSolver { // after every push. template Amount drain(FlowCoreCounters& c, Amount limit, Amount total, OnPush on_push) { + const std::int32_t* const rev = net_->rev.data(); while (total < limit) { if (!build_levels

(c)) break; - std::copy(net_.off.begin(), net_.off.end() - 1, cur_.begin()); + std::copy(net_->off.begin(), net_->off.end() - 1, cur_.begin()); while (total < limit) { if (!find_path

(c)) break; Amount amount = limit - total; for (std::int32_t a : path_) amount = std::min(amount, res_[static_cast(a)]); for (std::int32_t a : path_) { res_[static_cast(a)] -= amount; - res_[static_cast(net_.rev[static_cast(a)])] += amount; + res_[static_cast(rev[static_cast(a)])] += amount; } total += amount; ++c.augmentations; @@ -493,23 +545,32 @@ class FlowSolver { template bool build_levels(FlowCoreCounters& c) { const auto& pi = potentials

(); + const std::int32_t* const off = net_->off.data(); + const std::int32_t* const arc_head = net_->arc_head.data(); + const std::int32_t* const rev = net_->rev.data(); + const Cost* const arc_cost = net_->arc_cost.data(); const auto t = static_cast(target_); + // Wraparound after 2^32 phases since size(): one solve from zero, or one resolver's phases + // over all its deletions, which no stated limit keeps below 2^32. + if (++mark_epoch_ == 0) { + std::fill(reach_.begin(), reach_.end(), 0); + mark_epoch_ = 1; + } // Reverse search from the target: the arc into v from arc_head[p] is the partner of v's slot p. - ++mark_epoch_; queue_.clear(); reach_[t] = mark_epoch_; queue_.push_back(target_); for (std::size_t qi = 0; qi < queue_.size(); ++qi) { const auto v = static_cast(queue_[qi]); const P pv = pi[v]; - const auto end = net_.off[v + 1]; - c.arc_scans += end - net_.off[v]; - for (auto p = net_.off[v]; p < end; ++p) { + const auto end = off[v + 1]; + c.arc_scans += end - off[v]; + for (auto p = off[v]; p < end; ++p) { const auto a = static_cast(p); - const auto u = static_cast(net_.arc_head[a]); + const auto u = static_cast(arc_head[a]); if (reach_[u] == mark_epoch_) continue; - const auto in = static_cast(net_.rev[a]); - if (res_[in] > 0 && net_.arc_cost[in] + pi[u] == pv) { + const auto in = static_cast(rev[a]); + if (res_[in] > 0 && arc_cost[in] + pi[u] == pv) { reach_[u] = mark_epoch_; queue_.push_back(static_cast(u)); } @@ -527,12 +588,12 @@ class FlowSolver { if (level_[t] >= 0 && level_[u] >= level_[t]) break; const P pu = pi[u]; const auto next = level_[u] + 1; - const auto end = net_.off[u + 1]; - c.arc_scans += end - net_.off[u]; - for (auto p = net_.off[u]; p < end; ++p) { + const auto end = off[u + 1]; + c.arc_scans += end - off[u]; + for (auto p = off[u]; p < end; ++p) { const auto a = static_cast(p); - const auto v = static_cast(net_.arc_head[a]); - if (level_[v] < 0 && reach_[v] == mark_epoch_ && res_[a] > 0 && net_.arc_cost[a] + pu == pi[v]) { + const auto v = static_cast(arc_head[a]); + if (level_[v] < 0 && reach_[v] == mark_epoch_ && res_[a] > 0 && arc_cost[a] + pu == pi[v]) { level_[v] = next; queue_.push_back(static_cast(v)); } @@ -546,6 +607,9 @@ class FlowSolver { template bool find_path(FlowCoreCounters& c) { const auto& pi = potentials

(); + const std::int32_t* const off = net_->off.data(); + const std::int32_t* const arc_head = net_->arc_head.data(); + const Cost* const arc_cost = net_->arc_cost.data(); stack_.clear(); path_.clear(); stack_.push_back(origin_); @@ -553,14 +617,14 @@ class FlowSolver { const auto u = static_cast(stack_.back()); if (static_cast(u) == target_) return true; bool advanced = false; - const auto end = net_.off[u + 1]; + const auto end = off[u + 1]; const auto next = level_[u] + 1; const P pu = pi[u]; while (cur_[u] < end) { ++c.arc_scans; const auto a = static_cast(cur_[u]); - const auto v = static_cast(net_.arc_head[a]); - if (res_[a] > 0 && level_[v] == next && net_.arc_cost[a] + pu == pi[v]) { + const auto v = static_cast(arc_head[a]); + if (res_[a] > 0 && level_[v] == next && arc_cost[a] + pu == pi[v]) { stack_.push_back(static_cast(v)); path_.push_back(static_cast(a)); advanced = true; @@ -607,17 +671,19 @@ class FlowSolver { // The full residual closure of the real sources (reverse and virtual arcs included) into // seen_. Returns whether t* is outside it. bool close_sources() { + const std::int32_t* const off = net_->off.data(); + const std::int32_t* const arc_head = net_->arc_head.data(); seen_.assign(static_cast(n_), 0); queue_.clear(); - for (std::int32_t s : net_.sources) { + for (std::int32_t s : net_->sources) { seen_[static_cast(s)] = 1; queue_.push_back(s); } for (std::size_t qi = 0; qi < queue_.size(); ++qi) { const auto u = static_cast(queue_[qi]); - for (auto p = net_.off[u]; p < net_.off[u + 1]; ++p) { + for (auto p = off[u]; p < off[u + 1]; ++p) { const auto a = static_cast(p); - const auto v = static_cast(net_.arc_head[a]); + const auto v = static_cast(arc_head[a]); if (res_[a] > 0 && seen_[v] == 0) { seen_[v] = 1; queue_.push_back(static_cast(v)); @@ -630,10 +696,13 @@ class FlowSolver { // The input edges of positive view capacity from the closure to its complement (ascending), // the input edge `skip` (a deleted edge) left out. [[nodiscard]] std::vector cut_edges(std::int64_t skip) const { + const std::int32_t* const tail = net_->tail.data(); + const std::int32_t* const head = net_->head.data(); + const Amount* const cap = net_->cap.data(); std::vector cut; - for (std::size_t k = 0; k < net_.edges.size(); ++k) { - if (static_cast(k) != skip && seen_[static_cast(net_.tail[k])] != 0 && - seen_[static_cast(net_.head[k])] == 0 && net_.cap[k] > 0) { + for (std::size_t k = 0; k < net_->edges.size(); ++k) { + if (static_cast(k) != skip && seen_[static_cast(tail[k])] != 0 && + seen_[static_cast(head[k])] == 0 && cap[k] > 0) { cut.push_back(static_cast(k)); } } @@ -648,7 +717,8 @@ class FlowSolver { void emit(int /*repair*/, bool /*push*/, Amount /*moved*/, const FlowCoreCounters& /*c*/) const noexcept {} #endif - Network net_ {}; + Network own_ {}; // a solve from zero's network (build()) + const Network* net_ {&own_}; // the network the searches read: own_, or a captured one (load()) std::int32_t n_ {0}; std::int32_t s_ {0}; std::int32_t t_ {0}; @@ -700,6 +770,8 @@ DeletionState& DeletionState::operator=(const DeletionState& other) { return *this = std::move(copy); } +// The solver reads the network that state_ shares: it lives as long as state_, a move included +// (a move transfers the owner, the network stays where it is). DeletionResolver::DeletionResolver(const DeletionState& state) : state_(state), solver_(std::make_unique()) { if (state_.net_ == nullptr) throw std::invalid_argument("deletion re-solve: an empty state (no solve captured it)"); solver_->load(state_.network()); @@ -750,18 +822,7 @@ FlowCoreResult place_global_units(const Graph& g, std::optional cap, DeletionCapture* capture) { const auto num_edges = static_cast(g.num_edges()); - if (view.size() != num_edges) { - throw std::invalid_argument("view has length " + std::to_string(view.size()) + ", expected " + - std::to_string(num_edges) + " (one entry per edge)"); - } - const auto capacity = g.capacity_units(); - for (std::size_t e = 0; e < num_edges; ++e) { - if (view[e] < 0 || view[e] > capacity[e]) { - throw std::invalid_argument("view[" + std::to_string(e) + "] = " + std::to_string(view[e]) + - " is outside [0, capacity_units[" + std::to_string(e) + "] = " + - std::to_string(capacity[e]) + "]"); - } - } + check_edge_amounts(g, view, "view"); const auto srcs = node_set(sources, g.num_nodes(), "sources"); const auto dsts = node_set(destinations, g.num_nodes(), "destinations"); check_disjoint(srcs, dsts, "sources", "destinations"); diff --git a/src/flow_ledger.cpp b/src/flow_ledger.cpp index 99d4f62..e743dae 100644 --- a/src/flow_ledger.cpp +++ b/src/flow_ledger.cpp @@ -1,9 +1,10 @@ /* - The integer ledger FlowLedger (APPROACH 8). + The integer ledger FlowLedger. - The aggregate allocation is kept exactly (integers) and changed only by an atomic commit or a - detach; residual() is derived from it (baseline - allocated). Records are kept by id (FlowId - order); ids come from a per-ledger counter and are never reused, also across reset(). + The aggregate allocation is kept exactly (integers) and changed only by an atomic commit, a + detach or a reset (which zeroes it); residual() is derived from it (baseline - allocated). + Records are kept by id (FlowId order); ids come from a per-ledger counter and are never + reused, also across reset(). */ #include "netgraph/core/flow_ledger.hpp" @@ -12,6 +13,7 @@ #include #include +#include "netgraph/core/detail/edge_amounts.hpp" #include "netgraph/core/detail/endpoints.hpp" #include "netgraph/core/detail/ledger_access.hpp" #include "netgraph/core/detail/uid.hpp" @@ -78,18 +80,7 @@ void FlowLedger::check_usable() const { } std::vector FlowLedger::checked_baseline(std::span residual) const { - const auto capacity = graph_->capacity_units(); - if (residual.size() != capacity.size()) { - throw std::invalid_argument("residual has length " + std::to_string(residual.size()) + ", expected " + - std::to_string(capacity.size()) + " (one entry per edge)"); - } - for (std::size_t e = 0; e < residual.size(); ++e) { - if (residual[e] < 0 || residual[e] > capacity[e]) { - throw std::invalid_argument("residual[" + std::to_string(e) + "] = " + std::to_string(residual[e]) + - " is outside [0, capacity_units[" + std::to_string(e) + "] = " + - std::to_string(capacity[e]) + "]"); - } - } + detail::check_edge_amounts(*graph_, residual, "residual"); return std::vector(residual.begin(), residual.end()); } @@ -224,13 +215,13 @@ bool is_bound(const std::map& owners, std::int64_t owner) { return it != owners.end() && it->second; } -// The dense per-edge delta of one commit, in a per-thread array of slots. An edge is touched in -// this commit when its slot carries the commit's epoch stamp: that test does not depend on the -// delta's value, so a delta that returns to zero during accumulation (-1, then +1) keeps its one -// entry in the touched list and is published once (review-1b-fixes N2). A slot is reset on its -// first touch in a commit, so nothing needs clearing between commits: a commit that throws -// leaves stale slots behind an old epoch. Delta and stamp share one array, so growing it either -// succeeds whole or leaves the previous array (review-1b-fixes N1). +// Per-thread deltas indexed by EdgeId. Each commit advances an epoch (a numeric stamp). +// A matching slot stamp means the edge is already in the touched list, even if its delta +// returned to zero (-1, then +1), so each edge is published once. Reset a slot on first touch; +// later commits ignore old stamps, including after an exception. Only stamp wraparound (after +// 2^32 commits on one thread, which a long-running process reaches) needs a full stamp clear. +// Delta and stamp share one array: failed growth leaves the old array intact (the strong +// guarantee). class DeltaScratch { public: explicit DeltaScratch(std::size_t num_edges) : s_(storage()) { diff --git a/src/flow_policy.cpp b/src/flow_policy.cpp index e238b28..a325865 100644 --- a/src/flow_policy.cpp +++ b/src/flow_policy.cpp @@ -1,29 +1,31 @@ /* - The policy engine FlowPolicy (APPROACH 8 and 9): one placement transaction for every routing + The policy engine FlowPolicy: one placement transaction for every routing mode, committed through the ledger as one atomic exchange per call. - * CostOnly, CapacityAware and Pinned run the per-source transaction of 9.2 and 9.3: the - roster (fixed at construction) in ascending NodeId, each source offered the increment of its - round-robin share of the cumulative request, stages 1 (creation), 2 (reoptimisation), 3 - (amounts) and 4 (growth) on the shared working residual W; one `created` counter per call. - Pinned has one actor, the policy, with its one source. - * Global re-solves the minimum-cost flow of the cumulative value on V = W + own (the flow core - of 2.2 over the whole eligible graph) and decomposes it per source canonically (9.1). - - Every stage works on copies; nothing is published before the commit, which either succeeds or - throws leaving the ledger and the policy unchanged; the epoch state follows the commit with - operations that cannot throw. The working residual is a per-thread overlay over the ledger's - residual (only the edges a call touches cost anything), so a Pinned call, and a CostOnly call - after its cohort's creation, costs O(its flows' members), never O(E); CostOnly creation of a - single path, CapacityAware searches and Global solves read a dense copy. - - The transaction trace (prototype 2's keys) exists in a build with the private hooks only: every + * CostOnly, CapacityAware and Pinned visit the roster (eligible sources that statically + reach an allowed destination, fixed at construction) in ascending NodeId. Round robin gives each + source an equal integer share of the cumulative request, with leftover units going to + earlier sources. Offer each source the increase in its share, then run stages 1 creation, + 2 reoptimisation, 3 amounts and 4 growth on shared unused capacity W (the working residual). + One `created` counter covers the call. Pinned has a single source. + * Global replaces its flow with a minimum-cost flow for the cumulative value on V = W + own, + over all eligible edges; own is its current allocation. Split the result by source in the + fixed order described by decompose(). + + Work on copies and commit all changes together; an exception leaves ledger and policy intact. + Update policy epoch state (accumulated since construction or remove()) after commit, without + throwing. Store only changes to ledger residual in per-thread slots. A Pinned call, or a + CostOnly call after creating a source's group of flows (cohort), costs O(its flows' members), + not O(E). CapacityAware and Global read a dense residual copy; CostOnly creation reads the + working residual edge by edge. + + The transaction trace (the call's intermediate values, keyed as the tests' model of the policy + keys them) exists in a build with the private hooks only: every trace block is guarded by kTraced and is compile-time dead without them. */ #include "netgraph/core/flow_policy.hpp" #include -#include #include #include #include @@ -37,7 +39,6 @@ #include "netgraph/core/detail/int128.hpp" #include "netgraph/core/detail/kernels.hpp" #include "netgraph/core/detail/ledger_access.hpp" -#include "netgraph/core/detail/node_map.hpp" #include "netgraph/core/detail/policy_engine.hpp" #include "netgraph/core/detail/pred_dag_builder.hpp" #include "netgraph/core/detail/private_hooks.hpp" @@ -53,22 +54,18 @@ std::atomic& max_flows_per_call() noexcept { static std::atomic cap {kMaxFlowsPerCall}; return cap; } - -std::atomic& sensitivity_fail_edge() noexcept { - static std::atomic edge {-1}; - return edge; -} #endif namespace { // ================================================================ working residual -// W = baseline - allocated + delta, the delta being this call's working changes (releases of the -// policy's own allocations and new working allocations). Per-thread slots stamped with the -// call's epoch: a slot is valid only under the current epoch, so nothing is cleared between calls -// and a call that throws leaves stale slots behind an old epoch. One array of slots: growing it -// either succeeds whole or leaves the previous array (strong guarantee). +// W = baseline - allocated + delta; delta holds this call's releases and new allocations. +// Each call advances a scratch epoch (a numeric stamp, separate from the policy epoch). +// Read a slot only when its stamp matches; later calls ignore stale slots even after a throw. +// Clear all stamps only on wraparound (after 2^32 calls on one thread, which a long-running +// process reaches). Slots share one per-thread array, so failed growth leaves the old array +// intact. class Working { public: explicit Working(const FlowLedger& fg) @@ -95,7 +92,7 @@ class Working { } x.delta += v; } - // The member-aligned working residual of `members`. + // Unused capacity for each edge of `members`, in the same order (member-aligned). void gather(std::span members, std::vector& out) const { out.resize(members.size()); for (std::size_t m = 0; m < members.size(); ++m) out[m] = at(members[m]); @@ -161,7 +158,7 @@ const char* outcome_name(Termination t) noexcept { return "?"; } -// The reduction rank of 9.4 (higher wins among unserved sources). +// The reduction rank of the call's termination (higher wins among unserved sources). int rank(Termination t) noexcept { switch (t) { case Termination::LimitFlows: return 4; @@ -190,7 +187,7 @@ void align(std::span members, const std::vector void cumulative_offers(const Counter128& requested, Amount v, std::size_t k, Offer&& offer) { @@ -275,6 +272,20 @@ std::shared_ptr plan_on(const Call& c, NodeId src, const PredD make_plan(c.g, cfg.placement, cfg.admission, cfg.selection.priority, src, c.pe.dests_ok, route, creation, cost)); } +// The priority values of the creation view W, as the selection reads them (CostOnly). The +// PriorityFn of `on_w` points at this view: it must outlive every call through that PriorityFn. +struct WorkingPriority { + const Working* W; + std::span capacity; + EdgePriority priority; + [[nodiscard]] PriorityFn on_w() const noexcept { + return {this, [](const void* ctx, EdgeId e) noexcept { + const auto& v = *static_cast(ctx); + return priority_value(v.priority, v.W->at(e), v.capacity[static_cast(e)]); + }}; + } +}; + // The record bundle of a route: `share` itself when it is the whole route, else a new DAG. std::shared_ptr bundle_of(const Graph& g, std::span route, const std::shared_ptr& share) { @@ -282,26 +293,16 @@ std::shared_ptr bundle_of(const Graph& g, std::span return std::make_shared(PredDAGBuilder::of_edges(g, route)); } -// CostOnly: the static support restricted to the walks from src, then the selection projected on -// the creation view W (APPROACH 9.1, 10): Collapse by (cost, priority under PreferHigherPriority, -// lowest EdgeId), and the staged canonical single path for multipath = false, both reading W's -// priority values and never gating on them (the traversal core's support projection). +// CostOnly: keep static shortest-path support reachable from src, then apply selection on W. +// For multi_edge = false (Collapse), choose one edge per (u, v) by (cost, higher priority under +// PreferHigherPriority, lowest EdgeId). For multipath = false, choose the canonical single +// path in stages. Both use W's priority values without excluding edges for low residual. Route cost_only_route(const Call& c, NodeId src) { const auto& g = c.g; const auto& pe = c.pe; const auto& sel = pe.config.selection; - const auto capacity = g.capacity_units(); - struct View { - const Working* W; - std::span capacity; - EdgePriority priority; - }; - const View view {&c.W, capacity, sel.priority}; - const PriorityFn on_w {&view, [](const void* ctx, EdgeId e) noexcept { - const auto& v = *static_cast(ctx); - return priority_value(v.priority, v.W->at(e), v.capacity[static_cast(e)]); - }}; - auto r = support_route(g, PredDAGBuilder::ascending(*pe.support), src, pe.dests_ok, sel, on_w); + const WorkingPriority view {&c.W, g.capacity_units(), sel.priority}; + auto r = support_route(g, PredDAGBuilder::ascending(*pe.support), src, pe.dests_ok, sel, view.on_w()); Route out; out.edges = std::move(r.edges); if (!sel.multipath && !out.edges.empty()) out.cost = r.cost; @@ -313,21 +314,12 @@ Route cost_only_route(const Call& c, NodeId src) { std::optional creation_products(const Call& c, NodeId src) { const auto& pe = c.pe; const auto& sel = pe.config.selection; - struct View { - const Working* W; - std::span capacity; - EdgePriority priority; - }; - const View view {&c.W, c.g.capacity_units(), sel.priority}; - const PriorityFn on_w {&view, [](const void* ctx, EdgeId e) noexcept { - const auto& v = *static_cast(ctx); - return priority_value(v.priority, v.W->at(e), v.capacity[static_cast(e)]); - }}; - return cost_only_creation(c.g, pe.support, src, pe.dests_ok, sel, on_w, pe.config.placement, - pe.config.admission); + const WorkingPriority view {&c.W, c.g.capacity_units(), sel.priority}; + return cost_only_creation(c.g, pe.support, src, pe.dests_ok, sel, view.on_w(), pe.config.placement, + pe.config.admission); } -// CapacityAware: the canonical route of section 6 on the selection view `view` (dense): static +// CapacityAware: the canonical route on the selection view `view` (dense): static // eligibility, residual >= max(1, min_flow_bandwidth), Collapse on the view, the tight DAG or the // staged single path toward the allowed destinations, then the max_path_cost ceiling. std::optional search(const Call& c, std::size_t ri, std::span view) { @@ -364,8 +356,8 @@ Amount flow_t_max(const Graph& g, const KernelPlan& plan, std::span& plans, const Working& W) : plans_(plans) { @@ -439,7 +431,7 @@ class CohortEval { }; // Splits T over the cohort by round robin; a flow whose share changed (or is new) gets the -// allocation of its new share (APPROACH 9.2 stage 3 EB). +// allocation of its new share (the amounts stage of EqualBalanced). void set_cohort(std::vector& flows, Amount T) { std::vector parts(flows.size()); round_robin(T, parts); @@ -774,6 +766,9 @@ void stage3_lossy(Call& c, SourceRun& st) { const auto members = f.members(); c.W.gather(members, w_m); const auto step = lossy_step(*f.plan, offer, w_m); + // The 2^127 checks below can fail: finite offers bring an offered total up to the request + // total (below 2^127), and a +inf call then adds its injection. A drop counter (at most its + // flow's offered counter) can reach 2^127 only in such a call, and its check runs first. c.W.consume(members, step.carried); for (std::size_t m = 0; m < members.size(); ++m) { f.alloc[m] += step.carried[m]; @@ -808,7 +803,8 @@ void stage3_lossy(Call& c, SourceRun& st) { // ---------------------------------------------------------------- stage 4: growth -// fraction * v as the reduced rational "num/den" (or "num"), prototype 2's trace text. +// fraction * v as the reduced rational "num/den" (or "num"): the exact text the transaction trace +// records for the diminishing guard's threshold. std::string fraction_text(double fraction, Amount v) { const auto [m, x] = dyadic(fraction); U128 num = mul_u64(m, static_cast(v)); @@ -836,7 +832,8 @@ std::string fraction_text(double fraction, Amount v) { return dec(num) + "/2**" + std::to_string(k); } -// gains.sum < fraction * v, exactly (fraction the dyadic rational it is, v < 2^62). +// Compare gains.sum < fraction * v exactly, reading fraction as its exact stored double value; +// v < 2^62. bool below_fraction(Amount recent, double fraction, Amount v) { const auto [m, x] = dyadic(fraction); // x < 0 since fraction < 1 const U128 rhs = mul_u64(m, static_cast(v)); // below 2^115 @@ -887,7 +884,7 @@ void stage4(Call& c, SourceRun& st) { break; } if (pe.config.selection.multipath) { - // Revision 24 (owner): a multipath DAG already spans every equal-cost path, so a second + // A multipath DAG already spans every equal-cost path, so a second // flow on a structurally equal DAG gains only round-robin remainders: growth stops. A // repeated single path is a legitimate tunnel. bool equal = false; @@ -1166,7 +1163,7 @@ PlacementResult place_flows(PolicyEngine& pe, std::optional v, const Out for (const auto& [i, j] : fresh) drafts.push_back(draft_of(pe, runs[i].flows[j])); // Everything the publication of the epoch state needs is allocated before the commit. Counter128 requested = pe.requested; - if (v.has_value() && !requested.add(*v)) throw std::invalid_argument("the cumulative request counter would reach 2^127"); + if (v.has_value()) (void)requested.add(*v); // checked in place_impl auto next = next_cohorts(runs, k); Amount placed = 0; std::vector> outcomes; @@ -1217,10 +1214,10 @@ PlacementResult place_flows(PolicyEngine& pe, std::optional v, const Out // ---------------------------------------------------------------- Global -// The canonical per-source decomposition (9.1): sources ascending; for each, while it has -// supply, the lexicographically smallest EdgeId walk with positive remaining flow from it to the -// first destination it reaches, saturated by min(the walk's bottleneck, the remaining supply). -// Per node the first out-edge with remaining flow only moves forward (remaining flow never grows). +// Split flow by source in ascending NodeId. While a source has supply left, take the smallest +// EdgeId sequence with positive remaining flow, stopping at the first destination. Assign +// min(supply left, smallest remaining edge flow on that walk), then subtract it from the walk. +// Each node's out-edge cursor only advances: remaining flow never increases. std::vector>> decompose(const Graph& g, std::vector rem, std::span roster, std::span dests) { @@ -1233,7 +1230,6 @@ std::vector>> decompose(const Graph& g, st std::vector cur(row.begin(), row.end() - 1); std::vector>> out(roster.size()); std::vector walk; - std::vector mine; for (std::size_t i = 0; i < roster.size(); ++i) { const NodeId s = roster[i]; const auto si = static_cast(s); @@ -1349,7 +1345,7 @@ PlacementResult place_global(PolicyEngine& pe, std::optional v, const Ou // New records in source order (ascending NodeId), after the retained ones. std::stable_partition(drafts.begin(), drafts.end(), [](const Draft& d) { return d.id.has_value(); }); Counter128 requested = pe.requested; - if (v.has_value() && !requested.add(*v)) throw std::invalid_argument("the cumulative request counter would reach 2^127"); + if (v.has_value()) (void)requested.add(*v); // checked in place_impl PlacementResult out; out.placed = sol.placed - P; if (v.has_value()) out.remaining = *v - out.placed; @@ -1393,7 +1389,7 @@ PlacementResult place_global(PolicyEngine& pe, std::optional v, const Ou } if (outputs.bottleneck) out.min_cut = std::move(sol.min_cut); if (any_output(outputs)) prepare_outputs(pe, outputs, draft_edges(drafts), out); - auto curve = *out.cost_distribution; // the cumulative curve a later zero call reports (9.1) + auto curve = *out.cost_distribution; // the cumulative curve a later zero call reports if (staged != nullptr && *staged) (*staged)(); const auto ids = LedgerAccess::commit(pe.fg, pe.owner, drafts); // Committed: the epoch state follows (nothing below throws). @@ -1415,7 +1411,7 @@ void validate_config(const FlowPolicyConfig& cfg, std::size_t num_sources, std:: check_member(cfg.placement, "placement"); check_member(cfg.admission, "admission"); check_selection(cfg.selection); - check_kernel(cfg.placement, cfg.admission, cfg.selection.priority); + check_kernel_combination(cfg.placement, cfg.admission, cfg.selection.priority); if (cfg.admission == Admission::Lossy && (std::holds_alternative(r) || std::holds_alternative(r))) { invalid("Lossy admission is rejected with Global and CapacityAware routing (hop-by-hop forwarding without " @@ -1468,7 +1464,7 @@ using detail::PolicyEngine; namespace { // The engine of a new policy, bound to its ledger as the last step. A supplied frozen cache's hit -// is recorded in `tally` (NOTES R27): the public constructor publishes it once construction has +// is recorded in `tally`: the public constructor publishes it once construction has // succeeded, an evaluator once its whole call has. std::unique_ptr make_engine(FlowLedger& ledger, std::span sources, std::span destinations, FlowPolicyConfig config, @@ -1533,12 +1529,16 @@ std::unique_ptr make_engine(FlowLedger& ledger, std::span(s) + 1]) pe->roster.push_back(s); } } else { + // The static costs fix the roster; only a Stretch ceiling reads them again, so only it keeps them. + const auto* ca = std::get_if(&pe->config.routing); + const bool keep_costs = + ca != nullptr && ca->max_path_cost.has_value() && std::holds_alternative(*ca->max_path_cost); const auto dist = detail::static_costs(g, elig, pe->dests_ok, pe->sources); for (std::size_t i = 0; i < pe->sources.size(); ++i) { const NodeId s = pe->sources[i]; if (!node_ok(s) || dist[i] == kUnreachable) continue; pe->roster.push_back(s); - pe->baseline.push_back(dist[i]); + if (keep_costs) pe->baseline.push_back(dist[i]); } } pe->cohorts.resize(pe->roster.size()); @@ -1559,7 +1559,7 @@ FlowPolicy::FlowPolicy(FlowLedger& ledger, std::span sources, std: FlowPolicyConfig config, const Eligibility* elig, DagCache* cache, detail::FrozenTally& tally) : engine_(make_engine(ledger, sources, destinations, std::move(config), elig, cache, tally)) {} -// The destruction law (section 8): the records stay in the ledger as orphans. +// The destruction law: the records stay in the ledger as orphans. FlowPolicy::~FlowPolicy() { LedgerAccess::release(engine_->fg, engine_->owner); } namespace { @@ -1569,16 +1569,18 @@ namespace { // nothing changed (the Python binding builds its result objects there). PlacementResult place_impl(PolicyEngine& pe, std::optional volume, const OutputOptions& outputs, detail::Trace* trace, const std::function* staged) { - // Validation before any mutation, also before the zero shortcut (section 8). + // Validation before any mutation, also before the zero shortcut. if (volume.has_value() && (*volume < 0 || *volume >= kDomainLimit)) { throw std::invalid_argument("request " + std::to_string(*volume) + " is outside [0, 2^62) units (requests are integer units or +inf)"); } if (pe.infinite && volume.has_value()) { throw std::invalid_argument( - "a finite request after +inf is rejected within an epoch (the cumulative finite request is undefined; " - "rule 13)"); + "a finite request after +inf is rejected within an epoch (the cumulative finite request is undefined)"); } + // The only check of the cumulative request: nothing changes the requested total between here + // and the call's publication, so the later additions (below, in place_flows and in + // place_global) repeat this sum and cannot fail. if (volume.has_value()) { Counter128 probe = pe.requested; if (!probe.add(*volume)) throw std::invalid_argument("the cumulative request counter would reach 2^127"); @@ -1588,8 +1590,9 @@ PlacementResult place_impl(PolicyEngine& pe, std::optional volume, const out.remaining = 0; out.local_outcomes.reserve(pe.roster.size()); for (NodeId s : pe.roster) out.local_outcomes.emplace_back(s, Termination::Complete); - // Global: the cumulative curve (9.1: its amounts sum to placed_demand(); empty before the - // first placement); a zero call solves nothing, so it claims no cut (2.2's cap-zero payload). + // Global: the cumulative curve (its amounts sum to placed_demand(); empty before the + // first placement); a zero call solves nothing, so it claims no cut (as the flow core's + // payload for a cap of 0). if (detail::is(pe)) out.cost_distribution = pe.curve; detail::prepare_outputs(pe, outputs, detail::current_edges(pe), out); if (staged != nullptr && *staged) (*staged)(); @@ -1600,7 +1603,7 @@ PlacementResult place_impl(PolicyEngine& pe, std::optional volume, const out.termination = Termination::NoRoute; if (detail::is(pe)) { // No source reaches an allowed destination: the cumulative flow is 0 and maximal, so its - // marginal curve is empty and the full-residual cut of 2.2 is empty (the closure of the + // marginal curve is empty and the full-residual cut is empty (the closure of the // sources has no arc of positive view leaving it); None would claim a residual path. out.cost_distribution.emplace(); if (outputs.bottleneck) out.min_cut.emplace(); @@ -1608,7 +1611,7 @@ PlacementResult place_impl(PolicyEngine& pe, std::optional volume, const if (detail::kTraced && trace) trace->set("roster", detail::Trace::list()); detail::prepare_outputs(pe, outputs, detail::current_edges(pe), out); if (staged != nullptr && *staged) (*staged)(); - // The epoch records every positive request, also one that finds no roster (section 8); + // The epoch records every positive request, also one that finds no roster; // nothing below throws (the counter was checked above). if (volume.has_value()) { (void)pe.requested.add(*volume); diff --git a/src/graph.cpp b/src/graph.cpp index f70f645..4e1bff1 100644 --- a/src/graph.cpp +++ b/src/graph.cpp @@ -1,13 +1,12 @@ /* - Graph: the one graph type on integer units (APPROACH 2.1, 3). - - Construction validates the inputs in the documented order, then builds layout B' by stable - counting passes (each distributes items into buckets numbered by a small integer key and keeps - the earlier order inside a bucket). The cost rank of an edge is the position of its cost among - the K distinct costs, sorted. The id order is (src, cost, dst, input), whose rank is the public - EdgeId, so the out-edges of u are the id range [row_offsets[u], row_offsets[u + 1]); the - in-edges of v are listed in (cost, src, input) order. An edgeless graph is its validation and - two zero-filled offset arrays. + Graph: the one graph type on integer units. + + Validate inputs in the documented order, then build edge ids and CSR arrays. N is the node + count, E the edge count, and K the number of distinct costs. Stable counting passes group by + a small integer key while preserving order within each group. A cost's rank is its position + among sorted distinct costs. EdgeIds sort by (src, cost, dst, input); u's out-edges occupy + [row_offsets[u], row_offsets[u + 1]); v's in-edges sort by (cost, src, input). + An edgeless graph is its validation and two zero-filled offset arrays. * N K >= E: three passes (dst, cost rank, src; least significant first) give the id order, and two over the ids (cost rank, then dst) the reverse lists: within one (dst, cost) group the id order is (src, input). @@ -17,23 +16,23 @@ The offset arrays serve as their own cursors; a pass that advances them is followed by one that walks back, so no pass over the nodes restores them. - The ranks: each edge first gets its cost's first-appearance ordinal from an open-addressing - table (fixed mixing, linear probing, load at most 1/4, doubled from 64 up to 2^18 slots, each - growth rebuilt from the distinct costs in their order); an - edge whose cost equals the previous edge's reuses that ordinal without a lookup. The K - distinct costs are then radix-sorted. Hashing stops on the 65,537th distinct cost, or before - a probe (one table slot examined, the terminating one included, counted across the lookups - and the growths) would exceed 8E + 8 x 65,536; the ranks then come from a radix sort of all - costs (16-bit digits). Both paths give the same ranks. - - Memory: every retained array is allocated first, at its final size; until it is filled, each - serves as scratch. The ranks go into in_col_indices_; the hash table borrows capacity_ (keys) and - src_ (ordinals) while it has at most E slots, the distinct costs cost_, their sort dst_, - input_index_ and in_adj_edge_index_; the passes use in_adj_edge_index_ (the middle forward - pass), capacity_ and cost_ (the K rank cursors or the combined histograms), and the fallback - input_index_ and in_adj_edge_index_. The only other buffers are a table larger than E slots (its - own vectors, the old and new ones briefly together at a growth) and the fallback's 2^16 digit - counts. A table of at most E slots frees no separate buffer that the allocator could keep cached. + Assign each cost its first-appearance index (ordinal) with a hash table. Resolve collisions + by trying successive slots (linear probing). Use fixed mixing, at most 1/4 occupancy, and + double from 64 to 2^18 slots, rebuilding in distinct-cost order. Consecutive equal costs reuse + the previous ordinal without lookup. Radix-sort the K distinct costs, processing digits from + least to most significant. Stop hashing at the 65,537th distinct cost, or before examining + more than 8E + 8 x 65,536 slots. Count every examined slot, including the final slot, across + lookups and growths. On either limit, radix-sort all costs in 16-bit digits. + Both methods produce identical ranks. + + Allocate all retained arrays at final size, then use them as scratch before filling them. + Store ranks in in_col_indices_. While the hash table has <= E slots, capacity_ holds its keys + and src_ its ordinals. cost_ holds distinct costs; dst_, input_index_ and in_adj_edge_index_ + serve their sort. Counting passes use in_adj_edge_index_ for the middle forward pass, and + capacity_/cost_ for K rank cursors or combined histograms. The fallback uses input_index_ and + in_adj_edge_index_. Extra buffers are limited to tables > E slots (old and new coexist during + growth) and the fallback's 2^16 digit counts. Smaller tables free no separate buffer that the + allocator could retain. */ #include "netgraph/core/graph.hpp" @@ -48,7 +47,7 @@ #include #include "netgraph/core/detail/graph_access.hpp" -#include "netgraph/core/detail/private_hooks.hpp" +#include "netgraph/core/detail/hooks_flag.hpp" #include "netgraph/core/detail/uid.hpp" namespace netgraph::core { @@ -164,16 +163,19 @@ struct RankOutcome { std::int64_t budget {0}; }; -// Ranks from a sort of all E costs: a stable LSD radix sort of the input positions by cost, -// 16 bits per pass, then one scan of the sorted positions. Digits above the largest cost's bit -// length are not visited, and a pass whose digit is the same for every cost is skipped. -// `a` and `b` (E slots each) hold the positions; `count` has 2^16 entries. +// Ranks from a stable LSD radix sort of the positions of `cost` by cost, kBits bits per pass (all +// E costs and 16 bits on the fallback path, the K distinct costs and 8 bits on the hashed path), +// then one scan of the sorted positions. Digits above the largest cost's bit length are not +// visited, and a pass whose digit is the same for every cost is skipped. `a` and `b` +// (cost.size() slots each) hold the positions; `count` has 2^kBits entries. `cost` is never +// empty: ranks_by_hash returns Hashed for an edgeless graph before calling this, a successful +// collection of positive costs keeps at least one, and the fallback sort of all costs runs only +// after hashing failed, which needs an edge. template std::int64_t ranks_by_sort(std::span cost, Cost max_cost, std::span rank, std::span a, std::span b, std::span count) { const std::size_t m = cost.size(); - if (m == 0) return 0; constexpr std::uint64_t kMask = (std::uint64_t {1} << kBits) - 1; std::iota(a.begin(), a.end(), 0); const auto top = static_cast(max_cost); @@ -269,11 +271,11 @@ RankOutcome ranks_by_hash(std::span cost, std::span ra return out; } -// Offsets from cursors that a scatter advanced to the ends: off[k] = start(k + 1) becomes -// off[k] = start(k), off[0] = 0 (the last entry of an (N + 1)-array keeps its value). -// Counts in off[0, n) become inclusive sums (each node's end), off[n] the total: a scatter that -// decrements these cursors, walking its input backwards, leaves the starts (stable, no shift). -// The sum stays in a register (a sum through memory chains every node on the previous store). +// Convert counts in off[0, n) to cumulative ends; off[n] becomes the total. Scattering input +// backwards while decrementing these cursors leaves group starts and preserves order within +// each group. Forward scattering leaves ends; ends_to_starts below shifts those back to starts +// (off[k] = off[k - 1] for every k >= 1, then off[0] = 0). Keep the running sum in a register so +// each iteration does not wait for the previous store. void counts_to_ends(std::span off) noexcept { std::int32_t sum = 0; for (std::size_t k = 0; k + 1 < off.size(); ++k) { @@ -311,7 +313,8 @@ Amount to_units(double x, double resolution) { if (x < 0.0) { throw std::invalid_argument("to_units: a negative amount has no unit value"); } - // x / q is exact (q is a power of two) unless it overflows to infinity. + // x / q is exact (q is a power of two) unless it overflows to infinity or falls below the + // normal range (below 2^-1022 it may lose low bits; it then rounds to 0 units either way). const double y = std::ldexp(x, -k); constexpr double kTwo52 = 4503599627370496.0; // 2^52: doubles at or above it are integers constexpr double kLimit = 4611686018427387904.0; // 2^62 @@ -363,7 +366,7 @@ Graph::Graph(std::int32_t num_nodes, if (m > static_cast(std::numeric_limits::max())) { throw std::invalid_argument("the number of edges exceeds INT32_MAX (array length)"); } - // Zero or negative costs (rule 28). + // Zero or negative costs (costs are positive). Cost max_cost = 0; Cost cost_gcd = 0; for (std::size_t i = 0; i < m; ++i) { @@ -445,17 +448,17 @@ Graph::Graph(std::int32_t num_nodes, c.last_budget = ro.budget; } #endif - // An edgeless graph is its validation and its two zero-filled offset arrays (APPROACH 3). + // An edgeless graph is its validation and its two zero-filled offset arrays. if (m == 0) return; // The passes. With few nodes times few costs (N K < E), the cost rank rides with an endpoint as - // one key: forward by dst, then by (src, rank); reverse by (dst, rank) over the ids (proposal P4). + // one key: forward by dst, then by (src, rank); reverse by (dst, rank) over the ids. // Otherwise the five single-key passes: forward by dst, rank, src; reverse by rank, then dst. - // Both give the same orders. P4's two (N K + 1)-entry histograms live in the not-yet-filled - // capacity and cost arrays (N K + 1 <= E), so its work is O(E). + // Both give the same orders. The combined-key passes keep their two (N K + 1)-entry histograms + // in the not-yet-filled capacity and cost arrays (N K + 1 <= E), so their work is O(E). const bool fused = n * kk < m; - const std::span by_dst_rank(capacity_); // P4: (dst, rank) histogram, then cursors - const std::span by_src_rank(cost_); // P4: (src, rank) histogram, then cursors + const std::span by_dst_rank(capacity_); // fused: (dst, rank) histogram, then cursors + const std::span by_src_rank(cost_); // fused: (src, rank) histogram, then cursors if (fused) { #if NETGRAPH_CORE_PRIVATE_HOOKS ++detail::thread_construction_counts().fused; @@ -463,7 +466,7 @@ Graph::Graph(std::int32_t num_nodes, const std::size_t nk = n * kk; std::fill(by_dst_rank.begin(), by_dst_rank.begin() + static_cast(nk + 1), 0); std::fill(by_src_rank.begin(), by_src_rank.begin() + static_cast(nk + 1), 0); - // One counter update per loop: two in one loop ran several times slower (NOTES section 1). + // Keep the counter updates in separate loops for speed. for (std::size_t i = 0; i < m; ++i) ++by_dst_rank[static_cast(dst[i]) * kk + static_cast(rank[i]) + 1]; for (std::size_t i = 0; i < m; ++i) ++by_src_rank[static_cast(src[i]) * kk + static_cast(rank[i]) + 1]; for (std::size_t x = 0, sum = 0; x <= nk; ++x) sum = static_cast(by_dst_rank[x] += static_cast(sum)); @@ -479,8 +482,9 @@ Graph::Graph(std::int32_t num_nodes, } // Forward pass 1, by dst, from input order. The reverse offsets serve as cursors, so no pass - // over the nodes restores them: P4 walks the input backwards from the ends to the starts; the - // single-key passes walk it forwards from the starts to the ends, and reverse pass 2 walks back. + // over the nodes restores them: the combined-key passes walk the input backwards from the ends + // to the starts; the single-key passes walk it forwards from the starts to the ends, and + // reverse pass 2 walks back. if (fused) { for (std::size_t i = m; i-- > 0;) { order[static_cast(--in_row_offsets_[static_cast(dst[i])])] = static_cast(i); diff --git a/src/kernels.cpp b/src/kernels.cpp index 899bc83..251b6f2 100644 --- a/src/kernels.cpp +++ b/src/kernels.cpp @@ -1,10 +1,11 @@ /* - Apportionment and the placement kernels in units (APPROACH 2.1, 7 and 16). + Apportionment and the placement kernels in units. T is a flow's total. - Every amount is an int64 number of units below 2^62: a flow's T is bounded by its source - members' view, every inflow by T, every share by its node's inflow. Highest averages compares - quotients by exact 128-bit cross products (weights and divisors below 2^63, products below - 2^126); lower quotas floor(I * w / W) are 128-bit products divided by W < 2^62. + Every amount is an int64 number of units below 2^62: T is bounded by the view on source + members, each node's inflow by T, and each share by that inflow. Highest averages compares + w_i/d_i and w_j/d_j through w_i*d_j and w_j*d_i (operands < 2^63, products < 2^126). The + lower quota floor(I*w/W) uses a 128-bit product, with inflow I, weight w and weight sum + W < 2^62. */ #include "netgraph/core/detail/kernels.hpp" @@ -81,10 +82,7 @@ void highest_averages(Amount total, std::span weights, std::span(at, num_dsts); @@ -172,11 +177,12 @@ KernelPlan& KernelPlan::operator=(KernelPlan&& other) noexcept { namespace { -// Per-thread per-node state of build_split, indexed by NodeId and valid for a node only -// when its stamp is the current epoch (no clearing between calls; a call that throws leaves stale -// entries behind an old epoch). One array of slots, so growing it either succeeds whole or leaves -// the previous array in place (review-1b-fixes N1: separately grown arrays could be left at -// different sizes by a failed allocation). +// Per-thread build_split state, by NodeId, for its backward depth-first search (DFS) and +// forward pass. A call advances an epoch (a numeric stamp); a slot is valid only if its stamp +// matches. Later calls ignore stale entries, including after exceptions; only stamp wraparound +// (after 2^32 calls on one thread, which a long-running process reaches) clears all stamps. One +// array holds every node's fields, so a failed growth leaves the old array intact (the strong +// guarantee); separate arrays could end at mismatched sizes. struct DagScratch { struct Node { std::uint32_t stamp {0}; @@ -218,19 +224,16 @@ DagScratch& dag_scratch() { return s; } -// The split structure of a flow from src to the destination set over a DAG, on the DAG's own -// predecessor lists (no local renumbering, no arc arrays, no CSR to build), restricted to the -// edges `keep(e)` accepts: the kept edges on some walk from src to a destination (destinations -// terminal) become plan.members, with the plan's topological fields. One pass: a backward DFS -// from the destinations over the predecessor entries lists the nodes that reach a destination in -// postorder (every node after its parents, a topological order); a forward pass over that order -// marks the nodes the source reaches. Members ascend by EdgeId and B' ids rank edges by source -// first, so the members of one tail are consecutive. Any topological order gives the same -// allocation (a node splits once its inflow is complete). -// -// With `cost`, the forward pass also computes the minimum walk cost from the source to a -// destination over the members (kUnreachable without members): the DP runs in the same -// topological order. Without `out` (route_cost) the pass ends with the DP: no plan is built. +// Build plan.members from the DAG's incoming-edge lists: keep edges accepted by keep(e) that +// lie on a src-to-destination walk; exclude edges leaving destinations. No node renumbering, +// extra arc arrays or adjacency offsets are needed. A backward DFS from destinations lists +// each node after its parents (postorder, also a topological order here). +// A forward pass then marks nodes reachable from src. Members ascend by EdgeId, so one tail's +// members are consecutive. Any topological order gives the same allocation because each node +// splits only after receiving all its inflow. +// With `cost`, the forward pass computes minimum cost to each node from its parents' costs +// (dynamic programming, DP), giving the minimum source-to-destination cost over members, or +// kUnreachable if none exist. Without `out` (route_cost), stop there and build no plan. template void build_split(const Graph& g, const PredDAG& dag, NodeId src, std::span dsts, Keep keep, KernelPlan* out, Cost* cost) { @@ -239,8 +242,8 @@ void build_split(const Graph& g, const PredDAG& dag, NodeId src, std::span(g.num_nodes())); - // The DAG's sparse storage scattered into the per-node slots (O(member nodes)); every other - // node is touched with no entries. + // The DAG's sparse storage scattered into the per-node slots (O(member nodes)); a node outside + // it has no entries when first touched (a destination, src or a parent the search meets). { const auto nodes = PredDAGBuilder::member_nodes(dag); const auto offs = PredDAGBuilder::member_offsets(dag); @@ -324,9 +327,8 @@ void build_split(const Graph& g, const PredDAG& dag, NodeId src, std::span(m); } else if (static_cast(nt.first + nt.count) != m) { - throw std::logic_error("kernel plan: the members of a node are not consecutive (EdgeIds not in B' order)"); + throw std::logic_error("kernel plan: the members of a node are not consecutive (member EdgeIds not grouped by source node)"); } ++nt.count; } @@ -463,10 +465,11 @@ KernelPlan make_route_plan(const Graph& g, FlowPlacement placement, Admission ad namespace { // Per-thread state of cost_only_creation. Per node (by NodeId) a slot valid only under the -// current epoch (DagScratch's pattern: nothing is cleared between calls, a call that throws leaves -// stale slots behind an old epoch, and the one array of slots grows whole or not at all); every -// other buffer is indexed by the reached nodes' local indices (their rank in ascending NodeId) or -// by route position, and is rewritten before it is read. +// current epoch (DagScratch's pattern: nothing is cleared between calls except on stamp +// wraparound, a call that throws leaves stale slots behind an old epoch, and the one array of +// slots grows whole or not at all); every other buffer is indexed by the reached nodes' local +// indices (their rank in ascending NodeId) or by route position, and is rewritten before it is +// read. struct CreationScratch { struct Node { std::uint32_t stamp {0}; @@ -534,7 +537,8 @@ std::size_t lower_bound_from(std::span a, std::size_t hint, EdgeId } hi = std::min(hi, n); } else { - // The answer lies in [0, hint] (hint == n, or a[hint] >= x): a[hi] >= x or hi == n throughout. + // The answer lies in [0, min(hint, n)] (hint >= n, or a[hint] >= x): a[hi] >= x or hi == n + // throughout. hi = std::min(hint, n); for (std::size_t step = 1;; step <<= 1) { if (hi < step) { @@ -572,7 +576,7 @@ std::optional cost_only_creation(const Graph& g, const std::sh s.begin(static_cast(g.num_nodes())); for (NodeId d : dsts) s.touch(d).target = 1; // support_route's walk over the support from src: every reached node keeps its support - // out-edges as one range (B' ids rank edges by source first: one tail's edges are consecutive). + // out-edges as one range (EdgeIds rank edges by source first: one tail's edges are consecutive). s.reached.clear(); s.touch(src).reached = 1; s.reached.push_back(src); @@ -812,7 +816,7 @@ namespace { // A floating-point estimate of T_max: the fraction f of the source's inflow that each member // carries under the plan's split (1/k per round-robin member, w/W per Weighted member), then // min over members of view / f. It only chooses the first probe of the exact search below -// (APPROACH 7, revision 26: approximate arithmetic may choose probes, nothing else). +// (approximate arithmetic may choose probes, nothing else). double estimate_t_max(const KernelPlan& plan, std::span view_m) { thread_local std::vector f; f.assign(plan.topo.size(), 0.0); @@ -840,17 +844,15 @@ double estimate_t_max(const KernelPlan& plan, std::span view_m) { constexpr std::int32_t kProbeBudget = 62; // a plain bisection over units below 2^62 -// The largest T in [lo, hi) whose allocation fits view_m, given that alloc(lo) fits and alloc(hi) -// does not (APPROACH 7 and 16.3, revision 26). fits(T) is exact (integer allocation compared -// member by member with the view) and monotone in T (the allocation is non-decreasing member by -// member: house monotonicity), so the result is exact whatever probes are taken. The probes: one -// at the floating-point estimate (the only approximate arithmetic, which chooses that probe and -// nothing else), then a galloping bracket, then bisection; both bracket ends are always exact -// probe results (or the given ends). The number of probes stays within kProbeBudget = 62, the -// bound of a plain bisection: a seed or galloping probe is taken only while the probes made so -// far, that probe and a worst-case bisection of the current bracket fit in the budget; the bound -// is checked on return. With `best`, every fitting probe's allocation is kept there (swapped in), -// so on return it holds alloc(result) whenever result > the given lo. +// Find the largest fitting T in [lo, hi): alloc(lo) fits view_m; alloc(hi) does not. +// Fit checks compare integer amounts per member exactly. Increasing T never reduces a +// member's allocation, so these bounds remain valid regardless of probe choices. +// First try a floating-point estimate, then double step sizes to narrow the bounds, then +// bisect. Only probe choice is approximate; bounds always come from exact checks or the given +// ends. Allow an estimate or doubling probe only if it and the worst-case remaining bisection +// fit within kProbeBudget = 62, counting probes already made. Check the budget on return. +// Swap each fitting allocation into `best` when supplied; on return it holds alloc(result) +// whenever result is greater than the original lo. Amount largest_fit(const KernelPlan& plan, std::span view_m, Amount lo, Amount hi, std::vector& probe, std::vector* best, std::int32_t& probes) { auto fits = [&](Amount t) { diff --git a/src/pred_dag.cpp b/src/pred_dag.cpp index 8f5aac5..1ed17da 100644 --- a/src/pred_dag.cpp +++ b/src/pred_dag.cpp @@ -1,5 +1,5 @@ /* - PredDAG and Path (APPROACH 5): immutable, graph-bound route structures validated once at + PredDAG and Path: immutable, graph-bound route structures validated once at construction, in the documented order, before any traversal. */ #include "netgraph/core/pred_dag.hpp" @@ -77,7 +77,8 @@ PredDAG PredDAG::from_csr(const Graph& g, std::span offsets, seen[e] = 1; } } - // Acyclic: Kahn over the entries (a self-loop is a cycle). + // Repeatedly remove nodes with no remaining predecessors, with their outgoing entries + // (Kahn's algorithm). Entries left over mean a cycle; a self-loop is one. { std::vector out_count(n + 1, 0); std::vector indeg(n, 0); diff --git a/src/search.cpp b/src/search.cpp index 0488ed0..010281e 100644 --- a/src/search.cpp +++ b/src/search.cpp @@ -1,16 +1,16 @@ /* - The one traversal core of every search (detail/traversal.hpp; APPROACH 6 and 14), the public + The one traversal core of every search (detail/traversal.hpp), the public spf, and the constructors of the search options. Distances are int64 path costs: every cost is positive and the sum of all costs is below 2^62, so no simple path cost overflows and kUnreachable (INT64_MAX) never collides. - The queues pop in (distance, node) order: the heap by its key, the buckets because each bucket - holds one distance at a time (pending distances lie in (d, d + max_cost] for the distance d - popped last, all multiples of the cost gcd) and the active bucket is sorted by node. A node is - pushed only when its distance strictly improves, so a bucket holds a node at most once. Pop - order among equal distances never changes a result: positive costs make every tight - predecessor of a node strictly nearer, hence settled before it. + Both queues pop in (distance, node) order: the heap by its key, the cyclic buckets because + each holds one distance at a time and the active bucket is sorted by node. After popping + distance d, pending distances lie in [d, d + max_cost], all multiples of cost_gcd. A node is + pushed only on a strict improvement, so it appears at most once per bucket. Equal-distance pop + order cannot change the result: positive costs make every tight predecessor of a node + strictly nearer, so it is finalised first. */ #include "netgraph/core/search.hpp" @@ -23,9 +23,11 @@ #include #include +#include "netgraph/core/detail/edge_amounts.hpp" #include "netgraph/core/detail/endpoints.hpp" #include "netgraph/core/detail/enum_domain.hpp" #include "netgraph/core/detail/graph_access.hpp" +#include "netgraph/core/detail/hooks_flag.hpp" #include "netgraph/core/detail/int128.hpp" #include "netgraph/core/detail/pred_dag_builder.hpp" #include "netgraph/core/detail/traversal.hpp" @@ -386,9 +388,10 @@ void mark(Workspace& ws, NodeId v, std::uint8_t bit) noexcept { f = static_cast(f | bit); } -// A KSP spur's rules (forward searches only): the spur node, its banned out-edges (Expand) or -// the banned heads of its transitions (Collapse: the heads carry kBannedHead), and the excluded -// prefix nodes (kExcluded). +// A KSP spur is the node where an alternative path leaves an earlier path's prefix. Spur rules +// apply to forward searches only: excluded prefix nodes (kExcluded) prevent cycles. At the spur, +// multi_edge = true (Expand) bans the listed edges; false (Collapse) bans every edge to a head +// marked kBannedHead. struct Spur { NodeId node {-1}; std::span banned_edges {}; @@ -499,9 +502,6 @@ Cost run(Workspace& ws, const SearchIn& in) { ws.buckets.begin(bucket_width(g), std::max(1, g.cost_gcd()), ws.dist.size(), in.roots); return forward ? loop(ws, ws.buckets, in) : loop(ws, ws.buckets, in); } -#if NETGRAPH_CORE_PRIVATE_HOOKS - ++thread_queue_counts().heap; -#endif ws.heap.begin(in.roots); return forward ? loop(ws, ws.heap, in) : loop(ws, ws.heap, in); } @@ -578,7 +578,7 @@ PredDAG emit_full(Workspace& ws, const Emit& em) { via = list; parents.resize(via.size()); for (std::size_t k = 0; k < via.size(); ++k) parents[k] = esrc[static_cast(via[k])]; - // Ascending EdgeId = grouped by source (B' ids rank by source first), ascending within one. + // Ascending EdgeId = grouped by source (EdgeIds rank by source first), ascending within one. std::vector slot(n + 1, 0); for (NodeId p : parents) ++slot[static_cast(p) + 1]; for (std::size_t v = 1; v <= n; ++v) slot[v] += slot[v - 1]; @@ -659,14 +659,14 @@ void prune(Workspace& ws, const Emit& em, std::span targets, Cost } } -// The staged canonical single path (APPROACH 6) on a tight DAG: `edges` (ws.pruned, any order) and -// its nodes `nodes` (ws.kept, each with kKeep), `pot(v)` the potential of a node (its forward -// distance from the walks' starts: 0 at the starts, `total` at the ends; a topological order). -// Stage 2 under PreferHigherPriority (`prio`): B*, the max-min priority over the DAG's walks, -// capped by `cap` (a KSP root prefix's bottleneck), and the DAG restricted to priority >= B*. -// Stage 3: the reverse hop count h from the ends and the greedy walk from the starts of minimum -// h, each step the smallest edge whose head has h(v) = h(u) - 1 (prefix-monotone, so the walk is -// the lexicographic minimum and simple). +// Choose one path from tight DAG `edges` (ws.pruned, any order) and `nodes` (ws.kept, kKeep). +// pot(v) is distance from the starts: 0 there, `total` at the ends. Increasing pot orders +// every predecessor before its child (topological order). +// Under PreferHigherPriority (`prio`), first maximise the smallest edge priority on a walk +// (its bottleneck), capped by `cap` for a KSP prefix; call this B*. Keep edges with priority >= B*. +// Then compute minimum remaining hops h from the ends. Start where h is smallest and repeatedly +// take the lowest EdgeId with h(v) = h(u) - 1. Decreasing h prevents cycles; choosing the smallest +// eligible id at each position gives the smallest EdgeId sequence among minimum-hop paths. struct Walk { std::vector edges {}; // in walk order Cost cost {0}; @@ -822,23 +822,7 @@ Emit emit_of(const Graph& g, Direction direction, const PathSelection& selection return em; } -void check_residual(const Graph& g, std::span residual) { - const auto m = static_cast(g.num_edges()); - if (residual.size() != m) { - throw std::invalid_argument("residual has length " + std::to_string(residual.size()) + ", expected " + - std::to_string(m) + " (one entry per edge)"); - } - const auto capacity = g.capacity_units(); - for (std::size_t e = 0; e < m; ++e) { - if (residual[e] < 0 || residual[e] > capacity[e]) { - throw std::invalid_argument("residual[" + std::to_string(e) + "] = " + std::to_string(residual[e]) + - " is outside [0, capacity_units[" + std::to_string(e) + "] = " + - std::to_string(capacity[e]) + "]"); - } - } -} - -// One KSP spur search (APPROACH 6, rule 10): the canonical walk from `spur` to `dst` over the +// One KSP spur search: the canonical walk from `spur` to `dst` over the // candidates of `gate`, without the excluded prefix nodes, with the spur node's banned // transitions (Expand: the banned edges; Collapse: every edge to a banned head, no re-selection), // B* capped by `cap` (the root prefix's bottleneck). nullopt when dst is unreachable. @@ -916,7 +900,7 @@ std::vector enumerate_paths(const Graph& g, const PredDAG& dag, NodeId src if ((ws.flags[static_cast(src)] & kKeep) == 0) return out; // src is not on a walk to dst for (std::size_t i = 0; i < kept.size(); ++i) ws.loc[static_cast(kept[i])] = static_cast(i); auto local = [&](NodeId v) { return static_cast(ws.loc[static_cast(v)]); }; - // The hops: per child, its entries come in ascending via, so grouped by parent (B' ids rank by + // The hops: per child, its entries come in ascending via, so grouped by parent (EdgeIds rank by // source first); a hop is one parent's run (its members ascending, its cost the cheapest, its // key the lowest id), or one member when split. struct Hop { @@ -960,7 +944,8 @@ std::vector enumerate_paths(const Graph& g, const PredDAG& dag, NodeId src out_hop[static_cast(fill[static_cast(hops[h].tail)]++)] = static_cast(h); } } - // Per node: its completions found so far (sorted) and the heap of the next completion per hop. + // A completion is a walk suffix from a node to dst. Per node: the completions found so far + // (sorted) and a heap holding the next completion through each hop. struct Elem { Cost cost; std::int64_t hops; @@ -1158,7 +1143,7 @@ SearchRoute support_route(const Graph& g, std::span support, NodeI if (!selection.multi_edge) { // One member per (u, v) by select_adjacency on the view (no gating: the support's members are // all statically eligible). The members of a pair in the support have one cost, so they are - // consecutive in id order (B' ranks by (src, cost, dst, input)). + // consecutive in id order (EdgeIds rank by (src, cost, dst, input)). std::size_t w = 0; for (std::size_t i = 0; i < route.size();) { const auto ei = static_cast(route[i]); @@ -1187,8 +1172,7 @@ SearchRoute support_route(const Graph& g, std::span support, NodeI } PredDAG static_support(const Graph& g, std::span roots, Direction direction, const Eligibility* elig) { - const auto root_set = node_set(roots, g.num_nodes(), "roots"); - const auto live_roots = live(root_set, elig); + const auto live_roots = live(roots, elig); if (live_roots.empty()) return PredDAGBuilder::empty(g); Lease lease(static_cast(g.num_nodes())); Workspace& ws = lease.ws(); @@ -1207,10 +1191,7 @@ PredDAG static_support(const Graph& g, std::span roots, Direction std::vector static_costs(const Graph& g, const Eligibility* elig, std::span dests, std::span sources) { std::vector out(sources.size(), kInf); - std::vector roots(dests.begin(), dests.end()); - std::sort(roots.begin(), roots.end()); - roots.erase(std::unique(roots.begin(), roots.end()), roots.end()); - roots = live(roots, elig); + const auto roots = live(dests, elig); if (roots.empty()) return out; Lease lease(static_cast(g.num_nodes())); Workspace& ws = lease.ws(); @@ -1266,7 +1247,7 @@ SpfResult spf(const Graph& g, std::span roots, const SpfOptions& o throw std::invalid_argument("multipath = false requires targets (the single path joins a root to a target)"); } const std::span residual = opts.residual.value_or(std::span {}); - if (opts.residual.has_value()) check_residual(g, residual); + if (opts.residual.has_value()) check_edge_amounts(g, residual, "residual"); if (opts.min_residual < 1) { throw std::invalid_argument("min_residual must be at least 1 unit (got " + std::to_string(opts.min_residual) + ")"); } @@ -1327,20 +1308,20 @@ std::vector> ksp(const Graph& g, NodeId src, NodeId dst, c check_selection(opts.selection); const NodeId s1[1] = {src}; const NodeId t1[1] = {dst}; - (void)node_set(s1, g.num_nodes(), "src"); - (void)node_set(t1, g.num_nodes(), "dst"); + check_range(s1, g.num_nodes(), "src"); + check_range(t1, g.num_nodes(), "dst"); check_disjoint(s1, t1, "src", "dst"); if (opts.k < 1) throw std::invalid_argument("k must be >= 1 (got " + std::to_string(opts.k) + ")"); check_ceiling(opts.max_path_cost); const std::span residual = opts.residual.value_or(std::span {}); - if (opts.residual.has_value()) check_residual(g, residual); + if (opts.residual.has_value()) check_edge_amounts(g, residual, "residual"); if (opts.elig != nullptr && opts.elig->graph_uid() != g.uid()) { throw std::invalid_argument("foreign Eligibility: built for another graph"); } std::vector> out; if (opts.elig != nullptr && (!opts.elig->node_ok(src) || !opts.elig->node_ok(dst))) return out; // The ceiling: Absolute(c), or Stretch(f) times the static shortest cost of the member graph - // under the Eligibility (residual ignored: the policy ceiling's baseline of 9.1); no static route + // under the Eligibility (residual ignored, as for the CapacityAware ceiling); no static route // gives []. const auto* absolute = opts.max_path_cost.has_value() ? std::get_if(&*opts.max_path_cost) : nullptr; const auto* stretch = opts.max_path_cost.has_value() ? std::get_if(&*opts.max_path_cost) : nullptr; @@ -1358,11 +1339,12 @@ std::vector> ksp(const Graph& g, NodeId src, NodeId dst, c const PriorityValues values {&g, opts.selection.priority, residual}; const PriorityFn prio = PriorityFn::of(values); const bool prefer = opts.selection.tie_break == EdgeTieBreak::PreferHigherPriority; - // Yen deviations (APPROACH 6): the first path and every spur path are the canonical walk from - // the spur node under the spur's exclusions; roots are matched by edge prefix; a deviation bans - // the next edge of every accepted path with that prefix (Expand) or that transition (Collapse); - // the prefix's vertices are excluded; candidates live in an ordered set keyed by the tuple - // (structural deduplication). + // Yen's method finds alternatives by keeping a prefix and replacing its suffix at a spur. + // The first path and each spur suffix use the canonical search with that spur's exclusions. + // Match prefixes by EdgeId sequence. Ban the next edge of every accepted matching path + // (Expand), or its entire (u, v) transition (Collapse); exclude earlier prefix vertices to + // prevent cycles. Store candidates once in a set ordered by (cost, decreasing bottleneck + // under PreferHigherPriority, hops, EdgeId sequence). struct Cand { Cost cost {0}; Priority bottleneck {0, 1, true}; @@ -1411,10 +1393,10 @@ std::vector> ksp(const Graph& g, NodeId src, NodeId dst, c c.bottleneck = compare(sp->bottleneck, prefix_bottleneck) < 0 ? sp->bottleneck : prefix_bottleneck; c.edges.assign(last.begin(), last.begin() + static_cast(j)); c.edges.insert(c.edges.end(), sp->edges.begin(), sp->edges.end()); - // An accepted path never returns (its next edge after this prefix is banned); checked - // anyway, as the prototype does. - const bool known = std::any_of(accepted.begin(), accepted.end(), [&](const Cand& a) { return a.edges == c.edges; }); - if (!known) candidates.insert(std::move(c)); + // Never an accepted path: every accepted path with this prefix has its next edge (Expand) + // or its next transition (Collapse) banned at the spur, and the candidate's next edge is + // the spur walk's first. Only a pending candidate can be equal; the set keeps one. + candidates.insert(std::move(c)); } prefix_cost += cost[static_cast(last[j])]; if (prefer) { @@ -1439,8 +1421,8 @@ std::vector resolve_to_paths(const Graph& g, const PredDAG& dag, NodeId sr if (dag.graph_uid() != g.uid()) throw std::invalid_argument("foreign PredDAG: built for another graph"); const NodeId s1[1] = {src}; const NodeId t1[1] = {dst}; - (void)node_set(s1, g.num_nodes(), "src"); - (void)node_set(t1, g.num_nodes(), "dst"); + check_range(s1, g.num_nodes(), "src"); + check_range(t1, g.num_nodes(), "dst"); check_disjoint(s1, t1, "src", "dst"); if (max_paths.has_value() && *max_paths < 1) { throw std::invalid_argument("max_paths must be >= 1 (got " + std::to_string(*max_paths) + ")"); diff --git a/tests/cpp/cost_only_creation_tests.cpp b/tests/cpp/cost_only_creation_tests.cpp index 0f0a89c..7fd5d21 100644 --- a/tests/cpp/cost_only_creation_tests.cpp +++ b/tests/cpp/cost_only_creation_tests.cpp @@ -1,16 +1,21 @@ -// The CostOnly creation products in one pass (APPROACH 9.1, 10; task 1d-msd): cost_only_creation -// against the functions it replaces on the policy's path, kept as the reference (support_route's -// route, the record bundle of bundle_of, make_plan's plan and cost on that bundle), on random -// graphs for every source, selection and kernel: it answers exactly where that path's members are -// the whole route, with the same bundle (the support itself when the route is all of it), plan and -// cost. On crafted supports that break its preconditions (edges leaving a destination, dead ends, -// members below one unit, a cycle) it answers nothing or the reference's own result. Also -// lower_bound_from against std::lower_bound from every hint, and the plan's one block under copy -// and move. +// The CostOnly creation products in one pass: cost_only_creation +// against the general path the policy takes wherever the one pass declines (support_route's route, +// the record bundle of bundle_of, make_plan's plan and cost on that bundle), on random graphs for +// every source, selection and kernel: it answers exactly where that path's members are the whole +// route, with the same bundle (the support itself when the route is all of it), plan and cost. On +// crafted supports that break its preconditions (edges leaving a destination, dead ends, members +// below one unit, a cycle) it answers nothing or the general path's own result. Also +// lower_bound_from against std::lower_bound from every hint, the plan's one block under copy +// and move, and the plan comparison of test_utils.hpp against a plan changed in each field. The +// general path is production code, not an independent reference: its parts are +// checked independently elsewhere (support_route against search_reference.hpp, also under +// residual priority views, in Search.InternalEntriesMatchThePublicSearch; make_plan against the +// walk definition for every kernel in kernels_tests.cpp). #include #include #include +#include #include #include #include @@ -19,7 +24,7 @@ #include #include #include -#include +#include #include #include "netgraph/core/detail/kernels.hpp" @@ -33,47 +38,28 @@ using namespace netgraph::core::detail; namespace { -template -std::vector> vec(std::span v) { - return std::vector>(v.begin(), v.end()); -} - +// Every field of two plans equal (plan_differences, test_utils.hpp). void expect_same_plan(const KernelPlan& got, const KernelPlan& want, const std::string& where) { - EXPECT_EQ(got.placement, want.placement) << where; - EXPECT_EQ(got.admission, want.admission) << where; - EXPECT_EQ(got.src, want.src) << where; - EXPECT_EQ(vec(got.dsts), vec(want.dsts)) << where; - EXPECT_EQ(got.route, want.route) << where; - EXPECT_EQ(got.members, want.members) << where; - EXPECT_EQ(got.weights, want.weights) << where; - EXPECT_EQ(vec(got.topo), vec(want.topo)) << where; - EXPECT_EQ(vec(got.out_begin), vec(want.out_begin)) << where; - EXPECT_EQ(vec(got.out_end), vec(want.out_end)) << where; - EXPECT_EQ(vec(got.head_pos), vec(want.head_pos)) << where; - EXPECT_EQ(vec(got.terminal), vec(want.terminal)) << where; - EXPECT_EQ(got.src_out_begin, want.src_out_begin) << where; - EXPECT_EQ(got.src_out_end, want.src_out_end) << where; + EXPECT_EQ(plan_differences(got, want), std::vector {}) << where; } -// The policy's CostOnly creation before task 1d-msd (flow_policy.cpp: cost_only_route, bundle_of, -// new_flow's plan_on): the reference the one-pass products must equal. -struct Reference { +// The policy's general CostOnly creation (flow_policy.cpp: cost_only_route, bundle_of, new_flow's +// plan_on), taken wherever the one pass declines; where the one pass answers, it must equal this. +struct GeneralPath { std::vector route; std::shared_ptr bundle; KernelPlan plan; Cost cost {kUnreachable}; }; -Reference reference(const Graph& g, const std::shared_ptr& support, NodeId src, +GeneralPath general_path(const Graph& g, const std::shared_ptr& support, NodeId src, const std::vector& dsts, const PathSelection& sel, PriorityFn view, const std::vector& W, FlowPlacement placement, Admission admission) { - Reference out; + GeneralPath out; auto r = support_route(g, PredDAGBuilder::ascending(*support), src, dsts, sel, view); out.route = r.edges; Cost cost = !sel.multipath && !r.edges.empty() ? r.cost : kUnreachable; - out.bundle = PredDAGBuilder::ascending(*support).size() == r.edges.size() - ? support - : std::make_shared(PredDAGBuilder::of_edges(g, r.edges)); + out.bundle = PredDAGBuilder::ascending(*support).size() == r.edges.size() ? support : dag_of(g, r.edges); std::vector creation; if (placement == FlowPlacement::Weighted && sel.priority == EdgePriority::Residual) { for (EdgeId e : PredDAGBuilder::ascending(*out.bundle)) creation.push_back(W[static_cast(e)]); @@ -110,7 +96,7 @@ std::vector selections() { // Random edges with many ties (costs 1 to 3, times `factor`), parallel bursts, capacities 0 to 4 // units. -Graph random_graph(std::mt19937& rng, int n, int m, Cost factor = 1) { +Graph tied_graph(std::mt19937& rng, int n, int m, Cost factor = 1) { std::vector edges; while (static_cast(edges.size()) < m) { const auto u = static_cast(rng() % static_cast(n)); @@ -123,36 +109,27 @@ Graph random_graph(std::mt19937& rng, int n, int m, Cost factor = 1) { return make_graph(n, edges); } -std::vector live(std::vector set, const Eligibility* elig) { - std::sort(set.begin(), set.end()); - set.erase(std::unique(set.begin(), set.end()), set.end()); - std::vector out; - for (NodeId v : set) { - if (elig == nullptr || elig->node_ok(v)) out.push_back(v); - } - return out; -} - -// The comparison on `trials` random graphs whose edge costs are 1 to 3 times `factor`: for every -// source, every one of the 24 selections and every legal kernel, the one pass answers exactly where -// the reference's members are its whole multipath route, with the same bundle (the support itself -// when the route is all of it), every plan array and the exact cost. +// What the comparison below counts. struct Tally { long made {0}; long shared {0}; - long collapsed {0}; + long collapsed {0}; // answers whose route Collapse changed (one edge per node pair) long weighted {0}; long anycast {0}; - long declined {0}; + long unrouted {0}; // declined although the selection and kernel qualify: the general path has no route long beyond_int32 {0}; // answers whose route cost exceeds INT32_MAX }; -Tally compare_with_reference(std::mt19937& rng, int trials, Cost factor) { +// The comparison on `trials` random graphs whose edge costs are 1 to 3 times `factor`: for every +// source, every one of the 24 selections and every legal kernel, the one pass answers exactly where +// the general path's members are its whole multipath route, with the same bundle (the support itself +// when the route is all of it), every plan array and the exact cost. +Tally compare_with_general_path(std::mt19937& rng, int trials, Cost factor) { const auto sels = selections(); Tally t; for (int trial = 0; trial < trials; ++trial) { const int n = 2 + static_cast(rng() % 10); - const Graph g = random_graph(rng, n, static_cast(rng() % 40), factor); + const Graph g = tied_graph(rng, n, static_cast(rng() % 40), factor); std::optional elig; if (rng() % 3 == 0) { std::vector xn; @@ -164,8 +141,8 @@ Tally compare_with_reference(std::mt19937& rng, int trials, Cost factor) { const Eligibility* el = elig ? &*elig : nullptr; std::vector D; for (std::size_t k = 1 + rng() % 3; k > 0; --k) D.push_back(static_cast(rng() % static_cast(n))); - const auto dset = live(D, nullptr); - const auto dsts = live(D, el); + const auto dset = allowed_nodes(D, nullptr); + const auto dsts = allowed_nodes(D, el); if (dsts.empty()) continue; const auto support = std::make_shared(static_support(g, dset, Direction::Reverse, el)); std::vector W(static_cast(g.num_edges())); @@ -186,30 +163,36 @@ Tally compare_with_reference(std::mt19937& rng, int trials, Cost factor) { std::to_string(static_cast(sel.tie_break)) + std::to_string(static_cast(sel.priority)) + std::to_string(sel.multipath) + std::to_string(sel.multi_edge); - const auto want = reference(g, support, src, dsts, sel, view, W, k.placement, k.admission); + const auto want = general_path(g, support, src, dsts, sel, view, W, k.placement, k.admission); const auto got = cost_only_creation(g, support, src, dsts, sel, view, k.placement, k.admission); - // It answers exactly where the reference's members are its whole multipath route under a + // It answers exactly where the general path's members are its whole multipath route under a // kernel of static membership (on a valid support that is every route that exists). - const bool applies = sel.multipath && - !(k.placement == FlowPlacement::Weighted && sel.priority == EdgePriority::Residual) && - !want.route.empty(); + const bool qualifies = + sel.multipath && !(k.placement == FlowPlacement::Weighted && sel.priority == EdgePriority::Residual); + const bool applies = qualifies && !want.route.empty(); EXPECT_EQ(got.has_value(), applies) << where; if (got.has_value() != applies) return t; if (!got.has_value()) { - ++t.declined; + t.unrouted += qualifies ? 1 : 0; continue; } EXPECT_EQ(want.plan.members, want.route) << where; // the precondition the shortcut checks EXPECT_TRUE(got->bundle) << where; if (!got->bundle) return t; EXPECT_TRUE(*got->bundle == *want.bundle) << where; - EXPECT_EQ(vec(PredDAGBuilder::ascending(*got->bundle)), want.route) << where; + EXPECT_EQ(ascending_edges(*got->bundle), want.route) << where; EXPECT_EQ(got->bundle == support, want.bundle == support) << where; // the same shared value expect_same_plan(got->plan, want.plan, where); EXPECT_EQ(got->cost, want.cost) << where; ++t.made; if (want.bundle == support) ++t.shared; - if (!sel.multi_edge && want.route.size() < PredDAGBuilder::ascending(*support).size()) ++t.collapsed; + if (!sel.multi_edge) { + // Collapse ran when the route differs from the one the same selection takes with every + // parallel edge kept. + PathSelection all_edges = sel; + all_edges.multi_edge = true; + if (support_route(g, PredDAGBuilder::ascending(*support), src, dsts, all_edges, view).edges != want.route) ++t.collapsed; + } if (k.placement == FlowPlacement::Weighted) ++t.weighted; if (dsts.size() > 1) ++t.anycast; if (want.cost > std::numeric_limits::max()) ++t.beyond_int32; @@ -225,7 +208,8 @@ Tally compare_with_reference(std::mt19937& rng, int trials, Cost factor) { TEST(CostOnlyCreation, LowerBoundFromEveryHintIsStdLowerBound) { std::mt19937 rng(20261007); - int checked = 0; + int forward = 0; // the answer above the hint + int backward = 0; // the answer below the hint for (int trial = 0; trial < 400; ++trial) { const auto n = static_cast(rng() % 140); std::vector a(n); @@ -239,47 +223,50 @@ TEST(CostOnlyCreation, LowerBoundFromEveryHintIsStdLowerBound) { const auto want = static_cast(std::lower_bound(a.begin(), a.end(), key) - a.begin()); for (std::size_t hint = 0; hint <= n + 3; ++hint) { ASSERT_EQ(lower_bound_from(a, hint, key), want) << "n " << n << " key " << key << " hint " << hint; - ++checked; + forward += want > hint ? 1 : 0; + backward += want < hint ? 1 : 0; } } } - EXPECT_GT(checked, 100000); + EXPECT_GT(forward, 1000000); + EXPECT_GT(backward, 1000000); } -TEST(CostOnlyCreation, EqualsTheReferenceForEverySourceSelectionAndKernel) { +TEST(CostOnlyCreation, EqualsTheGeneralPathForEverySourceSelectionAndKernel) { std::mt19937 rng(1007); - const Tally t = compare_with_reference(rng, 300, 1); + const Tally t = compare_with_general_path(rng, 300, 1); // Not vacuous: every branch of the one pass was compared, and the declined inputs too. EXPECT_GT(t.made, 20000); EXPECT_GT(t.shared, 500); - EXPECT_GT(t.collapsed, 2000); + EXPECT_GT(t.collapsed, 2000); // this seed: 2,838 EXPECT_GT(t.weighted, 4000); EXPECT_GT(t.anycast, 4000); - EXPECT_GT(t.declined, 20000); + EXPECT_GT(t.unrouted, 10000); } -TEST(CostOnlyCreation, WideCostsEqualTheReference) { - // Repeat the reference comparison with factors 3, 64 and 2^35. These cases catch the +TEST(CostOnlyCreation, WideCostsEqualTheGeneralPath) { + // Repeat the general-path comparison with factors 3, 64 and 2^35. These cases catch the // int32 output truncation; with 2^35 every answered cost exceeds INT32_MAX. - // They do not cover every narrower representation: a 48-bit output mask survives. + // They do not catch every narrower representation: an output cut to 48 bits would pass here + // (ChainCostsBeyondInt32AreExact catches it). for (const Cost factor : {Cost {3}, Cost {64}, Cost {1} << 35}) { std::mt19937 rng(static_cast(2000 + factor % 997)); - const Tally t = compare_with_reference(rng, 120, factor); + const Tally t = compare_with_general_path(rng, 120, factor); if (::testing::Test::HasFailure()) return; EXPECT_GT(t.made, 7000) << "factor " << factor; EXPECT_GT(t.shared, 150) << "factor " << factor; - EXPECT_GT(t.collapsed, 700) << "factor " << factor; - EXPECT_GT(t.declined, 7000) << "factor " << factor; + EXPECT_GT(t.collapsed, 700) << "factor " << factor; // these seeds: 1,452, 946 and 1,210 + EXPECT_GT(t.unrouted, 4000) << "factor " << factor; if (factor == (Cost {1} << 35)) EXPECT_EQ(t.beyond_int32, t.made); } } TEST(CostOnlyCreation, ChainCostsBeyondInt32AreExact) { - // The review's witness (0 -> 1 -> 2, two edges of 2^35: the cost 2^36 = 68,719,476,736), a chain - // whose total 2,500,000,000 exceeds INT32_MAX without being a power of two, a diamond of 2^35 - // edges with a parallel pair, and a full-width chain of two edges of 2^60 + 1 (the cost - // 2^61 + 2, below the graph's 2^62 bound; a 48-bit output mask gives 2): the exact cost and every - // array, for every legal kernel, both multi_edge settings. + // A chain of two edges beyond int32 (0 -> 1 -> 2, two edges of 2^35: the cost 2^36 = + // 68,719,476,736), a chain whose total 2,500,000,000 exceeds INT32_MAX without being a power of + // two, a diamond of 2^35 edges with a parallel pair, and a full-width chain of two edges of + // 2^60 + 1 (the cost 2^61 + 2, below the graph's 2^62 bound; a 48-bit output mask gives 2): the + // exact cost and every array, for every legal kernel, both multi_edge settings. struct Chain { std::int32_t n; std::vector edges; @@ -293,7 +280,6 @@ TEST(CostOnlyCreation, ChainCostsBeyondInt32AreExact) { {4, {{0, 1, 4, big}, {0, 2, 3, big}, {1, 3, 2, big}, {2, 3, 1, big}, {2, 3, 2, big}}, 3, 2 * big}, {3, {{0, 1, 2, (Cost {1} << 60) + 1}, {1, 2, 2, (Cost {1} << 60) + 1}}, 2, Cost {2305843009213693954}}, }; - int checked = 0; for (const auto& c : chains) { ASSERT_GT(c.cost, std::numeric_limits::max()); const Graph g = make_graph(c.n, c.edges); @@ -307,29 +293,31 @@ TEST(CostOnlyCreation, ChainCostsBeyondInt32AreExact) { const std::string where = "chain cost " + std::to_string(c.cost) + " kernel " + std::to_string(static_cast(k.placement)) + "/" + std::to_string(static_cast(k.admission)) + " multi_edge " + std::to_string(me); - const auto want = reference(g, support, 0, dsts, sel, PriorityFn::of(values), W, k.placement, k.admission); + const auto want = general_path(g, support, 0, dsts, sel, PriorityFn::of(values), W, k.placement, k.admission); const auto got = cost_only_creation(g, support, 0, dsts, sel, PriorityFn::of(values), k.placement, k.admission); ASSERT_TRUE(got.has_value()) << where; EXPECT_EQ(want.cost, c.cost) << where; EXPECT_EQ(got->cost, c.cost) << where; EXPECT_TRUE(*got->bundle == *want.bundle) << where; - EXPECT_EQ(vec(PredDAGBuilder::ascending(*got->bundle)), want.route) << where; + EXPECT_EQ(ascending_edges(*got->bundle), want.route) << where; EXPECT_EQ(got->bundle == support, want.bundle == support) << where; expect_same_plan(got->plan, want.plan, where); - ++checked; } } } - EXPECT_EQ(checked, 4 * 2 * 5); } -TEST(CostOnlyCreation, LargerGraphsAndTheSupportItself) { - // Larger fabrics (Clos and random) where the walk crosses many ranges: every source to one and - // to two destinations, the EB and MaxFlow kernels, with and without Collapse. +TEST(CostOnlyCreation, LargerGraphsEqualTheGeneralPath) { + // Larger fabrics (Clos and random) where the walk crosses many ranges: every source of the two + // Clos fabrics and every 7th source of the two 200-node random graphs, to one and to two + // destinations, the EB and MaxFlow kernels, with and without Collapse. A bundle shares the + // support only when the general path's does (a bundle equal to the support is met by the + // two general-path comparisons above, which count it). std::mt19937 rng(7); - long made = 0; for (int shape = 0; shape < 4; ++shape) { + long made = 0; + long anycast = 0; // answers to two destinations Graph g = [&] { if (shape < 2) { std::vector edges; @@ -346,14 +334,14 @@ TEST(CostOnlyCreation, LargerGraphsAndTheSupportItself) { } return make_graph(leaves + spines, edges); } - return random_graph(rng, 200, 2400); + return tied_graph(rng, 200, 2400); }(); const int n = g.num_nodes(); std::vector W(g.capacity_units().begin(), g.capacity_units().end()); for (int q = 0; q < 6; ++q) { std::vector D = {static_cast(rng() % static_cast(n))}; if (q % 2 == 1) D.push_back(static_cast(rng() % static_cast(n))); - const auto dsts = live(D, nullptr); + const auto dsts = allowed_nodes(D, nullptr); const auto support = std::make_shared(static_support(g, dsts, Direction::Reverse, nullptr)); for (NodeId src = 0; src < n; src += (n > 50 ? 7 : 1)) { if (std::binary_search(dsts.begin(), dsts.end(), src)) continue; @@ -361,7 +349,7 @@ TEST(CostOnlyCreation, LargerGraphsAndTheSupportItself) { const PathSelection sel(EdgeTieBreak::PreferHigherPriority, EdgePriority::Capacity, true, me); const PriorityValues values {&g, sel.priority, W}; for (auto placement : {FlowPlacement::EqualBalanced, FlowPlacement::MaxFlow}) { - const auto want = reference(g, support, src, dsts, sel, PriorityFn::of(values), W, placement, + const auto want = general_path(g, support, src, dsts, sel, PriorityFn::of(values), W, placement, Admission::Lossless); const auto got = cost_only_creation(g, support, src, dsts, sel, PriorityFn::of(values), placement, Admission::Lossless); @@ -372,23 +360,26 @@ TEST(CostOnlyCreation, LargerGraphsAndTheSupportItself) { expect_same_plan(got->plan, want.plan, "shape " + std::to_string(shape) + " src " + std::to_string(src)); EXPECT_EQ(got->cost, want.cost); ++made; + anycast += dsts.size() > 1 ? 1 : 0; } } } } + EXPECT_GT(made, 150) << "shape " << shape; + EXPECT_GT(anycast, 80) << "shape " << shape; } - EXPECT_GT(made, 300); } -TEST(CostOnlyCreation, CraftedSupportsGiveNothingOrTheReference) { +TEST(CostOnlyCreation, CraftedSupportsGiveNothingOrTheGeneralPath) { // Acyclic edge sets that are no static support: edges leaving a destination, nodes that reach no - // destination, members below one unit. The one pass declines them or equals the reference. + // destination, members below one unit. The one pass declines them or equals the general path. std::mt19937 rng(99); long made = 0; + long trimmed = 0; // answers whose bundle is not the support itself long declined = 0; for (int trial = 0; trial < 1500; ++trial) { const int n = 3 + static_cast(rng() % 8); - const Graph g = random_graph(rng, n, 4 + static_cast(rng() % 30)); + const Graph g = tied_graph(rng, n, 4 + static_cast(rng() % 30)); std::vector perm(static_cast(n)); std::iota(perm.begin(), perm.end(), 0); std::shuffle(perm.begin(), perm.end(), rng); @@ -400,7 +391,7 @@ TEST(CostOnlyCreation, CraftedSupportsGiveNothingOrTheReference) { const auto v = g.edge_dst()[static_cast(e)]; if (rank[static_cast(u)] < rank[static_cast(v)] && rng() % 4 != 0) chosen.push_back(e); } - const auto support = std::make_shared(PredDAGBuilder::of_edges(g, chosen)); + const auto support = dag_of(g, chosen); const NodeId src = perm.front(); std::vector dsts = {perm.back()}; if (n > 4 && rng() % 2 == 0) dsts.push_back(perm[static_cast(n / 2)]); // may have out-edges @@ -410,7 +401,7 @@ TEST(CostOnlyCreation, CraftedSupportsGiveNothingOrTheReference) { const PathSelection sel(EdgeTieBreak::Deterministic, EdgePriority::Capacity, true, me); const PriorityValues values {&g, sel.priority, W}; for (const auto& k : kKernels) { - const auto want = reference(g, support, src, dsts, sel, PriorityFn::of(values), W, k.placement, k.admission); + const auto want = general_path(g, support, src, dsts, sel, PriorityFn::of(values), W, k.placement, k.admission); const auto got = cost_only_creation(g, support, src, dsts, sel, PriorityFn::of(values), k.placement, k.admission); const std::string where = "trial " + std::to_string(trial); @@ -423,18 +414,21 @@ TEST(CostOnlyCreation, CraftedSupportsGiveNothingOrTheReference) { expect_same_plan(got->plan, want.plan, where); EXPECT_EQ(got->cost, want.cost) << where; ++made; + trimmed += *want.bundle == *support ? 0 : 1; if (::testing::Test::HasFailure()) return; } } } EXPECT_GT(made, 1000); + EXPECT_GT(trimmed, 800); EXPECT_GT(declined, 1000); } TEST(CostOnlyCreation, DeclinesEachBrokenPrecondition) { - // 0 -> 1 -> 3 and 0 -> 2 -> 3, destination 3: the reference keeps the whole route, and so does - // the one pass. Each variant breaks one precondition; the one pass then answers nothing (the - // reference's members differ from its route, or it throws). + // 0 -> 1 -> 3 and 0 -> 2 -> 3, destination 3: the general path keeps the whole route, and so + // does the one pass. Each variant breaks one precondition; the one pass then answers nothing (the + // general path's members differ from its route, or it throws), except that MaxFlow keeps a + // member below one unit. const Graph g = make_graph(6, {{0, 1, 2, 1}, {0, 2, 2, 1}, {1, 3, 2, 1}, {2, 3, 2, 1}, {3, 4, 2, 1}, {1, 5, 2, 1}, {2, 1, 2, 1}, {1, 2, 0, 1}}); const auto inv = id_of_input(g); @@ -445,7 +439,7 @@ TEST(CostOnlyCreation, DeclinesEachBrokenPrecondition) { auto run = [&](std::vector edges, FlowPlacement placement) { for (auto& e : edges) e = inv[static_cast(e)]; std::sort(edges.begin(), edges.end()); - const auto support = std::make_shared(PredDAGBuilder::of_edges(g, edges)); + const auto support = dag_of(g, edges); return std::make_pair(support, cost_only_creation(g, support, 0, dsts, sel, PriorityFn::of(values), placement, Admission::Lossless)); }; @@ -463,11 +457,11 @@ TEST(CostOnlyCreation, DeclinesEachBrokenPrecondition) { // A member below one unit (1 -> 2 has no capacity): declined for EB, kept by MaxFlow. EXPECT_FALSE(run({0, 1, 2, 3, 7}, FlowPlacement::EqualBalanced).second.has_value()); EXPECT_TRUE(run({0, 1, 2, 3, 7}, FlowPlacement::MaxFlow).second.has_value()); - // A cycle (1 -> 2 -> 1): declined, and the reference throws. + // A cycle (1 -> 2 -> 1): declined, and the general path throws. { const auto [support, got] = run({0, 1, 2, 3, 6, 7}, FlowPlacement::MaxFlow); EXPECT_FALSE(got.has_value()); - EXPECT_THROW((void)reference(g, support, 0, dsts, sel, PriorityFn::of(values), W, FlowPlacement::MaxFlow, + EXPECT_THROW((void)general_path(g, support, 0, dsts, sel, PriorityFn::of(values), W, FlowPlacement::MaxFlow, Admission::Lossless), std::logic_error); } @@ -475,7 +469,7 @@ TEST(CostOnlyCreation, DeclinesEachBrokenPrecondition) { { std::vector edges = {inv[0], inv[1], inv[2], inv[3]}; std::sort(edges.begin(), edges.end()); - const auto support = std::make_shared(PredDAGBuilder::of_edges(g, edges)); + const auto support = dag_of(g, edges); const PathSelection single(EdgeTieBreak::Deterministic, EdgePriority::Capacity, false, true); EXPECT_FALSE(cost_only_creation(g, support, 0, dsts, single, PriorityFn::of(values), FlowPlacement::EqualBalanced, Admission::Lossless) @@ -492,9 +486,7 @@ TEST(CostOnlyCreation, DeclinesEachBrokenPrecondition) { TEST(KernelPlanBlock, CopiesAndMovesKeepTheirOwnViews) { const Graph g = make_graph(4, {{0, 1, 3, 1}, {0, 2, 2, 1}, {1, 3, 3, 1}, {2, 3, 2, 1}}); - std::vector all(4); - std::iota(all.begin(), all.end(), 0); - const auto dag = PredDAGBuilder::of_edges(g, all); + const auto dag = PredDAGBuilder::of_edges(g, all_ids(g)); const std::vector dsts = {3}; const KernelPlan original = make_plan(g, FlowPlacement::Weighted, Admission::Lossless, EdgePriority::Capacity, 0, dsts, dag, {}); @@ -533,3 +525,77 @@ TEST(KernelPlanBlock, CopiesAndMovesKeepTheirOwnViews) { const KernelPlan empty_copy(empty); EXPECT_TRUE(empty_copy.dsts.empty() && empty_copy.topo.empty() && empty_copy.members.empty()); } + +// The plan comparison names every field that differs: a copy of a real plan with one field changed +// differs in exactly that field (a vector or view field both by its last entry and by one more +// entry), and without the route only the route may differ. +TEST(TestHelpers, PlanDifferencesNameEveryField) { + const Graph g = make_graph(4, {{0, 1, 3, 1}, {0, 2, 2, 1}, {1, 3, 3, 1}, {2, 3, 2, 1}}); + const auto dag = PredDAGBuilder::of_edges(g, all_ids(g)); + const std::vector dsts = {3}; + const KernelPlan plan = + make_plan(g, FlowPlacement::Weighted, Admission::Lossless, EdgePriority::Capacity, 0, dsts, dag, {}); + ASSERT_EQ(plan.weights.size(), 4U); + EXPECT_EQ(plan_differences(plan, plan), std::vector {}); + // The views' changed copies live here, as the plan's own views live in its block. + std::vector nodes; + std::vector positions; + std::vector flags; + using Change = std::function; + auto nodes_of = [&](std::span x, bool longer) { + nodes.assign(x.begin(), x.end()); + if (longer) nodes.push_back(0); + else ++nodes.back(); + return std::span(nodes); + }; + auto positions_of = [&](std::span x, bool longer) { + positions.assign(x.begin(), x.end()); + if (longer) positions.push_back(0); + else ++positions.back(); + return std::span(positions); + }; + const std::vector> changes = { + {"placement", [](auto& x) { x.placement = FlowPlacement::EqualBalanced; }}, + {"admission", [](auto& x) { x.admission = Admission::Lossy; }}, + {"src", [](auto& x) { ++x.src; }}, + {"dsts", [&](auto& x) { x.dsts = nodes_of(x.dsts, false); }}, + {"dsts", [&](auto& x) { x.dsts = nodes_of(x.dsts, true); }}, + {"route", [](auto& x) { x.route.push_back(0); }}, + {"route", [](auto& x) { ++x.route.back(); }}, + {"members", [](auto& x) { x.members.push_back(0); }}, + {"members", [](auto& x) { ++x.members.back(); }}, + {"weights", [](auto& x) { x.weights.push_back(1); }}, + {"weights", [](auto& x) { ++x.weights.back(); }}, + {"topo", [&](auto& x) { x.topo = nodes_of(x.topo, false); }}, + {"topo", [&](auto& x) { x.topo = nodes_of(x.topo, true); }}, + {"out_begin", [&](auto& x) { x.out_begin = positions_of(x.out_begin, false); }}, + {"out_begin", [&](auto& x) { x.out_begin = positions_of(x.out_begin, true); }}, + {"out_end", [&](auto& x) { x.out_end = positions_of(x.out_end, false); }}, + {"out_end", [&](auto& x) { x.out_end = positions_of(x.out_end, true); }}, + {"head_pos", [&](auto& x) { x.head_pos = positions_of(x.head_pos, false); }}, + {"head_pos", [&](auto& x) { x.head_pos = positions_of(x.head_pos, true); }}, + {"terminal", [&](auto& x) { + flags.assign(x.terminal.begin(), x.terminal.end()); + flags.back() = flags.back() == 0 ? 1 : 0; + x.terminal = flags; + }}, + {"terminal", [&](auto& x) { + flags.assign(x.terminal.begin(), x.terminal.end()); + flags.push_back(0); + x.terminal = flags; + }}, + {"src_out_begin", [](auto& x) { ++x.src_out_begin; }}, + {"src_out_end", [](auto& x) { ++x.src_out_end; }}, + }; + for (std::size_t k = 0; k < changes.size(); ++k) { + KernelPlan x = plan; + changes[k].second(x); + EXPECT_EQ(plan_differences(x, plan), std::vector {changes[k].first}) << k; + const auto without_route = changes[k].first == "route" ? std::vector {} + : std::vector {changes[k].first}; + EXPECT_EQ(plan_differences(x, plan, false), without_route) << k; + // expect_same_plan reports it. + EXPECT_EQ(failures_of([&] { expect_same_plan(x, plan, "changed"); }), 1) << k; + } + EXPECT_EQ(failures_of([&] { expect_same_plan(plan, plan, "same"); }), 0); +} diff --git a/tests/cpp/dag_cache_tests.cpp b/tests/cpp/dag_cache_tests.cpp index acd1148..f639b71 100644 --- a/tests/cpp/dag_cache_tests.cpp +++ b/tests/cpp/dag_cache_tests.cpp @@ -1,16 +1,21 @@ -// The DagCache's modes (APPROACH 10): the transition table (building and frozen lookups, freeze, -// clear, thaw, destruction), the statistics (a frozen miss counts one miss), DagCacheMiss as a -// dedicated exception, frozen hits from several threads, and the evaluators' sharing rules -// (section 11): a frozen cache shared by every worker, a building one rejected with two or more, -// DagCacheMiss propagated unchanged through batch_capacity and sensitivity, the reuse of a -// prepopulated Eligibility through an unchanged intersection, and the deletion-mode sensitivity -// with a supplied frozen cache. The thread cases are named DagCacheFrozen* (the TSan filter). +// The DagCache's modes: the transition table (building and frozen lookups, freeze, clear, thaw, +// destruction), the statistics (a frozen miss counts one miss), roots in any order sharing one +// entry and one validation, the key (a policy's selection is not part of it, the Eligibility uid +// is) and clear() releasing the values nobody holds, DagCacheMiss as a dedicated exception, frozen +// hits from several threads, and the evaluators' sharing rules: a frozen cache shared by every +// worker, a building one rejected with two or more, DagCacheMiss propagated unchanged through +// batch_capacity and sensitivity, the reuse of a prepopulated Eligibility through an unchanged +// intersection, the deletion-mode sensitivity (Global: each deletion repaired from the baseline's +// state) with a supplied frozen cache; a failed call's statistics, and concurrent calls and +// publications that lose no hit. The thread cases form the suite DagCacheFrozen; make tsan-test +// runs them under ThreadSanitizer with every other test. #include #include #include #include #include +#include #include #include #include @@ -27,6 +32,8 @@ using namespace netgraph::core; using namespace netgraph::core::test; +// DagCacheMiss is its own exception, neither a std::runtime_error nor a std::logic_error (a frozen +// miss is thrown as one: miss_of in the tests below). static_assert(!std::is_base_of_v); static_assert(!std::is_base_of_v); static_assert(std::is_base_of_v); @@ -46,18 +53,6 @@ std::shared_ptr fabric() { return std::make_shared(make_graph(9, e)); } -std::vector v(std::initializer_list x) { return x; } - -FlowPolicyConfig costonly() { - return FlowPolicyConfig {CostOnly {}, FlowPlacement::EqualBalanced, Admission::Lossless, - PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity)}; -} - -FlowPolicyConfig global() { - return FlowPolicyConfig {Global {}, FlowPlacement::MaxFlow, Admission::Lossless, - PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity)}; -} - std::string miss_of(const std::function& fn) { try { fn(); @@ -67,6 +62,17 @@ std::string miss_of(const std::function& fn) { return ""; } +// The bytes a DAG's stored arrays hold, from its public arrays: k nodes with entries and e +// entries store the k nodes, k + 1 offsets, e parents, e via edges and their ascending copy, all +// 32-bit values. +std::int64_t stored_bytes(const PredDAG& d) { + const auto off = d.parent_offsets(); + std::int64_t k = 0; + for (std::size_t i = 0; i + 1 < off.size(); ++i) k += off[i + 1] > off[i] ? 1 : 0; + const auto e = static_cast(d.via_edges().size()); + return 4 * (k + (k + 1) + e + e + e); +} + } // namespace TEST(DagCacheModes, TransitionTableAndStatistics) { @@ -80,6 +86,28 @@ TEST(DagCacheModes, TransitionTableAndStatistics) { EXPECT_EQ(st.misses, 1); EXPECT_EQ(st.hits, 1); EXPECT_EQ(st.entries, 1); + EXPECT_EQ(st.bytes, stored_bytes(*a)); + // Invalid queries throw std::invalid_argument, each with its own message, and change no + // statistic, while building here and while frozen below: a foreign graph, an Eligibility built + // for another graph (the same roots and direction as a stored entry), empty roots, a root out of + // range, a direction outside its enumeration (with the roots of a stored entry). + const auto other = fabric(); + const auto foreign = other->eligibility(v({}), std::vector {0}); + auto invalid_queries_count_nothing = [&](const char* mode) { + const auto before = cache.stats(); + EXPECT_EQ(error_of([&] { (void)cache.lookup(*other, v({0}), Direction::Reverse, nullptr); }), + "DagCache: the graph is not the cache's graph (foreign graph)") << mode; + EXPECT_EQ(error_of([&] { (void)cache.lookup(*g, v({0, 1}), Direction::Reverse, &foreign); }), + "foreign Eligibility: built for another graph") << mode; + EXPECT_EQ(error_of([&] { (void)cache.lookup(*g, v({}), Direction::Reverse, nullptr); }), "roots: the node set is empty") + << mode; + EXPECT_EQ(error_of([&] { (void)cache.lookup(*g, v({9}), Direction::Reverse, nullptr); }), + "roots: node 9 out of node range [0, 9)") << mode; + EXPECT_EQ(error_of([&] { (void)cache.lookup(*g, v({0, 1}), static_cast(9), nullptr); }), + "direction: 9 is not a value of Direction") << mode; + EXPECT_EQ(stats_of(cache.stats()), stats_of(before)) << mode; + }; + invalid_queries_count_nothing("building"); // Frozen: hits return the value; a miss counts one miss, inserts nothing, throws DagCacheMiss. cache.freeze(); cache.freeze(); // idempotent @@ -91,27 +119,17 @@ TEST(DagCacheModes, TransitionTableAndStatistics) { EXPECT_EQ(st.misses, 2); EXPECT_EQ(st.hits, 2); EXPECT_EQ(st.entries, 1); - // Invalid queries throw std::invalid_argument and count nothing, in frozen mode too. - const auto other = fabric(); - EXPECT_THROW((void)cache.lookup(*other, v({0}), Direction::Reverse, nullptr), std::invalid_argument); - EXPECT_THROW((void)cache.lookup(*g, v({}), Direction::Reverse, nullptr), std::invalid_argument); - EXPECT_THROW((void)cache.lookup(*g, v({9}), Direction::Reverse, nullptr), std::invalid_argument); - EXPECT_EQ(cache.stats().misses, 2); - EXPECT_EQ(cache.stats().hits, 2); + invalid_queries_count_nothing("frozen"); // clear() throws when frozen and keeps the entries. - try { - cache.clear(); - FAIL() << "clear() on a frozen cache"; - } catch (const std::invalid_argument& e) { - EXPECT_EQ(std::string(e.what()), - "DagCache.clear is rejected while the cache is frozen (join every reader, then thaw() and clear())"); - } + EXPECT_EQ(error_of([&] { cache.clear(); }), + "DagCache.clear is rejected while the cache is frozen (join every reader, then thaw() and clear())"); EXPECT_EQ(cache.stats().entries, 1); // thaw(): building again; a miss searches and inserts, clear() empties (counters persist). cache.thaw(); cache.thaw(); // no effect when building const auto b = cache.lookup(*g, v({2}), Direction::Forward, nullptr); EXPECT_EQ(cache.stats().entries, 2); + EXPECT_EQ(cache.stats().bytes, stored_bytes(*a) + stored_bytes(*b)); cache.clear(); st = cache.stats(); EXPECT_EQ(st.entries, 0); @@ -135,7 +153,7 @@ TEST(DagCacheModes, TransitionTableAndStatistics) { } TEST(DagCacheModes, RootsInAnyOrderShareOneEntryAndOneValidation) { - // Task 1d-msd: a lookup borrows roots that are already a sorted set (no copy on a hit) and + // A lookup borrows roots that are already a sorted set (no copy on a hit) and // normalises any other; either way the key is the sorted set, ordered by roots, then direction, // then Eligibility uid, and the checks and their messages are node_set's. const auto g = fabric(); @@ -171,22 +189,17 @@ TEST(DagCacheModes, RootsInAnyOrderShareOneEntryAndOneValidation) { EXPECT_EQ(cache.stats().hits, 4 + static_cast(keys.size())); // The checks in node_set's order, sorted or not: empty, then the first id out of range in input // order; nothing counted. - auto error_of = [&](std::vector roots) { - try { - (void)cache.lookup(*g, roots, Direction::Reverse, nullptr); - } catch (const std::invalid_argument& e) { - return std::string(e.what()); - } - return std::string(); + auto lookup_error = [&](const std::vector& roots) { + return error_of([&] { (void)cache.lookup(*g, roots, Direction::Reverse, nullptr); }); }; const auto before = cache.stats(); - EXPECT_EQ(error_of(v({9})), "roots: node 9 out of node range [0, 9)"); - EXPECT_EQ(error_of(v({3, 9})), "roots: node 9 out of node range [0, 9)"); - EXPECT_EQ(error_of(v({9, 3})), "roots: node 9 out of node range [0, 9)"); - EXPECT_EQ(error_of(v({-1, 9})), "roots: node -1 out of node range [0, 9)"); - EXPECT_EQ(error_of(v({9, -1})), "roots: node 9 out of node range [0, 9)"); - EXPECT_EQ(error_of(v({3, 3, 12})), "roots: node 12 out of node range [0, 9)"); - EXPECT_FALSE(error_of(v({})).empty()); + EXPECT_EQ(lookup_error(v({9})), "roots: node 9 out of node range [0, 9)"); + EXPECT_EQ(lookup_error(v({3, 9})), "roots: node 9 out of node range [0, 9)"); + EXPECT_EQ(lookup_error(v({9, 3})), "roots: node 9 out of node range [0, 9)"); + EXPECT_EQ(lookup_error(v({-1, 9})), "roots: node -1 out of node range [0, 9)"); + EXPECT_EQ(lookup_error(v({9, -1})), "roots: node 9 out of node range [0, 9)"); + EXPECT_EQ(lookup_error(v({3, 3, 12})), "roots: node 12 out of node range [0, 9)"); + EXPECT_EQ(lookup_error(v({})), "roots: the node set is empty"); const auto after = cache.stats(); EXPECT_EQ(after.misses, before.misses); EXPECT_EQ(after.hits, before.hits); @@ -200,27 +213,49 @@ TEST(DagCacheModes, RootsInAnyOrderShareOneEntryAndOneValidation) { EXPECT_EQ(cache.stats().misses, 11); } -TEST(DagCacheModes, MissIsADedicatedException) { - const auto g = fabric(); +TEST(DagCacheModes, SelectionIsNoPartOfTheKeyAndClearReleasesUnheldValues) { + // Two disjoint routes 0 -> 1 -> 3 and 0 -> 2 -> 3. EdgeIds: 0 = 0->1, 1 = 0->2, 2 = 1->3, 3 = 2->3. + const auto g = std::make_shared(make_graph(4, {{0, 1, 5, 1}, {1, 3, 5, 1}, {0, 2, 5, 1}, {2, 3, 5, 1}})); DagCache cache(*g); - cache.freeze(); - bool as_miss = false; - try { - (void)cache.lookup(*g, v({0}), Direction::Reverse, nullptr); - } catch (const std::runtime_error&) { - FAIL() << "DagCacheMiss caught as std::runtime_error"; - } catch (const std::logic_error&) { - FAIL() << "DagCacheMiss caught as std::logic_error"; - } catch (const DagCacheMiss&) { - as_miss = true; - } - EXPECT_TRUE(as_miss); + // Two CostOnly policies differing only in selection share one entry (selection is a + // projection at creation, never part of the key). + FlowLedger fg(g); + FlowPolicy a(fg, v({0}), v({3}), cost_only_config(), nullptr, &cache); + auto other_selection = cost_only_config(); + other_selection.selection = PathSelection(EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual, false, false); + FlowPolicy b(fg, v({0}), v({3}), other_selection, nullptr, &cache); + auto s = cache.stats(); + EXPECT_EQ(s.misses, 1); + EXPECT_EQ(s.hits, 1); + EXPECT_EQ(s.entries, 1); + EXPECT_GT(s.bytes, 0); + // The Eligibility uid is part of the key: the value under el is the static support without 0 -> 1. + const auto x = cache.lookup(*g, v({3}), Direction::Reverse, nullptr); + EXPECT_EQ(cache.stats().hits, 2); + (void)cache.lookup(*g, v({3}), Direction::Forward, nullptr); // held by the cache only + const auto el = g->eligibility({}, std::vector {0}); + const auto y = cache.lookup(*g, v({3}), Direction::Reverse, &el); + s = cache.stats(); + EXPECT_EQ(s.misses, 3); + EXPECT_EQ(s.entries, 3); + EXPECT_EQ(ascending_edges(*y), (std::vector {1, 2, 3})); + EXPECT_EQ(ascending_edges(*x), (std::vector {0, 1, 2, 3})); + // clear() releases the values nobody else holds: the Forward one dies, x and y stay alive. + EXPECT_EQ(cache.stats().live_values, 3); + cache.clear(); + EXPECT_EQ(cache.stats().live_values, 2); + EXPECT_EQ(ascending_edges(*x), (std::vector {0, 1, 2, 3})); + // After clear() a lookup misses and inserts again. + (void)cache.lookup(*g, v({3}), Direction::Reverse, nullptr); + EXPECT_EQ(cache.stats().misses, 4); + EXPECT_EQ(cache.stats().entries, 1); } namespace { // Frozen hits from `threads` threads at once over prepopulated keys; every value is the -// prepopulated one and every hit is counted. +// prepopulated one and every hit is counted. The threads start their lookups together, once all +// of them are running. void frozen_readers(int threads) { const auto g = fabric(); DagCache cache(*g); @@ -231,8 +266,10 @@ void frozen_readers(int threads) { std::atomic wrong {0}; std::vector pool; constexpr int kRounds = 200; + std::latch start(threads); for (int t = 0; t < threads; ++t) { pool.emplace_back([&, t] { + start.arrive_and_wait(); for (int round = 0; round < kRounds; ++round) { const NodeId r = static_cast((round + t) % 9); const NodeId roots[1] = {r}; @@ -258,32 +295,45 @@ TEST(DagCacheFrozen, HitsFromOneThread) { frozen_readers(1); } TEST(DagCacheFrozen, HitsFromTwoThreads) { frozen_readers(2); } TEST(DagCacheFrozen, HitsFromFourThreads) { frozen_readers(4); } +// A frozen cache shared by every worker of a batch gives the batch without a cache: per pair the +// placed amount and every output (on this symmetric fabric the amounts alone would not tell the +// pairs apart: the first three each place the same amount, on different edges). TEST(DagCacheFrozen, SharedByEveryWorkerOfABatch) { const auto g = fabric(); const std::vector, std::vector>> pairs = { {v({0}), v({3})}, {v({1}), v({4})}, {v({2}), v({5})}, {v({0, 1}), v({3, 4})}}; - const auto want = batch_capacity(*g, pairs, CapacityOptions {costonly()}, {}, 1); + const OutputOptions all {true, true, true}; + const auto want = batch_capacity(*g, pairs, CapacityOptions {cost_only_config(), nullptr, nullptr, all}, {}, 1); + for (const auto& r : want) EXPECT_GT(r.placed, 0); // every pair places: the comparison is not of zeros for (int workers : {1, 2, 4}) { DagCache cache(*g); for (const auto& [s, t] : pairs) (void)cache.lookup(*g, t, Direction::Reverse, nullptr); // A building cache with two or more workers is rejected; frozen, it is shared. if (workers > 1) { - EXPECT_THROW((void)batch_capacity(*g, pairs, CapacityOptions {costonly(), nullptr, &cache}, {}, workers), - std::invalid_argument); + EXPECT_THROW( + (void)batch_capacity(*g, pairs, CapacityOptions {cost_only_config(), nullptr, &cache, all}, {}, workers), + std::invalid_argument); } cache.freeze(); const auto before = cache.stats(); - const auto got = batch_capacity(*g, pairs, CapacityOptions {costonly(), nullptr, &cache}, {}, workers); + const auto got = batch_capacity(*g, pairs, CapacityOptions {cost_only_config(), nullptr, &cache, all}, {}, workers); ASSERT_EQ(got.size(), want.size()); - for (std::size_t i = 0; i < got.size(); ++i) EXPECT_EQ(got[i].placed, want[i].placed) << workers; + for (std::size_t i = 0; i < got.size(); ++i) { + ASSERT_TRUE(got[i].edge_flows.has_value() && want[i].edge_flows.has_value()) << workers << " " << i; + ASSERT_TRUE(got[i].residual.has_value() && want[i].residual.has_value()) << workers << " " << i; + EXPECT_EQ(got[i].placed, want[i].placed) << workers << " " << i; + EXPECT_EQ(got[i].edge_flows, want[i].edge_flows) << workers << " " << i; + EXPECT_EQ(got[i].residual, want[i].residual) << workers << " " << i; + EXPECT_EQ(got[i].bottleneck, want[i].bottleneck) << workers << " " << i; + } EXPECT_EQ(cache.stats().hits - before.hits, static_cast(pairs.size())); EXPECT_EQ(cache.stats().misses, before.misses); } } TEST(DagCacheFrozen, MissPropagatesThroughBatchCapacity) { - // An empty frozen cache: every pair misses; the lowest index's DagCacheMiss reaches the caller - // unchanged, with one worker and with several. + // A frozen cache holding only pair 1's key: pairs 0 and 2 miss; the lowest index's DagCacheMiss + // (pair 0's) reaches the caller unchanged, with one worker and with several. const auto g = fabric(); const std::vector, std::vector>> pairs = {{v({0}), v({3})}, {v({1}), v({4})}, {v({2}), v({5})}}; @@ -291,13 +341,13 @@ TEST(DagCacheFrozen, MissPropagatesThroughBatchCapacity) { DagCache cache(*g); (void)cache.lookup(*g, v({4}), Direction::Reverse, nullptr); // only pair 1's key cache.freeze(); - const auto msg = miss_of([&] { (void)batch_capacity(*g, pairs, CapacityOptions {costonly(), nullptr, &cache}, {}, workers); }); + const auto msg = miss_of([&] { (void)batch_capacity(*g, pairs, CapacityOptions {cost_only_config(), nullptr, &cache}, {}, workers); }); EXPECT_NE(msg.find("roots {3}"), std::string::npos) << workers << ": " << msg; // pair 0, the lowest index } } TEST(DagCacheFrozen, PerPairIntersectionsAndPrepopulatedUids) { - // APPROACH 10: an intersection equal to an operand returns that operand (its uid), so the + // An intersection equal to an operand returns that operand (its uid), so the // prepopulated key hits; one that differs from both operands gets a fresh uid and misses. const auto g = fabric(); const EdgeId e0[1] = {0}; @@ -316,20 +366,20 @@ TEST(DagCacheFrozen, PerPairIntersectionsAndPrepopulatedUids) { // A common value containing the per-pair one: intersect returns the per-pair operand. auto before = hits(); const std::vector contained = {&narrow, nullptr}; - EXPECT_EQ(batch_capacity(*g, pairs, CapacityOptions {costonly(), &common, &cache}, contained, 2).size(), 2U); + EXPECT_EQ(batch_capacity(*g, pairs, CapacityOptions {cost_only_config(), &common, &cache}, contained, 2).size(), 2U); EXPECT_EQ(hits() - before, 2); // An unchanged intersection (the operand itself) and already-effective values with a null // common value. before = hits(); const std::vector same = {&common, &common}; - EXPECT_EQ(batch_capacity(*g, pairs, CapacityOptions {costonly(), &common, &cache}, same, 2).size(), 2U); + EXPECT_EQ(batch_capacity(*g, pairs, CapacityOptions {cost_only_config(), &common, &cache}, same, 2).size(), 2U); const std::vector own = {&narrow, &common}; - EXPECT_EQ(batch_capacity(*g, pairs, CapacityOptions {costonly(), nullptr, &cache}, own, 2).size(), 2U); + EXPECT_EQ(batch_capacity(*g, pairs, CapacityOptions {cost_only_config(), nullptr, &cache}, own, 2).size(), 2U); EXPECT_EQ(hits() - before, 4); // An intersection that differs from both operands: a fresh uid, absent from the cache. const auto misses = cache.stats().misses; const std::vector changed = {&other, nullptr}; - const auto msg = miss_of([&] { (void)batch_capacity(*g, pairs, CapacityOptions {costonly(), &common, &cache}, changed, 2); }); + const auto msg = miss_of([&] { (void)batch_capacity(*g, pairs, CapacityOptions {cost_only_config(), &common, &cache}, changed, 2); }); EXPECT_NE(msg.find("roots {4}"), std::string::npos) << msg; EXPECT_EQ(msg.find("uid " + std::to_string(common.uid()) + " "), std::string::npos) << msg; EXPECT_EQ(msg.find("uid " + std::to_string(other.uid()) + " "), std::string::npos) << msg; @@ -337,16 +387,16 @@ TEST(DagCacheFrozen, PerPairIntersectionsAndPrepopulatedUids) { EXPECT_EQ(cache.stats().entries, 3); } -TEST(DagCacheFrozen, MissPropagatesThroughSensitivityNeverAsUnevaluable) { +TEST(DagCacheFrozen, MissPropagatesThroughSensitivity) { // CostOnly sensitivity evaluates each candidate under a new Eligibility (a new uid): with a // frozen cache holding only the baseline's key, the first re-evaluated deletion misses and the - // miss reaches the caller (it is not a std::runtime_error, so never `unevaluable`). + // miss reaches the caller. const auto g = fabric(); for (int workers : {1, 2, 4}) { DagCache cache(*g); (void)cache.lookup(*g, v({4}), Direction::Reverse, nullptr); cache.freeze(); - const auto msg = miss_of([&] { (void)sensitivity(*g, v({1}), v({4}), SensitivityOptions {costonly(), nullptr, &cache}, workers); }); + const auto msg = miss_of([&] { (void)sensitivity(*g, v({1}), v({4}), SensitivityOptions {cost_only_config(), nullptr, &cache}, workers); }); EXPECT_NE(msg.find("no entry for roots {4}"), std::string::npos) << workers << ": " << msg; EXPECT_EQ(cache.stats().entries, 1); // nothing inserted } @@ -356,11 +406,13 @@ TEST(DagCacheFrozen, DeletionModeSensitivityWithASuppliedFrozenCache) { // Global sensitivity repairs the baseline's state per deletion and reads no cache: a supplied // frozen cache, shared by two or four workers, gives the result without a cache. const auto g = fabric(); - const auto want = sensitivity(*g, v({0, 1}), v({4, 5}), SensitivityOptions {global()}, 1); + const auto want = sensitivity(*g, v({0, 1}), v({4, 5}), SensitivityOptions {global_config()}, 1); + ASSERT_GT(want.baseline.placed, 0); + EXPECT_TRUE(std::any_of(want.entries.begin(), want.entries.end(), [](const auto& x) { return x.loss > 0; })); for (int workers : {1, 2, 4}) { DagCache cache(*g); cache.freeze(); - const auto got = sensitivity(*g, v({0, 1}), v({4, 5}), SensitivityOptions {global(), nullptr, &cache}, workers); + const auto got = sensitivity(*g, v({0, 1}), v({4, 5}), SensitivityOptions {global_config(), nullptr, &cache}, workers); EXPECT_EQ(got.baseline.placed, want.baseline.placed); ASSERT_EQ(got.entries.size(), want.entries.size()); for (std::size_t i = 0; i < got.entries.size(); ++i) { @@ -371,19 +423,11 @@ TEST(DagCacheFrozen, DeletionModeSensitivityWithASuppliedFrozenCache) { } } -namespace { - -std::tuple all_of(const DagCache::Stats& s) { - return {s.hits, s.misses, s.entries, s.bytes, s.live_values}; -} - -} // namespace - TEST(DagCacheFrozen, AFailedCallChangesNoStatisticButItsMiss) { - // review-1d F1, NOTES R27: a public call reading a frozen cache publishes its hits only when it - // succeeds. A batch whose pair 0 hits and pair 1 misses raises the miss and keeps no hit; the - // miss counts once, with one worker and with several (the boundary counts it, not the workers); - // the policy constructor and CostOnly sensitivity do the same. + // A frozen cache's statistics are observable state: a public call reading a frozen cache + // publishes its hits only when it succeeds. A batch whose pair 0 hits and pair 1 misses raises + // the miss and keeps no hit; the miss counts once, with one worker and with several (the boundary + // counts it, not the workers); the policy constructor and CostOnly sensitivity do the same. const auto g = fabric(); const std::vector, std::vector>> pairs = { {v({0}), v({3})}, {v({1}), v({4})}, {v({2}), v({5})}, {v({0}), v({3})}}; @@ -391,28 +435,28 @@ TEST(DagCacheFrozen, AFailedCallChangesNoStatisticButItsMiss) { DagCache cache(*g); (void)cache.lookup(*g, v({3}), Direction::Reverse, nullptr); cache.freeze(); - auto before = all_of(cache.stats()); + auto before = stats_of(cache.stats()); // Pairs 1 and 2 miss; pairs 0 and 3 hit. - EXPECT_NE(miss_of([&] { (void)batch_capacity(*g, pairs, CapacityOptions {costonly(), nullptr, &cache}, {}, workers); }) + EXPECT_NE(miss_of([&] { (void)batch_capacity(*g, pairs, CapacityOptions {cost_only_config(), nullptr, &cache}, {}, workers); }) .find("roots {4}"), std::string::npos); auto want = before; std::get<1>(want) += 1; - EXPECT_EQ(all_of(cache.stats()), want) << workers; - before = all_of(cache.stats()); + EXPECT_EQ(stats_of(cache.stats()), want) << workers; + before = stats_of(cache.stats()); EXPECT_NE(miss_of([&] { - (void)sensitivity(*g, v({0}), v({3}), SensitivityOptions {costonly(), nullptr, &cache}, workers); + (void)sensitivity(*g, v({0}), v({3}), SensitivityOptions {cost_only_config(), nullptr, &cache}, workers); }).find("roots {3}"), std::string::npos); want = before; std::get<1>(want) += 1; - EXPECT_EQ(all_of(cache.stats()), want) << workers; // the baseline's hit is dropped + EXPECT_EQ(stats_of(cache.stats()), want) << workers; // the baseline's hit is dropped } DagCache cache(*g); cache.freeze(); FlowLedger fg(g); - EXPECT_FALSE(miss_of([&] { FlowPolicy p(fg, v({0}), v({3}), costonly(), nullptr, &cache); }).empty()); - EXPECT_EQ(all_of(cache.stats()), std::make_tuple(0, 1, 0, 0, 0)); + EXPECT_FALSE(miss_of([&] { FlowPolicy p(fg, v({0}), v({3}), cost_only_config(), nullptr, &cache); }).empty()); + EXPECT_EQ(stats_of(cache.stats()), std::make_tuple(0, 1, 0, 0, 0)); } TEST(DagCacheFrozen, ConcurrentCallsKeepEverySuccessfulHit) { @@ -424,15 +468,17 @@ TEST(DagCacheFrozen, ConcurrentCallsKeepEverySuccessfulHit) { for (NodeId d : {3, 4}) (void)cache.lookup(*g, v({d}), Direction::Reverse, nullptr); cache.freeze(); const auto before = cache.stats(); - const CapacityOptions opts {costonly(), nullptr, &cache}; + const CapacityOptions opts {cost_only_config(), nullptr, &cache}; const std::vector, std::vector>> good = {{v({0}), v({3})}, {v({1}), v({4})}}; const std::vector, std::vector>> bad = {{v({0}), v({3})}, {v({1}), v({5})}}; constexpr int kThreads = 4; constexpr int kRounds = 25; std::atomic wrong {0}; std::vector pool; + std::latch start(kThreads); // the threads start together, once all of them are running for (int t = 0; t < kThreads; ++t) { pool.emplace_back([&] { + start.arrive_and_wait(); for (int round = 0; round < kRounds; ++round) { if (batch_capacity(*g, good, opts, {}, 2).size() != 2) ++wrong; try { @@ -466,10 +512,12 @@ TEST(DagCacheFrozen, ConcurrentPublicationsLoseNoIncrement) { constexpr int kThreads = 8; constexpr int kRounds = 1500; std::vector pool; + std::latch start(kThreads); // the threads start together, once all of them are running for (int t = 0; t < kThreads; ++t) { pool.emplace_back([&] { FlowLedger fg(g); - for (int round = 0; round < kRounds; ++round) FlowPolicy p(fg, v({0}), v({3}), costonly(), nullptr, &cache); + start.arrive_and_wait(); + for (int round = 0; round < kRounds; ++round) FlowPolicy p(fg, v({0}), v({3}), cost_only_config(), nullptr, &cache); }); } for (auto& th : pool) th.join(); diff --git a/tests/cpp/eligibility_tests.cpp b/tests/cpp/eligibility_tests.cpp index ca503ea..66ab803 100644 --- a/tests/cpp/eligibility_tests.cpp +++ b/tests/cpp/eligibility_tests.cpp @@ -1,9 +1,9 @@ -// Eligibility (APPROACH 4) as the graph builds it: dense node and edge flags from excluded ids, +// Eligibility as the graph builds it: dense node and edge flags from excluded ids, // graph-bound, uid 0 for the all-allowed value. #include -#include #include +#include #include #include "netgraph/core/graph.hpp" @@ -19,6 +19,8 @@ std::vector flags(std::span s) { return std::vector); auto g = make_graph(3, {{0, 1, 1, 1}, {1, 2, 1, 1}}); auto el = g.eligibility(); EXPECT_EQ(el.uid(), 0u); @@ -42,15 +44,19 @@ TEST(Eligibility, ExcludedNodeExcludesIncidentEdges) { } TEST(Eligibility, ExcludedEdgesDuplicatesAndOrder) { - auto g = make_graph(3, {{0, 1, 1, 1}, {1, 2, 1, 1}, {0, 2, 1, 5}}); + // EdgeIds 0: 0 -> 1, 1: 0 -> 2, 2: 1 -> 2, 3: 3 -> 0 (the one edge left allowed). + auto g = make_graph(4, {{0, 1, 1, 1}, {1, 2, 1, 1}, {0, 2, 1, 5}, {3, 0, 1, 1}}); const std::vector xe = {2, 0, 2}; const std::vector xn = {2, 2}; auto el = g.eligibility(xn, xe); - EXPECT_EQ(flags(el.node_allowed()), (std::vector{1, 1, 0})); - for (EdgeId e = 0; e < 3; ++e) { + EXPECT_EQ(flags(el.node_allowed()), (std::vector{1, 1, 0, 1})); + int allowed = 0; + for (EdgeId e = 0; e < 4; ++e) { const bool excluded = e == 0 || e == 2 || g.edge_dst()[static_cast(e)] == 2; EXPECT_EQ(el.edge_ok(e), !excluded) << e; + allowed += excluded ? 0 : 1; } + EXPECT_EQ(allowed, 1); // both answers are expected } TEST(Eligibility, DistinctUidsForEqualLists) { @@ -68,9 +74,9 @@ TEST(Eligibility, OutOfRangeIdsRejected) { const std::vector bad_node = {2}; const std::vector bad_edge = {1}; const std::vector neg_node = {-1}; - EXPECT_THROW((void)g.eligibility(bad_node, {}), std::invalid_argument); - EXPECT_THROW((void)g.eligibility({}, bad_edge), std::invalid_argument); - EXPECT_THROW((void)g.eligibility(neg_node, {}), std::invalid_argument); + EXPECT_EQ(error_of([&] { (void)g.eligibility(bad_node, {}); }), "excluded node 2 out of node range [0, 2)"); + EXPECT_EQ(error_of([&] { (void)g.eligibility({}, bad_edge); }), "excluded edge 1 out of edge range [0, 1)"); + EXPECT_EQ(error_of([&] { (void)g.eligibility(neg_node, {}); }), "excluded node -1 out of node range [0, 2)"); } // Eligibility.intersect: an operand when the result equals it (its uid kept, a @@ -88,6 +94,14 @@ TEST(Eligibility, IntersectReturnsAnOperandWhenEqual) { EXPECT_EQ(Eligibility::intersect(a, all).uid(), a.uid()); EXPECT_EQ(Eligibility::intersect(all, all).uid(), 0u); EXPECT_EQ(Eligibility::intersect(a, a).uid(), a.uid()); + { + // Two equal values built separately (distinct uids): the first operand wins the tie. + const auto x = g.eligibility({}, e0); + const auto y = g.eligibility({}, e0); + ASSERT_NE(x.uid(), y.uid()); + EXPECT_EQ(Eligibility::intersect(x, y).uid(), x.uid()); + EXPECT_EQ(Eligibility::intersect(y, x).uid(), y.uid()); + } // a subset relation: the smaller value. const auto ab_list = g.eligibility({}, e01); EXPECT_EQ(Eligibility::intersect(a, ab_list).uid(), ab_list.uid()); @@ -111,5 +125,6 @@ TEST(Eligibility, IntersectReturnsAnOperandWhenEqual) { TEST(Eligibility, IntersectRejectsValuesOfDifferentGraphs) { auto g = make_graph(2, {{0, 1, 1, 1}}); auto h = make_graph(2, {{0, 1, 1, 1}}); - EXPECT_THROW((void)Eligibility::intersect(g.eligibility(), h.eligibility()), std::invalid_argument); + EXPECT_EQ(error_of([&] { (void)Eligibility::intersect(g.eligibility(), h.eligibility()); }), + "Eligibility.intersect: the two values belong to different graphs"); } diff --git a/tests/cpp/fault_injection.cpp b/tests/cpp/fault_injection.cpp new file mode 100644 index 0000000..2914262 --- /dev/null +++ b/tests/cpp/fault_injection.cpp @@ -0,0 +1,107 @@ +// The allocation-failure hook of the fault-injection executable (netgraph_core_fault_tests): +// the replacement global operator new and delete (throwing and nothrow forms), the countdown and +// the leak counter declared in fault_injection.hpp, and fails_at / fails_in_workers_at / +// reaches_in_workers_at, which every fault test file sweeps its operations through. +#include + +#include +#include +#include +#include +#include + +#include "fault_injection.hpp" + +// Allocations left before the injected failure; negative: no injection (fault_injection.hpp). +// Shared by every thread; with g_workers_only the calling test thread's allocations do not count. +std::atomic g_countdown {-1}; +// Live allocations of the replacement operator new (a successful allocation counts up, the +// delete of a non-null pointer counts down): the sweeps compare it before and after an operation +// to count leaks, which ASan's leak detection (off on macOS) would not. +std::atomic g_live {0}; +static std::atomic g_workers_only {false}; +static thread_local bool t_test_thread = false; + +void* operator new(std::size_t n) { + if (g_countdown.load(std::memory_order_relaxed) >= 0 && + !(t_test_thread && g_workers_only.load(std::memory_order_relaxed)) && + g_countdown.fetch_sub(1, std::memory_order_relaxed) == 0) { + throw std::bad_alloc(); + } + if (void* p = std::malloc(n == 0 ? 1 : n)) { + g_live.fetch_add(1, std::memory_order_relaxed); + return p; + } + throw std::bad_alloc(); +} +void* operator new[](std::size_t n) { return ::operator new(n); } +// The nothrow forms never inject: a caller that asks for memory it can do without (the temporary +// buffer of std::stable_partition) absorbs a failure by design, so an injected one would be +// reported as swallowed. Their allocations count as live like the others. (Without these +// replacements, the standard library's nothrow form would call the throwing one above and catch +// the injected std::bad_alloc.) +void* operator new(std::size_t n, const std::nothrow_t&) noexcept { + void* p = std::malloc(n == 0 ? 1 : n); + if (p != nullptr) g_live.fetch_add(1, std::memory_order_relaxed); + return p; +} +void* operator new[](std::size_t n, const std::nothrow_t&) noexcept { return ::operator new(n, std::nothrow); } +void operator delete(void* p) noexcept { + if (p != nullptr) g_live.fetch_sub(1, std::memory_order_relaxed); + std::free(p); +} +void operator delete[](void* p) noexcept { ::operator delete(p); } +void operator delete(void* p, std::size_t) noexcept { ::operator delete(p); } +void operator delete[](void* p, std::size_t) noexcept { ::operator delete(p); } +void operator delete(void* p, const std::nothrow_t&) noexcept { ::operator delete(p); } +void operator delete[](void* p, const std::nothrow_t&) noexcept { ::operator delete(p); } + +namespace { + +// Runs fn with the k-th allocation failing; returns whether it was reached, and in `failed` +// whether fn raised std::bad_alloc. Any other exception of fn propagates, with the hook disarmed +// first: left armed, it would fail an allocation of whatever runs next (the test framework's +// report of that exception, a later test). +bool run(long k, bool workers_only, const std::function& fn, bool& failed) { + const auto disarm = [] { + g_countdown = -1; + g_workers_only = false; + }; + t_test_thread = true; + g_workers_only = workers_only; + g_countdown = k; + failed = false; + try { + fn(); + } catch (const std::bad_alloc&) { + failed = true; + } catch (...) { + disarm(); + throw; + } + const bool reached = g_countdown.load() < 0; + disarm(); + return reached; +} + +bool inject(long k, bool workers_only, const std::function& fn) { + bool failed = false; + const bool reached = run(k, workers_only, fn, failed); + EXPECT_EQ(failed, reached) << "a std::bad_alloc was swallowed or replaced at allocation " << k + << (workers_only ? " (worker threads)" : ""); + return failed; +} + +} // namespace + +// Runs fn with the k-th allocation (from 0) failing; returns whether that failure happened. +bool fails_at(long k, const std::function& fn) { return inject(k, false, fn); } + +bool fails_in_workers_at(long k, const std::function& fn) { return inject(k, true, fn); } + +bool reaches_in_workers_at(long k, const std::function& fn, bool& bad_alloc) { + const bool reached = run(k, true, fn, bad_alloc); + EXPECT_TRUE(reached || !bad_alloc) << "a std::bad_alloc that was not injected, at allocation " << k + << " (worker threads)"; + return reached; +} diff --git a/tests/cpp/fault_injection.hpp b/tests/cpp/fault_injection.hpp index d46a039..de8f020 100644 --- a/tests/cpp/fault_injection.hpp +++ b/tests/cpp/fault_injection.hpp @@ -1,21 +1,24 @@ -// The allocation-failure hook of the fault-injection executable (netgraph_core_fault_tests) and -// the state its sweeps compare: flow_ledger_fault_tests.cpp replaces the global operator new so that -// the chosen allocation throws std::bad_alloc; every test file of the executable sweeps its -// operations through fails_at(). The counter is shared by every thread (the evaluators' workers -// allocate too): the k-th allocation in the process fails, whichever thread makes it. +// The allocation-failure hook of the fault-injection executable (netgraph_core_fault_tests), with +// the state its sweeps compare (state_comparators.hpp): fault_injection.cpp replaces the global +// operator new so that the chosen allocation throws std::bad_alloc (the nothrow form never fails +// and does not advance the countdown; its allocations count as live like the others); every test +// file of the executable sweeps its operations through fails_at(). The counter is shared by every thread (the +// evaluators' workers allocate too): the k-th allocation in the process fails, whichever thread +// makes it. #pragma once #include #include -#include #include +#include #include -#include +#include -#include "netgraph/core/detail/ledger_access.hpp" #include "netgraph/core/flow_ledger.hpp" #include "netgraph/core/flow_policy.hpp" +#include "state_comparators.hpp" +#include "test_utils.hpp" // Allocations left before the injected failure; negative: no injection. extern std::atomic g_countdown; @@ -30,74 +33,141 @@ bool fails_at(long k, const std::function& fn); // evaluators' worker threads): the k-th worker allocation fails. bool fails_in_workers_at(long k, const std::function& fn); -namespace netgraph::core::test { +// As fails_in_workers_at, for a call whose lowest-index task exception is the one raised (the +// evaluators): the injected std::bad_alloc may lose to another task's exception, which fn +// handles. Returns whether the k-th worker allocation was reached, and sets `bad_alloc` when fn +// raised std::bad_alloc (which must then be the injected one). +bool reaches_in_workers_at(long k, const std::function& fn, bool& bad_alloc); -// The ledger as a sweep compares it: the aggregate, the residual, every record field (FlowIds -// keep their local ids: a reference sequence runs on another ledger), the next FlowId and the -// bound and orphaned owners. -struct LedgerState { - std::vector allocated; - std::vector residual; - std::vector records; - std::int64_t next_id {0}; - std::vector bound; - std::vector orphans; -}; +namespace netgraph::core::test { -inline LedgerState ledger_state(const FlowLedger& fg) { - using detail::LedgerAccess; - LedgerState s {fg.allocated(), fg.residual(), fg.records(), LedgerAccess::next_flow_id(fg), - LedgerAccess::bound_owners(fg), LedgerAccess::orphan_owners(fg)}; - for (auto& r : s.records) { - EXPECT_EQ(r.flow_id.first, fg.uid()); - r.flow_id.first = 0; - } - return s; +// remove() and a policy's destruction allocate nothing: after place(3), remove() takes the +// policy's records away; after place(2), the destruction leaves them as orphans, the aggregate as +// it was and the policy no longer bound. +template +void expect_removal_and_release_allocate_nothing(const FlowLedger& fg, std::unique_ptr& p, + const std::string& where) { + (void)p->place(3); + ASSERT_FALSE(fg.records().empty()) << where; // something to remove + EXPECT_FALSE(fails_at(0, [&] { p->remove(); })) << where; + EXPECT_TRUE(fg.records().empty()) << where; + (void)p->place(2); + const LedgerState held = ledger_state(fg); + ASSERT_FALSE(held.records.empty()) << where; // something to orphan + ASSERT_EQ(held.bound.size(), 1U) << where; + EXPECT_FALSE(fails_at(0, [&] { p.reset(); })) << where; + const LedgerState orphaned = ledger_state(fg); + EXPECT_EQ(orphaned.allocated, held.allocated) << where; + EXPECT_EQ(orphaned.records.size(), held.records.size()) << where; + EXPECT_TRUE(orphaned.bound.empty()) << where; + EXPECT_EQ(orphaned.orphans, held.bound) << where; } -inline bool same_record(const FlowRecord& a, const FlowRecord& b) { - return a.flow_id == b.flow_id && a.owner == b.owner && a.sources == b.sources && a.destinations == b.destinations && - a.placement == b.placement && a.admission == b.admission && a.bundle && b.bundle && *a.bundle == *b.bundle && - a.route_cost == b.route_cost && a.offered == b.offered && a.delivered == b.delivered && a.edges == b.edges && - a.drops == b.drops; -} +// What a placement sweep sets up on a fresh ledger before the call under test: the policy it +// calls, and what must stay alive with it (third parties, a wrapper of the policy). +struct Staged { + std::shared_ptr keep; + FlowPolicy* policy {nullptr}; +}; -inline void expect_same(const LedgerState& a, const LedgerState& b, const std::string& where) { - EXPECT_EQ(a.allocated, b.allocated) << where; - EXPECT_EQ(a.residual, b.residual) << where; - EXPECT_EQ(a.next_id, b.next_id) << where; - EXPECT_EQ(a.bound, b.bound) << where; - EXPECT_EQ(a.orphans, b.orphans) << where; - ASSERT_EQ(a.records.size(), b.records.size()) << where; - for (std::size_t i = 0; i < a.records.size(); ++i) { - EXPECT_TRUE(same_record(a.records[i], b.records[i])) << where << " record " << i; +// The placement-atomicity sweep of one call. setup(ledger) stages everything before the call on a +// fresh ledger of g (third parties, the policy and its earlier calls); call(policy) is the call +// under test. The reference stages and calls once without a failure, then removes the policy. +// Attempt `at` (from 0) stages afresh (the same ledger state and epoch as the reference's) and +// makes the at-th allocation of the call fail; after the failure the ledger and the policy's epoch +// are as before the call and the aggregate is balanced, a clean retry gives the reference's result, +// ledger and epoch, and remove() leaves the ledger the reference's remove() left. Everything an +// attempt allocates is freed when it ends (the reference has already grown this thread's scratch), +// so a live allocation left after an attempt is a leak of the failed call. The sweep ends at the +// first attempt that completes, whose result, ledger and epoch must be the reference's, or at the first +// reported difference. Returns the failures injected. +inline long sweep_placement(const std::shared_ptr& g, const std::string& where, + const std::function& setup, + const std::function& call) { + FlowLedger ref(g); + const Staged staged_ref = setup(ref); + const LedgerState initial = ledger_state(ref); + const Epoch initial_epoch = epoch(*staged_ref.policy); + const PlacementResult want = call(*staged_ref.policy); + const LedgerState after = ledger_state(ref); + const Epoch want_epoch = epoch(*staged_ref.policy); + staged_ref.policy->remove(); + const LedgerState removed = ledger_state(ref); + long injected = 0; + for (long at = 0;; ++at) { + const std::string w = where + " allocation " + std::to_string(at); + const long live = g_live.load(); + bool completed = false; + { + FlowLedger fg(g); + const Staged staged = setup(fg); + FlowPolicy& p = *staged.policy; + expect_same(ledger_state(fg), initial, w + " (setup)"); + expect_same(epoch(p), initial_epoch, w + " (setup)"); + PlacementResult got; + if (!fails_at(at, [&] { got = call(p); })) { + expect_same(got, want, w + " (no failure)"); + expect_same(ledger_state(fg), after, w + " (no failure)"); + expect_same(epoch(p), want_epoch, w + " (no failure)"); + completed = true; + } else { + ++injected; + // Failed: nothing changed, ledger and policy. + expect_same(ledger_state(fg), initial, w + " (failed call)"); + expect_same(epoch(p), initial_epoch, w + " (failed call)"); + expect_balanced(fg, w + " (failed call)"); + // A clean retry is the reference call (no poisoned per-thread scratch). + expect_same(call(p), want, w + " (retry)"); + expect_same(ledger_state(fg), after, w + " (retry)"); + expect_same(epoch(p), want_epoch, w + " (retry)"); + expect_balanced(fg, w + " (retry)"); + p.remove(); + expect_same(ledger_state(fg), removed, w + " (remove)"); + } + } + EXPECT_EQ(g_live.load(), live) << w << ": a leak"; + if (completed || ::testing::Test::HasFailure()) break; } + return injected; } -// The ledger invariant: the aggregate is the sum of the records, within [0, baseline]. -inline void expect_balanced(const FlowLedger& fg, const std::string& where) { - std::vector sum(fg.allocated().size(), 0); - for (const auto& r : fg.records()) { - for (const auto& [e, x] : r.edges) sum[static_cast(e)] += x; +// The scratch-growth sweep. Attempt `at` (from 0) runs on a new thread, whose per-thread scratch +// starts empty: warm_up() (a call on a small graph), then attempt(at, where), which makes the +// at-th allocation of the swept call on a larger graph fail, checks the failed call, compares its +// retry (or the call itself, when the failure is not reached) with a reference call made +// beforehand without a failure, and returns whether the failure was reached; the sweep ends at the +// first attempt that completes. Each attempt's thread frees its scratch when it ends, so a live +// allocation left after the join is a leak of the failed call. Then, on one more new thread, +// warm_up() and warm(), which grows the scratch to the larger graph and returns how many +// allocations the swept call still makes: fewer than the sweep's failures, so the sweep reached +// the growth. Returns the sweep's failures. +inline long sweep_scratch_growth(const std::string& what, const std::function& warm_up, + const std::function& attempt, + const std::function& warm) { + long failures = 0; + for (long at = 0;; ++at) { + bool failed = false; + const std::string w = what + " allocation " + std::to_string(at); + const long live = g_live.load(); + std::thread([&] { + warm_up(); + failed = attempt(at, w); + }).join(); + EXPECT_EQ(g_live.load(), live) << w << ": a leak"; // the thread's scratch is gone with it + if (!failed) break; + ++failures; + if (::testing::Test::HasFailure() || at >= 1000) { + ADD_FAILURE() << what << ": stopped at allocation " << at; + return failures; + } } - EXPECT_EQ(sum, fg.allocated()) << where; - for (Amount x : fg.residual()) EXPECT_GE(x, 0) << where; -} - -// Every field of a placement result. -inline void expect_same(const PlacementResult& a, const PlacementResult& b, const std::string& where) { - EXPECT_EQ(a.placed, b.placed) << where; - EXPECT_EQ(a.remaining, b.remaining) << where; - EXPECT_EQ(a.termination, b.termination) << where; - EXPECT_EQ(a.local_outcomes, b.local_outcomes) << where; - EXPECT_EQ(a.routes_changed, b.routes_changed) << where; - EXPECT_EQ(a.weights_changed, b.weights_changed) << where; - EXPECT_EQ(a.drops, b.drops) << where; - EXPECT_EQ(a.cost_distribution, b.cost_distribution) << where; - EXPECT_EQ(a.edge_flows, b.edge_flows) << where; - EXPECT_EQ(a.residual, b.residual) << where; - EXPECT_EQ(a.bottleneck, b.bottleneck) << where; - EXPECT_EQ(a.min_cut, b.min_cut) << where; + long warm_count = 0; + std::thread([&] { + warm_up(); + warm_count = warm(); + }).join(); + EXPECT_GT(failures, warm_count) << what; + return failures; } } // namespace netgraph::core::test diff --git a/tests/cpp/flow_core_deletion_fault_tests.cpp b/tests/cpp/flow_core_deletion_fault_tests.cpp index 95acf70..f2687f8 100644 --- a/tests/cpp/flow_core_deletion_fault_tests.cpp +++ b/tests/cpp/flow_core_deletion_fault_tests.cpp @@ -1,11 +1,14 @@ -// Allocation-failure atomicity of the deletion re-solve's paths (APPROACH 2.2, 13): the capture +// Allocation-failure atomicity of the deletion re-solve's paths: the capture // of a solve's final state (on cold and growing threads), the copy and the copy assignment of a // state (between states of different sizes, on 1, 2 and 4 threads), a resolver's construction // and every re-solve are swept over every allocation they make, and Global sensitivity over every // allocation on 1, 2 and 4 workers. After each injected std::bad_alloc nothing is captured or -// constructed, an assigned state keeps its old value, the saved state is unchanged, and a clean -// retry (the same resolver included) gives the reference result. Runs in the fault-injection -// executable (fault_injection.hpp). +// constructed, no allocation is left live once the attempt's objects are gone, an +// assigned state keeps its old value, the network every state and resolver shares is unchanged, +// and a clean retry (the same resolver included) gives the reference result. A state's and a +// resolver's moves allocate nothing. The state comparators of these checks have negative +// controls field by field (the last test). Runs in the fault-injection executable +// (fault_injection.hpp). #include #include @@ -13,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -33,7 +37,7 @@ using detail::DeletionState; namespace { -// Two sources and two destinations; deletions with full, partial and no reroute. +// Two sources and three destinations; deletions with full and with no reroute. std::shared_ptr graph() { return std::make_shared( make_graph(8, {{0, 2, 2, 1}, {1, 2, 1, 2}, {2, 4, 2, 1}, {2, 5, 2, 3}, {1, 5, 1, 7}, {0, 4, 1, 9}, @@ -50,15 +54,13 @@ FlowCoreResult capture(const Graph& g, std::shared_ptr owner, return place_global_units(g, view, kSources, kDestinations, nullptr, std::nullopt, &req); } -bool same_result(const DeletionResult& a, const DeletionResult& b) { - const auto& x = a.counters; - const auto& y = b.counters; - return a.value == b.value && a.potentials == b.potentials && a.cut == b.cut && a.edge_flows == b.edge_flows && - x.dijkstra_runs == y.dijkstra_runs && x.dijkstra_pops == y.dijkstra_pops && x.settled == y.settled && - x.arc_scans == y.arc_scans && x.dinic_phases == y.dinic_phases && x.augmentations == y.augmentations; -} +// The comparators below read a state through its accessors only, so they apply alike to the +// StateFields stand-in of their negative controls (the last test), whose fields can be changed +// one at a time. -bool same_state(const DeletionState& a, const DeletionState& b) { +// The values of two states: residual, potentials, value and the maximum flag. +template +bool same_state(const State& a, const State& b) { return std::vector(a.residual().begin(), a.residual().end()) == std::vector(b.residual().begin(), b.residual().end()) && std::vector(a.potentials().begin(), a.potentials().end()) == @@ -68,17 +70,21 @@ bool same_state(const DeletionState& a, const DeletionState& b) { // Every field of two populated states: the graph and the network by identity (copies share // them), the rest by value. -bool identical(const DeletionState& a, const DeletionState& b) { - const auto& x = a.counters(); - const auto& y = b.counters(); +template +bool identical(const State& a, const State& b) { return &a.graph() == &b.graph() && &a.network() == &b.network() && same_state(a, b) && - x.dijkstra_runs == y.dijkstra_runs && x.dijkstra_pops == y.dijkstra_pops && x.settled == y.settled && - x.arc_scans == y.arc_scans && x.dinic_phases == y.dinic_phases && x.augmentations == y.augmentations; + same_counters(a.counters(), b.counters()); +} + +// A state without fields: no residual, no potentials, value 0, not maximum. +template +bool no_fields(const State& s) { + return s.residual().empty() && s.potentials().empty() && s.value() == 0 && !s.maximum(); } // A state that never held a solve: no fields, and no resolver accepts it. bool empty_state(const DeletionState& s) { - if (!s.residual().empty() || !s.potentials().empty() || s.value() != 0 || s.maximum()) return false; + if (!no_fields(s)) return false; try { DeletionResolver rs(s); } catch (const std::invalid_argument&) { @@ -87,6 +93,34 @@ bool empty_state(const DeletionState& s) { return false; } +// A state's fields, read through DeletionState's accessors and writable one at a time. +struct StateFields { + const Graph* graph_ {nullptr}; + const detail::Network* network_ {nullptr}; + std::vector residual_ {}; + std::vector potentials_ {}; + Amount value_ {0}; + bool maximum_ {false}; + detail::FlowCoreCounters counters_ {}; + [[nodiscard]] const Graph& graph() const { return *graph_; } + [[nodiscard]] const detail::Network& network() const { return *network_; } + [[nodiscard]] std::span residual() const { return residual_; } + [[nodiscard]] std::span potentials() const { return potentials_; } + [[nodiscard]] Amount value() const { return value_; } + [[nodiscard]] bool maximum() const { return maximum_; } + [[nodiscard]] const detail::FlowCoreCounters& counters() const { return counters_; } +}; + +StateFields fields_of(const DeletionState& s) { + return {&s.graph(), + &s.network(), + {s.residual().begin(), s.residual().end()}, + {s.potentials().begin(), s.potentials().end()}, + s.value(), + s.maximum(), + s.counters()}; +} + // A captured state with its re-solve of every positive-flow edge. struct Saved { DeletionState state; @@ -107,11 +141,7 @@ Saved saved(std::shared_ptr g, const std::vector& sources, } // The path 0 -> 1 -> ... -> n - 1 (cost 1 per edge) at capacity c. -Saved chain(std::int32_t n, Amount c) { - std::vector edges; - for (NodeId v = 0; v + 1 < n; ++v) edges.push_back({v, v + 1, c, 1}); - return saved(std::make_shared(make_graph(n, edges)), {0}, {n - 1}); -} +Saved chain(std::int32_t n, Amount c) { return saved(chain_graph(n, c), {0}, {n - 1}); } // The state is identical to `want`'s and serves its re-solves: a resolver on it gives every // reference result (never run on a state that is not identical: a hybrid could crash it). @@ -124,11 +154,6 @@ bool holds(const DeletionState& s, const Saved& want) { return !want.results.empty(); } -// Whether a copy of `from` needs more storage than `to` holds: then every assignment allocates. -bool grows(const DeletionState& to, const DeletionState& from) { - return from.residual().size() > to.residual().size() || from.potentials().size() > to.potentials().size(); -} - // States of different coordinate sizes (N + 2 vertices and slots: 4 and 6 for the 2-node path, // 8 and 14 for the 6-node paths, 10 and 34 for the 8-node graph, 13 and 24 for the 11-node path), // and two of equal sizes with different residuals and values. @@ -150,45 +175,88 @@ TEST(DeletionFaults, CaptureFailsWithoutAStateOrAChangedPayload) { DeletionCapture want; const auto payload = capture(*g, g, want); ASSERT_TRUE(want.state.has_value()); + // The same solve without a capture, its view allocated alike: the sweep below must also fail + // the allocations of the captured state. + long plain = 0; + while (fails_at(plain, [&] { + const std::vector view(g->capacity_units().begin(), g->capacity_units().end()); + (void)place_global_units(*g, view, kSources, kDestinations, nullptr, std::nullopt); + })) { + ++plain; + } int failures = 0; for (long at = 0;; ++at) { - DeletionCapture req; - FlowCoreResult got; - if (!fails_at(at, [&] { got = capture(*g, g, req); })) { - EXPECT_TRUE(same_payload(got, payload)); - ASSERT_TRUE(req.state.has_value()); - EXPECT_TRUE(same_state(*req.state, *want.state)); - break; + // Everything an attempt allocates is freed when it ends (the reference capture above grew + // this thread's solver), so an allocation left live is a leak of the failed capture. + const long live = g_live.load(); + bool failed = false; + { + DeletionCapture req; + FlowCoreResult got; + failed = fails_at(at, [&] { got = capture(*g, g, req); }); + if (!failed) { + EXPECT_TRUE(same_payload(got, payload)); + ASSERT_TRUE(req.state.has_value()); + EXPECT_TRUE(same_state(*req.state, *want.state)); + } else { + ++failures; + EXPECT_FALSE(req.state.has_value()) << "allocation " << at; + DeletionCapture retry; + EXPECT_TRUE(same_payload(capture(*g, g, retry), payload)) << "retry after allocation " << at; + } } - ++failures; - EXPECT_FALSE(req.state.has_value()) << "allocation " << at; - DeletionCapture retry; - EXPECT_TRUE(same_payload(capture(*g, g, retry), payload)) << "retry after allocation " << at; + EXPECT_EQ(g_live.load(), live) << "allocation " << at << ": a leak"; + if (!failed) break; } - EXPECT_GT(failures, 10); + EXPECT_GT(failures, plain); } +// A failed copy or resolver construction leaves nothing constructed and nothing allocated live; +// each sweep fails every allocation of its call (counted in the test below). TEST(DeletionFaults, CopyAndResolverConstructionFailAtomically) { const auto g = graph(); DeletionCapture req; (void)capture(*g, g, req); - const DeletionState saved = *req.state; - int failures = 0; + int copy_failures = 0; for (long at = 0;; ++at) { std::optional copy; + const long live = g_live.load(); if (!fails_at(at, [&] { copy.emplace(*req.state); })) break; - ++failures; + ++copy_failures; EXPECT_FALSE(copy.has_value()) << "copy, allocation " << at; - EXPECT_TRUE(same_state(*req.state, saved)) << "copy, allocation " << at; + EXPECT_EQ(g_live.load(), live) << "copy, allocation " << at << ": a leak"; } + int resolver_failures = 0; for (long at = 0;; ++at) { std::unique_ptr rs; + const long live = g_live.load(); if (!fails_at(at, [&] { rs = std::make_unique(*req.state); })) break; - ++failures; + ++resolver_failures; EXPECT_EQ(rs, nullptr) << "resolver, allocation " << at; - EXPECT_TRUE(same_state(*req.state, saved)) << "resolver, allocation " << at; + EXPECT_EQ(g_live.load(), live) << "resolver, allocation " << at << ": a leak"; + } + EXPECT_EQ(copy_failures, 2); // the residual and the potentials + EXPECT_EQ(resolver_failures, 1 + 10); // the resolver itself, then its construction (below) +} + +// A resolver reads its state's network in place and copies none of its 14 +// arrays. Its construction makes exactly the state copy's allocations (the residual and the +// potentials) plus 8 of its own: the solver, and its working arrays (levels, marks, current +// arcs, residual, wide potentials, wide distances, stamps). Counted on states of 4 to 13 +// vertices; a failure at each of them constructs nothing (the sweep above). +TEST(DeletionFaults, AResolverAllocatesNoNetwork) { + auto allocations = [](const std::function& fn) { + long at = 0; + while (fails_at(at, fn)) ++at; + return at; + }; + for (const auto& s : assignment_states()) { + const DeletionState& st = s.state; + const long copy = allocations([&] { DeletionState c(st); }); + const long resolver = allocations([&] { DeletionResolver rs(st); }); + EXPECT_EQ(copy, 2) << st.network().num_nodes; + EXPECT_EQ(resolver, copy + 8) << st.network().num_nodes; } - EXPECT_GT(failures, 10); } // Every positive-flow edge, every allocation of a re-solve on a fresh resolver (its working @@ -198,8 +266,8 @@ TEST(DeletionFaults, EveryReSolveFailsWithoutPoisoningItsResolver) { DeletionCapture req; (void)capture(*g, g, req); const DeletionState saved = *req.state; + const detail::Network network = saved.network(); // the shared network, as captured const auto flows = saved.edge_flows(); - int failures = 0; int candidates = 0; for (EdgeId e = 0; e < g->num_edges(); ++e) { if (flows[static_cast(e)] <= 0) continue; @@ -208,62 +276,74 @@ TEST(DeletionFaults, EveryReSolveFailsWithoutPoisoningItsResolver) { for (bool warm : {false, true}) { DeletionResolver kept(saved); if (warm) (void)kept.resolve(e); + int failures = 0; for (long at = 0;; ++at) { - DeletionResolver fresh(saved); - DeletionResolver& rs = warm ? kept : fresh; - DeletionResult got; - if (!fails_at(at, [&] { got = rs.resolve(e); })) { - EXPECT_TRUE(same_result(got, want)) << "edge " << e; - break; + // A fresh resolver ends with the attempt; the warm one already holds its grown storage. + // So an allocation left live after the attempt is a leak of the failed re-solve. + const long live = g_live.load(); + bool failed = false; + { + DeletionResolver fresh(saved); + DeletionResolver& rs = warm ? kept : fresh; + DeletionResult got; + failed = fails_at(at, [&] { got = rs.resolve(e); }); + if (!failed) { + EXPECT_TRUE(same_result(got, want)) << "edge " << e; + } else { + ++failures; + EXPECT_TRUE(same_network(saved.network(), network)) << "edge " << e << " allocation " << at; + EXPECT_TRUE(same_result(rs.resolve(e), want)) << "edge " << e << " retry after allocation " << at; + } } - ++failures; - EXPECT_TRUE(same_state(*req.state, saved)) << "edge " << e << " allocation " << at; - EXPECT_TRUE(same_result(rs.resolve(e), want)) << "edge " << e << " retry after allocation " << at; + EXPECT_EQ(g_live.load(), live) << "edge " << e << " warm " << warm << " allocation " << at << ": a leak"; + if (!failed) break; } + // Every re-solve allocates (its result's arrays at least), warm or fresh. + EXPECT_GE(failures, 3) << "edge " << e << " warm " << warm; } } EXPECT_GE(candidates, 5); - EXPECT_GT(failures, 50); } -// Review-coherence RC1: a copy assignment between populated states of different coordinate sizes -// (and onto itself) fails at every allocation without changing its target; the reviewer's -// reproducer (the 6-node path assigned to the 2-node path) is one of the pairs. A failed target is +// A copy assignment between populated states of different coordinate sizes +// (and onto itself) fails at every allocation without changing its target; the pairs include a +// larger state assigned to a smaller one (the 6-node path to the 2-node path). A failed target is // identical to its old value and serves its re-solves; a finished one is identical to the source. TEST(DeletionFaults, CopyAssignmentFailsWithoutChangingItsTarget) { const auto states = assignment_states(); - int sweeps = 0; - int failures = 0; for (std::size_t i = 0; i < states.size(); ++i) { for (std::size_t j = 0; j < states.size(); ++j) { int failed_here = 0; for (long at = 0;; ++at) { - DeletionState target = states[i].state; const std::string where = "target " + std::to_string(i) + " source " + std::to_string(j) + " allocation " + std::to_string(at); - const bool failed = fails_at(at, [&] { - if (i == j) { - const DeletionState& self = target; - target = self; + const long live = g_live.load(); + bool failed = false; + { + DeletionState target = states[i].state; + failed = fails_at(at, [&] { + if (i == j) { + const DeletionState& self = target; + target = self; + } else { + target = states[j].state; + } + }); + if (!failed) { + EXPECT_TRUE(holds(target, states[j])) << where; } else { - target = states[j].state; + ++failed_here; + EXPECT_TRUE(holds(target, states[i])) << where; } - }); - if (!failed) { - EXPECT_TRUE(holds(target, states[j])) << where; - break; } - ++failed_here; - EXPECT_TRUE(holds(target, states[i])) << where; + EXPECT_EQ(g_live.load(), live) << where << ": a leak"; // the copy built aside is gone + if (!failed) break; ASSERT_FALSE(::testing::Test::HasFailure()) << where; } - ++sweeps; - failures += failed_here; - if (grows(states[i].state, states[j].state)) EXPECT_GE(failed_here, 1) << "target " << i << " source " << j; + // The copy built aside allocates the residual and the potentials, whatever the sizes. + EXPECT_EQ(failed_here, 2) << "target " << i << " source " << j; } } - EXPECT_EQ(sweeps, 25); - EXPECT_GE(failures, 2 * sweeps); // the copy built aside allocates the residual and the potentials } // An empty state (no solve) as the target keeps nothing after a failure and accepts no resolver; @@ -271,14 +351,25 @@ TEST(DeletionFaults, CopyAssignmentFailsWithoutChangingItsTarget) { TEST(DeletionFaults, CopyAssignmentWithAnEmptyStateIsAllOrNothing) { const auto states = assignment_states(); for (std::size_t j = 0; j < states.size(); ++j) { + long failed = 0; for (long at = 0;; ++at) { - DeletionState target; - if (!fails_at(at, [&] { target = states[j].state; })) { - EXPECT_TRUE(holds(target, states[j])) << "source " << j; - break; + const long live = g_live.load(); + bool done = false; + { + DeletionState target; + done = !fails_at(at, [&] { target = states[j].state; }); + if (done) { + EXPECT_TRUE(holds(target, states[j])) << "source " << j; + } else { + ++failed; + EXPECT_TRUE(empty_state(target)) << "source " << j << " allocation " << at; + } } - EXPECT_TRUE(empty_state(target)) << "source " << j << " allocation " << at; + EXPECT_EQ(g_live.load(), live) << "source " << j << " allocation " << at << ": a leak"; + if (done) break; } + // The copy built aside allocates the residual and the potentials (as a copy construction). + EXPECT_EQ(failed, 2) << "source " << j; DeletionState target = states[j].state; const DeletionState none; EXPECT_FALSE(fails_at(0, [&] { target = none; })) << "target " << j; @@ -311,110 +402,133 @@ TEST(DeletionFaults, MovesNeverAllocate) { } // Copy assignments on 1, 2 and 4 threads at once, each thread into its own target from a source -// whose graph and network the other threads' sources share; every allocation of the run fails -// once (any thread's, or the workers' only). A target whose assignment failed or never started -// keeps its old value, every other one holds its source. +// whose graph and network the next thread's target, when there is one, shares (a copy of the same +// state); every allocation of the run fails once (any thread's, or the workers' only). A target +// whose assignment failed or never started keeps its old value, every other one holds its source; +// the runs that failed are those of the threads' own allocations plus two per assignment. TEST(DeletionFaults, CopyAssignmentsOnWorkerThreadsAreAllOrNothing) { const auto states = assignment_states(); const auto k = states.size(); + // Runs body(t) on `workers` threads (on the caller for one), then raises the first error. + const auto on_threads = [](std::size_t workers, const std::function& body, + const std::vector& errors) { + if (workers == 1) { + body(0); + } else { + std::vector pool; + pool.reserve(workers); + try { + for (std::size_t t = 0; t < workers; ++t) pool.emplace_back([&body, t] { body(t); }); // not a copy of body + } catch (...) { + for (auto& th : pool) th.join(); + throw; + } + for (auto& th : pool) th.join(); + } + for (const auto& e : errors) { + if (e) std::rethrow_exception(e); + } + }; for (std::size_t workers : {1, 2, 4}) { for (bool workers_only : {false, true}) { if (workers == 1 && workers_only) continue; - int failures = 0; + // The allocations of the threads alone (their bodies empty). + const std::vector none(workers); + const std::function empty = [](std::size_t) {}; + const auto idle = [&] { on_threads(workers, empty, none); }; + long threads_only = 0; + while (workers_only ? fails_in_workers_at(threads_only, idle) : fails_at(threads_only, idle)) ++threads_only; + long failures = 0; for (long at = 0;; ++at) { - std::vector targets; - for (std::size_t t = 0; t < workers; ++t) targets.push_back(states[t % k].state); - std::vector done(workers, 0); - std::vector errors(workers); - auto body = [&](std::size_t t) noexcept { - try { - targets[t] = states[(t + 1) % k].state; - done[t] = 1; - } catch (...) { - errors[t] = std::current_exception(); - } - }; - auto run = [&] { - if (workers == 1) { - body(0); - } else { - std::vector pool; - pool.reserve(workers); + // The targets, the threads and the copies built aside end with the attempt: an + // allocation left live is a leak of a failed assignment. + const long live = g_live.load(); + bool failed = false; + { + std::vector targets; + for (std::size_t t = 0; t < workers; ++t) targets.push_back(states[t % k].state); + std::vector done(workers, 0); + std::vector errors(workers); + // Wrapped before the sweep's window (the wrapper may allocate). + const std::function body = [&](std::size_t t) noexcept { try { - for (std::size_t t = 0; t < workers; ++t) pool.emplace_back(body, t); + targets[t] = states[(t + 1) % k].state; + done[t] = 1; } catch (...) { - for (auto& th : pool) th.join(); - throw; + errors[t] = std::current_exception(); } - for (auto& th : pool) th.join(); + }; + auto run = [&] { on_threads(workers, body, errors); }; + failed = workers_only ? fails_in_workers_at(at, run) : fails_at(at, run); + for (std::size_t t = 0; t < workers; ++t) { + const auto& want = done[t] != 0 ? states[(t + 1) % k] : states[t % k]; + EXPECT_TRUE(holds(targets[t], want)) << "workers " << workers << " thread " << t << " allocation " << at; } - for (const auto& e : errors) { - if (e) std::rethrow_exception(e); + if (!failed) { + for (std::size_t t = 0; t < workers; ++t) EXPECT_EQ(done[t], 1) << "workers " << workers << " thread " << t; } - }; - const bool failed = workers_only ? fails_in_workers_at(at, run) : fails_at(at, run); - for (std::size_t t = 0; t < workers; ++t) { - const auto& want = done[t] != 0 ? states[(t + 1) % k] : states[t % k]; - EXPECT_TRUE(holds(targets[t], want)) << "workers " << workers << " thread " << t << " allocation " << at; - } - if (!failed) { - for (std::size_t t = 0; t < workers; ++t) EXPECT_EQ(done[t], 1) << "workers " << workers << " thread " << t; - break; } + EXPECT_EQ(g_live.load(), live) << "workers " << workers << " allocation " << at << ": a leak"; + if (!failed) break; ++failures; ASSERT_FALSE(::testing::Test::HasFailure()); ASSERT_LT(at, 1000); } - // Thread 0 assigns the 6-node path to the 2-node path: it allocates in every run. - EXPECT_GE(failures, 1) << "workers " << workers << " workers_only " << workers_only; + // Each assignment allocates twice (the copy built aside), and every allocation failed once. + EXPECT_EQ(failures, threads_only + 2 * static_cast(workers)) + << "workers " << workers << " workers_only " << workers_only; } } } -// Review-coherence's strengthened sweep, kept as regression evidence: every allocation of the -// capture on a thread whose solver is cold, and on one whose solver first served a smaller graph -// (its storage grows), leaves no state and an unchanged payload; the retry on the same thread -// gives the reference payload and state. +// Every allocation of the capture on a thread whose solver is cold, and on one whose solver first +// served a smaller graph (its storage grows), leaves no state and an unchanged payload; the retry +// on the same thread gives the reference payload and state. The sweep's failures outnumber the +// allocations of a capture on a thread whose solver already served this graph: the sweep reached +// the solver's growth (sweep_scratch_growth). TEST(DeletionFaults, CaptureFailsAtEveryAllocationOnAColdOrGrowingThread) { const auto g = graph(); DeletionCapture ref; const auto payload = capture(*g, g, ref); for (bool grow : {false, true}) { - int failures = 0; - for (long at = 0;; ++at) { - bool failed = false; - std::thread worker([&] { - if (grow) (void)chain(2, 2); - DeletionCapture req; - FlowCoreResult got; - failed = fails_at(at, [&] { got = capture(*g, g, req); }); - if (failed) { - EXPECT_FALSE(req.state.has_value()) << "allocation " << at; - got = capture(*g, g, req); - } - EXPECT_TRUE(same_payload(got, payload)) << "allocation " << at; - ASSERT_TRUE(req.state.has_value()); - EXPECT_TRUE(same_state(*req.state, *ref.state)) << "allocation " << at; - }); - worker.join(); - if (!failed) break; - ++failures; - ASSERT_FALSE(::testing::Test::HasFailure()); - ASSERT_LT(at, 1000); - } - EXPECT_GT(failures, 30) << "grow " << grow; + sweep_scratch_growth( + grow ? "growing thread" : "cold thread", + [&] { + if (grow) (void)chain(2, 2); + }, + [&](long at, const std::string& w) { + DeletionCapture req; + FlowCoreResult got; + const bool failed = fails_at(at, [&] { got = capture(*g, g, req); }); + if (failed) { + EXPECT_FALSE(req.state.has_value()) << w; + got = capture(*g, g, req); + } + EXPECT_TRUE(same_payload(got, payload)) << w; + EXPECT_TRUE(req.state.has_value() && same_state(*req.state, *ref.state)) << w; + return failed; + }, + [&] { + DeletionCapture first; + (void)capture(*g, g, first); // the solver's storage grows to this graph + long warm = 0; + while (fails_at(warm, [&] { + DeletionCapture again; + (void)capture(*g, g, again); + })) { + ++warm; + } + return warm; + }); } } -// Review-coherence's strengthened sweep, kept as regression evidence: Global sensitivity at every -// allocation (any thread's, and the workers' only) on 1, 2 and 4 workers; after each failure a -// retry gives the reference baseline and every entry. +// Global sensitivity at every allocation (any thread's, and the workers' only) on 1, 2 and 4 +// workers; after each failure a retry gives the reference baseline and every entry. TEST(DeletionFaults, GlobalSensitivityFailsAtEveryAllocation) { const auto g = std::make_shared( make_graph(5, {{0, 1, 2, 1}, {1, 2, 2, 1}, {1, 3, 1, 2}, {3, 2, 1, 2}, {2, 4, 2, 1}, {0, 3, 1, 5}})); - const FlowPolicyConfig cfg {Global {}, FlowPlacement::MaxFlow, Admission::Lossless, - PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity)}; - const SensitivityOptions opts {cfg, nullptr, nullptr, OutputOptions {true, true, true}}; + const SensitivityOptions opts {global_config(), nullptr, nullptr, OutputOptions {true, true, true}}; const std::vector sources {0}; const std::vector destinations {4}; const auto want = sensitivity(*g, sources, destinations, opts, 1); @@ -425,8 +539,6 @@ TEST(DeletionFaults, GlobalSensitivityFailsAtEveryAllocation) { for (std::size_t i = 0; i < want.entries.size(); ++i) { EXPECT_EQ(got.entries[i].edge, want.entries[i].edge) << where; EXPECT_EQ(got.entries[i].loss, want.entries[i].loss) << where; - EXPECT_EQ(got.entries[i].status, want.entries[i].status) << where; - EXPECT_EQ(got.entries[i].reason, want.entries[i].reason) << where; } }; for (std::int32_t workers : {1, 2, 4}) { @@ -434,16 +546,25 @@ TEST(DeletionFaults, GlobalSensitivityFailsAtEveryAllocation) { if (workers == 1 && workers_only) continue; int failures = 0; for (long at = 0;; ++at) { - SensitivityResult got; - const auto call = [&] { got = sensitivity(*g, sources, destinations, opts, workers); }; const std::string where = "workers " + std::to_string(workers) + (workers_only ? " (workers only)" : "") + " allocation " + std::to_string(at); - if (!(workers_only ? fails_in_workers_at(at, call) : fails_at(at, call))) { - same(got, where); - break; + // The result and the retry end with the attempt, the workers' scratch with their threads, + // and the reference above grew this thread's: an allocation left live is a leak. + const long live = g_live.load(); + bool failed = false; + { + SensitivityResult got; + const auto call = [&] { got = sensitivity(*g, sources, destinations, opts, workers); }; + failed = workers_only ? fails_in_workers_at(at, call) : fails_at(at, call); + if (!failed) { + same(got, where); + } else { + same(sensitivity(*g, sources, destinations, opts, workers), "retry after " + where); + } } + EXPECT_EQ(g_live.load(), live) << where << ": a leak"; + if (!failed) break; ++failures; - same(sensitivity(*g, sources, destinations, opts, workers), "retry after " + where); ASSERT_FALSE(::testing::Test::HasFailure()); ASSERT_LT(at, 10000); } @@ -451,3 +572,69 @@ TEST(DeletionFaults, GlobalSensitivityFailsAtEveryAllocation) { } } } + +// The negative controls of the comparators above: a copy of a captured state's fields with any +// one field changed is not identical, and with any value field changed not the same state +// either; a vector field is changed both by one more entry and by its last entry's value (so a +// comparator of sizes only, or of a prefix only, fails). A state without fields has none, and +// each field set makes it one with a field. A state serves its own re-solves; another state +// (also one of equal values on another graph), a changed reference result or an empty list of +// results does not. +TEST(DeletionFaults, TheStateComparatorsCompareEveryField) { + const Saved six = chain(6, 2); + const Saved other = chain(6, 3); + const StateFields st = fields_of(six.state); + const StateFields elsewhere = fields_of(other.state); + ASSERT_TRUE(identical(st, st)); + ASSERT_TRUE(st.maximum_ && st.value_ > 0 && !st.residual_.empty() && !st.potentials_.empty()); + ASSERT_NE(st.graph_, elsewhere.graph_); + ASSERT_NE(st.network_, elsewhere.network_); + const std::vector> values = { + [](auto& x) { x.residual_.push_back(0); }, + [](auto& x) { ++x.residual_.back(); }, + [](auto& x) { x.potentials_.push_back(detail::I128 {0}); }, + [](auto& x) { x.potentials_.back() += detail::I128 {1}; }, + [](auto& x) { ++x.value_; }, + [](auto& x) { x.maximum_ = !x.maximum_; }, + }; + for (std::size_t k = 0; k < values.size(); ++k) { + StateFields x = st; + values[k](x); + EXPECT_FALSE(same_state(x, st)) << "value field " << k; + EXPECT_FALSE(identical(x, st)) << "value field " << k; + } + const std::vector> others = { + [&](auto& x) { x.graph_ = elsewhere.graph_; }, + [&](auto& x) { x.network_ = elsewhere.network_; }, + [](auto& x) { ++x.counters_.dijkstra_runs; }, + [](auto& x) { ++x.counters_.augmentations; }, + }; + for (std::size_t k = 0; k < others.size(); ++k) { + StateFields x = st; + others[k](x); + EXPECT_TRUE(same_state(x, st)) << "identity or counter field " << k; + EXPECT_FALSE(identical(x, st)) << "identity or counter field " << k; + } + const StateFields none {}; + EXPECT_TRUE(no_fields(none)); + for (std::size_t k = 0; k < values.size(); ++k) { + if (k == 1 || k == 3) continue; // the last entry's value: none has no entry + StateFields x = none; + values[k](x); + EXPECT_FALSE(no_fields(x)) << "value field " << k; + } + EXPECT_TRUE(empty_state(DeletionState {})); + EXPECT_FALSE(empty_state(six.state)); + EXPECT_TRUE(holds(six.state, six)); + EXPECT_FALSE(holds(other.state, six)); + const Saved twin = chain(6, 2); // equal values on another graph and network + ASSERT_TRUE(same_state(twin.state, six.state)); + EXPECT_FALSE(holds(twin.state, six)); + Saved changed = six; + ASSERT_FALSE(changed.results.empty()); + ++changed.results.back().second.value; + EXPECT_FALSE(holds(six.state, changed)); + Saved no_results = six; + no_results.results.clear(); + EXPECT_FALSE(holds(six.state, no_results)); +} diff --git a/tests/cpp/flow_core_deletion_tests.cpp b/tests/cpp/flow_core_deletion_tests.cpp index 7a86141..774ad85 100644 --- a/tests/cpp/flow_core_deletion_tests.cpp +++ b/tests/cpp/flow_core_deletion_tests.cpp @@ -1,10 +1,17 @@ -// The deletion re-solve of the flow core (APPROACH 2.2) and the exact sensitivity model that uses -// it (APPROACH 11). Every check is independent of the repair: a solve from zero on the deleted -// view, the Bellman-Ford curve of test_utils.hpp, residuals rebuilt from flows, divergences and -// reduced costs computed here in __int128. +// The deletion re-solve of the flow core and the exact sensitivity model that uses it. Every check +// is independent of the repair: a solve from zero on the deleted view, the Bellman-Ford curve of +// test_utils.hpp, residuals rebuilt from flows, divergences and reduced costs computed here in +// the exact test integer of test_int128.hpp. Without the private hooks, check_deletion checks +// each re-solve's final result only: the probe that observes every intermediate state of the +// repairs is a hook. Three tests check the test tools themselves: the portable 128-bit classes of +// test_int128.hpp against the built-in type, the equality helpers of test_utils.hpp field by +// field, and this file's check_residual against a broken state per clause. #include +#include #include +#include +#include #include #include #include @@ -32,9 +39,7 @@ using detail::Network; constexpr std::size_t kNone = std::numeric_limits::max(); -__int128 wide(I128 x) { - return static_cast<__int128>((static_cast(static_cast(x.hi)) << 64) | x.lo); -} +Int128 wide(I128 x) { return int128_of(x.hi, x.lo); } // A solve to capture: the graph, the view, the endpoint sets, the eligibility and the cap. struct Case { @@ -64,14 +69,8 @@ DeletionCapture capture_of(const Case& c, FlowCoreResult* payload = nullptr) { return req; } -__int128 cost_of(const Graph& g, const std::vector& flows) { - __int128 total = 0; - for (std::size_t e = 0; e < flows.size(); ++e) total += static_cast<__int128>(g.cost()[e]) * flows[e]; - return total; -} - -__int128 cost_sum(const Graph& g) { - __int128 c = 0; +Int128 cost_sum(const Graph& g) { + Int128 c = 0; for (Cost w : g.cost()) c += w; return c; } @@ -124,22 +123,22 @@ std::vector residual_of(const Network& net, const std::vector& f } // The divergences (outflow minus inflow) every vertex should have: `at` lists (vertex, value). -std::vector<__int128> divergences(const Network& net, std::initializer_list> at) { - std::vector<__int128> d(static_cast(net.num_nodes) + 2, 0); +std::vector divergences(const Network& net, std::initializer_list> at) { + std::vector d(static_cast(net.num_nodes) + 2, 0); for (auto [v, x] : at) d[static_cast(v)] += x; return d; } -// The failed checks of a residual with potentials on the network (APPROACH 2.2): capacity +// The failed checks of a residual with potentials on the network: capacity // bounds (both arcs of a pair non-negative, summing to the pair's capacity; the deleted edge's // pair zero), the divergence vector `want`, non-negative reduced cost on every positive // residual arc (real and virtual), and the bound `bound` on every potential (non-negative). std::vector check_residual(const Network& net, std::span res, std::span pi, - std::size_t deleted, const std::vector<__int128>& want, __int128 bound) { + std::size_t deleted, const std::vector& want, Int128 bound) { std::vector fails; const auto n = static_cast(net.num_nodes) + 2; if (res.size() != net.arc_head.size() || pi.size() != n) return {"shape"}; - std::vector<__int128> div(n, 0); + std::vector div(n, 0); for (std::size_t k = 0; k < net.edges.size(); ++k) { const auto fwd = static_cast(net.edge_slot[k]); const auto bwd = static_cast(net.rev[fwd]); @@ -157,7 +156,7 @@ std::vector check_residual(const Network& net, std::span(net.rev[a])] < 0 || - static_cast<__int128>(res[a]) + res[static_cast(net.rev[a])] != kMaxFlowCap) { + static_cast(res[a]) + res[static_cast(net.rev[a])] != kMaxFlowCap) { fails.push_back("capacity bounds at a virtual arc"); } div[from] += flow; @@ -177,7 +176,7 @@ std::vector check_residual(const Network& net, std::span(tail[a]); const auto v = static_cast(net.arc_head[a]); - if (static_cast<__int128>(net.arc_cost[a]) + wide(pi[u]) - wide(pi[v]) < 0) { + if (static_cast(net.arc_cost[a]) + wide(pi[u]) - wide(pi[v]) < 0) { fails.push_back("negative reduced cost on slot " + std::to_string(a) + (u >= S || v >= S ? " (virtual)" : "")); } } @@ -187,8 +186,8 @@ std::vector check_residual(const Network& net, std::span res) { +// The vertices the real sources reach over positive residual arcs (their residual closure). +std::vector closure_of(const Network& net, std::span res) { const auto n = static_cast(net.num_nodes) + 2; std::vector seen(n, 0); std::vector queue; @@ -206,68 +205,105 @@ bool connected(const Network& net, std::span res) { } } } - return seen[n - 1] != 0; + return seen; +} + +// Whether the real sources reach t* over positive residual arcs. +bool connected(const Network& net, std::span res) { return closure_of(net, res).back() != 0; } + +// The cut of a residual: the network's edges of positive capacity, the deleted one aside, from the +// closure of the real sources to its complement, ascending EdgeId. +std::vector closure_cut(const Network& net, std::span res, std::size_t deleted) { + const auto seen = closure_of(net, res); + std::vector cut; + for (std::size_t k = 0; k < net.edges.size(); ++k) { + if (k == deleted || net.cap[k] <= 0) continue; + if (seen[static_cast(net.tail[k])] != 0 && seen[static_cast(net.head[k])] == 0) { + cut.push_back(net.edges[k]); + } + } + return cut; +} + +// The Dijkstra-run bound of a re-solve of an edge carrying f units: at most m + 2 r runs, where r +// is the remainder (the units lost) and m = f - r the units repair 1 reroutes. The failed clause, +// "" when the bound holds. +std::string run_bound_failure(const DeletionState& st, Amount f, const DeletionResult& r) { + const Amount rem = st.value() - r.value; + if (rem < 0) return "the re-solve gained value"; + if (r.counters.dijkstra_runs > (f - rem) + 2 * rem) return "more Dijkstra runs than m + 2 r"; + return ""; } #if NETGRAPH_CORE_PRIVATE_HOOKS +// What the probe reported, per repair (0 after the deletion, 1 to 3 the repairs, 4 at +// completion): the events of each kind and the amount the repair had moved at its last event. +struct Observation { + int updates[5] = {0, 0, 0, 0, 0}; + int pushes[5] = {0, 0, 0, 0, 0}; + Amount moved[5] = {0, 0, 0, 0, 0}; +}; + // The re-solve of e observed by the probe: every observed state (after the deletion, after every // potential update and push of the three repairs, at completion) is checked against the -// capacity bounds, the exact divergence vector of 2.2 for the amounts moved so far and dual +// capacity bounds, the exact divergence vector for the amounts moved so far and dual // feasibility on every positive residual arc. `updates` collects the counters after every -// potential update with its repair. +// potential update with its repair; `seen` counts the events. DeletionResult observed(DeletionResolver& rs, const DeletionState& st, EdgeId e, std::vector& fails, - bool rerun = false, std::vector>* updates = nullptr) { + bool rerun = false, std::vector>* updates = nullptr, + Observation* seen = nullptr) { const Network& net = st.network(); const std::size_t k = local_of(net, e); const std::int32_t u = net.tail[k]; const std::int32_t v = net.head[k]; const Amount F = st.value(); const Amount f = st.edge_flows()[static_cast(e)]; - const __int128 n = net.num_nodes + 2; - const __int128 bound = (22 * n + 15) * cost_sum(st.graph()); + const Int128 n = net.num_nodes + 2; + const Int128 bound = (22 * n + 15) * cost_sum(st.graph()); Amount moved[5] = {0, 0, 0, 0, 0}; detail::DeletionProbe probe; probe.rerun_failed_search = rerun; probe.observe = [&](const detail::DeletionEvent& ev) { if (ev.repair >= 1 && ev.repair <= 3) moved[ev.repair] = ev.moved; const auto want = divergences(net, {{net.num_nodes, F - moved[2]}, - {net.num_nodes + 1, -static_cast<__int128>(F) + moved[3]}, - {u, -static_cast<__int128>(f) + moved[1] + moved[2]}, - {v, static_cast<__int128>(f) - moved[1] - moved[3]}}); + {net.num_nodes + 1, -static_cast(F) + moved[3]}, + {u, -static_cast(f) + moved[1] + moved[2]}, + {v, static_cast(f) - moved[1] - moved[3]}}); for (const auto& s : check_residual(net, ev.residual, ev.potentials, k, want, bound)) { fails.push_back("repair " + std::to_string(ev.repair) + (ev.push ? " push" : " update") + ": " + s); } if (updates != nullptr && !ev.push && ev.repair >= 1 && ev.repair <= 3) updates->emplace_back(ev.repair, *ev.counters); + if (seen != nullptr && ev.repair >= 0 && ev.repair <= 4) { + ++(ev.push ? seen->pushes : seen->updates)[ev.repair]; + seen->moved[ev.repair] = ev.moved; + } }; return rs.resolve(e, probe); } #endif -// Every check of 2.2 for the deletion of positive-flow edge e from the captured state of c. -void check_deletion(const Case& c, const DeletionState& st, EdgeId e, const std::string& where) { +// A completed deletion's certificate, separate from the solve so broken answers can be fed to +// every clause. The view, a fresh solve and an independent cost curve supply the expected values. +void expect_deletion(const Case& c, const DeletionState& st, EdgeId e, const DeletionResult& r, + const std::string& where) { const Network& net = st.network(); const std::size_t k = local_of(net, e); - ASSERT_NE(k, kNone) << where; - const Amount f = st.edge_flows()[static_cast(e)]; - ASSERT_GT(f, 0) << where; - DeletionResolver rs(st); - const auto r = rs.resolve(e); -#if NETGRAPH_CORE_PRIVATE_HOOKS - std::vector fails; - const auto ro = observed(rs, st, e, fails); - for (const auto& s : fails) ADD_FAILURE() << where << ": " << s; - EXPECT_EQ(ro.value, r.value) << where; - EXPECT_EQ(ro.edge_flows, r.edge_flows) << where; -#endif - // From zero on V \ {e}, and the independent Bellman-Ford curve. + // Feasible on V \ {e} read per EdgeId: within the view, zero on the deleted edge and on every + // edge the Eligibility excludes. auto view = c.view; view[static_cast(e)] = 0; + ASSERT_EQ(r.edge_flows.size(), view.size()) << where; + for (std::size_t x = 0; x < view.size(); ++x) { + EXPECT_TRUE(r.edge_flows[x] >= 0 && r.edge_flows[x] <= view[x]) << where << " edge " << x; + if (c.el() != nullptr && !c.el()->edge_ok(static_cast(x))) EXPECT_EQ(r.edge_flows[x], 0) << where; + } + // From zero on V \ {e}, and the independent Bellman-Ford curve. const auto fresh = place_global_units(*c.g, view, c.sources, c.destinations, c.el(), std::nullopt); const auto ref = independent_curve(*c.g, view, c.sources, c.destinations, c.el()); EXPECT_EQ(r.value, fresh.placed) << where; EXPECT_EQ(r.value, ref.back().first) << where; - EXPECT_TRUE(cost_of(*c.g, r.edge_flows) == cost_of(*c.g, fresh.edge_flows)) << where; - EXPECT_TRUE(cost_of(*c.g, r.edge_flows) == ref.back().second) << where; + EXPECT_TRUE(flow_cost(*c.g, r.edge_flows) == flow_cost(*c.g, fresh.edge_flows)) << where; + EXPECT_TRUE(flow_cost(*c.g, r.edge_flows) == ref.back().second) << where; ASSERT_TRUE(fresh.min_cut.has_value()) << where; EXPECT_EQ(r.cut, *fresh.min_cut) << where; Amount cut_value = 0; @@ -275,19 +311,54 @@ void check_deletion(const Case& c, const DeletionState& st, EdgeId e, const std: EXPECT_EQ(cut_value, r.value) << where; EXPECT_EQ(r.edge_flows[static_cast(e)], 0) << where; // The final residual from the flow: bounds, divergences (value at s*), working potentials - // valid on every positive residual arc, the destinations disconnected. + // valid on every positive residual arc, the destinations disconnected, and the cut its closure's. const auto res = residual_of(net, r.edge_flows, k); - const __int128 n = net.num_nodes + 2; - const auto want = divergences(net, {{net.num_nodes, r.value}, {net.num_nodes + 1, -static_cast<__int128>(r.value)}}); + const Int128 n = net.num_nodes + 2; + const auto want = divergences(net, {{net.num_nodes, r.value}, {net.num_nodes + 1, -static_cast(r.value)}}); for (const auto& s : check_residual(net, res, r.potentials, k, want, (22 * n + 15) * cost_sum(*c.g))) { ADD_FAILURE() << where << ": final: " << s; } EXPECT_FALSE(connected(net, res)) << where; - // At most m + 2 r Dijkstra runs. + EXPECT_EQ(r.cut, closure_cut(net, res, k)) << where; // the cut of that residual + EXPECT_EQ(run_bound_failure(st, st.edge_flows()[static_cast(e)], r), "") << where; +} + +// Every certificate check for the deletion of positive-flow edge e from the captured state of c. +void check_deletion(const Case& c, const DeletionState& st, EdgeId e, const std::string& where) { + const Network& net = st.network(); + const std::size_t k = local_of(net, e); + ASSERT_NE(k, kNone) << where; + const Amount f = st.edge_flows()[static_cast(e)]; + ASSERT_GT(f, 0) << where; + DeletionResolver rs(st); + const auto r = rs.resolve(e); +#if NETGRAPH_CORE_PRIVATE_HOOKS + // rem is the remainder (the units lost) and m the units repair 1 reroutes. const Amount rem = st.value() - r.value; const Amount m = f - rem; - EXPECT_GE(rem, 0) << where; - EXPECT_LE(r.counters.dijkstra_runs, m + 2 * rem) << where; + std::vector fails; + Observation seen; + const auto ro = observed(rs, st, e, fails, false, nullptr, &seen); + for (const auto& s : fails) ADD_FAILURE() << where << ": " << s; + EXPECT_TRUE(same_result(ro, r)) << where; + // The probe fired at every stage: one event after the deletion and one at completion; repair 1 + // searched at least once and pushed exactly when it moved units, m in all; repairs 2 and 3 + // pushed the remainder when there is one and are silent otherwise (repair 2 may reuse repair + // 1's failed search, so it need not search). + EXPECT_EQ(seen.updates[0], 1) << where; + EXPECT_EQ(seen.updates[4], 1) << where; + EXPECT_EQ(seen.pushes[0] + seen.pushes[4], 0) << where; + EXPECT_GE(seen.updates[1], 1) << where; + EXPECT_EQ(seen.pushes[1] > 0, m > 0) << where; + EXPECT_EQ(seen.moved[1], m) << where; + for (const int i : {2, 3}) { + EXPECT_EQ(seen.pushes[i] > 0, rem > 0) << where << " repair " << i; + EXPECT_EQ(seen.moved[i], rem) << where << " repair " << i; + } + EXPECT_EQ(seen.updates[3] > 0, rem > 0) << where; + EXPECT_TRUE(rem > 0 || seen.updates[2] == 0) << where; +#endif + expect_deletion(c, st, e, r, where); } // The captured state's own checks: bounds, divergences, valid working potentials within @@ -296,23 +367,21 @@ void check_state(const Case& c, const DeletionState& st, const FlowCoreResult& p const Network& net = st.network(); EXPECT_EQ(st.value(), payload.placed) << where; EXPECT_EQ(st.edge_flows(), payload.edge_flows) << where; - const __int128 n = net.num_nodes + 2; + const Int128 n = net.num_nodes + 2; const auto want = - divergences(net, {{net.num_nodes, st.value()}, {net.num_nodes + 1, -static_cast<__int128>(st.value())}}); + divergences(net, {{net.num_nodes, st.value()}, {net.num_nodes + 1, -static_cast(st.value())}}); for (const auto& s : check_residual(net, st.residual(), st.potentials(), kNone, want, (n + 1) * cost_sum(*c.g))) { ADD_FAILURE() << where << ": state: " << s; } EXPECT_EQ(st.maximum(), !connected(net, st.residual())) << where; - const auto& x = st.counters(); - const auto& y = payload.counters; - EXPECT_TRUE(x.dijkstra_runs == y.dijkstra_runs && x.dijkstra_pops == y.dijkstra_pops && x.settled == y.settled && - x.arc_scans == y.arc_scans && x.dinic_phases == y.dinic_phases && x.augmentations == y.augmentations) - << where; + EXPECT_TRUE(same_counters(st.counters(), payload.counters)) << where; } // Every allowed edge of the captured state: positive flow by the re-solve, zero flow by a -// solve from zero on V \ {e} (the baseline's value and cost; no re-solve, which rejects it). -void check_every_edge(const Case& c, const std::string& where) { +// solve from zero on V \ {e} and the independent Bellman-Ford curve there (the baseline's value +// and cost; no re-solve, which rejects it). +// `resolved`, when given, counts the edges checked by a re-solve. +void check_every_edge(const Case& c, const std::string& where, int* resolved = nullptr) { FlowCoreResult payload; const auto req = capture_of(c, &payload); ASSERT_TRUE(req.state.has_value()) << where; @@ -320,18 +389,28 @@ void check_every_edge(const Case& c, const std::string& where) { check_state(c, st, payload, where); ASSERT_TRUE(st.maximum()) << where; const auto flows = st.edge_flows(); + DeletionResolver reused(st); + const auto positive = std::find_if(st.network().edges.begin(), st.network().edges.end(), + [&](EdgeId e) { return flows[static_cast(e)] > 0; }); for (EdgeId e : st.network().edges) { const std::string at = where + " edge " + std::to_string(e); if (flows[static_cast(e)] > 0) { check_deletion(c, st, e, at); + if (resolved != nullptr) ++*resolved; } else { auto view = c.view; view[static_cast(e)] = 0; const auto fresh = place_global_units(*c.g, view, c.sources, c.destinations, c.el(), std::nullopt); EXPECT_EQ(fresh.placed, st.value()) << at; - EXPECT_TRUE(cost_of(*c.g, fresh.edge_flows) == cost_of(*c.g, flows)) << at; - DeletionResolver rs(st); - EXPECT_THROW((void)rs.resolve(e), std::invalid_argument) << at; + EXPECT_TRUE(flow_cost(*c.g, fresh.edge_flows) == flow_cost(*c.g, flows)) << at; + const auto ref = independent_curve(*c.g, view, c.sources, c.destinations, c.el()); + EXPECT_EQ(ref.back().first, st.value()) << at; + EXPECT_TRUE(ref.back().second == flow_cost(*c.g, flows)) << at; + EXPECT_EQ(error_of([&] { (void)reused.resolve(e); }), "deletion re-solve: edge " + std::to_string(e) + " carries no flow") + << at; + if (positive != st.network().edges.end()) { + EXPECT_TRUE(same_result(reused.resolve(*positive), DeletionResolver(st).resolve(*positive))) << at; + } } if (::testing::Test::HasFailure()) return; } @@ -353,6 +432,8 @@ Case random_case(std::mt19937_64& rng) { for (std::int64_t i = 0; i < m; ++i) { edges.push_back({static_cast(pick(0, n - 1)), static_cast(pick(0, n - 1)), pick(0, 4), pick(1, 13)}); } + const Cost factor = m % 3 == 0 ? 1000003 : 1; + for (auto& edge : edges) edge.cost *= factor; Case c = make_case(n, edges, s, d); for (auto& x : c.view) { if (pick(0, 1) == 1) x = pick(0, x); // a reduced view @@ -371,6 +452,16 @@ Case random_case(std::mt19937_64& rng) { return c; } +// The positive-flow edges of a state. +std::vector positive_edges(const DeletionState& st) { + std::vector out; + const auto flows = st.edge_flows(); + for (EdgeId e : st.network().edges) { + if (flows[static_cast(e)] > 0) out.push_back(e); + } + return out; +} + } // namespace // ------------------------------------------------------------------ the arithmetic @@ -383,29 +474,107 @@ TEST(FlowCoreDeletion, SignedWideArithmeticMatchesInt128) { x.lo = rng(); return x; }; + int equal = 0; for (int i = 0; i < 20000; ++i) { const I128 a = any(); - const I128 b = any(); - const __int128 x = wide(a); - const __int128 y = wide(b); - const auto wrap = [](__int128 z) { return static_cast<__int128>(static_cast(z)); }; - EXPECT_TRUE(wide(a + b) == wrap(static_cast<__int128>(static_cast(x) + static_cast(y)))); - EXPECT_TRUE(wide(a - b) == wrap(static_cast<__int128>(static_cast(x) - static_cast(y)))); + const I128 b = i % 8 == 0 ? a : any(); // equal operands too (two random ones never are) + const Int128 x = wide(a); + const Int128 y = wide(b); + // Modulo 2^128, through the unsigned type (a signed built-in overflow is undefined). + EXPECT_TRUE(wide(a + b) == static_cast(static_cast(x) + static_cast(y))); + EXPECT_TRUE(wide(a - b) == static_cast(static_cast(x) - static_cast(y))); EXPECT_EQ(a < b, x < y); EXPECT_EQ(a == b, x == y); + equal += a == b ? 1 : 0; const auto c = static_cast(rng()); EXPECT_TRUE(wide(I128 {c}) == c); } + EXPECT_GT(equal, 2000); } +#if defined(__SIZEOF_INT128__) +// The portable classes of test_int128.hpp (the test integer where the compiler has no built-in +// 128-bit type) against the built-in type: every operation, the compound assignments included, +// on operands of every magnitude, the signed and unsigned orders and the conversions. The signed +// built-in sums and products are formed in the unsigned type (a signed overflow is undefined; the +// classes wrap). +TEST(PortableInt128, PortableClassesMatchTheBuiltInType) { + using B = unsigned __int128; + using BS = __int128; + using P = PortableUInt128; + using PS = PortableInt128; + auto to_p = [](B x) { return P::of_words(static_cast(x >> 64), static_cast(x)); }; + auto same = [](P p, B x) { return p.high() == static_cast(x >> 64) && p.low() == static_cast(x); }; + auto same_s = [&](PS p, BS x) { return same(static_cast

(p), static_cast(x)); }; + std::mt19937_64 rng(128); + // Words of every bit length, so carries, borrows, one-word and two-word operands all occur. + auto word = [&] { return rng() >> (rng() % 64); }; + auto any = [&] { + const int shape = static_cast(rng() % 4); + const std::uint64_t hi = shape == 0 ? 0 : (shape == 1 ? ~std::uint64_t {0} : word()); + return (static_cast(hi) << 64) | word(); + }; + int wide_divisors = 0; + for (int i = 0; i < 20000; ++i) { + const B x = any(); + const B y = i % 5 == 0 ? static_cast(1 + rng() % 16) : any(); + const P a = to_p(x); + const P b = to_p(y); + ASSERT_TRUE(same(a + b, x + y) && same(a - b, x - y) && same(a * b, x * y)) << i; + ASSERT_TRUE(same(a | b, x | y) && same(a & b, x & y)) << i; + P t = a; // the compound assignments, in sequence on one value + ASSERT_TRUE(same(t += b, x + y) && same(t -= b, x) && same(t *= b, x * y)) << i; + ASSERT_EQ(a == b, x == y); + ASSERT_EQ(a < b, x < y); + ASSERT_EQ(a <= b, x <= y); + const int s = static_cast(rng() % 128); + ASSERT_TRUE(same(a << s, x << s) && same(a >> s, x >> s)) << i << " shift " << s; + if (y != 0) { + ASSERT_TRUE(same(a / b, x / y) && same(a % b, x % y)) << i; + P q = a; + ASSERT_TRUE(same(q /= b, x / y)) << i; + wide_divisors += (y >> 64) != 0 ? 1 : 0; // the two-word long division + } + ASSERT_EQ(static_cast(a), static_cast(x)); + ASSERT_EQ(static_cast(a), static_cast(x)); + const auto bx = static_cast(x); + const auto by = static_cast(y); + const PS sa(a); + const PS sb(b); + ASSERT_TRUE(same_s(sa + sb, static_cast(x + y)) && same_s(sa - sb, static_cast(x - y)) && + same_s(sa * sb, static_cast(x * y)) && same_s(-sa, static_cast(B {0} - x))) + << i; + PS st = sa; + ASSERT_TRUE(same_s(st += sb, static_cast(x + y)) && same_s(st -= sb, bx) && + same_s(st *= sb, static_cast(x * y))) + << i; + ASSERT_EQ(sa == sb, bx == by); + ASSERT_EQ(sa < sb, bx < by); + ASSERT_EQ(sa > sb, bx > by); + const BS min = static_cast(B {1} << 127); + if (by != 0 && !(bx == min && by == -1)) { + ASSERT_TRUE(same_s(sa / sb, bx / by) && same_s(sa % sb, bx % by)) << i; + } + ASSERT_EQ(static_cast(sa), static_cast(bx)); + const auto c = static_cast(rng()) >> (rng() % 64); + const auto u = rng() >> (rng() % 64); + ASSERT_TRUE(same_s(PS {c}, BS {c}) && same(P {c}, static_cast(c)) && same(P {u}, B {u})) << i; + ASSERT_TRUE(same_s(PS {u}, BS {u}) && same_s(PS {static_cast(c)}, BS {static_cast(c)})); + } + EXPECT_GT(wide_divisors, 5000); +} +#endif + // ------------------------------------------------------------------ the capture // The capture changes no field of the ordinary payload (every field, all six counters): cap 0 // (no search), an infeasible positive cap, caps below, at and above the maximum, uncapped; -// never-reached sentinels and the final failed tier included. +// never-reached sentinels and the final failed tier included. Every captured state also passes +// the state checks, whose reachability test must then say "reachable" for each state stopped +// below the maximum. TEST(FlowCoreDeletion, CaptureLeavesTheFromZeroPayloadUnchanged) { std::mt19937_64 rng(5); - int compared = 0; + int below_maximum = 0; for (int trial = 0; trial < 250; ++trial) { Case c = random_case(rng); const auto ref = independent_curve(*c.g, c.view, c.sources, c.destinations, c.el()); @@ -420,7 +589,9 @@ TEST(FlowCoreDeletion, CaptureLeavesTheFromZeroPayloadUnchanged) { ASSERT_TRUE(same_payload(plain, with)) << "trial " << trial; ASSERT_TRUE(req.state.has_value()); EXPECT_EQ(req.state->maximum(), !cap.has_value() || *cap >= fmax || fmax == 0) << "trial " << trial; - ++compared; + check_state(c, *req.state, with, "trial " + std::to_string(trial)); + if (::testing::Test::HasFailure()) return; + below_maximum += req.state->maximum() ? 0 : 1; } } // An infeasible positive cap: no path, the failed first tier. @@ -433,7 +604,191 @@ TEST(FlowCoreDeletion, CaptureLeavesTheFromZeroPayloadUnchanged) { EXPECT_EQ(with.placed, 0); EXPECT_EQ(with.counters.dijkstra_runs, 1); EXPECT_TRUE(req.state->maximum()); - EXPECT_GT(compared, 1000); + EXPECT_GT(below_maximum, 100); +} + +// The shared equality helpers of test_utils.hpp compare every field: a copy with any one field +// changed is unequal, a vector field both by one more entry and by its last entry's value (so a +// helper comparing sizes only, or a prefix only, fails). +TEST(TestHelpers, EqualityHelpersCompareEveryField) { + Case c = make_case(5, {{0, 1, 3, 3}, {0, 2, 1, 3}, {1, 2, 2, 7}, {2, 3, 1, 3}, {3, 1, 1, 2}}, {0}, {3}); + FlowCoreResult payload; + const auto req = capture_of(c, &payload); + ASSERT_TRUE(req.state.has_value()); + const auto& st = *req.state; + ASSERT_GT(st.value(), 0); + const std::vector> counters = { + [](auto& x) { ++x.dijkstra_runs; }, [](auto& x) { ++x.dijkstra_pops; }, [](auto& x) { ++x.settled; }, + [](auto& x) { ++x.arc_scans; }, [](auto& x) { ++x.dinic_phases; }, [](auto& x) { ++x.augmentations; }, + }; + for (std::size_t k = 0; k < counters.size(); ++k) { + auto x = payload.counters; + counters[k](x); + EXPECT_FALSE(same_counters(x, payload.counters)) << "counter " << k; + } + const std::vector> payloads = { + [](auto& x) { ++x.placed; }, + [](auto& x) { x.cost_distribution.emplace_back(1, 1); }, + [](auto& x) { ++x.cost_distribution.back().first; }, + [](auto& x) { ++x.cost_distribution.back().second; }, + [](auto& x) { x.edge_flows.push_back(0); }, + [](auto& x) { ++x.edge_flows.back(); }, + [](auto& x) { x.min_cut.reset(); }, + [](auto& x) { x.min_cut->push_back(0); }, + [](auto& x) { ++x.min_cut->back(); }, + [](auto& x) { x.potentials.push_back(0); }, + [](auto& x) { ++x.potentials.back(); }, + [](auto& x) { ++x.counters.settled; }, + }; + ASSERT_TRUE(payload.min_cut.has_value() && !payload.min_cut->empty()); + ASSERT_FALSE(payload.cost_distribution.empty()); + for (std::size_t k = 0; k < payloads.size(); ++k) { + auto x = payload; + payloads[k](x); + EXPECT_FALSE(same_payload(x, payload)) << "payload field " << k; + } + EdgeId carrying = 0; + while (st.edge_flows()[static_cast(carrying)] == 0) ++carrying; + const auto result = DeletionResolver(st).resolve(carrying); + const std::vector> results = { + [](auto& x) { ++x.value; }, + [](auto& x) { x.potentials.push_back(I128 {0}); }, + [](auto& x) { x.potentials.back() += I128 {1}; }, + [](auto& x) { x.cut.push_back(0); }, + [](auto& x) { ++x.cut.back(); }, + [](auto& x) { x.edge_flows.push_back(0); }, + [](auto& x) { ++x.edge_flows.back(); }, + [](auto& x) { ++x.counters.augmentations; }, + }; + ASSERT_FALSE(result.cut.empty()); + for (std::size_t k = 0; k < results.size(); ++k) { + auto x = result; + results[k](x); + EXPECT_FALSE(same_result(x, result)) << "result field " << k; + } + const Network& net = st.network(); + const std::vector> networks = { + [](auto& x) { ++x.num_nodes; }, [](auto& x) { x.edges.push_back(0); }, + [](auto& x) { x.tail.push_back(0); }, [](auto& x) { x.head.push_back(0); }, + [](auto& x) { x.cost.push_back(0); }, [](auto& x) { x.cap.push_back(0); }, + [](auto& x) { x.sources.push_back(0); }, [](auto& x) { x.destinations.push_back(0); }, + [](auto& x) { x.off.push_back(0); }, [](auto& x) { x.arc_head.push_back(0); }, + [](auto& x) { x.rev.push_back(0); }, [](auto& x) { x.arc_cost.push_back(0); }, + [](auto& x) { x.edge_slot.push_back(0); }, [](auto& x) { x.src_rev.push_back(0); }, + [](auto& x) { x.dst_fwd.push_back(0); }, + [](auto& x) { ++x.edges.back(); }, [](auto& x) { ++x.tail.back(); }, + [](auto& x) { ++x.head.back(); }, [](auto& x) { ++x.cost.back(); }, + [](auto& x) { ++x.cap.back(); }, [](auto& x) { ++x.sources.back(); }, + [](auto& x) { ++x.destinations.back(); }, [](auto& x) { ++x.off.back(); }, + [](auto& x) { ++x.arc_head.back(); }, [](auto& x) { ++x.rev.back(); }, + [](auto& x) { ++x.arc_cost.back(); }, [](auto& x) { ++x.edge_slot.back(); }, + [](auto& x) { ++x.src_rev.back(); }, [](auto& x) { ++x.dst_fwd.back(); }, + }; + for (std::size_t k = 0; k < networks.size(); ++k) { + Network x = net; + networks[k](x); + EXPECT_FALSE(same_network(x, net)) << "network field " << k; + } + EXPECT_TRUE(same_network(net, net)); +} + +// check_residual is not vacuous: a captured state passes it, and each mutation below breaks the +// clause it names. +TEST(FlowCoreDeletion, ResidualCheckRejectsEachBrokenClause) { + Case c = make_case(5, {{0, 1, 3, 3}, {0, 2, 1, 3}, {1, 2, 2, 7}, {2, 3, 1, 3}, {3, 1, 1, 2}}, {0}, {3}); + const auto req = capture_of(c); + ASSERT_TRUE(req.state.has_value()); + const auto& st = *req.state; + const Network& net = st.network(); + const std::vector res(st.residual().begin(), st.residual().end()); + const std::vector pi(st.potentials().begin(), st.potentials().end()); + const auto want = + divergences(net, {{net.num_nodes, st.value()}, {net.num_nodes + 1, -static_cast(st.value())}}); + const Int128 bound = (static_cast(net.num_nodes) + 3) * cost_sum(*c.g); // (n + 1) C, n = num_nodes + 2 + auto check = [&](const std::vector& r, const std::vector& p, std::size_t deleted = kNone) { + return check_residual(net, r, p, deleted, want, bound); + }; + ASSERT_TRUE(check(res, pi).empty()); + ASSERT_GT(st.value(), 0); + // An edge that carries flow: its backward arc is positive. + std::size_t k = 0; + while (k < net.edges.size() && res[static_cast(net.rev[static_cast(net.edge_slot[k])])] == 0) { + ++k; + } + ASSERT_LT(k, net.edges.size()); + const auto fwd = static_cast(net.edge_slot[k]); + const auto bwd = static_cast(net.rev[fwd]); + // One more unit on the forward arc: the pair exceeds the edge's capacity (the divergences read + // the backward arc only). + auto over = res; + ++over[fwd]; + EXPECT_TRUE(has_failure(check(over, pi), "capacity bounds at edge " + std::to_string(net.edges[k]))); + // One unit from the backward to the forward arc: the pair's sum holds, the edge carries one unit + // less, so its endpoints' divergences are off. + auto less = res; + --less[bwd]; + ++less[fwd]; + EXPECT_TRUE(has_failure(check(less, pi), "divergence vector")); + // A source's virtual pair no longer sums to kMaxFlowCap. + auto virt = res; + ++virt[static_cast(net.src_rev[0])]; + EXPECT_TRUE(has_failure(check(virt, pi), "capacity bounds at a virtual arc")); + // The same residual read as if edge k were deleted: its arcs should be zero. + EXPECT_TRUE(has_failure(check(res, pi, k), "the deleted edge keeps a residual arc")); + // A positive real arc u -> v with pi(v) raised to pi(u) + cost + 1: reduced cost -1. + const auto tail = slot_tails(net); + std::size_t a = 0; + while (a < res.size() && (res[a] <= 0 || tail[a] >= net.num_nodes || net.arc_head[a] >= net.num_nodes)) ++a; + ASSERT_LT(a, res.size()); + auto raised = pi; + raised[static_cast(net.arc_head[a])] = + raised[static_cast(tail[a])] + I128 {net.arc_cost[a] + 1}; + EXPECT_TRUE(has_failure(check(res, raised), "negative reduced cost on slot " + std::to_string(a))); + // A potential below 0 and one above the bound. + auto low = pi; + low[1] = I128 {-1}; + EXPECT_TRUE(has_failure(check(res, low), "potential outside [0, bound] at 1")); + auto high = pi; + high[1] = I128 {static_cast(bound) + 1}; + EXPECT_TRUE(has_failure(check(res, high), "potential outside [0, bound] at 1")); + // Either array one entry too long (a zero residual entry, read by no other clause). + auto longer_res = res; + longer_res.push_back(0); + EXPECT_EQ(check(longer_res, pi), std::vector {"shape"}); + auto longer_pi = pi; + longer_pi.push_back(I128 {0}); + EXPECT_EQ(check(res, longer_pi), std::vector {"shape"}); + // Each arc of a pair negative while the pair's sum holds: edge k's, then the source's virtual + // pair (the moved units also shift the divergences). + auto negative = [&](std::size_t x) { + auto r = res; + const auto y = static_cast(net.rev[x]); + r[y] += r[x] + 1; + r[x] = -1; + return r; + }; + EXPECT_TRUE(has_failure(check(negative(fwd), pi), "capacity bounds at edge " + std::to_string(net.edges[k]))); + EXPECT_TRUE(has_failure(check(negative(bwd), pi), "capacity bounds at edge " + std::to_string(net.edges[k]))); + const auto src_arc = static_cast(net.src_rev[0]); + EXPECT_TRUE(has_failure(check(negative(src_arc), pi), "capacity bounds at a virtual arc")); + EXPECT_TRUE(has_failure(check(negative(static_cast(net.rev[src_arc])), pi), + "capacity bounds at a virtual arc")); + // Read as deleted, edge k with only its forward arc, then only its backward arc, positive. + for (const bool forward : {true, false}) { + auto kept = res; + kept[fwd] = forward ? 1 : 0; + kept[bwd] = forward ? 0 : 1; + EXPECT_TRUE(has_failure(check(kept, pi, k), "the deleted edge keeps a residual arc")) << forward; + } + // A positive virtual arc with its head's potential raised by its cost + 1 over its tail's. + std::size_t b = 0; + while (b < res.size() && (res[b] <= 0 || (tail[b] < net.num_nodes && net.arc_head[b] < net.num_nodes))) ++b; + ASSERT_LT(b, res.size()); + auto raised_virtual = pi; + raised_virtual[static_cast(net.arc_head[b])] = + raised_virtual[static_cast(tail[b])] + I128 {net.arc_cost[b] + 1}; + EXPECT_TRUE( + has_failure(check(res, raised_virtual), "negative reduced cost on slot " + std::to_string(b) + " (virtual)")); } #if NETGRAPH_CORE_PRIVATE_HOOKS @@ -441,7 +796,7 @@ namespace { // Captures c with the independent iterative all-vertex update beside: after every search, each // vertex by its distance or, unreached, by the search's largest finite distance, from 0. -DeletionCapture capture_iterative(const Case& c, std::vector<__int128>& pi, int& searches) { +DeletionCapture capture_iterative(const Case& c, std::vector& pi, int& searches) { pi.assign(static_cast(c.g->num_nodes()) + 2, 0); searches = 0; DeletionCapture req; @@ -467,14 +822,14 @@ DeletionCapture capture_iterative(const Case& c, std::vector<__int128>& pi, int& TEST(FlowCoreDeletion, CheckpointsEqualTheIterativeUpdate) { #if NETGRAPH_CORE_PRIVATE_HOOKS std::mt19937_64 rng(17); - int searched = 0; + int several = 0; for (int trial = 0; trial < 300; ++trial) { Case c = random_case(rng); const Amount fmax = independent_curve(*c.g, c.view, c.sources, c.destinations, c.el()).back().first; for (std::optional cap : {std::optional {}, std::optional {0}, std::optional {fmax / 2}, std::optional {fmax + 1}}) { c.cap = cap; - std::vector<__int128> pi; + std::vector pi; int searches = 0; const auto req = capture_iterative(c, pi, searches); const auto& st = *req.state; @@ -482,13 +837,13 @@ TEST(FlowCoreDeletion, CheckpointsEqualTheIterativeUpdate) { for (std::size_t v = 0; v < pi.size(); ++v) ASSERT_TRUE(wide(st.potentials()[v]) == pi[v]) << trial << " " << v; EXPECT_EQ(searches, st.counters().dijkstra_runs); if (cap == 0) EXPECT_EQ(searches, 0); - searched += searches; + several += searches >= 2 ? 1 : 0; // a running sum of more than one search } } - EXPECT_GT(searched, 1000); + EXPECT_GT(several, 150); // The common-offset mutant: valid reduced costs, the wrong convention. Case c = make_case(5, {{0, 1, 3, 3}, {0, 2, 1, 3}, {1, 2, 2, 7}, {2, 3, 1, 3}, {3, 1, 1, 2}}, {0}, {3}); - std::vector<__int128> pi; + std::vector pi; int searches = 0; const auto req = capture_iterative(c, pi, searches); const auto& st = *req.state; @@ -496,7 +851,7 @@ TEST(FlowCoreDeletion, CheckpointsEqualTheIterativeUpdate) { for (auto& p : shifted) p += 1000; const Network& net = st.network(); const auto want = - divergences(net, {{net.num_nodes, st.value()}, {net.num_nodes + 1, -static_cast<__int128>(st.value())}}); + divergences(net, {{net.num_nodes, st.value()}, {net.num_nodes + 1, -static_cast(st.value())}}); EXPECT_TRUE(check_residual(net, st.residual(), shifted, kNone, want, cost_sum(*c.g) * 1000).empty()); bool same = true; for (std::size_t v = 0; v < pi.size(); ++v) same = same && wide(shifted[v]) == pi[v]; @@ -510,7 +865,7 @@ TEST(FlowCoreDeletion, CheckpointsEqualTheIterativeUpdate) { TEST(FlowCoreDeletion, EveryEdgeOfRandomViewsAgreesWithAFreshSolve) { std::mt19937_64 rng(3005); - int deletions = 0; + int deletions = 0, masked = 0, several = 0, scaled = 0, rerouted = 0, partial = 0, cut = 0, capped = 0; for (int trial = 0; trial < 700; ++trial) { Case c = random_case(rng); const Amount fmax = independent_curve(*c.g, c.view, c.sources, c.destinations, c.el()).back().first; @@ -518,16 +873,85 @@ TEST(FlowCoreDeletion, EveryEdgeOfRandomViewsAgreesWithAFreshSolve) { for (std::optional cap : {std::optional {}, std::optional {std::max(fmax, 1)}, std::optional {fmax + 2}}) { c.cap = cap; + capped += cap.has_value() && *cap == fmax && fmax > 0 ? 1 : 0; check_every_edge(c, "trial " + std::to_string(trial)); if (::testing::Test::HasFailure()) return; } const auto req = capture_of(c); - for (Amount x : req.state->edge_flows()) deletions += x > 0 ? 1 : 0; + const auto& st = *req.state; + for (const EdgeId e : positive_edges(st)) { + ++deletions; + const auto result = DeletionResolver(st).resolve(e); + const Amount flow = st.edge_flows()[static_cast(e)]; + const Amount lost = st.value() - result.value; + masked += c.elig.has_value() && c.elig->uid() != 0 ? 1 : 0; + several += c.sources.size() > 1 || c.destinations.size() > 1 ? 1 : 0; + scaled += c.g->max_cost() > 1000000 ? 1 : 0; + rerouted += lost == 0 ? 1 : 0; + partial += lost > 0 && lost < flow ? 1 : 0; + cut += lost == flow ? 1 : 0; + } } EXPECT_GT(deletions, 600); + EXPECT_GT(masked, 30); + EXPECT_GT(several, 100); + EXPECT_GT(scaled, 20); + EXPECT_GT(rerouted, 10); + EXPECT_GT(partial, 2); + EXPECT_GT(cut, 100); + EXPECT_GT(capped, 100); +} + +// Deliberately damaged answers must trip each of the final-result certificate's clauses. +TEST(FlowCoreDeletion, CertificateRejectsDamagedAnswers) { + Case c = make_case(4, {{0, 1, 3, 1}, {1, 3, 3, 1}, {0, 2, 3, 3}, {2, 3, 3, 1}, + {0, 3, 1, 9}, {2, 2, 1, 7}}, {0}, {3}); + const auto ids = id_of_input(*c.g); + c.elig = c.g->eligibility({}, std::vector {ids[5]}); + const auto captured = capture_of(c); + const auto& st = *captured.state; + const EdgeId deleted = ids[4]; + const auto good = DeletionResolver(st).resolve(deleted); + ASSERT_EQ(good.value, 6); + EXPECT_EQ(failures_of([&] { expect_deletion(c, st, deleted, good, "good"); }), 0); + auto rejects = [&](const DeletionResult& bad, const std::vector& clauses) { + ::testing::TestPartResultArray results; + { + ::testing::ScopedFakeTestPartResultReporter capture( + ::testing::ScopedFakeTestPartResultReporter::INTERCEPT_ONLY_CURRENT_THREAD, &results); + expect_deletion(c, st, deleted, bad, "damaged"); + } + for (const auto& clause : clauses) { + bool found = false; + for (int i = 0; i < results.size(); ++i) { + found = found || std::string(results.GetTestPartResult(i).message()).find(clause) != std::string::npos; + } + EXPECT_TRUE(found) << "missing certificate failure: " << clause; + } + }; + auto bad = good; + bad.edge_flows.pop_back(); + rejects(bad, {"r.edge_flows.size()"}); + bad = good; + bad.edge_flows[static_cast(deleted)] = 1; + rejects(bad, {"r.edge_flows[x] >= 0", "r.edge_flows[static_cast(e)]", + "flow_cost(*c.g, r.edge_flows) == flow_cost(*c.g, fresh.edge_flows)", + "flow_cost(*c.g, r.edge_flows) == ref.back().second"}); + bad = good; + bad.edge_flows[static_cast(ids[5])] = 1; + rejects(bad, {"r.edge_flows[x]"}); + bad = good; + ++bad.value; + rejects(bad, {"fresh.placed", "ref.back().first", "cut_value"}); + bad = good; + bad.cut.clear(); + rejects(bad, {"*fresh.min_cut", "closure_cut(net, res, k)"}); + bad = good; + std::fill(bad.edge_flows.begin(), bad.edge_flows.end(), 0); + rejects(bad, {"connected(net, res)", "final:"}); } -// ------------------------------------------------------------------ witnesses +// ------------------------------------------------------------------ hand-built examples // Repair 1 reroutes everything (m = f, r = 0): u -> w -> v replaces u -> v. TEST(FlowCoreDeletion, FullRepairOneReroute) { @@ -537,14 +961,12 @@ TEST(FlowCoreDeletion, FullRepairOneReroute) { const EdgeId uv = id_of_input(*c.g)[1]; const auto r = DeletionResolver(*req.state).resolve(uv); EXPECT_EQ(r.value, 1); - EXPECT_TRUE(cost_of(*c.g, r.edge_flows) == 12); + EXPECT_TRUE(flow_cost(*c.g, r.edge_flows) == 12); EXPECT_EQ(r.counters.dijkstra_runs, 1); } // No reroute (m = 0, r = 1): deleting the sole unit-capacity source-to-destination edge takes -// exactly two Dijkstra runs; the control that reruns repair 1's failed search takes three and -// fails the m + 2 r bound, with the same flow, potentials, phases and paths; the reuse skips -// exactly that search's work. +// exactly two Dijkstra runs. TEST(FlowCoreDeletion, SoleEdgeTakesTwoSearches) { const Case c = make_case(2, {{0, 1, 1, 1}}, {0}, {1}); check_every_edge(c, "sole edge"); @@ -553,7 +975,25 @@ TEST(FlowCoreDeletion, SoleEdgeTakesTwoSearches) { const auto r = rs.resolve(0); EXPECT_EQ(r.value, 0); EXPECT_EQ(r.counters.dijkstra_runs, 2); + // The run bound of check_deletion (m + 2 r = 2 here) holds, and each of its two clauses reports + // a result that breaks it. + EXPECT_EQ(run_bound_failure(*req.state, 1, r), ""); + DeletionResult gained = r; + gained.value = req.state->value() + 1; + EXPECT_EQ(run_bound_failure(*req.state, 1, gained), "the re-solve gained value"); + DeletionResult slower = r; + ++slower.counters.dijkstra_runs; + EXPECT_EQ(run_bound_failure(*req.state, 1, slower), "more Dijkstra runs than m + 2 r"); +} + +// The same deletion through the probe, with the control that reruns repair 1's failed search +// instead of reusing it: three searches, failing the m + 2 r = 2 bound, with the same flow, +// potentials, phases and paths; the reuse skips exactly that search's work. +TEST(FlowCoreDeletion, RerunningTheFailedSearchAddsExactlyOneSearch) { #if NETGRAPH_CORE_PRIVATE_HOOKS + const Case c = make_case(2, {{0, 1, 1, 1}}, {0}, {1}); + const auto req = capture_of(c); + DeletionResolver rs(*req.state); std::vector fails; std::vector> reuse_updates; std::vector> rerun_updates; @@ -561,7 +1001,10 @@ TEST(FlowCoreDeletion, SoleEdgeTakesTwoSearches) { const auto b = observed(rs, *req.state, 0, fails, true, &rerun_updates); for (const auto& s : fails) ADD_FAILURE() << s; EXPECT_EQ(a.counters.dijkstra_runs, 2); - EXPECT_EQ(b.counters.dijkstra_runs, 3); // > m + 2 r = 2: the control fails the bound + EXPECT_EQ(b.counters.dijkstra_runs, 3); + // m + 2 r = 2: the bound check of check_deletion accepts the reuse and rejects the control. + EXPECT_EQ(run_bound_failure(*req.state, 1, a), ""); + EXPECT_EQ(run_bound_failure(*req.state, 1, b), "more Dijkstra runs than m + 2 r"); EXPECT_EQ(a.edge_flows, b.edge_flows); EXPECT_EQ(a.potentials, b.potentials); EXPECT_EQ(a.cut, b.cut); @@ -570,10 +1013,12 @@ TEST(FlowCoreDeletion, SoleEdgeTakesTwoSearches) { // The control's extra search: the counters between repair 1's last update and repair 2's // first (no Dinic work between them). ASSERT_EQ(rerun_updates.size(), reuse_updates.size() + 1); - std::size_t i = 0; - while (rerun_updates[i].first != 2) ++i; - const auto& before = rerun_updates[i - 1].second; - const auto& after = rerun_updates[i].second; + const auto first_of_2 = + std::find_if(rerun_updates.begin(), rerun_updates.end(), [](const auto& u) { return u.first == 2; }); + ASSERT_NE(first_of_2, rerun_updates.end()); + ASSERT_NE(first_of_2, rerun_updates.begin()); + const auto& before = std::prev(first_of_2)->second; + const auto& after = first_of_2->second; EXPECT_EQ(after.dinic_phases, before.dinic_phases); EXPECT_EQ(after.augmentations, before.augmentations); EXPECT_EQ(b.counters.dijkstra_runs - (after.dijkstra_runs - before.dijkstra_runs), a.counters.dijkstra_runs); @@ -581,6 +1026,8 @@ TEST(FlowCoreDeletion, SoleEdgeTakesTwoSearches) { EXPECT_EQ(b.counters.settled - (after.settled - before.settled), a.counters.settled); EXPECT_EQ(b.counters.arc_scans - (after.arc_scans - before.arc_scans), a.counters.arc_scans); EXPECT_GT(after.settled - before.settled, 0); +#else + GTEST_SKIP() << "needs the private hooks (NETGRAPH_CORE_PRIVATE_HOOKS)"; #endif } @@ -593,10 +1040,45 @@ TEST(FlowCoreDeletion, PartialRerouteThenBothRemainderPushes) { ASSERT_EQ(req.state->edge_flows()[static_cast(uv)], 2); const auto r = DeletionResolver(*req.state).resolve(uv); EXPECT_EQ(r.value, 1); // r = 1, m = 1 - EXPECT_TRUE(cost_of(*c.g, r.edge_flows) == 6); + EXPECT_TRUE(flow_cost(*c.g, r.edge_flows) == 6); EXPECT_LE(r.counters.dijkstra_runs, 1 + 2 * 1); } +// Every cost times a factor: each re-solve of the scaled graph passes the independent checks, and +// against the unscaled one its working potentials scale while its value, flows, cut and operation +// counters do not (every search meets the same keys in the same order). +TEST(FlowCoreDeletion, ScalingEveryCostScalesOnlyThePotentials) { + const std::vector edges = {{0, 1, 2, 1}, {1, 2, 2, 1}, {1, 3, 1, 2}, {3, 2, 1, 2}, {2, 4, 2, 1}, {0, 3, 1, 5}}; + const Case base = make_case(5, edges, {0}, {4}); + const auto base_req = capture_of(base); + int compared = 0; + for (Cost factor : {3, 1000003}) { + std::vector scaled_edges = edges; + for (auto& e : scaled_edges) e.cost *= factor; + const Case scaled = make_case(5, scaled_edges, {0}, {4}); + check_every_edge(scaled, "factor " + std::to_string(factor)); + const auto req = capture_of(scaled); + for (EdgeId e : base_req.state->network().edges) { + if (base_req.state->edge_flows()[static_cast(e)] == 0) continue; + const auto want = DeletionResolver(*base_req.state).resolve(e); + const auto got = DeletionResolver(*req.state).resolve(e); + const std::string where = "factor " + std::to_string(factor) + " edge " + std::to_string(e); + EXPECT_EQ(got.value, want.value) << where; + EXPECT_EQ(got.edge_flows, want.edge_flows) << where; + EXPECT_EQ(got.cut, want.cut) << where; + ASSERT_EQ(got.potentials.size(), want.potentials.size()) << where; + for (std::size_t v = 0; v < want.potentials.size(); ++v) { + EXPECT_TRUE(wide(got.potentials[v]) == wide(want.potentials[v]) * factor) << where << " vertex " << v; + } + EXPECT_TRUE(same_counters(got.counters, want.counters)) << where; + ++compared; + } + } + // Both units take 0 -> 1 -> 2 -> 4 (cost 3; 2 -> 4 caps the value at 2): three positive-flow + // edges, for each factor. + EXPECT_EQ(compared, 6); +} + // Total loss: a bridge carrying every unit. TEST(FlowCoreDeletion, TotalLoss) { const Case c = make_case(3, {{0, 1, 3, 1}, {1, 2, 2, 1}, {1, 2, 1, 3}}, {0}, {2}); @@ -620,6 +1102,7 @@ TEST(FlowCoreDeletion, ZeroFlowDeletions) { auto view = positive.view; view[static_cast(spare)] = 0; const auto fresh = place_global_units(*positive.g, view, positive.sources, positive.destinations, nullptr, std::nullopt); + ASSERT_TRUE(base.min_cut.has_value() && fresh.min_cut.has_value()); EXPECT_NE(*base.min_cut, *fresh.min_cut); const Case zero = make_case(2, {{0, 1, 0, 1}}, {0}, {1}); check_every_edge(zero, "zero view capacity"); @@ -629,7 +1112,7 @@ TEST(FlowCoreDeletion, ZeroFlowDeletions) { } // Parallel equal-cost ties: the re-solve's flow differs from the fresh solve's, its value, cost -// and cut do not (review-resolve/focused.py). +// and cut do not. TEST(FlowCoreDeletion, TiedFlowsDifferValueCostAndCutAgree) { const Case c = make_case(3, {{0, 1, 1, 2}, {0, 1, 2, 3}, {1, 2, 1, 1}, {1, 2, 1, 1}}, {0}, {2}); check_every_edge(c, "ties"); @@ -642,7 +1125,8 @@ TEST(FlowCoreDeletion, TiedFlowsDifferValueCostAndCutAgree) { EXPECT_EQ(r.edge_flows, (std::vector {1, 0, 0, 1})); EXPECT_EQ(fresh.edge_flows, (std::vector {1, 0, 1, 0})); EXPECT_EQ(r.value, 1); - EXPECT_TRUE(cost_of(*c.g, r.edge_flows) == 3); + EXPECT_TRUE(flow_cost(*c.g, r.edge_flows) == 3); + ASSERT_TRUE(fresh.min_cut.has_value()); EXPECT_EQ(r.cut, *fresh.min_cut); EXPECT_EQ(r.cut, (std::vector {0})); } @@ -654,32 +1138,39 @@ TEST(FlowCoreDeletion, EndpointsMasksAndUnreachedVertices) { {{0, 2, 2, 1}, {1, 2, 1, 2}, {2, 4, 2, 1}, {2, 5, 2, 3}, {1, 5, 1, 7}, {0, 4, 1, 9}, {4, 5, 1, 1}, {3, 6, 4, 1}, {6, 7, 4, 1}, {5, 2, 1, 1}}, {0, 1, 3}, {4, 5}); - check_every_edge(c, "endpoints"); + // Each configuration re-solves at least one deletion (its flow is positive). + int resolved = 0; + check_every_edge(c, "endpoints", &resolved); + EXPECT_GT(resolved, 0); c.elig = c.g->eligibility(std::vector {1}, std::vector {}); - check_every_edge(c, "masked source"); + resolved = 0; + check_every_edge(c, "masked source", &resolved); + EXPECT_GT(resolved, 0); c.elig = c.g->eligibility(std::vector {4}, std::vector {id_of_input(*c.g)[3]}); - check_every_edge(c, "masked destination and edge"); + resolved = 0; + check_every_edge(c, "masked destination and edge", &resolved); + EXPECT_GT(resolved, 0); } -// The near-domain cost witness of review-resolve/REPORT.md C2: H = 2^61 and a cost sum H + 11 +// A near-domain cost witness: H = 2^61 and a cost sum H + 11 // below 2^62; the working potentials exceed int64 after the deletion, vertex 4 is never // reached (the published sentinel, a numeric working potential). TEST(FlowCoreDeletion, NearDomainCostsNeedWideWorkingPotentials) { const Cost H = Cost {1} << 61; const Case c = make_case(5, {{0, 1, 3, 3}, {0, 2, 1, 3}, {1, 2, 2, H}, {2, 3, 1, 3}, {3, 1, 1, 2}}, {0}, {3}); - ASSERT_TRUE(cost_sum(*c.g) == static_cast<__int128>(H) + 11); + ASSERT_TRUE(cost_sum(*c.g) == static_cast(H) + 11); FlowCoreResult payload; const auto req = capture_of(c, &payload); const auto& st = *req.state; EXPECT_EQ(payload.potentials[4], kNeverReached); - const std::vector<__int128> base {0, 3, static_cast<__int128>(H) + 3, static_cast<__int128>(H) + 6, - static_cast<__int128>(H) + 6, 0, static_cast<__int128>(H) + 6}; + const std::vector base {0, 3, static_cast(H) + 3, static_cast(H) + 6, + static_cast(H) + 6, 0, static_cast(H) + 6}; for (std::size_t v = 0; v < base.size(); ++v) EXPECT_TRUE(wide(st.potentials()[v]) == base[v]) << v; const EdgeId e = id_of_input(*c.g)[3]; check_deletion(c, st, e, "near domain"); const auto r = DeletionResolver(st).resolve(e); - const __int128 h = H; - const std::vector<__int128> after {3 * h + 5, 2 * h + 8, 3 * h + 8, 2 * h + 6, 4 * h + 11, 3 * h + 5, 2 * h + 6}; + const Int128 h = H; + const std::vector after {3 * h + 5, 2 * h + 8, 3 * h + 8, 2 * h + 6, 4 * h + 11, 3 * h + 5, 2 * h + 6}; for (std::size_t v = 0; v < after.size(); ++v) EXPECT_TRUE(wide(r.potentials[v]) == after[v]) << v; EXPECT_TRUE(wide(r.potentials[4]) > std::numeric_limits::max()); } @@ -691,24 +1182,30 @@ TEST(FlowCoreDeletion, OnlyMaximumStatesAreAccepted) { c.cap = 1; const auto req = capture_of(c); ASSERT_FALSE(req.state->maximum()); - const DeletionState saved = *req.state; DeletionResolver rs(*req.state); const EdgeId cheap = id_of_input(*c.g)[0]; - EXPECT_THROW((void)rs.resolve(cheap), std::invalid_argument); - EXPECT_EQ(std::vector(saved.residual().begin(), saved.residual().end()), - std::vector(req.state->residual().begin(), req.state->residual().end())); + // Rejected before any repair (the resolver works on its own copy of the state). + EXPECT_EQ(error_of([&] { (void)rs.resolve(cheap); }), + "deletion re-solve: the state is not certified maximum (a residual path from the sources to the " + "destinations remains)"); for (std::optional cap : {std::optional {}, std::optional {4}}) { c.cap = cap; const auto ok = capture_of(c); ASSERT_TRUE(ok.state->maximum()); EXPECT_EQ(DeletionResolver(*ok.state).resolve(cheap).value, 3); } - // Not an edge of the view, no flow, an empty state. + // Not an edge of the view (excluded by the Eligibility), and an empty state (an edge of the + // view that carries no flow: check_every_edge). c.cap = std::nullopt; c.elig = c.g->eligibility(std::vector {}, std::vector {cheap}); const auto masked = capture_of(c); - EXPECT_THROW((void)DeletionResolver(*masked.state).resolve(cheap), std::invalid_argument); - EXPECT_THROW(DeletionResolver {DeletionState {}}, std::invalid_argument); + DeletionResolver reused(*masked.state); + EXPECT_EQ(error_of([&] { (void)reused.resolve(cheap); }), + "deletion re-solve: edge 0 is not in the solve's view"); + const EdgeId allowed = id_of_input(*c.g)[1]; + EXPECT_TRUE(same_result(reused.resolve(allowed), DeletionResolver(*masked.state).resolve(allowed))); + EXPECT_EQ(error_of([] { DeletionResolver {DeletionState {}}; }), + "deletion re-solve: an empty state (no solve captured it)"); } // ------------------------------------------------------------------ the saved state @@ -723,10 +1220,7 @@ TEST(FlowCoreDeletion, ASavedStateServesCandidatesIndependently) { const Case c = random_case(rng); const auto req = capture_of(c); const DeletionState& st = *req.state; - std::vector positive; - for (EdgeId e : st.network().edges) { - if (st.edge_flows()[static_cast(e)] > 0) positive.push_back(e); - } + const std::vector positive = positive_edges(st); if (positive.size() < 2) continue; ++checked; const std::vector res0(st.residual().begin(), st.residual().end()); @@ -736,17 +1230,11 @@ TEST(FlowCoreDeletion, ASavedStateServesCandidatesIndependently) { (void)place_global_units(*other.g, other.view, other.sources, other.destinations, other.el(), std::nullopt); const EdgeId A = positive.front(); const EdgeId B = positive.back(); - auto same = [](const DeletionResult& x, const DeletionResult& y) { - return x.value == y.value && x.potentials == y.potentials && x.cut == y.cut && x.edge_flows == y.edge_flows && - x.counters.dijkstra_runs == y.counters.dijkstra_runs && x.counters.dijkstra_pops == y.counters.dijkstra_pops && - x.counters.settled == y.counters.settled && x.counters.arc_scans == y.counters.arc_scans && - x.counters.dinic_phases == y.counters.dinic_phases && x.counters.augmentations == y.counters.augmentations; - }; DeletionResolver one(copy); const auto a1 = one.resolve(A); const auto b1 = one.resolve(B); const auto a2 = one.resolve(A); - EXPECT_TRUE(same(a1, a2)) << trial; + EXPECT_TRUE(same_result(a1, a2)) << trial; EXPECT_EQ(b1.value, DeletionResolver(st).resolve(B).value) << trial; for (int threads : {2, 4}) { std::vector resolvers; @@ -764,7 +1252,8 @@ TEST(FlowCoreDeletion, ASavedStateServesCandidatesIndependently) { } for (auto& th : pool) th.join(); for (const auto& out : got) { - EXPECT_TRUE(same(out[0], a1) && same(out[2], a1) && same(out[1], b1)) << trial << " threads " << threads; + EXPECT_TRUE(same_result(out[0], a1) && same_result(out[2], a1) && same_result(out[1], b1)) + << trial << " threads " << threads; } } EXPECT_EQ(std::vector(st.residual().begin(), st.residual().end()), res0) << trial; @@ -775,23 +1264,128 @@ TEST(FlowCoreDeletion, ASavedStateServesCandidatesIndependently) { EXPECT_EQ(checked, 20); } -// ------------------------------------------------------------------ sensitivity - namespace { -FlowPolicyConfig global_config() { - return FlowPolicyConfig {Global {}, FlowPlacement::MaxFlow, Admission::Lossless, - PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity)}; +// Whether a re-solve of e agrees with a solve from zero on the view without e: the value, the +// cost and the minimum cut (independent of the resolver). +bool matches_fresh_solve(const Case& c, EdgeId e, const DeletionResult& r) { + auto view = c.view; + view[static_cast(e)] = 0; + const auto fresh = place_global_units(*c.g, view, c.sources, c.destinations, c.el(), std::nullopt); + return r.value == fresh.placed && flow_cost(*c.g, r.edge_flows) == flow_cost(*c.g, fresh.edge_flows) && + fresh.min_cut.has_value() && r.cut == *fresh.min_cut; } } // namespace +// A resolver reads its state's network in place, so the network must live as +// long as the resolver by the resolver's own copy of the state. Built from a copy that then goes +// away with the capture request, moved by construction, by the reallocations of a growing vector +// and by an assignment over a resolver of another state (which drops that resolver's share of the +// other network; the network goes with the last of grow's resolvers), and after a solve of another +// network on the same thread, it serves every candidate twice, in reverse and in order, exactly as +// a resolver that never moved, whose results agree with solves from zero. AddressSanitizer +// reports a read of a released network. +TEST(FlowCoreDeletion, AResolverKeepsItsNetworkAliveAcrossMoves) { + std::mt19937_64 rng(31); + int checked = 0; + for (int trial = 0; trial < 400 && checked < 20; ++trial) { + const Case c = random_case(rng); + std::vector positive; + std::vector want; + std::optional kept; + { + auto req = capture_of(c); + ASSERT_TRUE(req.state->maximum()) << "trial " << trial; // random_case sets no cap + positive = positive_edges(*req.state); + if (positive.empty()) continue; + { + DeletionResolver still(*req.state); + for (EdgeId e : positive) want.push_back(still.resolve(e)); + } + for (std::size_t i = 0; i < positive.size(); ++i) { + EXPECT_TRUE(matches_fresh_solve(c, positive[i], want[i])) << "trial " << trial << " edge " << positive[i]; + } + auto copy = std::make_unique(*req.state); + req.state = std::nullopt; + kept.emplace(*copy); + } // the copy and the request are gone: the resolver's state is the network's only owner + ++checked; + const Case other = random_case(rng); + auto other_req = capture_of(other); // rebuilds this thread's solver for another network + std::vector grow; + grow.emplace_back(std::move(*kept)); + kept = std::nullopt; + for (int i = 0; i < 6; ++i) grow.emplace_back(*other_req.state); // reallocations move grow[0] + DeletionResolver target(*other_req.state); + other_req.state = std::nullopt; + target = std::move(grow.front()); + grow.clear(); // the other network's last owners go + for (std::size_t i = positive.size(); i-- > 0;) { + EXPECT_TRUE(same_result(target.resolve(positive[i]), want[i])) << "trial " << trial << " edge " << positive[i]; + } + for (std::size_t i = 0; i < positive.size(); ++i) { + EXPECT_TRUE(same_result(target.resolve(positive[i]), want[i])) << "trial " << trial << " edge " << positive[i]; + } + } + EXPECT_EQ(checked, 20); +} + +// Resolvers of one state on 1, 2 and 4 threads read the one network the state +// copies share and never write it; each keeps its own residual, potentials and scratch. Each +// thread runs every candidate in its own rotation of the order, twice; every result equals the +// single-thread one (which agrees with a solve from zero), and the network equals its deep copy +// taken before, field by field. +// ThreadSanitizer checks the concurrent reads. +TEST(FlowCoreDeletion, WorkersReadOneNetworkAndNeverWriteIt) { + std::mt19937_64 rng(37); + int checked = 0; + for (int trial = 0; trial < 400 && checked < 15; ++trial) { + const Case c = random_case(rng); + const auto req = capture_of(c); + const DeletionState& st = *req.state; + ASSERT_TRUE(st.maximum()) << "trial " << trial; // random_case sets no cap + const auto positive = positive_edges(st); + if (positive.size() < 2) continue; + ++checked; + const Network before = st.network(); + std::vector want; + for (EdgeId e : positive) { + want.push_back(DeletionResolver(st).resolve(e)); + EXPECT_TRUE(matches_fresh_solve(c, e, want.back())) << trial << " edge " << e; + } + for (int threads : {1, 2, 4}) { + std::vector resolvers; + for (int t = 0; t < threads; ++t) resolvers.emplace_back(st); + std::vector wrong(static_cast(threads), 0); + std::vector pool; + for (int t = 0; t < threads; ++t) { + pool.emplace_back([&, t] { + auto& rs = resolvers[static_cast(t)]; + for (int round = 0; round < 2; ++round) { + for (std::size_t j = 0; j < positive.size(); ++j) { + const std::size_t i = (j + static_cast(t)) % positive.size(); + if (!same_result(rs.resolve(positive[i]), want[i])) ++wrong[static_cast(t)]; + } + } + }); + } + for (auto& th : pool) th.join(); + for (int t = 0; t < threads; ++t) EXPECT_EQ(wrong[static_cast(t)], 0) << trial << " thread " << t; + EXPECT_TRUE(same_network(st.network(), before)) << trial << " threads " << threads; + } + } + EXPECT_EQ(checked, 15); +} + +// ------------------------------------------------------------------ sensitivity + // The exact model's loss is placed minus capacity() with the edge excluded, for every // candidate (the baseline's positive-flow edges), on 1, 2 and 4 workers; zero-flow edges are -// not candidates. +// not candidates. Both maxima also equal the independent Bellman-Ford curve's. TEST(Sensitivity, GlobalLossesEqualCapacityWithTheEdgeExcluded) { std::mt19937_64 rng(41); - int entries = 0; + int entries = 0, masked = 0, several = 0, scaled = 0, rerouted = 0, partial = 0, cut = 0; for (int trial = 0; trial < 300; ++trial) { const Case c = random_case(rng); const SensitivityOptions opts {global_config(), c.el()}; @@ -799,6 +1393,8 @@ TEST(Sensitivity, GlobalLossesEqualCapacityWithTheEdgeExcluded) { OutputOptions {true, false, false}}); const auto s1 = sensitivity(*c.g, c.sources, c.destinations, opts, 1); EXPECT_EQ(s1.baseline.placed, base.placed) << trial; + const std::vector caps(c.g->capacity_units().begin(), c.g->capacity_units().end()); + EXPECT_EQ(base.placed, independent_curve(*c.g, caps, c.sources, c.destinations, c.el()).back().first) << trial; std::vector want; for (EdgeId e = 0; e < c.g->num_edges(); ++e) { if ((*base.edge_flows)[static_cast(e)] > 0) want.push_back(e); @@ -807,13 +1403,23 @@ TEST(Sensitivity, GlobalLossesEqualCapacityWithTheEdgeExcluded) { for (std::size_t i = 0; i < want.size(); ++i) { const auto& entry = s1.entries[i]; EXPECT_EQ(entry.edge, want[i]) << trial; - EXPECT_EQ(entry.status, SensitivityStatus::Evaluated) << trial; const EdgeId one[1] = {entry.edge}; Eligibility without = c.g->eligibility({}, one); if (c.elig) without = Eligibility::intersect(*c.elig, without); const auto fresh = capacity(*c.g, c.sources, c.destinations, CapacityOptions {global_config(), &without}); EXPECT_EQ(entry.loss, base.placed - fresh.placed) << trial << " edge " << entry.edge; + auto without_view = caps; + without_view[static_cast(entry.edge)] = 0; + EXPECT_EQ(fresh.placed, independent_curve(*c.g, without_view, c.sources, c.destinations, c.el()).back().first) + << trial << " edge " << entry.edge; ++entries; + const Amount flow = (*base.edge_flows)[static_cast(entry.edge)]; + masked += c.elig.has_value() && c.elig->uid() != 0 ? 1 : 0; + several += c.sources.size() > 1 || c.destinations.size() > 1 ? 1 : 0; + scaled += c.g->max_cost() > 1000000 ? 1 : 0; + rerouted += entry.loss == 0 ? 1 : 0; + partial += entry.loss > 0 && entry.loss < flow ? 1 : 0; + cut += entry.loss == flow ? 1 : 0; } for (std::int32_t workers : {2, 4}) { const auto sw = sensitivity(*c.g, c.sources, c.destinations, opts, workers); @@ -825,6 +1431,12 @@ TEST(Sensitivity, GlobalLossesEqualCapacityWithTheEdgeExcluded) { } } EXPECT_GT(entries, 300); + EXPECT_GT(masked, 20); + EXPECT_GT(several, 70); + EXPECT_GT(scaled, 10); + EXPECT_GT(rerouted, 10); + EXPECT_GT(partial, 1); + EXPECT_GT(cut, 100); } // OutputOptions controls only the published baseline: every combination captures the same state @@ -846,7 +1458,13 @@ TEST(Sensitivity, GlobalBaselineKeepsEveryOutputsCombination) { EXPECT_EQ(s.baseline.residual, cap.residual) << bits; EXPECT_EQ(s.baseline.bottleneck, cap.bottleneck) << bits; EXPECT_EQ(s.baseline.min_cut, cap.min_cut) << bits; + // Each requested output is present, each other one absent; Global's curve always, and its + // min_cut with bottleneck (the uncapped baseline is maximal). EXPECT_EQ(s.baseline.edge_flows.has_value(), outputs.edge_flows) << bits; + EXPECT_EQ(s.baseline.residual.has_value(), outputs.residual) << bits; + EXPECT_EQ(s.baseline.bottleneck.has_value(), outputs.bottleneck) << bits; + EXPECT_EQ(s.baseline.min_cut.has_value(), outputs.bottleneck) << bits; + EXPECT_TRUE(s.baseline.cost_distribution.has_value()) << bits; ASSERT_EQ(s.entries.size(), reference.entries.size()) << bits; for (std::size_t i = 0; i < s.entries.size(); ++i) { EXPECT_EQ(s.entries[i].edge, reference.entries[i].edge) << bits; diff --git a/tests/cpp/flow_core_tests.cpp b/tests/cpp/flow_core_tests.cpp index b9177c2..c35b306 100644 --- a/tests/cpp/flow_core_tests.cpp +++ b/tests/cpp/flow_core_tests.cpp @@ -1,15 +1,15 @@ -// The flow core of APPROACH 2.2 through place_global_units: a first solve on a fresh graph, the -// review witnesses (expected -// values derived by hand and from prototype 1, `prototypes/p1_amount/flowcore.py`), the -// certificate in its two branches, input validation, determinism, and randomized graphs -// certified against an exhaustive optimum (tier a) and an independent solver (tier b). +// The integer minimum-cost flow core through place_global_units: a first solve on a fresh graph, +// the witness graphs (hand-built examples; expected values derived by hand and from the reference flow core, +// tests/reference/policy/p1_amount/flowcore.py), the certificate in its two branches, input +// validation, determinism, and randomized graphs certified against an exhaustive optimum (tier a) +// and an independent solver (tier b), which is itself checked against the exhaustive optimum. #include #include #include +#include #include #include -#include #include #include @@ -23,11 +23,12 @@ namespace { constexpr Amount kL62m1 = (Amount{1} << 62) - 1; -// The counters fixed by the normative per-tier order and the augmenting-path sequence, compared -// with prototype 1: dijkstra_runs, dijkstra_pops, settled, dinic_phases, augmentations. arc_scans +// The counters fixed by the per-tier order and the augmenting-path sequence, compared +// with the reference flow core (tests/reference/policy/p1_amount/flowcore.py): dijkstra_runs, +// dijkstra_pops, settled, dinic_phases, augmentations. arc_scans // is the implementation's own diagnostic (the level search here first marks the vertices that // reach t* and stops at t*'s level, so it examines a different number of arcs than the -// prototype's full BFS); it is checked for determinism and presence only. +// reference's full BFS); it is checked for determinism and presence only. using Counters = std::vector; Counters counters_of(const FlowCoreResult& r) { const auto& c = r.counters; @@ -49,15 +50,6 @@ void expect_certified(const Graph& g, const std::vector& s, const std::v EXPECT_TRUE(fails.empty()) << (fails.empty() ? "" : fails.front()); } -std::string error_of(const std::function& fn) { - try { - fn(); - } catch (const std::invalid_argument& e) { - return e.what(); - } - return ""; -} - } // namespace TEST(FlowCore, ConstructAndSolve) { @@ -70,10 +62,11 @@ TEST(FlowCore, ConstructAndSolve) { ASSERT_EQ(r.cost_distribution.size(), 2u); EXPECT_EQ(r.cost_distribution[0], (std::pair {30, 10})); // 0 -> 1 -> 2 first EXPECT_EQ(r.cost_distribution[1], (std::pair {100, 10})); // then the direct edge + expect_certified(g, {0}, {2}, std::nullopt, r); } TEST(FlowCore, QuantisedCapacitiesFamily) { - // Physical 2.5q is 3 units and 0.5q is 1 unit (review-2 R1 at 1a: the quantised network). + // Physical 2.5q is 3 units and 0.5q is 1 unit (the quantised network). const double q = std::ldexp(1.0, -12); const Amount a = to_units(2.5 * q, q); const Amount b = to_units(0.5 * q, q); @@ -91,13 +84,13 @@ TEST(FlowCore, QuantisedCapacitiesFamily) { } TEST(FlowCore, ChainPlusBypassesCancellation) { - // review-14 flow.chain, review-1 C7: value 2 needs the middle edge cancelled. + // A chain with bypasses: value 2 needs the middle edge cancelled. auto g = make_graph(4, chain_plus_bypasses()); auto one = solve(g, {0}, {3}, 1); EXPECT_EQ(one.placed, 1); EXPECT_EQ(one.cost_distribution, curve({{3, 1}})); EXPECT_EQ(to_input(g, one.edge_flows), (std::vector{1, 1, 1, 0, 0})); - EXPECT_FALSE(one.min_cut.has_value()); // review-14 flow.finite_cap: value 1 of a maximum 2 + EXPECT_FALSE(one.min_cut.has_value()); // value 1 of a maximum 2: no cut EXPECT_EQ(counters_of(one), (Counters{1, 8, 6, 1, 1})); expect_certified(g, {0}, {3}, 1, one); auto two = solve(g, {0}, {3}, 2); @@ -165,7 +158,7 @@ TEST(FlowCore, ChainPlusBypassesMasksAndCostFactors) { } TEST(FlowCore, EqualCostTierNeedsTwoDinicPhases) { - // review-14 flow.equal_cost_multiple_hop_tiers, review-16 solver.multiple_dinic_phases: + // Equal-cost walks of several hop counts in one tier: // C = {0: 0, 1: 3, 2: 6} in one tier of cost 3 drained by two successful level builds (plus the // tier's last unsuccessful one). auto g = make_graph(4, {{0, 3, 1, 3}, {0, 1, 1, 1}, {1, 2, 1, 1}, {2, 3, 1, 1}}); @@ -179,7 +172,7 @@ TEST(FlowCore, EqualCostTierNeedsTwoDinicPhases) { } TEST(FlowCore, CyclicAdmissibleNeedsUpdateBeforeDrain) { - // review-16 solver.cyclic_admissible: two parallel cost-1 links. Potentials are updated by + // Two parallel cost-1 links. Potentials are updated by // the first Dijkstra before the drain (on the zero potentials no arc has reduced cost 0), and // the zero-reduced-cost residual cycle 0 -> 1 -> 0 left after the first push is drained in // the same tier. @@ -189,7 +182,7 @@ TEST(FlowCore, CyclicAdmissibleNeedsUpdateBeforeDrain) { EXPECT_EQ(r.cost_distribution, curve({{1, 2}})); EXPECT_EQ(counters_of(r), (Counters{1, 4, 4, 1, 2})); expect_certified(g, {0}, {1}, 2, r); - // review-17 partial_drain: cap 1 stops inside the tier; the destinations stay connected. + // A partial drain: cap 1 stops inside the tier; the destinations stay connected. auto p = solve(g, {0}, {1}, 1); EXPECT_EQ(p.placed, 1); EXPECT_EQ(p.cost_distribution, curve({{1, 1}})); @@ -199,7 +192,7 @@ TEST(FlowCore, CyclicAdmissibleNeedsUpdateBeforeDrain) { } TEST(FlowCore, FailedFinalRunRaisesPotentials) { - // review-17 failed_final_run: the side branch 3 is reached at 8 by the first run and at 10 by + // A failed final run: the side branch 3 is reached at 8 by the first run and at 10 by // the failed final run; dual feasibility is claimed inside the closure {0, 3} only. auto g = make_graph(4, {{0, 1, 1, 1}, {1, 2, 1, 1}, {1, 3, 1, 7}, {0, 3, 1, 10}}); auto r = solve(g, {0}, {2}, std::nullopt); @@ -213,8 +206,8 @@ TEST(FlowCore, FailedFinalRunRaisesPotentials) { } TEST(FlowCore, FreshSolveUsesReleasedCheapEdge) { - // review-14 flow.release_no_growth, review-15 fresh.owner_release_atomic: on the view W + own - // = [1, 1, 1] the solve at value 1 takes the cheap link (cost 2), not the old cost-4 one. + // A solve on the view [1, 1, 1] (as a re-placement sees it: the residual plus the owner's own + // allocation) at value 1 takes the cheap link (path cost 2), not the cost-3 link (path cost 4). auto g = make_graph(3, {{0, 1, 1, 1}, {0, 1, 2, 3}, {1, 2, 2, 1}}); const std::vector view = to_ids(g, std::vector{1, 1, 1}); auto r = solve(g, {0}, {2}, 1, &view); @@ -228,7 +221,7 @@ TEST(FlowCore, FreshSolveUsesReleasedCheapEdge) { } TEST(FlowCore, AttainedValueUnderCapAndInfinity) { - // review-15 C1 and review-16 certificate.internal_cap on one edge of capacity 3 and cost 7. + // The attained value under a cap, without one and at the internal cap, on one edge of capacity 3 and cost 7. auto g = make_graph(2, {{0, 1, 3, 7}}); auto c2 = solve(g, {0}, {1}, 2); EXPECT_EQ(c2.placed, 2); @@ -240,19 +233,20 @@ TEST(FlowCore, AttainedValueUnderCapAndInfinity) { ASSERT_TRUE(c3.min_cut.has_value()); EXPECT_EQ(*c3.min_cut, (std::vector{0})); EXPECT_EQ(counters_of(c3), (Counters{1, 4, 4, 1, 1})); // the cap stops before a failed run + expect_certified(g, {0}, {1}, 3, c3); auto inf = solve(g, {0}, {1}, std::nullopt); EXPECT_EQ(inf.placed, 3); EXPECT_EQ(inf.cost_distribution, curve({{7, 3}})); EXPECT_EQ(counters_of(inf), (Counters{2, 6, 6, 2, 1})); expect_certified(g, {0}, {1}, std::nullopt, inf); - // The internal cap 2^63 - 2: the solve runs to disconnection (no clamp, APPROACH 2.2 r25). + // The internal cap 2^63 - 2: the solve runs to disconnection (no clamp). auto big = solve(g, {0}, {1}, kMaxFlowCap); EXPECT_TRUE(same_payload(big, inf)); expect_certified(g, {0}, {1}, kMaxFlowCap, big); } TEST(FlowCore, ZeroCapIsThePrescribedZeroPayload) { - // review-16 certificate.zero_cap, review-17 C2.zero_cap_branch. + // A zero cap: no flow, no cut, every potential the never-reached sentinel, no work counted. auto g = make_graph(4, {{0, 1, 2, 1}, {1, 2, 2, 2}, {3, 2, 1, 1}}); auto r = solve(g, {0}, {2}, 0); EXPECT_EQ(r.placed, 0); @@ -265,7 +259,7 @@ TEST(FlowCore, ZeroCapIsThePrescribedZeroPayload) { } TEST(FlowCore, PositiveCapOnZeroView) { - // review-17 C2.positive_cap_no_out_capacity: a positive cap runs one failed Dijkstra even when + // A positive cap runs one failed Dijkstra even when // the view gives the sources no out-capacity; roots at 0, min_cut []. auto g = make_graph(3, {{0, 1, 5, 1}, {1, 2, 5, 1}}); const std::vector view = to_ids(g, std::vector{0, 5}); @@ -284,10 +278,11 @@ TEST(FlowCore, PositiveCapOnZeroView) { ASSERT_TRUE(z.min_cut.has_value()); EXPECT_TRUE(z.min_cut->empty()); EXPECT_EQ(z.potentials[0], 0); + expect_certified(g, {0}, {2}, 1, z, &zeros); } TEST(FlowCore, LegalMaximumUnits) { - // review-17 terminal_saturation: one edge at 2^62 - 1 units; the closure is seeded at the real + // Terminal saturation: one edge at 2^62 - 1 units; the closure is seeded at the real // sources, so the cut holds the real edge (virtual arcs never saturate). auto g = make_graph(2, {{0, 1, kL62m1, 1}}); auto r = solve(g, {0}, {1}, std::nullopt); @@ -310,7 +305,7 @@ TEST(FlowCore, LegalMaximumUnits) { } TEST(FlowCore, EndpointSetsAndVirtualTerminals) { - // review-17 C2.reverse_virtual and review-16 global.all_destinations: both destinations have + // Several destinations through the virtual terminal: both destinations have // inflow, so both carry pi(t*) = 2. auto g = make_graph(3, {{0, 1, 1, 1}, {0, 2, 1, 2}}); auto r = solve(g, {0}, {1, 2}, std::nullopt); @@ -330,7 +325,7 @@ TEST(FlowCore, EndpointSetsAndVirtualTerminals) { } TEST(FlowCore, ClosureAndSentinels) { - // review-14 flow.cut: the closure follows reverse arcs, so the forward-only boundary is wrong. + // The cut: the closure follows reverse arcs, so the forward-only boundary is wrong. auto g = make_graph(5, {{0, 1, 1, 1}, {0, 2, 1, 1}, {1, 3, 1, 1}, {2, 3, 1, 1}, {3, 4, 1, 1}}); auto r = solve(g, {0}, {4}, std::nullopt); EXPECT_EQ(r.placed, 1); @@ -367,7 +362,7 @@ TEST(FlowCore, ExcludedEndpointsAreSkipped) { expect_certified(g, {0, 1}, {2}, std::nullopt, rs, nullptr, &no_source); } -TEST(FlowCore, Review13MasksEqualTheirCuts) { +TEST(FlowCore, MaskedSolvesReachTheValueOfTheirCuts) { // Sources {0, 1}, destinations {3, 4} under three Eligibilities. auto g = make_graph(5, {{0, 2, 2, 2}, {1, 2, 1, 1}, {2, 3, 2, 3}, {2, 4, 1, 5}}); const auto inv = id_of_input(g); @@ -390,29 +385,90 @@ TEST(FlowCore, InputValidationBeforeAnyWork) { const Eligibility* el = nullptr) { return error_of([&] { (void)place_global_units(g, v, src, dst, el, cap); }); }; - EXPECT_NE(call({2}, s, d, 1).find("view has length 1, expected 2"), std::string::npos); - EXPECT_NE(call({2, 3}, s, d, 1).find("capacity_units"), std::string::npos); - EXPECT_NE(call({-1, 2}, s, d, 1).find("outside"), std::string::npos); - EXPECT_NE(call(view, {}, d, 1).find("sources: the node set is empty"), std::string::npos); - EXPECT_NE(call(view, s, {}, 1).find("destinations: the node set is empty"), std::string::npos); - EXPECT_NE(call(view, {3}, d, 1).find("out of node range"), std::string::npos); - EXPECT_NE(call(view, s, {-1}, 1).find("out of node range"), std::string::npos); - EXPECT_NE(call(view, {0, 2}, {2}, 1).find("both endpoint sets"), std::string::npos); - EXPECT_NE(call(view, s, d, -1).find("cap"), std::string::npos); - EXPECT_NE(call(view, s, d, kMaxFlowCap + 1).find("cap"), std::string::npos); + EXPECT_EQ(call({2}, s, d, 1), "view has length 1, expected 2 (one entry per edge)"); + EXPECT_EQ(call({2, 3}, s, d, 1), "view[1] = 3 is outside [0, capacity_units[1] = 2]"); + EXPECT_EQ(call({-1, 2}, s, d, 1), "view[0] = -1 is outside [0, capacity_units[0] = 2]"); + EXPECT_EQ(call(view, {}, d, 1), "sources: the node set is empty"); + EXPECT_EQ(call(view, s, {}, 1), "destinations: the node set is empty"); + EXPECT_EQ(call(view, {3}, d, 1), "sources: node 3 out of node range [0, 3)"); + EXPECT_EQ(call(view, s, {-1}, 1), "destinations: node -1 out of node range [0, 3)"); + EXPECT_EQ(call(view, {0, 2}, {2}, 1), "node 2 is in both endpoint sets: sources and destinations must not overlap"); + EXPECT_EQ(call(view, s, d, -1), "cap -1 is outside [0, 2^63 - 2]"); + EXPECT_EQ(call(view, s, d, kMaxFlowCap + 1), "cap 9223372036854775807 is outside [0, 2^63 - 2]"); auto foreign = h.eligibility(); - EXPECT_NE(call(view, s, d, 1, &foreign).find("foreign Eligibility"), std::string::npos); + EXPECT_EQ(call(view, s, d, 1, &foreign), "foreign Eligibility: built for another graph"); // The overlap is checked on the full sets, also when an Eligibility excludes the vertex. const std::vector x0 = {0}; auto el = g.eligibility(x0, {}); - EXPECT_NE(call(view, {0}, {0}, 1, &el).find("both endpoint sets"), std::string::npos); - // Order: the view before the endpoints, the endpoints before the cap. - EXPECT_NE(call({2}, {}, d, -1).find("view"), std::string::npos); - EXPECT_NE(call(view, {}, d, -1).find("sources"), std::string::npos); + EXPECT_EQ(call(view, {0}, {0}, 1, &el), "node 0 is in both endpoint sets: sources and destinations must not overlap"); + // Order: the view before the endpoints, the sources before the destinations, the destinations + // before the overlap, the overlap before the cap, the cap before the Eligibility. + EXPECT_EQ(call({2}, {}, d, -1), "view has length 1, expected 2 (one entry per edge)"); + EXPECT_EQ(call(view, {}, d, -1), "sources: the node set is empty"); + EXPECT_EQ(call(view, {}, {}, -1, &foreign), "sources: the node set is empty"); + EXPECT_EQ(call(view, {0, 2}, {2, 9}, -1, &foreign), "destinations: node 9 out of node range [0, 3)"); + EXPECT_EQ(call(view, {0, 2}, {2}, -1, &foreign), + "node 2 is in both endpoint sets: sources and destinations must not overlap"); + EXPECT_EQ(call(view, s, d, -1, &foreign), "cap -1 is outside [0, 2^63 - 2]"); +} + +// A view with several defects at once (one error, from the shared edge-amount check, labelled "view"): +// its length before any entry, then its first entry outside [0, capacity] in EdgeId order, before +// the endpoints, the cap and the Eligibility. +TEST(FlowCore, ViewDefectsReportTheFirstInOrder) { + const auto g = edge_amount_graph(); + const auto other = make_graph(4, {{0, 1, 2, 1}}); + const auto foreign = other.eligibility(); + for (const auto& c : edge_amount_defects()) { + EXPECT_EQ(error_of([&] { (void)place_global_units(g, c.amounts, {}, {}, &foreign, -1); }), "view" + c.error); + } + const auto bounds = edge_amounts_at_the_bounds(); + EXPECT_EQ(place_global_units(g, bounds, v({0}), v({3}), nullptr, std::nullopt).placed, 0); +} + +// A solve numbers its network in int32: the N real vertices plus s* and t*, and two arc slots per +// edge, per source and per destination. Every solver takes its vertex count from +// network_vertices(), called here with the counts alone, so no network of the boundary sizes is +// allocated. +TEST(FlowCore, NetworkSizeLimits) { + constexpr std::int32_t kMax = std::numeric_limits::max(); + constexpr std::size_t kPairs = (std::size_t {1} << 30) - 1; // twice it, 2^31 - 2, fits int32; 2^31 does not + constexpr std::size_t kHuge = std::numeric_limits::max(); + const std::string nodes = + "flow core: the number of nodes exceeds INT32_MAX - 2 (a solve adds a virtual source and sink)"; + const std::string slots = + "flow core: the edges, sources and destinations of a solve number more than 2^30 - 1 together (each takes " + "two int32 arc slots)"; + auto error = [](std::int32_t n, std::size_t e, std::size_t s, std::size_t d) { + return error_of([&] { (void)detail::network_vertices(n, e, s, d); }); + }; + // The vertices: N + 2, up to N = INT32_MAX - 2. + EXPECT_EQ(detail::network_vertices(0, 0, 0, 0), 2); + EXPECT_EQ(detail::network_vertices(kMax - 2, 0, 1, 1), kMax); + EXPECT_EQ(error(kMax - 1, 0, 1, 1), nodes); + EXPECT_EQ(error(kMax, 0, 1, 1), nodes); + // The slots: edges + sources + destinations up to 2^30 - 1, every term counted. + EXPECT_EQ(detail::network_vertices(2, kPairs - 2, 1, 1), 4); + EXPECT_EQ(error(2, kPairs - 1, 1, 1), slots); + EXPECT_EQ(error(2, kPairs - 1, 2, 0), slots); + EXPECT_EQ(error(2, kPairs - 1, 0, 2), slots); + EXPECT_EQ(detail::network_vertices(2, kPairs, 0, 0), 4); + EXPECT_EQ(detail::network_vertices(2, 0, kPairs, 0), 4); + EXPECT_EQ(detail::network_vertices(2, 0, 0, kPairs), 4); + EXPECT_EQ(error(2, kPairs + 1, 0, 0), slots); + EXPECT_EQ(error(2, 0, kPairs + 1, 0), slots); + EXPECT_EQ(error(2, 0, 0, kPairs + 1), slots); + // Counts whose plain sum would wrap around are refused, not wrapped into range. + EXPECT_EQ(error(2, kHuge, 2, 0), slots); + EXPECT_EQ(error(2, 0, kHuge, 2), slots); + EXPECT_EQ(error(2, 2, 0, kHuge), slots); + // The node limit is reported first. + EXPECT_EQ(error(kMax, kPairs + 1, 0, 0), nodes); } TEST(FlowCore, IdenticalCallsIdenticalPayloads) { std::mt19937 rng(7); + int positive = 0; for (int trial = 0; trial < 20; ++trial) { std::vector edges; for (int e = 0; e < 40; ++e) { @@ -423,7 +479,9 @@ TEST(FlowCore, IdenticalCallsIdenticalPayloads) { auto a = solve(g, {0, 1}, {10, 11}, std::nullopt); auto b = solve(g, {0, 1}, {10, 11}, std::nullopt); EXPECT_TRUE(same_payload(a, b)); + positive += a.placed > 0; } + EXPECT_GT(positive, 10); // two empty payloads would be identical too } namespace { @@ -470,6 +528,25 @@ RandomCase random_case(std::mt19937& rng, int n, int m, Amount max_cap, Cost max return {std::move(g), s, d, view, cap, xn, xe}; } +// The kinds of random trials that reach a certificate branch only by chance, counted so a +// population that stopped drawing them fails: masked (an Eligibility), stopped by a positive cap +// below the maximum (no min_cut: the destinations stay connected) and capped at zero. +struct Population { + int masked = 0; + int capped = 0; + int zero_cap = 0; + void count(const RandomCase& c, const Eligibility* elig, const FlowCoreResult& r) { + masked += elig != nullptr ? 1 : 0; + capped += c.cap.has_value() && *c.cap > 0 && !r.min_cut.has_value() ? 1 : 0; + zero_cap += c.cap == Amount {0} ? 1 : 0; + } + void expect_at_least(int m, int c, int z) const { + EXPECT_GE(masked, m); + EXPECT_GE(capped, c); + EXPECT_GE(zero_cap, z); + } +}; + } // namespace TEST(FlowCore, RandomGraphsTierAExhaustive) { @@ -477,6 +554,7 @@ TEST(FlowCore, RandomGraphsTierAExhaustive) { // every payload certified, the curve against the minimum cost of every integer value. std::mt19937 rng(20261004); int positive = 0; + Population kinds; for (int trial = 0; trial < 2000; ++trial) { const int n = 2 + static_cast(rng() % 4); const int m = 1 + static_cast(rng() % 6); @@ -488,13 +566,18 @@ TEST(FlowCore, RandomGraphsTierAExhaustive) { const auto fails = certify(c.g, c.view, c.s, c.d, c.cap, elp, r, true); ASSERT_TRUE(fails.empty()) << "trial " << trial << ": " << fails.front(); positive += r.placed > 0; + kinds.count(c, elp, r); } EXPECT_GT(positive, 250); // the population is not vacuous + // 859, 68 and 443 at this seed with libc++: random_case's std::shuffle differs between standard + // libraries, so the floors stay well below the counts. + kinds.expect_at_least(400, 30, 200); } TEST(FlowCore, RandomGraphsTierBIndependent) { std::mt19937 rng(1816); int multi_tier = 0; + Population kinds; for (int trial = 0; trial < 150; ++trial) { const int n = 6 + static_cast(rng() % 20); const int m = n + static_cast(rng() % (4 * n)); @@ -506,13 +589,51 @@ TEST(FlowCore, RandomGraphsTierBIndependent) { const auto fails = certify(c.g, c.view, c.s, c.d, c.cap, elp, r, false); ASSERT_TRUE(fails.empty()) << "trial " << trial << ": " << fails.front(); multi_tier += r.cost_distribution.size() > 1; + kinds.count(c, elp, r); } EXPECT_GT(multi_tier, 30); + kinds.expect_at_least(30, 15, 10); // 64, 35 and 23 at this seed with libc++ (as above) +} + +// The independent solver is tier b's only optimum (and the deletion and Global tests'), so it is +// itself checked against the exhaustive optimum: on small random graphs with partial views, +// endpoint sets and masks, its curve has the same maximum and equals the minimum cost of every +// integer value, many of them after two augmentations or more. +TEST(FlowCore, IndependentSolverEqualsTheExhaustiveOptimum) { + // The chain with bypasses needs a cancellation: the second unit reroutes the first. + const auto chain = make_graph(4, chain_plus_bypasses()); + const std::vector unit(5, 1); + EXPECT_EQ(independent_curve(chain, unit, {0}, {3}, nullptr), + (std::vector> {{0, 0}, {1, 3}, {2, 8}})); + EXPECT_EQ(exhaustive_costs(chain, unit, {0}, {3}, nullptr), (std::map {{0, 0}, {1, 3}, {2, 8}})); + std::mt19937 rng(2028); + int several = 0; + int masked = 0; + for (int trial = 0; trial < 1000; ++trial) { + const int n = 3 + static_cast(rng() % 3); + const int m = 4 + static_cast(rng() % 5); + const auto c = random_case(rng, n, m, 2, 5, true); + std::optional el; + if (!c.xn.empty() || !c.xe.empty()) el = c.g.eligibility(c.xn, c.xe); + const Eligibility* elp = el ? &*el : nullptr; + const auto best = exhaustive_costs(c.g, c.view, c.s, c.d, elp); + const auto ref = independent_curve(c.g, c.view, c.s, c.d, elp); + ASSERT_EQ(ref.back().first, best.rbegin()->first) << "trial " << trial; + for (const auto& [x, cost] : best) ASSERT_EQ(curve_at(ref, x), cost) << "trial " << trial << " value " << x; + several += ref.size() > 2; + masked += elp != nullptr && ref.size() > 1; + } + // Not vacuous: curves of two augmentations or more, and masked cases with a positive flow (91 + // and 116 at this seed with libc++, whose std::shuffle in random_case another standard library + // does not reproduce). + EXPECT_GT(several, 60); + EXPECT_GT(masked, 90); } TEST(FlowCore, RandomLargeUnitsTierB) { // Capacities near the domain bound (sum below 2^62) and costs near 2^40. std::mt19937_64 rng(62); + int positive = 0; for (int trial = 0; trial < 40; ++trial) { const int n = 4 + static_cast(rng() % 6); const int m = 3 + static_cast(rng() % 12); @@ -529,36 +650,245 @@ TEST(FlowCore, RandomLargeUnitsTierB) { nullptr, std::nullopt); const auto fails = certify(g, view, {0}, {n - 1}, std::nullopt, nullptr, r, false); ASSERT_TRUE(fails.empty()) << "trial " << trial << ": " << fails.front(); + positive += r.placed > 0; } + // Only a positive flow reaches the arithmetic near the bound, and many of these sparse random + // graphs place nothing, so the trials that place something are counted. + EXPECT_GT(positive, 10); } TEST(FlowCore, CertificateRejectsMutatedPayloads) { - // The test oracle itself is not vacuous: the review-17 negative controls are rejected. + // The test oracle itself is not vacuous: each negative control below is rejected by the clause + // it breaks. Every clean payload certifies first, so the rejection comes from the mutation. auto one = make_graph(2, {{0, 1, 3, 7}}); std::vector v1 = {3}; auto under = solve(one, {0}, {1}, 1); - EXPECT_FALSE(certify(one, v1, {0}, {1}, 2, nullptr, under, true).empty()); // underfill under cap 2 + ASSERT_TRUE(certify(one, v1, {0}, {1}, 1, nullptr, under, true).empty()); + // Placed 1 under cap 2 with the edge still open. + EXPECT_TRUE(has_failure(certify(one, v1, {0}, {1}, 2, nullptr, under, true), "attained value: early stop")); + // The same payload without a cap, in both branches: an uncapped solve must be maximal, so the + // two clauses that prove it fail and nothing else does (the curve up to the placed unit is + // optimal): the destination is still reachable, and 1 is not F_max = 3. + for (const bool tier_a : {true, false}) { + EXPECT_EQ(certify(one, v1, {0}, {1}, std::nullopt, nullptr, under, tier_a), + (std::vector {"uncapped solve left the destinations connected", + "placed differs from min(cap, F_max)"})) + << tier_a; + } auto slope = solve(one, {0}, {1}, 2); - slope.cost_distribution = curve({{6, 2}}); - EXPECT_FALSE(certify(one, v1, {0}, {1}, 2, nullptr, slope, true).empty()); + ASSERT_TRUE(certify(one, v1, {0}, {1}, 2, nullptr, slope, true).empty()); + slope.cost_distribution = curve({{6, 2}}); // moment 12, the flow costs 14 + EXPECT_TRUE(has_failure(certify(one, v1, {0}, {1}, 2, nullptr, slope, true), "curve moment differs")); + // The curve-shape clauses, each the only failure in both branches: an entry of volume 0 before + // the real one (an empty curve segment, which the curve comparison must skip, not divide by its + // zero length), and the one tier split in two at the same cost. Total, moment and pi(t*) hold. + auto full = solve(one, {0}, {1}, std::nullopt); + ASSERT_EQ(full.cost_distribution, curve({{7, 3}})); + for (const bool tier_a : {true, false}) { + ASSERT_TRUE(certify(one, v1, {0}, {1}, std::nullopt, nullptr, full, tier_a).empty()) << tier_a; + auto empty_entry = full; + empty_entry.cost_distribution = curve({{6, 0}, {7, 3}}); + EXPECT_EQ(certify(one, v1, {0}, {1}, std::nullopt, nullptr, empty_entry, tier_a), + std::vector {"curve volume not positive"}) + << tier_a; + auto split = full; + split.cost_distribution = curve({{7, 1}, {7, 2}}); + EXPECT_EQ(certify(one, v1, {0}, {1}, std::nullopt, nullptr, split, tier_a), + std::vector {"curve costs not strictly increasing"}) + << tier_a; + } auto cut = solve(one, {0}, {1}, std::nullopt); + ASSERT_TRUE(certify(one, v1, {0}, {1}, std::nullopt, nullptr, cut, true).empty()); cut.min_cut = std::vector{}; - EXPECT_FALSE(certify(one, v1, {0}, {1}, std::nullopt, nullptr, cut, true).empty()); + EXPECT_TRUE(has_failure(certify(one, v1, {0}, {1}, std::nullopt, nullptr, cut, true), "min_cut differs")); auto par = make_graph(2, {{0, 1, 1, 1}, {0, 1, 1, 3}}); std::vector vp = {1, 1}; auto omitted = solve(par, {0}, {1}, std::nullopt); + ASSERT_TRUE(certify(par, vp, {0}, {1}, std::nullopt, nullptr, omitted, true).empty()); omitted.cost_distribution = curve({{2, 2}}); // same moment, omitted breakpoint at 1 - EXPECT_FALSE(certify(par, vp, {0}, {1}, std::nullopt, nullptr, omitted, true).empty()); - EXPECT_FALSE(certify(par, vp, {0}, {1}, std::nullopt, nullptr, omitted, false).empty()); + EXPECT_TRUE(has_failure(certify(par, vp, {0}, {1}, std::nullopt, nullptr, omitted, true), + "curve differs from the exhaustive optimum at 1")); + EXPECT_TRUE(has_failure(certify(par, vp, {0}, {1}, std::nullopt, nullptr, omitted, false), + "curve differs from the independent solver at 1")); auto any = make_graph(3, {{0, 1, 1, 1}, {0, 2, 1, 2}}); std::vector va = {1, 1}; auto pot = solve(any, {0}, {1, 2}, std::nullopt); + ASSERT_TRUE(certify(any, va, {0}, {1, 2}, std::nullopt, nullptr, pot, true).empty()); pot.potentials[1] = 1; // an inflow destination below pi(t*) - EXPECT_FALSE(certify(any, va, {0}, {1, 2}, std::nullopt, nullptr, pot, true).empty()); + EXPECT_TRUE(has_failure(certify(any, va, {0}, {1, 2}, std::nullopt, nullptr, pot, true), + "inflow destination potential differs")); auto sentinel = solve(one, {0}, {1}, 1); + ASSERT_TRUE(certify(one, v1, {0}, {1}, 1, nullptr, sentinel, true).empty()); sentinel.potentials = {kNeverReached, kNeverReached}; - EXPECT_FALSE(certify(one, v1, {0}, {1}, 1, nullptr, sentinel, true).empty()); + EXPECT_TRUE(has_failure(certify(one, v1, {0}, {1}, 1, nullptr, sentinel, true), "closure vertex holds NEVER_REACHED")); auto zero = solve(one, {0}, {1}, 0); + ASSERT_TRUE(certify(one, v1, {0}, {1}, 0, nullptr, zero, true).empty()); zero.counters.dijkstra_runs = 1; - EXPECT_FALSE(certify(one, v1, {0}, {1}, 0, nullptr, zero, true).empty()); + EXPECT_TRUE(has_failure(certify(one, v1, {0}, {1}, 0, nullptr, zero, true), "zero cap: the prescribed zero payload")); + + // The flow clauses. Two parallel edges of capacity 1 and cost 1 carry one unit each; both + // units on EdgeId 0 keep the value, conservation and the cost but exceed its view of 1. + auto twin = make_graph(2, {{0, 1, 1, 1}, {0, 1, 1, 1}}); + std::vector vt = {1, 1}; + auto over = solve(twin, {0}, {1}, std::nullopt); + ASSERT_TRUE(certify(twin, vt, {0}, {1}, std::nullopt, nullptr, over, true).empty()); + ASSERT_EQ(over.edge_flows, (std::vector{1, 1})); + over.edge_flows = {2, 0}; + EXPECT_TRUE(has_failure(certify(twin, vt, {0}, {1}, std::nullopt, nullptr, over, true), "feasibility at edge 0")); + // EdgeId 1 masked: the solve uses EdgeId 0; moving its unit to EdgeId 1 puts flow on the + // ineligible edge at the same cost. + const std::vector second = {1}; + const auto mask = twin.eligibility({}, second); + auto masked = solve(twin, {0}, {1}, std::nullopt, nullptr, &mask); + ASSERT_TRUE(certify(twin, vt, {0}, {1}, std::nullopt, &mask, masked, true).empty()); + ASSERT_EQ(masked.edge_flows, (std::vector{1, 0})); + masked.edge_flows = {0, 1}; + EXPECT_TRUE(has_failure(certify(twin, vt, {0}, {1}, std::nullopt, &mask, masked, true), + "flow on an ineligible edge 1")); + // The chain 0 -> 1 -> 2 carries 2 units; one unit less out of node 1 leaves it unbalanced. + auto chain = make_graph(3, {{0, 1, 2, 1}, {1, 2, 2, 1}}); + std::vector vc = {2, 2}; + auto leak = solve(chain, {0}, {2}, std::nullopt); + ASSERT_TRUE(certify(chain, vc, {0}, {2}, std::nullopt, nullptr, leak, true).empty()); + const auto out_of_1 = static_cast(chain.edge_src()[0] == 1 ? 0 : 1); + leak.edge_flows[out_of_1] = 1; + EXPECT_TRUE(has_failure(certify(chain, vc, {0}, {2}, std::nullopt, nullptr, leak, true), "conservation at node 1")); + // The same chain with capacities 5, capped at 1: every vertex is in the closure with + // potentials (0, 1, 2). Raising pi(1) to 3 makes the reduced costs of 0 -> 1 (1 + 0 - 3) and of + // the backward arc 2 -> 1 (-1 + 2 - 3) negative; pi(t*) = 2 is still the last curve cost, so + // these two are the only failures. + auto wide_chain = make_graph(3, {{0, 1, 5, 1}, {1, 2, 5, 1}}); + std::vector vw = {5, 5}; + auto reduced = solve(wide_chain, {0}, {2}, 1); + ASSERT_TRUE(certify(wide_chain, vw, {0}, {2}, 1, nullptr, reduced, true).empty()); + ASSERT_EQ(reduced.potentials, (std::vector{0, 1, 2})); + reduced.potentials[1] = 3; + EXPECT_EQ(certify(wide_chain, vw, {0}, {2}, 1, nullptr, reduced, true), + (std::vector{"negative reduced cost inside the closure", + "negative reduced cost inside the closure"})); +} + +TEST(FlowCore, CertificateRejectsEveryOtherClause) { + // The clauses (and each condition of a clause with several) that the test above does not reach, + // one control each. `mutated` solves on the full capacities, certifies the clean payload, then + // returns the failed clauses of the mutated copy. A control expects the exact list where the + // mutation can break one clause alone, else the clause it targets among those it also breaks. + using Mutation = std::function; + auto mutated = [](const Graph& g, const std::vector& s, const std::vector& d, + std::optional cap, const Eligibility* el, const Mutation& mutate) { + std::vector view(g.capacity_units().begin(), g.capacity_units().end()); + auto r = place_global_units(g, view, s, d, el, cap); + EXPECT_TRUE(certify(g, view, s, d, cap, el, r, true).empty()); + mutate(r); + return certify(g, view, s, d, cap, el, r, true); + }; + using Fails = std::vector; + // One edge 0 -> 1 of capacity 3 and cost 7. Uncapped, the edge is saturated, so the closure is + // {0} and the destination's potential (7) is pinned only by the last curve cost; under cap 1 the + // destination stays in the closure. + const auto one = make_graph(2, {{0, 1, 3, 7}}); + const auto s = v({0}); + const auto d = v({1}); + // The shape: either array one entry too long. + EXPECT_EQ(mutated(one, s, d, std::nullopt, nullptr, [](auto& r) { r.edge_flows.push_back(0); }), Fails{"shape"}); + EXPECT_EQ(mutated(one, s, d, std::nullopt, nullptr, [](auto& r) { r.potentials.push_back(0); }), Fails{"shape"}); + // The zero-cap payload, each of its fields alone (the counters are the test above's control). + const std::vector nonzero = { + [](auto& r) { r.placed = 1; }, + [](auto& r) { r.cost_distribution = curve({{7, 1}}); }, + [](auto& r) { r.min_cut = std::vector{}; }, + [](auto& r) { r.edge_flows[0] = 1; }, + [](auto& r) { r.potentials[0] = 0; }, + }; + for (std::size_t k = 0; k < nonzero.size(); ++k) { + EXPECT_EQ(mutated(one, s, d, 0, nullptr, nonzero[k]), Fails{"zero cap: the prescribed zero payload"}) << k; + } + // A negative edge flow (the test above sends one above the view). + EXPECT_TRUE(has_failure(mutated(one, s, d, std::nullopt, nullptr, [](auto& r) { r.edge_flows[0] = -1; }), + "feasibility at edge 0")); + // Sources {0, 1}, destination 2, edges 0 -> 1 and 1 -> 2: source 1 sends its unit directly. + // Moving it to 0 -> 1 makes source 1 absorb a unit. + const auto two_sources = make_graph(3, {{0, 1, 1, 1}, {1, 2, 1, 1}}); + const auto ts = id_of_input(two_sources); + EXPECT_TRUE(has_failure(mutated(two_sources, v({0, 1}), v({2}), std::nullopt, nullptr, + [&](auto& r) { + r.edge_flows[static_cast(ts[0])] = 1; + r.edge_flows[static_cast(ts[1])] = 0; + }), + "source absorbs flow")); + // The same edges with destinations {1, 2} and source 0: the unit stops at destination 1; + // moving it to 1 -> 2 makes destination 1 emit a unit. + EXPECT_TRUE(has_failure(mutated(two_sources, v({0}), v({1, 2}), std::nullopt, nullptr, + [&](auto& r) { + r.edge_flows[static_cast(ts[0])] = 0; + r.edge_flows[static_cast(ts[1])] = 1; + }), + "destination emits flow")); + // Excluded endpoints: 0 -> 2 and 1 -> 2 with sources {0, 1} and source 0 excluded (and with it + // the edge 0 -> 2); a unit on 0 -> 2 is supplied by the excluded source. Mirrored, 0 -> 1 and + // 0 -> 2 with destinations {1, 2} and destination 2 excluded. + const auto into_2 = make_graph(3, {{0, 2, 1, 1}, {1, 2, 1, 1}}); + const auto x0 = into_2.eligibility(v({0}), {}); + EXPECT_TRUE(has_failure( + mutated(into_2, v({0, 1}), v({2}), std::nullopt, &x0, + [&](auto& r) { r.edge_flows[static_cast(id_of_input(into_2)[0])] = 1; }), + "excluded source supplies")); + const auto out_of_0 = make_graph(3, {{0, 1, 1, 1}, {0, 2, 1, 1}}); + const auto x2 = out_of_0.eligibility(v({2}), {}); + EXPECT_TRUE(has_failure( + mutated(out_of_0, v({0}), v({1, 2}), std::nullopt, &x2, + [&](auto& r) { r.edge_flows[static_cast(id_of_input(out_of_0)[1])] = 1; }), + "excluded destination receives")); + // The value against the supply alone and the inflow alone: on the chain 0 -> 1 -> 2 carrying + // 2, one unit less into node 1 lowers the supply only, one unit less out of it the inflow only. + const auto chain = make_graph(3, {{0, 1, 2, 1}, {1, 2, 2, 1}}); + for (const int edge : {0, 1}) { + EXPECT_TRUE(has_failure( + mutated(chain, s, v({2}), std::nullopt, nullptr, + [&](auto& r) { r.edge_flows[static_cast(id_of_input(chain)[edge])] = 1; }), + "value differs from supply or inflow")) + << edge; + } + // placed outside [0, cap]: above the cap, and negative without one. + EXPECT_TRUE(has_failure(mutated(one, s, d, 1, nullptr, [](auto& r) { r.placed = 2; }), "placed outside [0, cap]")); + EXPECT_TRUE( + has_failure(mutated(one, s, d, std::nullopt, nullptr, [](auto& r) { r.placed = -1; }), "placed outside [0, cap]")); + // The cut: present while cap 1 leaves the destination reachable, missing on the maximal solve, + // and its value (3) against a lowered placed (which also breaks the value and the curve total). + EXPECT_EQ(mutated(one, s, d, 1, nullptr, [](auto& r) { r.min_cut = std::vector{}; }), + Fails{"min_cut present on a connected solve"}); + EXPECT_EQ(mutated(one, s, d, std::nullopt, nullptr, [](auto& r) { r.min_cut.reset(); }), + Fails{"min_cut missing on a maximal solve"}); + EXPECT_TRUE( + has_failure(mutated(one, s, d, std::nullopt, nullptr, [](auto& r) { r.placed = 2; }), "cut value differs from placed")); + // The potentials: node 1's (outside the closure) below the sentinel (also below pi(t*)), the + // root's (the closure), pi(t*) against the last curve cost, and no destination potential at all + // although 3 units arrive (node 1 is reached at zero flow, so the sentinel also breaks that + // clause). + EXPECT_TRUE(has_failure(mutated(one, s, d, std::nullopt, nullptr, [](auto& r) { r.potentials[1] = kNeverReached - 1; }), + "potential below the sentinel")); + EXPECT_EQ(mutated(one, s, d, std::nullopt, nullptr, [](auto& r) { r.potentials[0] = 1; }), + Fails{"allowed root with potential other than 0"}); + EXPECT_EQ(mutated(one, s, d, std::nullopt, nullptr, [](auto& r) { r.potentials[1] = 8; }), + Fails{"pi(t*) differs from the last curve cost"}); + EXPECT_EQ(mutated(one, s, d, std::nullopt, nullptr, [](auto& r) { r.potentials[1] = kNeverReached; }), + (Fails{"NEVER_REACHED differs from reachability at zero flow at node 1", + "flow delivered but no destination reached"})); + // The sentinel against reachability at zero flow, each direction alone. On 0 -> 1 and 2 -> 1 + // nothing reaches node 2, which must hold NEVER_REACHED, not 0 or another value. On 0 -> 1 + // (capacity 3, cost 7) and 1 -> 2, uncapped, the first search settles node 2 through node 1 and + // the saturated edge then leaves both outside the closure: node 2 must not hold NEVER_REACHED. + const auto unreached = make_graph(3, {{0, 1, 1, 1}, {2, 1, 1, 1}}); + for (const Cost p : {Cost {0}, Cost {123456}}) { + EXPECT_EQ(mutated(unreached, s, d, std::nullopt, nullptr, [&](auto& r) { r.potentials[2] = p; }), + Fails{"NEVER_REACHED differs from reachability at zero flow at node 2"}) + << p; + } + const auto beyond = make_graph(3, {{0, 1, 3, 7}, {1, 2, 1, 1}}); + EXPECT_EQ(mutated(beyond, s, d, std::nullopt, nullptr, [](auto& r) { r.potentials[2] = kNeverReached; }), + Fails{"NEVER_REACHED differs from reachability at zero flow at node 2"}); + // The curve total: a free tier of 1 before the real one keeps the moment (21) and pi(t*), and + // the curve then also differs from the optimum at 1. + EXPECT_EQ(mutated(one, s, d, std::nullopt, nullptr, [](auto& r) { r.cost_distribution = curve({{0, 1}, {7, 3}}); }), + (Fails{"curve total differs from placed", "curve differs from the exhaustive optimum at 1"})); } diff --git a/tests/cpp/flow_ledger_fault_tests.cpp b/tests/cpp/flow_ledger_fault_tests.cpp index 7d8a000..0d44acb 100644 --- a/tests/cpp/flow_ledger_fault_tests.cpp +++ b/tests/cpp/flow_ledger_fault_tests.cpp @@ -1,17 +1,29 @@ -// Allocation-failure atomicity of the ledger and of a one-bundle Pinned policy's transaction -// (APPROACH 8, 9.2; review-1b D1). This executable replaces the global operator new so that one chosen -// allocation throws std::bad_alloc. Every operation is swept over every allocation it makes: -// after each injected failure the ledger (aggregate, residual, every record field, bound and -// orphaned owners, next FlowId) and the policy (epoch state, flow id, plan) must equal their -// state before the call, the per-thread scratch must not be poisoned (a clean retry gives the -// reference result), and remove() must restore the state before the policy placed anything. +// Allocation-failure atomicity of the ledger and of a one-bundle Pinned policy's transaction. +// The fault-injection executable replaces the global operator new so that one chosen allocation +// throws std::bad_alloc (fault_injection.cpp). Every operation is swept over every allocation it +// makes: after each injected failure the ledger (aggregate, residual, every record field, bound +// and orphaned owners, next FlowId) and the policy's epoch state (requested, +inf, placed demand, +// and its flow's id, route cost and plan: route, members, weights) must equal their state before +// the call, the per-thread scratch must not be poisoned (a clean retry gives the reference +// result), and remove() must leave the state the reference's own remove() leaves (the policy's +// records gone, their FlowIds retired). The ledger's own calls are swept the same way: reset() and +// reset(residual) change nothing and leak nothing when they fail; a failed construction or copy +// of a ledger, and a failed construction of a policy, build nothing and leak nothing; a move +// changes its source only once it can no longer fail; the completing copy and move hold +// everything the source held; remove() and a policy's destruction allocate nothing. One check +// belongs to the flow core: its node map never allocates when it adds a node (the last test but +// one). The first test checks the comparators of state_comparators.hpp themselves: each must +// report a copy with any one field changed; the next two check the hook of fault_injection.cpp: it +// reports a swallowed or a foreign std::bad_alloc, its nothrow form never fails, and any other +// exception of a swept call leaves it disarmed. #include -#include +#include #include #include #include #include +#include #include #include #include @@ -24,95 +36,12 @@ #include "pinned_one.hpp" #include "test_utils.hpp" -// Allocations left before the injected failure; negative: no injection (fault_injection.hpp). -// Shared by every thread; with g_workers_only the calling test thread's allocations do not count. -std::atomic g_countdown {-1}; -// Live allocations of the replacement operator new (a successful allocation counts up, the -// delete of a non-null pointer counts down): the sweeps compare it before and after an operation -// to count leaks, which ASan's leak detection (off on macOS) would not. -std::atomic g_live {0}; -static std::atomic g_workers_only {false}; -static thread_local bool t_test_thread = false; - -void* operator new(std::size_t n) { - if (g_countdown.load(std::memory_order_relaxed) >= 0 && - !(t_test_thread && g_workers_only.load(std::memory_order_relaxed)) && - g_countdown.fetch_sub(1, std::memory_order_relaxed) == 0) { - throw std::bad_alloc(); - } - if (void* p = std::malloc(n == 0 ? 1 : n)) { - g_live.fetch_add(1, std::memory_order_relaxed); - return p; - } - throw std::bad_alloc(); -} -void* operator new[](std::size_t n) { return ::operator new(n); } -void operator delete(void* p) noexcept { - if (p != nullptr) g_live.fetch_sub(1, std::memory_order_relaxed); - std::free(p); -} -void operator delete[](void* p) noexcept { ::operator delete(p); } -void operator delete(void* p, std::size_t) noexcept { ::operator delete(p); } -void operator delete[](void* p, std::size_t) noexcept { ::operator delete(p); } - using namespace netgraph::core; using namespace netgraph::core::test; using detail::LedgerAccess; namespace { -// The shared comparators of fault_injection.hpp, beside this file's own overloads. -using test::expect_same; - -bool inject(long k, bool workers_only, const std::function& fn) { - t_test_thread = true; - g_workers_only = workers_only; - g_countdown = k; - bool failed = false; - try { - fn(); - } catch (const std::bad_alloc&) { - failed = true; - } - const bool reached = g_countdown.load() < 0; - g_countdown = -1; - g_workers_only = false; - EXPECT_EQ(failed, reached) << "a std::bad_alloc was swallowed or replaced at allocation " << k - << (workers_only ? " (worker threads)" : ""); - return failed; -} - -} // namespace - -// Runs fn with the k-th allocation (from 0) failing; returns whether that failure happened. -bool fails_at(long k, const std::function& fn) { return inject(k, false, fn); } - -bool fails_in_workers_at(long k, const std::function& fn) { return inject(k, true, fn); } - -namespace { - -struct Epoch { - Counter128 requested; - bool infinite {false}; - std::optional flow_id; - bool planned {false}; - Amount placed {0}; -}; - -Epoch epoch(const PinnedOne& p) { - auto id = p.flow_id(); - if (id.has_value()) id->first = 0; - return {p.requested(), p.infinite(), id, p.plan() != nullptr, p.placed_demand()}; -} - -void expect_same(const Epoch& a, const Epoch& b, const std::string& where) { - EXPECT_EQ(a.requested, b.requested) << where; - EXPECT_EQ(a.infinite, b.infinite) << where; - EXPECT_EQ(a.flow_id, b.flow_id) << where; - EXPECT_EQ(a.planned, b.planned) << where; - EXPECT_EQ(a.placed, b.placed) << where; -} - struct Kernel { FlowPlacement placement; Admission admission; @@ -139,7 +68,6 @@ struct Case { }; std::vector cases() { - auto shared = [](Graph g) { return std::make_shared(std::move(g)); }; return { {"edge", shared(make_graph(2, {{0, 1, 9, 1}})), 0, 1}, {"chain", shared(make_graph(3, {{0, 1, 9, 1}, {1, 2, 9, 1}})), 0, 2}, @@ -150,14 +78,8 @@ std::vector cases() { }; } -std::shared_ptr all_edges(const Graph& g) { - std::vector e(static_cast(g.num_edges())); - for (std::size_t i = 0; i < e.size(); ++i) e[i] = static_cast(i); - return std::make_shared(detail::PredDAGBuilder::of_edges(g, e)); -} - -// One scenario: a third party holding 1 unit on every edge, then the policy's earlier calls, -// then the call under test. +// One scenario: a third party's 1-unit EqualBalanced placement on the same bundle, then the +// policy's earlier calls, then the call under test. struct Scenario { std::vector> before; std::optional call; @@ -176,9 +98,203 @@ const std::vector& scenarios() { } // namespace +// The sweeps' only "nothing changed" comparisons compare every field: same_record and the +// expect_same overloads of the ledger, a result and a policy's epoch report a copy with any one +// field changed, and expect_balanced reports an aggregate that leaves the sum of the records or +// [0, baseline] on one edge. +TEST(LedgerFaults, TheSweepComparatorsCompareEveryField) { + const auto g = shared(make_graph(3, {{0, 1, 9, 1}, {1, 2, 9, 1}, {0, 2, 4, 3}})); + FlowLedger fg(g); + auto p = std::make_unique(fg, v({0}), v({2}), cost_only_config()); + const PlacementResult r = p->place(5, OutputOptions {true, true, true}); + const LedgerState st = ledger_state(fg); + ASSERT_EQ(st.records.size(), 1U); + const FlowRecord& rec = st.records.front(); + ASSERT_TRUE(rec.bundle != nullptr); + ASSERT_TRUE(same_record(rec, rec)); + const auto other_bundle = all_edges(*g); + ASSERT_FALSE(*other_bundle == *rec.bundle); + const std::vector> records = { + [](auto& x) { ++x.flow_id.second; }, + [](auto& x) { ++x.owner; }, + [](auto& x) { x.sources.push_back(1); }, + [](auto& x) { x.destinations.push_back(1); }, + [](auto& x) { x.placement = FlowPlacement::MaxFlow; }, + [](auto& x) { x.admission = Admission::Lossy; }, + [&](auto& x) { x.bundle = other_bundle; }, + [](auto& x) { ++x.route_cost; }, + [](auto& x) { x.offered = Counter128 {}; }, + [](auto& x) { ++x.delivered; }, + [](auto& x) { x.edges.emplace_back(2, 1); }, + [](auto& x) { x.drops.emplace_back(2, Counter128 {}); }, + }; + ASSERT_EQ(rec.placement, FlowPlacement::EqualBalanced); + ASSERT_EQ(rec.admission, Admission::Lossless); + ASSERT_FALSE(rec.offered.has_value()); + for (std::size_t k = 0; k < records.size(); ++k) { + FlowRecord x = rec; + records[k](x); + EXPECT_FALSE(same_record(x, rec)) << "record field " << k; + } + // The uid clause of ledger_state: the records' own uid passes, another fails. + { + auto own = fg.records(); + EXPECT_EQ(failures_of([&] { expect_uid_then_clear(own, fg.uid()); }), 0); + EXPECT_EQ(own.front().flow_id.first, 0U); + auto foreign = fg.records(); + EXPECT_EQ(failures_of([&] { expect_uid_then_clear(foreign, fg.uid() + 1); }), 1); + } + EXPECT_EQ(failures_of([&] { expect_same(st, st, "same"); }), 0); + const std::vector> states = { + [](auto& x) { ++x.allocated[0]; }, + [](auto& x) { ++x.residual[0]; }, + [](auto& x) { x.records.emplace_back(); }, + [](auto& x) { ++x.records[0].delivered; }, + [](auto& x) { ++x.next_id; }, + [](auto& x) { x.bound.push_back(7); }, + [](auto& x) { x.orphans.push_back(7); }, + }; + for (std::size_t k = 0; k < states.size(); ++k) { + LedgerState x = st; + states[k](x); + EXPECT_GT(failures_of([&] { expect_same(x, st, "state"); }), 0) << "ledger field " << k; + } + ASSERT_TRUE(r.edge_flows.has_value() && r.residual.has_value() && r.bottleneck.has_value()); + EXPECT_EQ(failures_of([&] { expect_same(r, r, "same"); }), 0); + const std::vector> results = { + [](auto& x) { ++x.placed; }, + [](auto& x) { x.remaining = Amount {1}; }, + [](auto& x) { x.termination = Termination::NoRoute; }, + [](auto& x) { x.local_outcomes.emplace_back(1, Termination::NoRoute); }, + [](auto& x) { x.routes_changed = !x.routes_changed; }, + [](auto& x) { x.weights_changed = !x.weights_changed; }, + [](auto& x) { x.drops.emplace_back(2, 1); }, + [](auto& x) { x.cost_distribution = std::vector> {{9, 1}}; }, + [](auto& x) { x.edge_flows->push_back(0); }, + [](auto& x) { x.residual->push_back(0); }, + [](auto& x) { x.bottleneck->push_back(0); }, + [](auto& x) { x.min_cut = std::vector {0}; }, + }; + ASSERT_NE(r.remaining, std::optional {1}); + ASSERT_NE(r.termination, Termination::NoRoute); + ASSERT_FALSE(r.cost_distribution.has_value()); // CostOnly: Global's curve only + ASSERT_FALSE(r.min_cut.has_value()); + for (std::size_t k = 0; k < results.size(); ++k) { + PlacementResult x = r; + results[k](x); + EXPECT_GT(failures_of([&] { expect_same(x, r, "result"); }), 0) << "result field " << k; + } + // The epoch: every field of the call state and of a flow. + const Epoch ep = epoch(*p); + ASSERT_FALSE(ep.cohorts.empty() || ep.cohorts.front().empty()); + EXPECT_EQ(failures_of([&] { expect_same(ep, ep, "same"); }), 0); + const std::vector> epochs = { + [](auto& x) { ++x.requested.hi; }, + [](auto& x) { ++x.requested.lo; }, + [](auto& x) { x.infinite = !x.infinite; }, + [](auto& x) { ++x.placed; }, + [](auto& x) { x.curve.emplace_back(9, 1); }, + [](auto& x) { x.cohorts.emplace_back(); }, + [](auto& x) { ++x.cohorts[0][0].id; }, + [](auto& x) { ++x.cohorts[0][0].cost; }, + [](auto& x) { x.cohorts[0][0].route.push_back(0); }, + [](auto& x) { x.cohorts[0][0].members.push_back(0); }, + [](auto& x) { x.cohorts[0][0].weights.push_back(1); }, + }; + for (std::size_t k = 0; k < epochs.size(); ++k) { + Epoch x = ep; + epochs[k](x); + EXPECT_GT(failures_of([&] { expect_same(x, ep, "epoch"); }), 0) << "epoch field " << k; + } + // expect_balanced: one clause broken at a time on a copy of the ledger (its record an orphan: + // a ledger with a bound policy cannot be copied), through the copy's stored aggregate and + // record (the copy is not const, so writing through the borrowed views is defined). + p.reset(); + EXPECT_EQ(failures_of([&] { expect_balanced(fg, "balanced"); }), 0); + const EdgeId e = rec.edges.front().first; + const auto ei = static_cast(e); + const std::vector>> ledgers = { + {"aggregate above the sum of the records", + [&](FlowLedger& x) { ++const_cast(detail::LedgerAccess::allocated(x)[ei]); }}, + {"record and aggregate above the baseline", + [&](FlowLedger& x) { + const Amount over = detail::LedgerAccess::baseline(x)[ei] + 1 - detail::LedgerAccess::allocated(x)[ei]; + const_cast(detail::LedgerAccess::allocated(x)[ei]) += over; + auto& live = const_cast(detail::LedgerAccess::live(x, rec.flow_id.second)); + for (auto& [edge, amount] : live.edges) amount += edge == e ? over : 0; + }}, + {"record and aggregate below zero", + [&](FlowLedger& x) { + const Amount under = detail::LedgerAccess::allocated(x)[ei] + 1; + const_cast(detail::LedgerAccess::allocated(x)[ei]) -= under; + auto& live = const_cast(detail::LedgerAccess::live(x, rec.flow_id.second)); + for (auto& [edge, amount] : live.edges) amount -= edge == e ? under : 0; + }}, + }; + for (const auto& [name, breaks] : ledgers) { + FlowLedger x(fg); + breaks(x); + EXPECT_GT(failures_of([&] { expect_balanced(x, name); }), 0) << name; + } +} + +// The hook itself. fails_at's check that an injected std::bad_alloc propagated is every sweep's +// only check of it, so it must report a failure that fn swallows and a std::bad_alloc it did not +// inject; a call that allocates nothing reaches no failure. The nothrow operator new never fails +// and counts as live like the throwing one, so a call that asks for memory it can do without (the +// temporary buffer of std::stable_partition) is neither a reported failure nor a leak. +TEST(FaultHook, ReportsSwallowedAndForeignFailuresAndNeverFailsNothrow) { + EXPECT_EQ(failures_of([] { EXPECT_FALSE(fails_at(0, [] {})); }), 0); + // Reached and swallowed: the injected failure does not reach fails_at. + EXPECT_EQ(failures_of([] { + (void)fails_at(0, [] { + try { + ::operator delete(::operator new(8)); + } catch (const std::bad_alloc&) { + } + }); + }), + 1); + // A std::bad_alloc of fn's own while the armed failure is never reached. + EXPECT_EQ(failures_of([] { (void)fails_at(1000, [] { throw std::bad_alloc(); }); }), 1); + // A standard algorithm's nothrow temporary buffer: whether the library takes it through the + // nothrow form (not a failure point) or the throwing one (a failure that propagates), the hook + // reports nothing, and the completing call partitions. + std::vector> v(6); + for (int i = 0; i < 6; ++i) v[static_cast(i)].push_back(i); + const auto even = [](const std::vector& x) { return x[0] % 2 == 0; }; + EXPECT_EQ(failures_of([&] { (void)fails_at(0, [&] { (void)std::stable_partition(v.begin(), v.end(), even); }); }), + 0); + (void)std::stable_partition(v.begin(), v.end(), even); + EXPECT_EQ(v, (std::vector> {{0}, {2}, {4}, {1}, {3}, {5}})); + // The nothrow form under an armed failure: it allocates, counts and leaves the countdown alone. + const long live = g_live.load(); + void* q = nullptr; + EXPECT_EQ(failures_of([&] { EXPECT_FALSE(fails_at(0, [&] { q = ::operator new(16, std::nothrow); })); }), 0); + ASSERT_NE(q, nullptr); + EXPECT_EQ(g_live.load(), live + 1); + ::operator delete(q); + EXPECT_EQ(g_live.load(), live); +} + +// A swept call that throws something other than std::bad_alloc (a validation error, a failed +// check of the library) leaves the hook with that exception, unchanged, and disarmed: an armed +// countdown would fail an allocation of whatever runs next (the test framework's report of the +// exception, a later test), so one real failure would show up as unrelated ones. Each check reads +// the countdown and disarms it in one step, so a failure here does not spread either. +TEST(FaultHook, AnyOtherExceptionLeavesTheHookDisarmed) { + EXPECT_THROW((void)fails_at(1000, [] { throw std::invalid_argument("a validation error"); }), std::invalid_argument); + EXPECT_LT(g_countdown.exchange(-1), 0); + EXPECT_THROW((void)fails_in_workers_at(1000, [] { throw std::logic_error("a failed check"); }), std::logic_error); + EXPECT_LT(g_countdown.exchange(-1), 0); + bool bad_alloc = true; + EXPECT_THROW((void)reaches_in_workers_at(1000, [] { throw std::invalid_argument("x"); }, bad_alloc), + std::invalid_argument); + EXPECT_LT(g_countdown.exchange(-1), 0); + EXPECT_FALSE(bad_alloc); +} + TEST(LedgerFaults, EveryAllocationOfAPlacementFailsAtomically) { - int sweeps = 0; - long injected = 0; for (const auto& c : cases()) { const auto dag = all_edges(*c.g); for (const auto& k : kernels()) { @@ -186,63 +302,42 @@ TEST(LedgerFaults, EveryAllocationOfAPlacementFailsAtomically) { const std::string where = c.name + " kernel " + std::to_string(static_cast(k.placement)) + "/" + std::to_string(static_cast(k.admission)) + "/" + std::to_string(static_cast(k.priority)) + " " + sc.name; - // The reference: the same sequence without a failure. - FlowLedger ref(c.g); - PinnedOne third_ref(ref, c.src, c.dst, dag, FlowPlacement::EqualBalanced, Admission::Lossless, - EdgePriority::Residual); - (void)third_ref.place(1); - PinnedOne pref(ref, c.src, c.dst, dag, k.placement, k.admission, k.priority); - for (auto v : sc.before) (void)pref.place(v); - const LedgerState initial = ledger_state(ref); - const PlacementResult want = pref.place(sc.call); - const LedgerState after = ledger_state(ref); - const Epoch want_epoch = epoch(pref); - pref.remove(); - const LedgerState removed = ledger_state(ref); - ++sweeps; - for (long at = 0;; ++at) { - const std::string w = where + " allocation " + std::to_string(at); - FlowLedger fg(c.g); - PinnedOne third(fg, c.src, c.dst, dag, FlowPlacement::EqualBalanced, Admission::Lossless, - EdgePriority::Residual); - (void)third.place(1); - PinnedOne p(fg, c.src, c.dst, dag, k.placement, k.admission, k.priority); - for (auto v : sc.before) (void)p.place(v); - const LedgerState before = ledger_state(fg); - const Epoch before_epoch = epoch(p); - expect_same(before, initial, w + " (setup)"); - PlacementResult got; - if (!fails_at(at, [&] { got = p.place(sc.call); })) { - expect_same(got, want, w + " (no failure)"); - expect_same(ledger_state(fg), after, w + " (no failure)"); - break; - } - ++injected; - // Failed: nothing changed, ledger and policy. - expect_same(ledger_state(fg), before, w + " (failed call)"); - expect_same(epoch(p), before_epoch, w + " (failed call)"); - expect_balanced(fg, w + " (failed call)"); - // A clean retry is the reference call (no poisoned per-thread scratch). - expect_same(p.place(sc.call), want, w + " (retry)"); - expect_same(ledger_state(fg), after, w + " (retry)"); - expect_same(epoch(p), want_epoch, w + " (retry)"); - expect_balanced(fg, w + " (retry)"); - p.remove(); - expect_same(ledger_state(fg), removed, w + " (remove)"); - if (::testing::Test::HasFailure()) return; - } + // A third party's 1-unit EqualBalanced placement on the same bundle, then the policy's + // earlier calls. + const auto setup = [&](FlowLedger& fg) { + auto third = std::make_shared(fg, c.src, c.dst, dag, FlowPlacement::EqualBalanced, + Admission::Lossless, EdgePriority::Residual); + (void)third->place(1); + auto p = std::make_shared(fg, c.src, c.dst, dag, k.placement, k.admission, k.priority); + for (auto v : sc.before) (void)p->place(v); + FlowPolicy* policy = &p->policy(); + return Staged {std::make_shared, std::shared_ptr>>( + std::move(third), std::move(p)), + policy}; + }; + const long injected = + sweep_placement(c.g, where, setup, [&](FlowPolicy& p) { return p.place(sc.call); }); + if (::testing::Test::HasFailure()) return; + EXPECT_GT(injected, 2) << where; // not vacuous: every placement allocates } } } - EXPECT_EQ(sweeps, 3 * 6 * 4); - EXPECT_GT(injected, sweeps * 5); // not vacuous: every placement allocates } +// A failed policy construction binds nothing, changes nothing and leaks nothing (the thread's +// scratch is already grown by a clean construction of the same policy, so every allocation the +// failed one leaves live is a leak). TEST(LedgerFaults, PolicyConstructionFailsWithoutBinding) { for (const auto& c : cases()) { const auto dag = all_edges(*c.g); for (const auto& k : kernels()) { + { + FlowLedger warm(c.g); + (void)PinnedOne(warm, c.src, c.dst, dag, k.placement, k.admission, k.priority).place(1); + } + long injected = 0; for (long at = 0;; ++at) { + const std::string w = c.name + " construction allocation " + std::to_string(at); FlowLedger fg(c.g); { PinnedOne other(fg, c.src, c.dst, dag, FlowPlacement::EqualBalanced, Admission::Lossless, @@ -251,75 +346,303 @@ TEST(LedgerFaults, PolicyConstructionFailsWithoutBinding) { } // an orphan const LedgerState before = ledger_state(fg); std::optional p; + const long live = g_live.load(); if (!fails_at(at, [&] { p.emplace(fg, c.src, c.dst, dag, k.placement, k.admission, k.priority); })) { EXPECT_EQ(LedgerAccess::bound_owners(fg).size(), 1U); break; } - expect_same(ledger_state(fg), before, c.name + " construction allocation " + std::to_string(at)); - EXPECT_FALSE(p.has_value()); + ++injected; + expect_same(ledger_state(fg), before, w); + EXPECT_FALSE(p.has_value()) << w; + EXPECT_EQ(g_live.load(), live) << w << ": a leak"; + if (::testing::Test::HasFailure()) return; } + EXPECT_GT(injected, 0) << c.name; // not vacuous } } } -// review-1b-fixes N1: the per-thread scratch of the plan builders and of the commit grows with -// the graph. Every position fails on a graph larger than any before it in this process, so the -// failure lands while the scratch grows; the failed call changes nothing and a clean retry on the -// same graph works (ASan/UBSan builds report any access outside a partly grown array). -TEST(LedgerFaults, ScratchGrowthFailureLeavesNothingHalfGrown) { - int failures = 0; - std::int32_t n = 200; - // Construction (the route plan's per-node scratch): one edge between the last two nodes. - for (long at = 0;; ++at, n += 150) { - auto g = std::make_shared(make_graph(n, {{n - 2, n - 1, 3, 1}})); - const auto dag = all_edges(*g); - FlowLedger fg(g); - std::optional p; - const bool failed = fails_at(at, [&] { - p.emplace(fg, n - 2, n - 1, dag, FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); - }); - if (failed) { - ++failures; - EXPECT_FALSE(p.has_value()); - EXPECT_TRUE(LedgerAccess::bound_owners(fg).empty()); - p.emplace(fg, n - 2, n - 1, dag, FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); +// reset() and reset(residual) build the new baseline before they change anything: a failure at +// any of their allocations leaves the orphaned records, the aggregate, the baseline and the next +// FlowId as they were, and the completing call gives the reference reset state. +TEST(LedgerFaults, EveryAllocationOfAResetFailsAtomically) { + for (const auto& c : cases()) { + const auto dag = all_edges(*c.g); + const auto with_orphan = [&] { + auto fg = std::make_unique(c.g); + // The policy is destroyed without remove(): its record is an orphan. + (void)PinnedOne(*fg, c.src, c.dst, dag, FlowPlacement::EqualBalanced, Admission::Lossless, + EdgePriority::Residual) + .place(2); + return fg; + }; + const std::vector half = [&] { + std::vector r(c.g->capacity_units().begin(), c.g->capacity_units().end()); + for (auto& x : r) x /= 2; + return r; + }(); + for (const bool to_capacities : {true, false}) { + const std::string where = c.name + (to_capacities ? " reset()" : " reset(residual)"); + const auto reset = [&](FlowLedger& fg) { + if (to_capacities) { + fg.reset(); + } else { + fg.reset(half); + } + }; + const auto ref = with_orphan(); + const LedgerState initial = ledger_state(*ref); + ASSERT_FALSE(initial.records.empty()) << where; // something to clear + reset(*ref); + const LedgerState want = ledger_state(*ref); + EXPECT_EQ(want.residual, to_capacities ? std::vector(c.g->capacity_units().begin(), + c.g->capacity_units().end()) + : half) + << where; + long injected = 0; + for (long at = 0;; ++at) { + const std::string w = where + " allocation " + std::to_string(at); + // The attempt's ledger ends with it (the reference above grew this thread's scratch), so + // an allocation left live is a leak of the failed reset. + const long live = g_live.load(); + bool failed = false; + { + const auto fg = with_orphan(); + expect_same(ledger_state(*fg), initial, w + " (setup)"); + failed = fails_at(at, [&] { reset(*fg); }); + if (!failed) { + expect_same(ledger_state(*fg), want, w + " (no failure)"); + } else { + ++injected; + expect_same(ledger_state(*fg), initial, w + " (failed call)"); + // A clean retry is the reference reset. + reset(*fg); + expect_same(ledger_state(*fg), want, w + " (retry)"); + } + } + EXPECT_EQ(g_live.load(), live) << w << ": a leak"; + if (!failed) break; + if (::testing::Test::HasFailure()) return; + } + EXPECT_GT(injected, 0) << where; // not vacuous: the new baseline is allocated + } + } +} + +// A failed construction of a ledger, from the capacities or from a residual, builds nothing and +// leaks nothing; the completing one holds the expected baseline and nothing else. +TEST(LedgerFaults, EveryAllocationOfALedgerConstructionFailsWithoutALeak) { + for (const auto& c : cases()) { + const std::vector capacities(c.g->capacity_units().begin(), c.g->capacity_units().end()); + std::vector half = capacities; + for (auto& x : half) x /= 2; + for (const bool from_residual : {false, true}) { + const std::string where = c.name + (from_residual ? " FlowLedger(graph, residual)" : " FlowLedger(graph)"); + long injected = 0; + for (long at = 0;; ++at) { + const std::string w = where + " allocation " + std::to_string(at); + std::optional fg; + const long live = g_live.load(); + if (!fails_at(at, [&] { + if (from_residual) { + fg.emplace(c.g, half); + } else { + fg.emplace(c.g); + } + })) { + ASSERT_TRUE(fg.has_value()) << w; + EXPECT_EQ(fg->residual(), from_residual ? half : capacities) << w; + EXPECT_EQ(fg->allocated(), std::vector(capacities.size(), 0)) << w; + EXPECT_TRUE(fg->records().empty()) << w; + break; + } + ++injected; + EXPECT_FALSE(fg.has_value()) << w; + EXPECT_EQ(g_live.load(), live) << w << ": a leak"; + if (::testing::Test::HasFailure()) return; + } + EXPECT_GE(injected, 2) << where; // the baseline and the aggregate (MSVC's maps add their own) } - EXPECT_EQ(p->place(2).placed, 2) << "construction allocation " << at; - expect_balanced(fg, "construction allocation " + std::to_string(at)); - p->remove(); - EXPECT_EQ(fg.allocated()[0], 0); - if (!failed) break; } - // Placement (the commit's per-edge scratch and the kernels' buffers): a chain of n - 1 edges. - for (long at = 0;; ++at, n += 150) { - std::vector chain; - for (NodeId v = 0; v + 1 < n; ++v) chain.push_back({v, v + 1, 5, 1}); - auto g = std::make_shared(make_graph(n, chain)); - const auto dag = all_edges(*g); - FlowLedger fg(g); - PinnedOne p(fg, 0, n - 1, dag, FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); - const LedgerState before = ledger_state(fg); - PlacementResult got; - const bool failed = fails_at(at, [&] { got = p.place(2); }); - if (failed) { - ++failures; - expect_same(ledger_state(fg), before, "placement allocation " + std::to_string(at)); - got = p.place(2); +} + +// A failed copy of a ledger constructs nothing, leaks nothing and leaves the source as it was; +// the completing copy holds the source's records, aggregate, baseline, next FlowId and owners, +// and so does the moved-to ledger. A move moves every member, so it allocates nothing with libc++ +// and libstdc++ (MSVC's maps allocate a new empty node when constructed): any allocation it makes +// comes before it changes the source. +TEST(LedgerFaults, EveryAllocationOfALedgerCopyOrMoveFailsWithoutALeak) { + for (const auto& c : cases()) { + const auto dag = all_edges(*c.g); + // A baseline below the capacities: a ledger that rebuilt its baseline from them would differ. + std::vector half(c.g->capacity_units().begin(), c.g->capacity_units().end()); + for (auto& x : half) x /= 2; + FlowLedger fg(c.g, half); + { + PinnedOne first(fg, c.src, c.dst, dag, FlowPlacement::EqualBalanced, Admission::Lossless, + EdgePriority::Residual); + (void)first.place(1); + PinnedOne second(fg, c.src, c.dst, dag, FlowPlacement::Weighted, Admission::Lossy, EdgePriority::Capacity); + (void)second.place(2); + } // two orphans: a copy may only be taken of a ledger without bound policies + const LedgerState source = ledger_state(fg); + const auto baseline_of = [](const FlowLedger& x) { + const auto b = LedgerAccess::baseline(x); + return std::vector(b.begin(), b.end()); + }; + const std::vector source_baseline = baseline_of(fg); + const auto fg_uid = fg.uid(); + ASSERT_EQ(source.records.size(), 2U) << c.name; + long injected = 0; + for (long at = 0;; ++at) { + const std::string w = c.name + " allocation " + std::to_string(at); + std::optional copy; + const long live = g_live.load(); + if (!fails_at(at, [&] { copy.emplace(fg); })) { + ASSERT_TRUE(copy.has_value()) << w; + EXPECT_NE(copy->uid(), fg.uid()) << w; + expect_same(ledger_state(*copy), source, w + " (copy)"); + EXPECT_EQ(baseline_of(*copy), source_baseline) << w << " (copy)"; + break; + } + ++injected; + EXPECT_FALSE(copy.has_value()) << w; + EXPECT_EQ(g_live.load(), live) << w << ": a leak"; + expect_same(ledger_state(fg), source, w + " (source)"); + if (::testing::Test::HasFailure()) return; + } + EXPECT_GT(injected, 2) << c.name; // not vacuous: the baseline, the aggregate and the records + for (long at = 0;; ++at) { + const std::string w = c.name + " move, allocation " + std::to_string(at); + std::optional moved; + const long live = g_live.load(); + if (!fails_at(at, [&] { moved.emplace(std::move(fg)); })) { + ASSERT_TRUE(moved.has_value()) << w; + EXPECT_EQ(moved->uid(), fg_uid) << w; + EXPECT_THROW((void)fg.records(), std::logic_error) << w; // a moved-from ledger is unusable + // Everything the source held: the records, the aggregate, the baseline, the next FlowId + // and the owners (the uid is kept, so the records' FlowIds compare as they are). + expect_same(ledger_state(*moved), source, w + " (moved)"); + EXPECT_EQ(baseline_of(*moved), source_baseline) << w << " (moved)"; + moved->reset(); // the moved ledger works + break; + } + EXPECT_FALSE(moved.has_value()) << w; + EXPECT_EQ(g_live.load(), live) << w << ": a leak"; + expect_same(ledger_state(fg), source, w + " (source)"); + if (::testing::Test::HasFailure()) return; } - EXPECT_EQ(got.placed, 2) << "placement allocation " << at; - expect_balanced(fg, "placement allocation " + std::to_string(at)); - p.remove(); // ids are not reused: only the aggregate and the records return - EXPECT_EQ(fg.allocated(), before.allocated) << "placement allocation " << at; - EXPECT_TRUE(fg.records().empty()); - if (!failed) break; } - EXPECT_GT(failures, 4); // not vacuous } -// The bimodality guard (NOTES "Bimodality"): NodeMap::add runs once per edge endpoint in the -// flow core's subset solve. It must stay allocation-free (reset() sizes both arrays) so that it -// stays a few inlined instructions: the push_back version was an out-of-line call whose register -// save/restore stalled in some process layouts. +// The per-thread scratch of the plan builders and of the commit grows with the graph. Each +// attempt runs on a new thread (its scratch empty) that first serves a 3-node graph, then the +// swept call on a larger one, so every allocation of that call fails once while its scratch grows +// from the small size; the failed call changes nothing, and a clean retry on the same graph (or +// the call itself, once no failure is reached) and then a placement give the reference's ledger, +// epoch and result (ASan/UBSan builds report any access outside a partly grown array). The same +// call on a new thread whose scratch has first grown to the large graph allocates less: the sweep +// reached the growth. +TEST(LedgerFaults, ScratchGrowthFailureLeavesNothingHalfGrown) { + const auto small = chain_graph(3, 5); + const auto small_dag = all_edges(*small); + const auto large = chain_graph(3000, 5); + const auto dag = all_edges(*large); + const std::int32_t n = large->num_nodes(); + const auto policy = [&](FlowLedger& fg) { + return PinnedOne(fg, 0, n - 1, dag, FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); + }; + // The reference, without a failure: the empty ledger, the ledger and epoch right after the + // construction, and the result, ledger and epoch of place(2). + LedgerState fresh; + LedgerState built; + Epoch built_epoch; + PlacementResult want; + LedgerState want_state; + Epoch want_epoch; + { + FlowLedger fg(large); + fresh = ledger_state(fg); + PinnedOne p = policy(fg); + built = ledger_state(fg); + built_epoch = epoch(p.policy()); + want = p.place(2); + want_state = ledger_state(fg); + want_epoch = epoch(p.policy()); + } + ASSERT_EQ(want.placed, 2); // the whole chain carries the request: the comparison is not of zeros + ASSERT_EQ(want_state.records.size(), 1U); + // Construction (the route plan's per-node scratch) and a placement (the commit's per-edge + // scratch and the kernels' buffers) on the small graph. + const auto warm_up = [&] { + FlowLedger fg(small); + PinnedOne p(fg, 0, 2, small_dag, FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); + EXPECT_EQ(p.place(2).placed, 2); + }; + sweep_scratch_growth( + "construction", warm_up, + [&](long at, const std::string& w) { + FlowLedger fg(large); + std::optional p; + const bool failed = fails_at(at, [&] { p.emplace(policy(fg)); }); + if (failed) { + EXPECT_FALSE(p.has_value()) << w; + expect_same(ledger_state(fg), fresh, w); // nothing bound + p.emplace(policy(fg)); + } + expect_same(ledger_state(fg), built, w); + expect_same(epoch(p->policy()), built_epoch, w); + expect_same(p->place(2), want, w); + expect_same(ledger_state(fg), want_state, w); + expect_same(epoch(p->policy()), want_epoch, w); + expect_balanced(fg, w); + return failed; + }, + [&] { + FlowLedger fg(large); + std::optional p(policy(fg)); + (void)p->place(2); // the scratch grows to the large graph + p->remove(); + p.reset(); + long warm = 0; + while (fails_at(warm, [&] { p.emplace(policy(fg)); })) ++warm; + return warm; + }); + sweep_scratch_growth( + "placement", warm_up, + [&](long at, const std::string& w) { + FlowLedger fg(large); + PinnedOne p = policy(fg); + const LedgerState before = ledger_state(fg); + const Epoch before_epoch = epoch(p.policy()); + PlacementResult got; + const bool failed = fails_at(at, [&] { got = p.place(2); }); + if (failed) { + expect_same(ledger_state(fg), before, w); + expect_same(epoch(p.policy()), before_epoch, w); + got = p.place(2); + } + expect_same(got, want, w); + expect_same(ledger_state(fg), want_state, w); + expect_same(epoch(p.policy()), want_epoch, w); + expect_balanced(fg, w); + return failed; + }, + [&] { + FlowLedger fg(large); + PinnedOne p = policy(fg); + (void)p.place(2); // the scratch grows to the large graph + p.remove(); + long warm = 0; + while (fails_at(warm, [&] { (void)p.place(2); })) ++warm; + return warm; + }); +} + +// The flow core's NodeMap::add runs once per edge endpoint in its subset solve. reset() sizes both +// of its arrays, so add() never allocates and is noexcept: it stays a few inlined instructions (an +// out-of-line call there made the solve's timing switch between two levels from one build to the +// next). static_assert(noexcept(std::declval().add(NodeId {0}))); TEST(LedgerFaults, NodeMapAddNeverAllocates) { @@ -343,16 +666,26 @@ TEST(LedgerFaults, RemovalAndReleaseNeverAllocate) { for (const auto& k : kernels()) { FlowLedger fg(c.g); auto p = std::make_unique(fg, c.src, c.dst, dag, k.placement, k.admission, k.priority); - (void)p->place(3); - // remove() and the destructor (the records become orphans) make no allocation. - EXPECT_FALSE(fails_at(0, [&] { p->remove(); })); - (void)p->place(2); - const LedgerState held = ledger_state(fg); - EXPECT_FALSE(fails_at(0, [&] { p.reset(); })); - const LedgerState orphaned = ledger_state(fg); - EXPECT_EQ(orphaned.allocated, held.allocated); - EXPECT_TRUE(orphaned.bound.empty()); - EXPECT_EQ(orphaned.orphans.size(), 1U); + expect_removal_and_release_allocate_nothing(fg, p, c.name + " kernel " + std::to_string(static_cast(k.placement))); } } } + +TEST(LedgerFaults, PlacementSweepRejectsAPartialCommit) { + const auto g = shared(make_graph(3, {{0, 1, 10, 1}, {1, 2, 10, 1}})); + const auto setup = [](FlowLedger& fg) { + auto p = std::make_shared(fg, std::vector {0}, std::vector {2}, cost_only_config()); + return Staged {p, p.get()}; + }; + long reached = 0; + EXPECT_EQ(failures_of([&] { + reached = sweep_placement(g, "one call", setup, [](FlowPolicy& p) { return p.place(2); }); + }), 0); + EXPECT_GT(reached, 0); + EXPECT_GT(failures_of([&] { + (void)sweep_placement(g, "partial commit", setup, [](FlowPolicy& p) { + (void)p.place(1); + return p.place(1); + }); + }), 0); +} diff --git a/tests/cpp/flow_ledger_tests.cpp b/tests/cpp/flow_ledger_tests.cpp index d1d1fd2..fda6f22 100644 --- a/tests/cpp/flow_ledger_tests.cpp +++ b/tests/cpp/flow_ledger_tests.cpp @@ -1,6 +1,8 @@ -// The integer ledger FlowLedger (APPROACH 8): the baseline, snapshots, FlowIds, bottleneck, and +// The integer ledger FlowLedger: the baseline, snapshots, FlowIds, bottleneck, and // every row and transition of the lifetime table, driven through a FlowPolicy with Pinned -// routing and one bundle (test::PinnedOne, pinned_one.hpp). +// routing and one bundle (test::PinnedOne, pinned_one.hpp); the 128-bit counters of the records; +// and the commit rule itself (each edge published once, the multi-owner model, the fresh-record +// path), driven through detail::LedgerAccess directly. // // | State | place | remove() | destroyed | reset | copy | // | bound, no flows | place(0) no-op; positive | resets the epoch | none | rejected | rejected | @@ -20,6 +22,7 @@ #include #include #include +#include #include #include "netgraph/core/detail/int128.hpp" @@ -27,6 +30,7 @@ #include "netgraph/core/detail/pred_dag_builder.hpp" #include "netgraph/core/flow_ledger.hpp" #include "pinned_one.hpp" +#include "state_comparators.hpp" #include "test_utils.hpp" using namespace netgraph::core; @@ -36,31 +40,24 @@ using netgraph::core::Termination; namespace { -std::shared_ptr shared(Graph g) { - return std::make_shared(std::move(g)); -} - -std::shared_ptr bundle(const Graph& g, std::vector edges) { - return std::make_shared(detail::PredDAGBuilder::of_edges(g, edges)); -} - -std::shared_ptr all_edges(const Graph& g) { - std::vector e(static_cast(g.num_edges())); - for (std::size_t i = 0; i < e.size(); ++i) e[i] = static_cast(i); - return bundle(g, e); -} +std::vector alloc_in(const FlowLedger& fg) { return to_input(*fg.graph(), fg.allocated()); } -std::string error_of(const std::function& fn) { +// What fn raises: the message when the exception's dynamic type is exactly std::logic_error (the +// ledger's refusals of a moved-from ledger and of a malformed commit), or a description of anything +// else (std::invalid_argument and the other standard errors derived from std::logic_error are other +// checks' refusals and must not pass for these). +std::string logic_error_of(const std::function& fn) { try { fn(); - } catch (const std::invalid_argument& e) { - return e.what(); + } catch (const std::exception& e) { + if (typeid(e) == typeid(std::logic_error)) return e.what(); + return std::string("another exception type (") + typeid(e).name() + "): " + e.what(); + } catch (...) { + return "an exception not derived from std::exception"; } - return ""; + return "no exception"; } -std::vector alloc_in(const FlowLedger& fg) { return to_input(*fg.graph(), fg.allocated()); } - } // namespace TEST(FlowLedger, BaselineValidationAndSnapshots) { @@ -68,7 +65,8 @@ TEST(FlowLedger, BaselineValidationAndSnapshots) { EXPECT_THROW(FlowLedger(g, std::vector {6}), std::invalid_argument); EXPECT_THROW(FlowLedger(g, std::vector {-1}), std::invalid_argument); EXPECT_THROW(FlowLedger(g, std::vector {1, 1}), std::invalid_argument); - EXPECT_THROW(FlowLedger(nullptr), std::invalid_argument); + EXPECT_EQ(error_of([] { FlowLedger fg(nullptr); }), "FlowLedger: graph is null"); + EXPECT_EQ(error_of([] { FlowLedger fg(nullptr, std::vector {}); }), "FlowLedger: graph is null"); FlowLedger fg(g, std::vector {3}); EXPECT_EQ(fg.residual(), (std::vector{3})); EXPECT_EQ(fg.allocated(), (std::vector{0})); @@ -77,6 +75,52 @@ TEST(FlowLedger, BaselineValidationAndSnapshots) { EXPECT_EQ(fg.graph().get(), g.get()); FlowLedger other(g); EXPECT_NE(fg.uid(), other.uid()); + EXPECT_EQ(other.residual(), (std::vector{5})); // no baseline: the capacities + // Either constructor shares the ownership of the graph: it lives while the ledger does. + for (const bool with_baseline : {false, true}) { + auto owned = shared(make_graph(2, {{0, 1, 5, 1}})); + const std::weak_ptr watch = owned; + const FlowLedger held = with_baseline ? FlowLedger(owned, std::vector {3}) : FlowLedger(owned); + owned.reset(); + EXPECT_FALSE(watch.expired()) << with_baseline; + EXPECT_EQ(held.graph()->num_edges(), 1) << with_baseline; + } +} + +// A baseline with one or several defects (one error, from the shared edge-amount check): its length +// before any entry, then its first entry outside [0, capacity] in EdgeId order, at construction +// and at reset; a bound policy is reported before the baseline, and a rejected reset changes +// nothing (records, allocation, baseline, the next FlowId). +TEST(FlowLedger, BaselineDefectsReportTheFirstInOrder) { + const auto g = shared(edge_amount_graph()); + for (const auto& c : edge_amount_defects()) { + EXPECT_EQ(error_of([&] { FlowLedger fg(g, c.amounts); }), "residual" + c.error); + } + FlowLedger fg(g); + { + PinnedOne q(fg, 0, 3, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); + ASSERT_EQ(q.place(1).placed, 1); + for (const auto& c : edge_amount_defects()) { + EXPECT_EQ(error_of([&] { fg.reset(c.amounts); }), "FlowLedger.reset is rejected while a policy is bound (1 bound)"); + } + } // destroyed without remove(): the record stays as an orphan + const auto records = fg.records(); + const auto allocated = fg.allocated(); + const auto residual = fg.residual(); + ASSERT_EQ(records.size(), 1U); + for (const auto& c : edge_amount_defects()) { + EXPECT_EQ(error_of([&] { fg.reset(c.amounts); }), "residual" + c.error); + ASSERT_EQ(fg.records().size(), 1U); + EXPECT_EQ(fg.records()[0].flow_id, records[0].flow_id); + EXPECT_EQ(fg.allocated(), allocated); + EXPECT_EQ(fg.residual(), residual); + EXPECT_EQ(LedgerAccess::next_flow_id(fg), 1); + } + fg.reset(edge_amounts_at_the_bounds()); + EXPECT_TRUE(fg.records().empty()); + EXPECT_EQ(fg.residual(), edge_amounts_at_the_bounds()); + fg.reset(); // no baseline: back to the capacities + EXPECT_EQ(fg.residual(), vec(g->capacity_units())); } TEST(FlowLedger, BoundNoFlowsRow) { @@ -90,10 +134,12 @@ TEST(FlowLedger, BoundNoFlowsRow) { EXPECT_TRUE(fg.records().empty()); EXPECT_EQ(LedgerAccess::next_flow_id(fg), 0); // reset and copy rejected while a policy is bound; move rejected too. - EXPECT_NE(error_of([&] { fg.reset(); }).find("bound"), std::string::npos); - EXPECT_THROW(FlowLedger copy(fg), std::invalid_argument); - EXPECT_THROW(FlowLedger moved(std::move(fg)), std::invalid_argument); - // remove() with no flows: no allocation to revert; the epoch state resets. + EXPECT_EQ(error_of([&] { fg.reset(); }), "FlowLedger.reset is rejected while a policy is bound (1 bound)"); + EXPECT_EQ(error_of([&] { FlowLedger copy(fg); }), "FlowLedger: a ledger with bound policies cannot be copied"); + EXPECT_EQ(error_of([&] { FlowLedger moved(std::move(fg)); }), + "FlowLedger: a ledger with bound policies cannot be moved"); + // remove() with no flows and no request: nothing to revert, the epoch stays empty (the reset of a + // recorded request without flows: NoRouteCallsRecordTheEpochState). p.remove(); EXPECT_TRUE(fg.records().empty()); EXPECT_FALSE(p.infinite()); @@ -136,32 +182,50 @@ TEST(FlowLedger, BoundWithFlowsRowAndEpochs) { FlowLedger fg(g); PinnedOne p(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual); EXPECT_EQ(p.place(2).placed, 2); - // Adds: T grows within the epoch. + // Adds: the requested total grows within the epoch. auto r = p.place(std::nullopt); EXPECT_EQ(r.placed, 3); EXPECT_EQ(r.termination, Termination::Exhausted); EXPECT_FALSE(r.remaining.has_value()); EXPECT_TRUE(p.infinite()); // A finite request after +inf is rejected within the epoch, place(0) included (validation - // precedes the zero shortcut); the state is unchanged. + // precedes the zero shortcut); the requested total, the +inf flag, the flow and the + // allocations are unchanged. + const Counter128 requested = p.requested(); + const auto id = p.flow_id(); EXPECT_NE(error_of([&] { (void)p.place(1); }).find("after +inf"), std::string::npos); EXPECT_NE(error_of([&] { (void)p.place(0); }).find("after +inf"), std::string::npos); + EXPECT_EQ(p.requested(), requested); + EXPECT_TRUE(p.infinite()); + EXPECT_EQ(p.flow_id(), id); + EXPECT_EQ(alloc_in(fg), (std::vector{5})); + // A rejected reset, copy or move of a ledger holding a record leaves the ledger as it was (the + // record, the allocations and the binding): remove() below still reverts the flow. + const LedgerState held = ledger_state(fg); + EXPECT_EQ(error_of([&] { fg.reset(); }), "FlowLedger.reset is rejected while a policy is bound (1 bound)"); + EXPECT_EQ(error_of([&] { FlowLedger copy(fg); }), "FlowLedger: a ledger with bound policies cannot be copied"); + EXPECT_EQ(error_of([&] { FlowLedger moved(std::move(fg)); }), + "FlowLedger: a ledger with bound policies cannot be moved"); + expect_same(ledger_state(fg), held, "the rejected reset, copy and move"); + EXPECT_EQ(fg.records().at(0).flow_id, *p.flow_id()); EXPECT_EQ(alloc_in(fg), (std::vector{5})); - EXPECT_NE(error_of([&] { fg.reset(); }).find("bound"), std::string::npos); - EXPECT_THROW(FlowLedger copy(fg), std::invalid_argument); // remove(): allocations reverted, record gone, id retired. const auto old = *p.flow_id(); p.remove(); EXPECT_EQ(alloc_in(fg), (std::vector{0})); EXPECT_TRUE(fg.records().empty()); - EXPECT_NE(error_of([&] { (void)fg.record(old); }).find("retired or unknown"), std::string::npos); + EXPECT_EQ(error_of([&] { (void)fg.record(old); }), + "FlowId (" + std::to_string(old.first) + ", " + std::to_string(old.second) + ") is retired or unknown"); // place(+inf); remove(); place(1) is accepted: a new epoch with a new id. r = p.place(1); EXPECT_EQ(r.placed, 1); EXPECT_TRUE(r.routes_changed); EXPECT_EQ(p.flow_id()->second, 1); - p.remove(); - p.remove(); // on an empty cohort: resets the epoch + p.remove(); // the epoch resets: the request of 1 is gone + EXPECT_TRUE(p.requested().is_zero()); + EXPECT_FALSE(p.infinite()); + p.remove(); // again, on an empty cohort: nothing changes + EXPECT_TRUE(fg.records().empty()); EXPECT_TRUE(p.requested().is_zero()); EXPECT_FALSE(p.infinite()); EXPECT_EQ(LedgerAccess::next_flow_id(fg), 2); @@ -205,16 +269,93 @@ TEST(FlowLedger, OrphanedRecordsResetAndCopy) { EXPECT_EQ(c.records()[0].delivered, 2); } -TEST(FlowLedger, MovedFromLedgerIsUnusable) { - auto g = shared(make_graph(2, {{0, 1, 5, 1}})); - FlowLedger fg(g); +// The copy and the move constructors carry everything the source holds: the baseline, the +// aggregate, every record field, the next FlowId and the owners (the orphaned ones and the id the +// next policy gets). The source holds values a new or reset ledger would not have, so a member +// left behind shows: a baseline below the capacities, a retired FlowId (the next FlowId is past +// every live one), a Lossy record with an offer and drops beside a Lossless one, and an orphaned +// owner without records. The copy has a new uid and leaves its source as it was; the move keeps +// the uid and leaves its source unusable. +TEST(FlowLedger, CopyAndMoveCarryTheWholeLedger) { + auto g = shared(make_graph(4, {{0, 1, 10, 1}, {0, 2, 10, 1}, {1, 3, 1, 1}, {2, 3, 10, 1}})); + const auto inv = id_of_input(*g); + std::vector baseline(4); + const Amount by_input[4] = {9, 8, 1, 9}; + for (std::size_t i = 0; i < 4; ++i) baseline[static_cast(inv[i])] = by_input[i]; + FlowLedger fg(g, baseline); + { + PinnedOne lossy(fg, 0, 3, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossy, EdgePriority::Residual); + (void)lossy.place(10); // owner 0, FlowId 0: delivers 6, drops 4 on the 1 -> 3 edge + PinnedOne removed(fg, 0, 3, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + (void)removed.place(1); // owner 1, FlowId 1, retired by remove() + removed.remove(); + PinnedOne lossless(fg, 0, 3, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + (void)lossless.place(2); // owner 2, FlowId 2 + } // three orphaned owners, two records: a copy or a move needs a ledger without bound policies + const LedgerState source = ledger_state(fg); + const std::vector source_baseline = vec(LedgerAccess::baseline(fg)); + // The source is what the comment above says, so that each part below can differ. + ASSERT_EQ(source.records.size(), 2u); + EXPECT_TRUE(source.records[0].offered.has_value()); + EXPECT_EQ(source.records[0].delivered, 6); + EXPECT_EQ(source.records[0].drops, (std::vector>{{inv[2], Counter128 {0, 4}}})); + EXPECT_FALSE(source.records[1].offered.has_value()); + EXPECT_EQ(source.records[1].flow_id.second, 2); + EXPECT_EQ(source.next_id, 3); + EXPECT_TRUE(source.bound.empty()); + EXPECT_EQ(source.orphans, (std::vector{0, 1, 2})); + EXPECT_NE(source_baseline, vec(g->capacity_units())); + EXPECT_NE(source.allocated, std::vector(4, 0)); + + // Everything the source held, then the next policy: it gets the next owner id (3) and the next + // FlowId. + auto expect_whole = [&](FlowLedger& x, const std::string& what) { + expect_same(ledger_state(x), source, what); + EXPECT_EQ(vec(LedgerAccess::baseline(x)), source_baseline) << what; + PinnedOne next(x, 0, 3, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + EXPECT_EQ(next.place(1).placed, 1) << what; + ASSERT_TRUE(next.flow_id().has_value()) << what; + EXPECT_EQ(next.flow_id()->second, source.next_id) << what; + EXPECT_EQ(x.record(*next.flow_id()).owner, 3) << what; + EXPECT_EQ(LedgerAccess::bound_owners(x), (std::vector{3})) << what; + next.remove(); + }; + + FlowLedger copy(fg); + EXPECT_NE(copy.uid(), fg.uid()); + expect_same(ledger_state(fg), source, "the source after the copy"); + expect_whole(copy, "the copy"); + expect_same(ledger_state(fg), source, "the source after a placement on the copy"); + + const auto uid = fg.uid(); FlowLedger moved(std::move(fg)); - EXPECT_THROW((void)fg.uid(), std::logic_error); - EXPECT_THROW((void)fg.records(), std::logic_error); - EXPECT_THROW((void)fg.residual(), std::logic_error); - EXPECT_THROW(fg.reset(), std::logic_error); - EXPECT_THROW((void)LedgerAccess::bind(fg), std::logic_error); - EXPECT_EQ(moved.residual(), (std::vector{5})); + EXPECT_EQ(moved.uid(), uid); + expect_whole(moved, "the move"); + // Every call on the moved-from ledger raises the moved-from refusal (the copy and the move of + // it included), never a result or another check's error. + // NOLINTBEGIN(bugprone-use-after-move): the documented moved-from state. + const FlowId some {uid, 0}; + const std::vector sources {0}; + const std::vector baseline_again(4, 1); + const std::vector>> calls = { + {"uid", [&] { (void)fg.uid(); }}, + {"graph", [&] { (void)fg.graph(); }}, + {"capacity_units", [&] { (void)fg.capacity_units(); }}, + {"residual", [&] { (void)fg.residual(); }}, + {"allocated", [&] { (void)fg.allocated(); }}, + {"records", [&] { (void)fg.records(); }}, + {"record", [&] { (void)fg.record(some); }}, + {"bottleneck", [&] { (void)fg.bottleneck(sources); }}, + {"reset", [&] { fg.reset(); }}, + {"reset(residual)", [&] { fg.reset(baseline_again); }}, + {"copy", [&] { FlowLedger again(fg); }}, + {"move", [&] { FlowLedger again(std::move(fg)); }}, + {"bind", [&] { (void)LedgerAccess::bind(fg); }}, + }; + for (const auto& [name, call] : calls) { + EXPECT_EQ(logic_error_of(call), "FlowLedger: a moved-from ledger is empty and unusable") << name; + } + // NOLINTEND(bugprone-use-after-move) } TEST(FlowLedger, FlowIdsOfOtherLedgersAndUnknownIds) { @@ -224,12 +365,24 @@ TEST(FlowLedger, FlowIdsOfOtherLedgersAndUnknownIds) { (void)p.place(1); const auto id = *p.flow_id(); EXPECT_EQ(a.record(id).delivered, 1); - EXPECT_NE(error_of([&] { (void)b.record(id); }).find("another ledger"), std::string::npos); - EXPECT_NE(error_of([&] { (void)a.record(FlowId {a.uid(), 7}); }).find("retired or unknown"), std::string::npos); + EXPECT_EQ(error_of([&] { (void)b.record(id); }), "FlowId (" + std::to_string(a.uid()) + ", " + std::to_string(id.second) + + ") belongs to another ledger (uid " + std::to_string(b.uid()) + ")"); + EXPECT_EQ(error_of([&] { (void)a.record(FlowId {a.uid(), 7}); }), + "FlowId (" + std::to_string(a.uid()) + ", 7) is retired or unknown"); + // A policy's record() reads its own records only: another policy's record of the same ledger is + // refused, and the ledger's own checks come first. + PinnedOne q(a, 0, 1, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + EXPECT_EQ(p.policy().record(id).delivered, 1); + EXPECT_EQ(error_of([&] { (void)q.policy().record(id); }), + "FlowId (" + std::to_string(a.uid()) + ", " + std::to_string(id.second) + ") is not a record of this policy"); + EXPECT_EQ(error_of([&] { (void)q.policy().record(FlowId {b.uid(), id.second}); }), + "FlowId (" + std::to_string(b.uid()) + ", " + std::to_string(id.second) + ") belongs to another ledger (uid " + + std::to_string(a.uid()) + ")"); } TEST(FlowLedger, RecordsCarryTheContractFields) { - // Lossy EqualBalanced on the diamond 10/10/1/10 (prototype 1 lossy_diamond): 10 then 1. + // Lossy EqualBalanced on the diamond 10/10/1/10: 10 then 1 (the reference fixture + // lossy_diamond_10_then_1, tests/reference/policy/p2_transaction/fixtures.py). auto g = shared(make_graph(4, {{0, 1, 10, 1}, {0, 2, 10, 1}, {1, 3, 1, 1}, {2, 3, 10, 1}})); const auto inv = id_of_input(*g); FlowLedger fg(g); @@ -242,6 +395,8 @@ TEST(FlowLedger, RecordsCarryTheContractFields) { EXPECT_EQ(r.placed, 0); EXPECT_EQ(r.termination, Termination::Complete); EXPECT_EQ(*r.remaining, 1); + // The call's own drops; the record keeps the cumulative 4 + 1 below. + EXPECT_EQ(r.drops, (std::vector>{{inv[2], 1}})); EXPECT_EQ(alloc_in(fg), (std::vector{6, 5, 1, 5})); const auto rec = fg.records().at(0); EXPECT_EQ(rec.flow_id, (FlowId {fg.uid(), 0})); @@ -255,18 +410,18 @@ TEST(FlowLedger, RecordsCarryTheContractFields) { EXPECT_TRUE(*rec.bundle == *all_edges(*g)); EXPECT_EQ(rec.route_cost, 2); ASSERT_TRUE(rec.offered.has_value()); - EXPECT_EQ(rec.offered->lo, 11u); + EXPECT_EQ(*rec.offered, (Counter128 {0, 11})); EXPECT_EQ(rec.delivered, 6); ASSERT_EQ(rec.drops.size(), 1u); EXPECT_EQ(rec.drops[0].first, inv[2]); - EXPECT_EQ(rec.drops[0].second.lo, 5u); + EXPECT_EQ(rec.drops[0].second, (Counter128 {0, 5})); // +inf on a fresh ledger: offered is the injection (20), delivered 11, drops 9. FlowLedger f2(g); PinnedOne q(f2, 0, 3, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossy, EdgePriority::Residual); r = q.place(std::nullopt); EXPECT_EQ(r.placed, 11); EXPECT_EQ(r.termination, Termination::Exhausted); - EXPECT_EQ(f2.records()[0].offered->lo, 20u); + EXPECT_EQ(*f2.records()[0].offered, (Counter128 {0, 20})); // Lossless records carry no offered. FlowLedger f3(g); PinnedOne m(f3, 0, 3, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); @@ -276,8 +431,8 @@ TEST(FlowLedger, RecordsCarryTheContractFields) { } TEST(FlowLedger, CountersBeyondSixtyFourBits) { - // Prototype 1 lossy_oversized_and_saturated_offers: every unit of a 2^62 - 1 offer dropped on - // a saturated link, many times: offered and drops grow beyond 2^64. + // Oversized offers on a link of capacity 1: of nine 2^62 - 1 offers one unit is delivered and + // every other unit dropped: offered and drops grow beyond 2^64. auto g = shared(make_graph(2, {{0, 1, 1, 1}})); FlowLedger fg(g); PinnedOne p(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossy, EdgePriority::Residual); @@ -285,12 +440,10 @@ TEST(FlowLedger, CountersBeyondSixtyFourBits) { for (int i = 0; i < 9; ++i) (void)p.place(big); const auto rec = fg.records().at(0); // offered = 9 (2^62 - 1) = 2^65 + 2^62 - 9 - EXPECT_EQ(rec.offered->hi, 2u); - EXPECT_EQ(rec.offered->lo, (std::uint64_t {1} << 62) - 9); + EXPECT_EQ(*rec.offered, (Counter128 {2, (std::uint64_t {1} << 62) - 9})); EXPECT_EQ(rec.delivered, 1); - EXPECT_EQ(rec.drops[0].second.hi, 2u); - EXPECT_EQ(rec.drops[0].second.lo, (std::uint64_t {1} << 62) - 10); - EXPECT_EQ(p.requested().hi, 2u); + EXPECT_EQ(rec.drops[0].second, (Counter128 {2, (std::uint64_t {1} << 62) - 10})); + EXPECT_EQ(p.requested(), (Counter128 {2, (std::uint64_t {1} << 62) - 9})); // the offers' total } // The public counter is self-contained (flow_record.hpp): its own hi and lo, no base class from @@ -333,36 +486,45 @@ TEST(FlowLedger, RequestDomainAndFailedCallsLeaveState) { FlowLedger fg(g); PinnedOne p(fg, 0, 1, all_edges(*g), FlowPlacement::Weighted, Admission::Lossless, EdgePriority::Capacity); (void)p.place(2); - const auto before = fg.records(); - EXPECT_THROW((void)p.place(kDomainLimit), std::invalid_argument); - EXPECT_THROW((void)p.place(-1), std::invalid_argument); - EXPECT_EQ(fg.records().size(), before.size()); + const LedgerState before = ledger_state(fg); + const auto id = p.flow_id(); + EXPECT_EQ(error_of([&] { (void)p.place(kDomainLimit); }), + "request 4611686018427387904 is outside [0, 2^62) units (requests are integer units or +inf)"); + EXPECT_EQ(error_of([&] { (void)p.place(-1); }), + "request -1 is outside [0, 2^62) units (requests are integer units or +inf)"); + // The failed calls changed nothing: the requested total, the +inf flag, the flow and the + // ledger (every record field, the aggregate, the residual, the next FlowId and the owners). + EXPECT_EQ(p.requested(), (Counter128 {0, 2})); + EXPECT_FALSE(p.infinite()); + EXPECT_EQ(p.flow_id(), id); + expect_same(ledger_state(fg), before, "after the rejected requests"); EXPECT_EQ(fg.allocated(), (std::vector{2})); + EXPECT_EQ(fg.residual(), (std::vector{3})); // Constructor validation. auto other = make_graph(2, {{0, 1, 5, 1}}); - EXPECT_NE(error_of([&] { - PinnedOne x(fg, 0, 1, bundle(other, {0}), FlowPlacement::MaxFlow, Admission::Lossless, + EXPECT_EQ(error_of([&] { + PinnedOne x(fg, 0, 1, dag_of(other, {0}), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); - }).find("foreign PredDAG"), - std::string::npos); - EXPECT_NE(error_of([&] { + }), + "foreign PredDAG: bundle built for another graph"); + EXPECT_EQ(error_of([&] { PinnedOne x(fg, 0, 0, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); - }).find("both endpoint sets"), - std::string::npos); - EXPECT_NE(error_of([&] { + }), + "node 0 is in both endpoint sets: sources and destinations must not overlap"); + EXPECT_EQ(error_of([&] { PinnedOne x(fg, 0, 1, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossy, EdgePriority::Residual); - }).find("Lossy"), - std::string::npos); - EXPECT_NE(error_of([&] { + }), + kLossyKernelError); + EXPECT_EQ(error_of([&] { PinnedOne x(fg, 0, 1, all_edges(*g), FlowPlacement::Weighted, Admission::Lossless, EdgePriority::Headroom); - }).find("Headroom"), - std::string::npos); + }), + kHeadroomKernelError); // A rejected construction binds nothing. EXPECT_EQ(LedgerAccess::bound_owners(fg), (std::vector{0})); } TEST(FlowLedger, BottleneckVersusMinCut) { - // Prototype 1 ledger_bottleneck_vs_min_cut: (0->1 cap 1 cost 1), (0->2 cap 2 cost 3), + // The ledger's bottleneck against the minimum cut: (0->1 cap 1 cost 1), (0->2 cap 2 cost 3), // (1->2 cap 1), (2->3 cap 2). After the MaxFlow +inf [1, 1, 1, 2] the bottleneck (forward // residual) is {0->1, 2->3}, not the cut {2->3}. auto g = shared(make_graph(4, {{0, 1, 1, 1}, {0, 2, 2, 3}, {1, 2, 1, 1}, {2, 3, 2, 1}})); @@ -374,31 +536,43 @@ TEST(FlowLedger, BottleneckVersusMinCut) { std::vector want = {inv[0], inv[3]}; std::sort(want.begin(), want.end()); EXPECT_EQ(fg.bottleneck(std::vector {0}), want); - // A zero-capacity edge at the frontier is not listed; an excluded edge is not crossed. - auto g2 = shared(make_graph(3, {{0, 1, 0, 1}, {0, 2, 1, 1}, {2, 1, 1, 1}})); - FlowLedger f2(g2); - EXPECT_TRUE(f2.bottleneck(std::vector {0}).empty()); + // The masks, on a graph whose unmasked bottleneck is not empty: (0->1 cap 1), (1->2 cap 1), + // (0->3 cap 1), (0->4 cap 0). Owner a fills 1->2 and owner b 0->3, so only 0->1 keeps a + // residual. Unmasked, the sources reach {0, 1}; the edges leaving that set are 1->2 and 0->3 + // (saturated, listed) and 0->4 (zero capacity, not listed). + auto g2 = shared(make_graph(5, {{0, 1, 1, 1}, {1, 2, 1, 1}, {0, 3, 1, 1}, {0, 4, 0, 1}})); const auto inv2 = id_of_input(*g2); - auto el = g2->eligibility({}, std::vector {inv2[2]}); - EXPECT_TRUE(f2.bottleneck(std::vector {0}, &el).empty()); // 2->1 excluded: not listed + FlowLedger f2(g2); + PinnedOne a(f2, 1, 2, dag_of(*g2, {inv2[1]}), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + PinnedOne b(f2, 0, 3, dag_of(*g2, {inv2[2]}), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + ASSERT_EQ(a.place(1).placed, 1); + ASSERT_EQ(b.place(1).placed, 1); + std::vector want2 = {inv2[1], inv2[2]}; + std::sort(want2.begin(), want2.end()); + EXPECT_EQ(f2.bottleneck(std::vector {0}), want2); + // 0->1 excluded: neither crossed (1->2 would be listed) nor listed itself; the sources reach + // only {0}, whose leaving edges are 0->1 (excluded), 0->3 (listed) and 0->4 (zero capacity). + auto el = g2->eligibility({}, std::vector {inv2[0]}); + EXPECT_EQ(f2.bottleneck(std::vector {0}, &el), (std::vector {inv2[2]})); // An excluded source contributes nothing; a foreign Eligibility throws. const std::vector xn = {0}; auto ex = g2->eligibility(xn, {}); EXPECT_TRUE(f2.bottleneck(std::vector {0}, &ex).empty()); auto other = make_graph(3, {{0, 1, 1, 1}}); auto foreign = other.eligibility({}, std::vector {0}); - EXPECT_THROW((void)f2.bottleneck(std::vector {0}, &foreign), std::invalid_argument); - EXPECT_THROW((void)f2.bottleneck(std::vector {}), std::invalid_argument); + EXPECT_EQ(error_of([&] { (void)f2.bottleneck(std::vector {0}, &foreign); }), + "foreign Eligibility: built for another graph"); + EXPECT_EQ(error_of([&] { (void)f2.bottleneck(std::vector {}); }), "sources: the node set is empty"); } TEST(FlowLedger, TwoOwnersNeverCancelEachOther) { - // Prototype 1 maxflow_two_owner_no_cross_cancellation: A holds the chain 0-1-2-3; B's bundle + // Two MaxFlow owners never cancel each other's flow: A holds the chain 0-1-2-3; B's bundle // (chain plus bypasses) could reach 2 only by cancelling A's middle edge: B places 0 (record // at zero delivery, exhausted) and A is untouched; after A's remove() B places 2. auto g = shared(make_graph(4, {{0, 1, 1, 1}, {1, 2, 1, 1}, {2, 3, 1, 1}, {0, 2, 1, 3}, {1, 3, 1, 3}})); const auto inv = id_of_input(*g); FlowLedger fg(g); - PinnedOne a(fg, 0, 3, bundle(*g, {inv[0], inv[1], inv[2]}), FlowPlacement::MaxFlow, Admission::Lossless, + PinnedOne a(fg, 0, 3, dag_of(*g, {inv[0], inv[1], inv[2]}), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); EXPECT_EQ(a.place(1).placed, 1); PinnedOne b(fg, 0, 3, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); @@ -416,9 +590,17 @@ TEST(FlowLedger, TwoOwnersNeverCancelEachOther) { TEST(FlowLedger, AggregatesEqualTheSumOfRecords) { // Random sequences of placements, removals and destructions on one ledger: allocated() is the - // exact sum of the records' edges and residual() = baseline - allocated() within [0, baseline]. + // exact sum of the records' edges and residual() = baseline - allocated() within [0, baseline], + // and every record is a flow of its own (expect_conserved, test_utils.hpp). std::mt19937 rng(5); + int delivering = 0; // placements that delivered units + int with_records = 0; // trials whose ledger held a record after some step + int removed = 0; // removals that dropped records + int orphaned = 0; // destructions that left records as orphans + int lossless_carrying = 0; // record checks of a Lossless record that carries something + int lossy_dropping = 0; // record checks of a Lossy record with drops for (int trial = 0; trial < 60; ++trial) { + bool recorded = false; const int n = 4 + static_cast(rng() % 3); std::vector edges; for (int i = 0; i < 3 * n; ++i) { @@ -443,46 +625,176 @@ TEST(FlowLedger, AggregatesEqualTheSumOfRecords) { auto& p = pols[rng() % pols.size()]; if (!p) continue; const int op = static_cast(rng() % 6); + const auto records = fg.records().size(); + const auto orphans = LedgerAccess::orphan_owners(fg).size(); if (op == 0) { p->remove(); + removed += fg.records().size() < records ? 1 : 0; } else if (op == 1) { p.reset(); + orphaned += LedgerAccess::orphan_owners(fg).size() > orphans ? 1 : 0; } else if (!p->infinite()) { - (void)p->place(op == 2 ? std::optional() : std::optional(rng() % 9)); + delivering += p->place(op == 2 ? std::optional() : std::optional(rng() % 9)).placed > 0; } - std::vector sum(static_cast(g->num_edges()), 0); - for (const auto& rec : fg.records()) { - for (const auto& [e, x] : rec.edges) sum[static_cast(e)] += x; + recorded = recorded || !fg.records().empty(); + const std::string where = "trial " + std::to_string(trial) + " step " + std::to_string(step); + expect_balanced(fg, where); + for (const auto& r : fg.records()) { + const bool carries = expect_conserved(*g, r, where + " record " + std::to_string(r.flow_id.second)); + if (r.admission == Admission::Lossless) lossless_carrying += carries ? 1 : 0; + if (r.admission == Admission::Lossy) lossy_dropping += r.drops.empty() ? 0 : 1; } - ASSERT_EQ(fg.allocated(), sum); const auto res = fg.residual(); + const auto allocated = fg.allocated(); for (std::size_t e = 0; e < res.size(); ++e) { - ASSERT_GE(res[e], 0); - ASSERT_EQ(res[e] + sum[e], g->capacity_units()[e]); + EXPECT_EQ(res[e] + allocated[e], g->capacity_units()[e]) << where << " edge " << e; } + ASSERT_FALSE(::testing::Test::HasFailure()) << where; + } + with_records += recorded; + } + // The trials did real work: a generator change that left every bundle without a walk, or every + // placement empty, would otherwise pass with nothing compared. + EXPECT_GT(delivering, 100); + EXPECT_GT(with_records, 50); + EXPECT_GT(removed, 25); + EXPECT_GT(orphaned, 40); + EXPECT_GT(lossless_carrying, 100); + EXPECT_GT(lossy_dropping, 20); +} + +// expect_conserved's clauses, each broken in a copy of a real record by a change that breaks no +// other clause: the check reports exactly that clause's violations, so a check that lost the +// clause would report fewer. The Lossless record is the max flow of the diamond 10/10/1/10 (1 unit +// over 0-1-3, 10 over 0-2-3); the Lossy one is RecordsCarryTheContractFields' record (offered 11, +// 6 delivered, 5 dropped on 1 -> 3, so offered covers what was delivered and dropped exactly). A +// drop at the source is outside the transit balance (the source's own drops are what offered +// leaves over). +TEST(FlowLedger, TheRecordCheckReportsEachBrokenClause) { + auto g = shared(make_graph(4, {{0, 1, 10, 1}, {0, 2, 10, 1}, {1, 3, 1, 1}, {2, 3, 10, 1}})); + const auto inv = id_of_input(*g); + FlowLedger f1(g); + FlowLedger f2(g); + PinnedOne max_flow(f1, 0, 3, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); + ASSERT_EQ(max_flow.place(std::nullopt).placed, 11); + PinnedOne lossy(f2, 0, 3, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossy, EdgePriority::Residual); + ASSERT_EQ(lossy.place(10).placed, 6); + ASSERT_EQ(lossy.place(1).placed, 0); + const FlowRecord lossless_record = f1.records().at(0); + const FlowRecord lossy_record = f2.records().at(0); + ASSERT_EQ(lossless_record.edges, + (std::vector> {{inv[0], 1}, {inv[1], 10}, {inv[2], 1}, {inv[3], 10}})); + ASSERT_EQ(lossless_record.delivered, 11); + ASSERT_EQ(lossy_record.edges, + (std::vector> {{inv[0], 6}, {inv[1], 5}, {inv[2], 1}, {inv[3], 5}})); + ASSERT_EQ(lossy_record.drops, (std::vector> {{inv[2], Counter128 {0, 5}}})); + ASSERT_EQ(lossy_record.offered, (std::optional {Counter128 {0, 11}})); + ASSERT_EQ(lossy_record.delivered, 6); + // The amount on the edge of input position i. + auto on = [&](FlowRecord& r, std::size_t i) -> Amount& { + const auto it = std::find_if(r.edges.begin(), r.edges.end(), [&](const auto& x) { return x.first == inv[i]; }); + if (it == r.edges.end()) throw std::logic_error("the record has no amount on that edge"); + return it->second; + }; + bool carries = false; + EXPECT_EQ(failures_of([&] { carries = expect_conserved(*g, lossless_record, "lossless"); }), 0); + EXPECT_TRUE(carries); + EXPECT_EQ(failures_of([&] { carries = expect_conserved(*g, lossy_record, "lossy"); }), 0); + EXPECT_TRUE(carries); + FlowRecord empty = lossless_record; + empty.edges.clear(); + empty.delivered = 0; + EXPECT_EQ(failures_of([&] { carries = expect_conserved(*g, empty, "empty"); }), 0); + EXPECT_FALSE(carries); + + struct Broken { + std::string clause; + std::function change; + int failures; + }; + const std::vector lossless_cases = { + {"positive edge amounts (the path 0-1-3 emptied, delivered with it)", + [&](FlowRecord& r) { + on(r, 0) = 0; + on(r, 2) = 0; + r.delivered -= 1; + }, + 2}, + {"edges in ascending EdgeId", [](FlowRecord& r) { std::swap(r.edges[0], r.edges[1]); }, 1}, + {"offered only on a Lossy record", [](FlowRecord& r) { r.offered = Counter128 {0, 11}; }, 1}, + {"drops only on a Lossy record", [&](FlowRecord& r) { r.drops.emplace_back(inv[0], Counter128 {0, 1}); }, 1}, + {"transit conservation (one unit moved from 0 -> 2 to 0 -> 1)", + [&](FlowRecord& r) { + ++on(r, 0); + --on(r, 1); + }, + 2}, + // Lossless: delivered is both the destinations' net inflow and the sources' net outflow. + {"delivered equal to the destinations' inflow and the sources' outflow", [](FlowRecord& r) { ++r.delivered; }, + 2}, + }; + const std::vector lossy_cases = { + {"drops below 2^64", [](FlowRecord& r) { r.drops[0].second.hi = 1; }, 1}, + {"positive drops (the transit node's inflow lowered with it)", + [&](FlowRecord& r) { + r.drops[0].second.lo = 0; + on(r, 0) = 1; + }, + 1}, + {"drops in ascending EdgeId (a second drop at the source, offered raised with it)", + [&](FlowRecord& r) { + r.drops.emplace_back(inv[0], Counter128 {0, 1}); + r.offered->lo += 1; + }, + 1}, + {"transit conservation net of drops (offered raised with the drop)", + [](FlowRecord& r) { + r.drops[0].second.lo += 1; + r.offered->lo += 1; + }, + 1}, + {"delivered equal to the destinations' inflow (offered raised with it)", + [](FlowRecord& r) { + ++r.delivered; + r.offered->lo += 1; + }, + 1}, + {"offered covers what was delivered and dropped", [](FlowRecord& r) { r.offered->lo -= 1; }, 1}, + {"offered below 2^64", [](FlowRecord& r) { r.offered->hi = 1; }, 1}, + // Without offered both the presence clause and the comparison with offered report. + {"offered on a Lossy record", [](FlowRecord& r) { r.offered.reset(); }, 2}, + }; + for (const auto& [base, cases] : {std::pair {&lossless_record, &lossless_cases}, std::pair {&lossy_record, &lossy_cases}}) { + for (const auto& c : *cases) { + FlowRecord x = *base; + c.change(x); + EXPECT_EQ(failures_of([&] { (void)expect_conserved(*g, x, c.clause); }), c.failures) << c.clause; } } } TEST(FlowLedger, NoRouteCallsRecordTheEpochState) { // A positive request with no usable bundle creates nothing, but the epoch records it: after - // +inf a finite request (place(0) included) is rejected until remove() (APPROACH 8, 9.2; - // prototype 2's reading; prototype 1 kept no state on such a call). + // +inf a finite request (place(0) included) is rejected until remove() (the epoch state: + // the reading of the reference in tests/reference/policy/p2_transaction/). auto g = shared(make_graph(2, {{0, 1, 5, 1}})); FlowLedger fg(g); auto el = std::make_shared(g->eligibility({}, std::vector {0})); PinnedOne p(fg, 0, 1, all_edges(*g), FlowPlacement::EqualBalanced, Admission::Lossless, EdgePriority::Residual, el); EXPECT_EQ(p.place(3).termination, Termination::NoRoute); - EXPECT_EQ(p.requested().lo, 3u); + EXPECT_EQ(p.requested(), (Counter128 {0, 3})); auto r = p.place(std::nullopt); EXPECT_EQ(r.termination, Termination::NoRoute); EXPECT_FALSE(r.remaining.has_value()); EXPECT_TRUE(p.infinite()); EXPECT_NE(error_of([&] { (void)p.place(1); }).find("after +inf"), std::string::npos); EXPECT_NE(error_of([&] { (void)p.place(0); }).find("after +inf"), std::string::npos); + EXPECT_EQ(p.requested(), (Counter128 {0, 3})); // the rejected calls added nothing + EXPECT_TRUE(p.infinite()); p.remove(); EXPECT_FALSE(p.infinite()); + EXPECT_TRUE(p.requested().is_zero()); EXPECT_EQ(p.place(1).termination, Termination::NoRoute); EXPECT_TRUE(fg.records().empty()); EXPECT_EQ(LedgerAccess::next_flow_id(fg), 0); @@ -514,8 +826,8 @@ TEST(FlowLedger, RouteCostIsTheBundlesMinimumWalk) { } TEST(FlowLedger, RemovalRestoresTheBaselineAndLeavesOtherOwnersIntact) { - // review-2 R7: removing a flow restores the baseline residual (5 on a capacity-10 link), not - // the capacity; review-check check_review6: another owner's allocation stays intact. + // Removing a flow restores the baseline residual (5 on a capacity-10 link), not + // the capacity; another owner's allocation stays intact. auto g = shared(make_graph(2, {{0, 1, 10, 1}})); FlowLedger fg(g, std::vector {5}); PinnedOne a(fg, 0, 1, all_edges(*g), FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Residual); @@ -533,7 +845,7 @@ TEST(FlowLedger, RemovalRestoresTheBaselineAndLeavesOtherOwnersIntact) { } TEST(FlowLedger, LossyOffersArePerCall) { - // review-13 lossy / rule 19: on the diamond, two calls of 1 offer each unit to the lowest + // On the diamond, two calls of 1 offer each unit to the lowest // EdgeId first (both on the first branch) while one call of 2 splits [1, 1]: not additive. auto g = shared(make_graph(4, {{0, 1, 10, 1}, {0, 2, 10, 1}, {1, 3, 1, 1}, {2, 3, 10, 1}})); FlowLedger f1(g), f2(g); @@ -550,7 +862,7 @@ TEST(FlowLedger, LossyOffersArePerCall) { EXPECT_EQ(alloc_in(f2), (std::vector{1, 1, 1, 1})); } -// review-1b-fixes N2: a commit carrying several records of one owner touches an edge from more +// A commit carrying several records of one owner touches an edge from more // than one record; its delta may pass through zero while it accumulates. Each edge's delta is // published once. namespace { @@ -595,13 +907,13 @@ TEST(FlowLedger, MultiRecordCommitsPublishEachEdgeOnce) { EXPECT_EQ(fg.residual(), (std::vector{0})); EXPECT_EQ(record_sum(fg, 0), 3); // Over capacity through the same pattern: rejected, nothing changed. - const auto held = fg.records(); - const auto live = LedgerAccess::bound_owners(fg); + const LedgerState held = ledger_state(fg); std::vector over {draft_on(dag, {{0, 1}}, ids[0].second), draft_on(dag, {{0, 2}}, ids[0].second + 1), draft_on(dag, {{0, 1}})}; - EXPECT_THROW((void)LedgerAccess::commit(fg, owner, over), std::logic_error); + EXPECT_EQ(logic_error_of([&] { (void)LedgerAccess::commit(fg, owner, over); }), + "ledger: infeasible commit on edge 0 (4 of 3)"); + expect_same(ledger_state(fg), held, "the refused commit"); EXPECT_EQ(fg.allocated(), (std::vector{3})); - EXPECT_EQ(fg.records().size(), held.size()); EXPECT_EQ(LedgerAccess::next_flow_id(fg), 2); // A kept record gives its unit up while a new record takes the edge: published once. std::vector moved {draft_on(dag, {}, ids[0].second), draft_on(dag, {{0, 2}}, ids[0].second + 1), @@ -617,8 +929,9 @@ TEST(FlowLedger, MultiRecordCommitsPublishEachEdgeOnce) { TEST(FlowLedger, RandomMultiOwnerCommitsMatchAModel) { // Three owners on four edges; every commit carries all of its owner's records (each kept, // changed, emptied or moved) plus up to two new ones, so deltas cancel and reappear on shared - // edges; detaches between commits. The ledger must equal an independent model after every call, - // and a rejected commit must change nothing. + // edges; detaches between commits. After every call the ledger must equal an independent model + // (the aggregate, every live record's owner and edge amounts, the next id), and a rejected + // commit must name an edge over its capacity and change nothing. std::mt19937 rng(11); auto g = shared(make_graph(4, {{0, 1, 6, 1}, {1, 2, 4, 1}, {2, 3, 5, 1}, {0, 3, 3, 1}})); const auto dag = all_edges(*g); @@ -629,8 +942,8 @@ TEST(FlowLedger, RandomMultiOwnerCommitsMatchAModel) { // The model: per owner, its live records (id -> per-edge amounts). std::vector>> model(owners.size()); std::int64_t next_id = 0; - int accepted = 0; - int rejected = 0; + int moved = 0; // accepted commits that changed the aggregate + int rejected = 0; // commits the ledger rejected auto random_edges = [&] { std::vector a(m, 0); for (std::size_t e = 0; e < m; ++e) a[e] = (rng() % 3) == 0 ? static_cast(rng() % 3) : 0; @@ -680,17 +993,27 @@ TEST(FlowLedger, RandomMultiOwnerCommitsMatchAModel) { bool feasible = true; for (std::size_t e = 0; e < m; ++e) feasible = feasible && total[e] <= g->capacity_units()[e]; const auto before = fg.allocated(); - const auto before_records = fg.records().size(); + const LedgerState before_state = ledger_state(fg); if (feasible) { - EXPECT_EQ(LedgerAccess::commit(fg, owners[o], drafts).size(), static_cast(fresh)); + // The new records take the next ids in draft order. + std::vector want_ids; + for (int k = 0; k < fresh; ++k) want_ids.emplace_back(fg.uid(), next_id + k); + EXPECT_EQ(LedgerAccess::commit(fg, owners[o], drafts), want_ids) << "step " << step; model[o] = after; next_id += fresh; - ++accepted; + moved += fg.allocated() != before ? 1 : 0; } else { - EXPECT_THROW((void)LedgerAccess::commit(fg, owners[o], drafts), std::logic_error); - EXPECT_EQ(fg.allocated(), before); - EXPECT_EQ(fg.records().size(), before_records); + // The refusal names one edge over its capacity, with its new total. + std::vector refusals; + for (std::size_t e = 0; e < m; ++e) { + if (total[e] <= g->capacity_units()[e]) continue; + refusals.push_back("ledger: infeasible commit on edge " + std::to_string(e) + " (" + std::to_string(total[e]) + + " of " + std::to_string(g->capacity_units()[e]) + ")"); + } + const std::string got = logic_error_of([&] { (void)LedgerAccess::commit(fg, owners[o], drafts); }); + EXPECT_TRUE(std::find(refusals.begin(), refusals.end(), got) != refusals.end()) << "step " << step << ": " << got; ++rejected; + expect_same(ledger_state(fg), before_state, "step " + std::to_string(step)); } } std::vector want(m, 0); @@ -702,14 +1025,27 @@ TEST(FlowLedger, RandomMultiOwnerCommitsMatchAModel) { ASSERT_EQ(fg.allocated(), want) << "step " << step; for (std::size_t e = 0; e < m; ++e) ASSERT_EQ(record_sum(fg, static_cast(e)), want[e]); ASSERT_EQ(LedgerAccess::next_flow_id(fg), next_id); + // Every live record by id: its owner and its edge amounts are the model's. + std::map>>> want_records; + for (std::size_t q = 0; q < owners.size(); ++q) { + for (const auto& [id, a] : model[q]) want_records[id] = {owners[q], as_pairs(a)}; + } + const auto records = fg.records(); + ASSERT_EQ(records.size(), want_records.size()) << "step " << step; + for (const auto& r : records) { + const auto it = want_records.find(r.flow_id.second); + ASSERT_NE(it, want_records.end()) << "step " << step << ": record " << r.flow_id.second; + EXPECT_EQ(r.owner, it->second.first) << "step " << step << ": record " << r.flow_id.second; + EXPECT_EQ(r.edges, it->second.second) << "step " << step << ": record " << r.flow_id.second; + } } - EXPECT_GT(accepted, 1000); - EXPECT_GT(rejected, 100); + EXPECT_GT(moved, 500); + EXPECT_GT(rejected, 500); for (auto owner : owners) LedgerAccess::release(fg, owner); } TEST(FlowLedger, FreshSingleRecordCommitsMatchTheGeneralRule) { - // Task 1d-msd: a commit of one new record by an owner without live records takes a shortcut + // A commit of one new record by an owner without live records takes a shortcut // when its edges strictly ascend (no aggregation). Against an independent model of the general // rule, for drafts that ascend, repeat an edge, run backwards, hold zeros or negatives, and // overflow at any position (another owner holding part of the capacity): the same outcome, the @@ -723,11 +1059,10 @@ TEST(FlowLedger, FreshSingleRecordCommitsMatchTheGeneralRule) { const auto other = LedgerAccess::bind(fg); std::vector held {draft_on(dag, {{0, 1}, {2, 2}, {5, 1}})}; (void)LedgerAccess::commit(fg, other, held); - int ascending = 0; - int general = 0; - int negative = 0; - int infeasible = 0; - int accepted = 0; + int shortcut = 0; // accepted non-empty drafts whose edges strictly ascend + int general = 0; // accepted drafts that repeat or reorder an edge + int negative = 0; // rejected for a negative amount + int infeasible = 0; // rejected for an infeasible edge for (int step = 0; step < 4000; ++step) { const auto owner = LedgerAccess::bind(fg); std::vector> edges; @@ -744,7 +1079,6 @@ TEST(FlowLedger, FreshSingleRecordCommitsMatchTheGeneralRule) { const bool strictly = std::adjacent_find(edges.begin(), edges.end(), [](const auto& a, const auto& b) { return a.first >= b.first; }) == edges.end(); - ++(strictly ? ascending : general); // The general rule: negatives first; then each touched edge in first-touch order with its sum. std::string want; for (const auto& [e, x] : edges) { @@ -757,7 +1091,7 @@ TEST(FlowLedger, FreshSingleRecordCommitsMatchTheGeneralRule) { delta[static_cast(e)] += x; } const auto before = fg.allocated(); - const auto before_records = fg.records(); + const LedgerState before_state = ledger_state(fg); const auto next_id = LedgerAccess::next_flow_id(fg); if (want.empty()) { for (EdgeId e : touched) { @@ -777,11 +1111,9 @@ TEST(FlowLedger, FreshSingleRecordCommitsMatchTheGeneralRule) { ADD_FAILURE() << "step " << step << ": accepted, want " << want; } catch (const std::logic_error& e) { EXPECT_EQ(std::string(e.what()), want) << "step " << step; + ++(std::string(e.what()).find("negative") != std::string::npos ? negative : infeasible); } - ++(want.find("negative") != std::string::npos ? negative : infeasible); - EXPECT_EQ(fg.allocated(), before) << "step " << step; - EXPECT_EQ(fg.records().size(), before_records.size()) << "step " << step; - EXPECT_EQ(LedgerAccess::next_flow_id(fg), next_id) << "step " << step; + expect_same(ledger_state(fg), before_state, "step " + std::to_string(step)); } else { const auto ids = LedgerAccess::commit(fg, owner, drafts); ASSERT_EQ(ids.size(), 1U); @@ -794,17 +1126,17 @@ TEST(FlowLedger, FreshSingleRecordCommitsMatchTheGeneralRule) { EXPECT_EQ(rec.edges, kept) << "step " << step; EXPECT_EQ(rec.owner, owner); for (std::size_t e = 0; e < m; ++e) EXPECT_EQ(fg.allocated()[e], before[e] + delta[e]) << "step " << step; - ++accepted; + if (strictly && !edges.empty()) ++shortcut; + if (!strictly) ++general; if (rng() % 2 == 0) (void)LedgerAccess::detach(fg, owner); } LedgerAccess::release(fg, owner); if (::testing::Test::HasFailure()) return; } - EXPECT_GT(ascending, 1000); - EXPECT_GT(general, 1000); + EXPECT_GT(shortcut, 140); + EXPECT_GT(general, 10); EXPECT_GT(negative, 300); EXPECT_GT(infeasible, 300); - EXPECT_GT(accepted, 1000); LedgerAccess::release(fg, other); // An owner with a live record, and a draft naming an id the owner does not hold, keep the // general path and its rule (a fresh ledger). @@ -813,10 +1145,11 @@ TEST(FlowLedger, FreshSingleRecordCommitsMatchTheGeneralRule) { std::vector one {draft_on(dag, {{3, 1}})}; const auto ids = LedgerAccess::commit(fg2, owner, one); std::vector without_it {draft_on(dag, {{4, 1}})}; - EXPECT_THROW((void)LedgerAccess::commit(fg2, owner, without_it), std::logic_error); + const std::string must_carry = "ledger: a commit must carry every live record of its policy"; + EXPECT_EQ(logic_error_of([&] { (void)LedgerAccess::commit(fg2, owner, without_it); }), must_carry); const auto fresh = LedgerAccess::bind(fg2); std::vector claims {draft_on(dag, {{4, 1}}, ids[0].second)}; - EXPECT_THROW((void)LedgerAccess::commit(fg2, fresh, claims), std::logic_error); + EXPECT_EQ(logic_error_of([&] { (void)LedgerAccess::commit(fg2, fresh, claims); }), must_carry); EXPECT_EQ(fg2.records().size(), 1U); LedgerAccess::release(fg2, owner); LedgerAccess::release(fg2, fresh); diff --git a/tests/cpp/flow_policy_fault_tests.cpp b/tests/cpp/flow_policy_fault_tests.cpp index 31dacfd..8142862 100644 --- a/tests/cpp/flow_policy_fault_tests.cpp +++ b/tests/cpp/flow_policy_fault_tests.cpp @@ -1,21 +1,38 @@ -// Allocation-failure atomicity of the policy engine, the DagCache and the evaluators (APPROACH 8, -// 9.2, 10, 11, 13). Every path that grows a buffer or publishes state is swept over every -// allocation it makes: after each injected std::bad_alloc the ledger (aggregate, residual, every -// record field, owners, next FlowId) and the policy's epoch state (requested, +inf, cohorts with -// their ids and plans) equal their state before the call; a clean retry gives the reference -// result (no poisoned per-thread scratch); and remove() restores the ledger. A supplied building -// cache is a memo (APPROACH 13, limit 2): it may keep the complete entries of the lookups that -// succeeded before the failure, never a partial one. Runs in the fault-injection executable -// (fault_injection.hpp). +// Allocation-failure atomicity of the policy engine, the DagCache and the evaluators. +// Exhaustive sweeps (each allocation fails once) cover the placement calls of the 13 routing and +// kernel modes below, with and without a static Eligibility; the zero and empty-roster calls; the +// policy construction, with and without a supplied building cache and an Eligibility; the +// DagCache construction and insertion; capacity() and batch_capacity() on one worker in every +// mode (batch_capacity: every mode but Pinned), without options, with an Eligibility and, in the +// CostOnly modes, with a supplied building cache; sensitivity() on one worker in one mode per +// routing model with the same options; and the frozen lookups. Sampled sweeps cover the other +// modes' sensitivity on one worker (every 11th allocation), the evaluators with 2 and 4 workers +// and the frozen-cache statistics: every 7th (batch_capacity; every 5th on a frozen cache), 11th +// (sensitivity) or step-th allocation in the process, and every third allocation of the worker +// threads only. The scratch-growth test sweeps every allocation of a placement on a 3,000-node +// graph after warming up on a 3-node graph, on a fresh thread for each attempt. After each injected +// std::bad_alloc the ledger (aggregate, residual, every record field, owners, next FlowId) and +// the policy's epoch state (requested, +inf, Global's cumulative curve, and per flow its id, route +// cost and plan: route, members, weights) equal their state before the call; a clean retry gives +// the reference result (no poisoned per-thread scratch); and remove() leaves the ledger as the +// reference's own remove() does. No failed call leaves an allocation live once its attempt's +// objects are gone (a supplied building cache with its kept entries included). remove(), a +// policy's destruction and DagCache::clear() allocate nothing. A supplied building cache is a +// memo: it keeps the complete entries of the lookups that succeeded before the failure (with +// their misses), never a partial one. The hook's worker-only mode, which the sweeps with several +// workers use, is checked against the caller's own allocations. Runs in the fault-injection +// executable (fault_injection.hpp). #include #include +#include #include #include #include #include #include #include +#include #include #include "fault_injection.hpp" @@ -35,43 +52,6 @@ using detail::LedgerAccess; namespace { -// The shared comparators of fault_injection.hpp, beside this file's own overloads. -using test::expect_same; - -// The policy's epoch state: the call state and per roster source its flows (ids, routes, members). -struct Epoch { - Counter128 requested; - bool infinite {false}; - std::vector>>> cohorts; -}; - -Epoch epoch(const FlowPolicy& p) { - const auto& pe = detail::PolicyAccess::engine(p); - Epoch e {pe.requested, pe.infinite, {}}; - for (const auto& c : pe.cohorts) { - auto& out = e.cohorts.emplace_back(); - for (const auto& f : c) { - if (f.plan) { - out.emplace_back(f.id, f.plan->members); - } else { - const auto edges = detail::PredDAGBuilder::ascending(*f.bundle); - out.emplace_back(f.id, std::vector(edges.begin(), edges.end())); - } - } - } - return e; -} - -void expect_same(const Epoch& a, const Epoch& b, const std::string& where) { - EXPECT_EQ(a.requested, b.requested) << where; - EXPECT_EQ(a.infinite, b.infinite) << where; - EXPECT_EQ(a.cohorts, b.cohorts) << where; -} - -PathSelection sel(EdgeTieBreak t, EdgePriority p, bool multipath = true, bool multi_edge = true) { - return PathSelection(t, p, multipath, multi_edge); -} - // Sources 0 and 1, destinations 5 and 6: three equal-cost routes with a parallel pair and a // shared middle, a costlier bypass, and a second destination. std::shared_ptr graph() { @@ -89,13 +69,13 @@ struct Mode { std::vector modes(const Graph& g) { std::vector out; - const auto det = sel(EdgeTieBreak::Deterministic, EdgePriority::Capacity); - const auto res = sel(EdgeTieBreak::Deterministic, EdgePriority::Residual); - const auto tunnel = sel(EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual, false, false); + const auto det = PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity); + const auto res = PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Residual); + const auto tunnel = PathSelection(EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual, false, false); const std::vector S = {0, 1}; const std::vector D = {5, 6}; out.push_back({"costonly maxflow", {CostOnly {}, FlowPlacement::MaxFlow, Admission::Lossless, det}, S, D}); - out.push_back({"costonly eb", {CostOnly {}, FlowPlacement::EqualBalanced, Admission::Lossless, det}, S, D}); + out.push_back({"costonly eb", cost_only_config(), S, D}); out.push_back({"costonly weighted residual", {CostOnly {}, FlowPlacement::Weighted, Admission::Lossless, res}, S, D}); out.push_back({"costonly lossy", {CostOnly {}, FlowPlacement::EqualBalanced, Admission::Lossy, det}, S, D}); out.push_back({"costonly single path collapse", @@ -114,18 +94,17 @@ std::vector modes(const Graph& g) { dim.diminishing = DiminishingReturns {1, 0.5}; dim.min_flow_bandwidth = 1; out.push_back({"capacityaware weighted diminishing", {dim, FlowPlacement::Weighted, Admission::Lossless, res}, S, D}); - out.push_back({"global", {Global {}, FlowPlacement::MaxFlow, Admission::Lossless, det}, S, D}); + out.push_back({"global", global_config(), S, D}); Pinned two; const auto inv = id_of_input(g); - two.bundles = {std::make_shared(detail::PredDAGBuilder::of_edges(g, std::vector {inv[0], inv[4], inv[5]})), - std::make_shared(detail::PredDAGBuilder::of_edges(g, std::vector {inv[1], inv[6]}))}; + two.bundles = {dag_of(g, {inv[0], inv[4], inv[5]}), dag_of(g, {inv[1], inv[6]})}; out.push_back({"pinned eb cohort", {two, FlowPlacement::EqualBalanced, Admission::Lossless, det}, {0}, {5}}); out.push_back({"pinned lossy", {two, FlowPlacement::Weighted, Admission::Lossy, det}, {0}, {5}}); - // CostOnly creation in one pass (task 1d-msd): its Collapse branch (multipath, multi_edge false, + // CostOnly creation in one pass: its Collapse branch (multipath, multi_edge false, // on the creation view) and its Weighted weights (Capacity priority). out.push_back({"costonly eb multipath collapse", {CostOnly {}, FlowPlacement::EqualBalanced, Admission::Lossless, - sel(EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual, true, false)}, + PathSelection(EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual, true, false)}, S, D}); out.push_back({"costonly weighted capacity", {CostOnly {}, FlowPlacement::Weighted, Admission::Lossless, det}, S, D}); return out; @@ -148,11 +127,22 @@ std::vector scenarios() { }; } +// The static Eligibility of g without the edge at input position `position`. +Eligibility without_input_edge(const Graph& g, std::size_t position) { + const EdgeId excluded[] = {id_of_input(g)[position]}; + return g.eligibility({}, excluded); +} + +// graph() without the costly bypass 0 -> 6 (input 9), and without the cross edge 2 -> 4 (input +// 10: batch_capacity's per-pair value, whose intersection with the first is a new value). +Eligibility without_bypass(const Graph& g) { return without_input_edge(g, 9); } +Eligibility without_cross(const Graph& g) { return without_input_edge(g, 10); } + // A third party holding one unit on the first route's edges. std::unique_ptr third_party(FlowLedger& fg, const Graph& g) { const auto inv = id_of_input(g); Pinned p; - p.bundles = {std::make_shared(detail::PredDAGBuilder::of_edges(g, std::vector {inv[0], inv[4]}))}; + p.bundles = {dag_of(g, {inv[0], inv[4]})}; auto t = std::make_unique(fg, std::vector {0}, std::vector {5}, FlowPolicyConfig {p, FlowPlacement::MaxFlow, Admission::Lossless, PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity)}); @@ -164,61 +154,39 @@ std::unique_ptr third_party(FlowLedger& fg, const Graph& g) { TEST(PolicyFaults, EveryAllocationOfEveryRoutingModeFailsAtomically) { const auto g = graph(); - int sweeps = 0; - long injected = 0; + const Eligibility no_bypass = without_bypass(*g); for (const auto& m : modes(*g)) { for (const auto& sc : scenarios()) { - const std::string where = m.name + " / " + sc.name; - FlowLedger ref(g); - auto ref_third = third_party(ref, *g); - FlowPolicy pref(ref, m.sources, m.destinations, m.cfg); - for (auto v : sc.before) (void)pref.place(v); - const LedgerState initial = ledger_state(ref); - const Epoch initial_epoch = epoch(pref); - const PlacementResult want = pref.place(sc.call, sc.outputs); - const LedgerState after = ledger_state(ref); - const Epoch want_epoch = epoch(pref); - pref.remove(); - const LedgerState removed = ledger_state(ref); - ++sweeps; - for (long at = 0;; ++at) { - const std::string w = where + " allocation " + std::to_string(at); - FlowLedger fg(g); - auto third = third_party(fg, *g); - FlowPolicy p(fg, m.sources, m.destinations, m.cfg); - for (auto v : sc.before) (void)p.place(v); - expect_same(ledger_state(fg), initial, w + " (setup)"); - PlacementResult got; - if (!fails_at(at, [&] { got = p.place(sc.call, sc.outputs); })) { - expect_same(got, want, w + " (no failure)"); - expect_same(ledger_state(fg), after, w + " (no failure)"); - break; - } - ++injected; - expect_same(ledger_state(fg), initial, w + " (failed call)"); - expect_same(epoch(p), initial_epoch, w + " (failed call)"); - expect_balanced(fg, w + " (failed call)"); - expect_same(p.place(sc.call, sc.outputs), want, w + " (retry)"); - expect_same(ledger_state(fg), after, w + " (retry)"); - expect_same(epoch(p), want_epoch, w + " (retry)"); - p.remove(); - expect_same(ledger_state(fg), removed, w + " (remove)"); + for (const Eligibility* elig : {static_cast(nullptr), &no_bypass}) { + const std::string where = m.name + " / " + sc.name + (elig != nullptr ? " / eligibility" : ""); + // The third party, then the policy's earlier calls. + const auto setup = [&](FlowLedger& fg) { + auto third = std::shared_ptr(third_party(fg, *g)); + auto p = std::make_shared(fg, m.sources, m.destinations, m.cfg, elig); + for (auto v : sc.before) (void)p->place(v); + FlowPolicy* policy = p.get(); + return Staged {std::make_shared, std::shared_ptr>>( + std::move(third), std::move(p)), + policy}; + }; + const long injected = + sweep_placement(g, where, setup, [&](FlowPolicy& p) { return p.place(sc.call, sc.outputs); }); if (::testing::Test::HasFailure()) return; + EXPECT_GT(injected, 2) << where; // not vacuous: every mode and scenario allocates } } } - EXPECT_EQ(sweeps, 13 * 5); - EXPECT_GT(injected, sweeps * 3); // not vacuous } // The calls that place nothing publish only their outputs and, for a positive request, the // epoch's request: a zero request, and a positive or +inf request when no source reaches a -// destination (an empty roster; the endpoints swapped: 5 and 6 have no out-edges). A failure -// changes neither the ledger nor the epoch state. +// destination (an empty roster; the endpoints swapped: 5 and 6 have no out-edges). Each runs +// through the shared placement sweep: a failure changes neither the ledger nor the epoch state and +// leaves no allocation live; the completing call, the retry and remove() leave the ledger as the +// reference's do. TEST(PolicyFaults, ZeroAndEmptyRosterCallsFailAtomically) { const auto g = graph(); const OutputOptions all {true, true, true}; - long injected = 0; struct Case { bool swap; std::optional volume; @@ -230,179 +198,436 @@ TEST(PolicyFaults, ZeroAndEmptyRosterCallsFailAtomically) { for (const auto& c : cases) { const auto& s = c.swap ? m.destinations : m.sources; const auto& d = c.swap ? m.sources : m.destinations; - FlowLedger ref(g); - auto ref_third = third_party(ref, *g); - FlowPolicy pref(ref, s, d, m.cfg); - const PlacementResult want = pref.place(c.volume, all); - const Epoch want_epoch = epoch(pref); - if (c.swap) EXPECT_TRUE(detail::PolicyAccess::engine(pref).roster.empty()) << m.name; - for (long at = 0;; ++at) { - const std::string w = m.name + " / " + c.name + " allocation " + std::to_string(at); - FlowLedger fg(g); - auto third = third_party(fg, *g); - FlowPolicy p(fg, s, d, m.cfg); - const LedgerState before = ledger_state(fg); - const Epoch before_epoch = epoch(p); - PlacementResult got; - if (!fails_at(at, [&] { got = p.place(c.volume, all); })) { - expect_same(got, want, w + " (no failure)"); - break; - } - ++injected; - expect_same(ledger_state(fg), before, w + " (failed call)"); - expect_same(epoch(p), before_epoch, w + " (failed call)"); - expect_same(p.place(c.volume, all), want, w + " (retry)"); - expect_same(epoch(p), want_epoch, w + " (retry)"); - if (::testing::Test::HasFailure()) return; - } + const std::string where = m.name + " / " + c.name; + const auto setup = [&](FlowLedger& fg) { + auto third = std::shared_ptr(third_party(fg, *g)); + auto p = std::make_shared(fg, s, d, m.cfg); + if (c.swap) EXPECT_TRUE(detail::PolicyAccess::engine(*p).roster.empty()) << where; + FlowPolicy* policy = p.get(); + return Staged {std::make_shared, std::shared_ptr>>( + std::move(third), std::move(p)), + policy}; + }; + const long injected = sweep_placement(g, where, setup, [&](FlowPolicy& p) { return p.place(c.volume, all); }); + if (::testing::Test::HasFailure()) return; + EXPECT_GT(injected, 0) << where; // not vacuous: every mode and case fails } } - EXPECT_GT(injected, 27); // every mode and case fails at least once } TEST(PolicyFaults, ConstructionFailsWithoutBindingAndTheCacheStaysConsistent) { const auto g = graph(); + const Eligibility no_bypass = without_bypass(*g); for (const auto& m : modes(*g)) { - for (const bool with_cache : {false, true}) { + for (const int v : {0, 1, 2, 3}) { + const bool with_cache = (v & 1) != 0; + const Eligibility* elig = (v & 2) != 0 ? &no_bypass : nullptr; + const std::string variant = std::string(with_cache ? " cached" : "") + (elig != nullptr ? " eligibility" : ""); + // The reference: a clean construction on a fresh ledger and cache, then place(2). + FlowLedger rf(g); + DagCache rc(*g); + FlowPolicy pr(rf, m.sources, m.destinations, m.cfg, elig, with_cache ? &rc : nullptr); + const DagCache::Stats fresh = rc.stats(); + const PlacementResult want = pr.place(2); + const LedgerState placed = ledger_state(rf); + long injected = 0; + int empty = 0; // failures that left the supplied cache empty, and that left its whole entry + int whole = 0; for (long at = 0;; ++at) { - FlowLedger fg(g); - DagCache cache(*g); - const LedgerState before = ledger_state(fg); - std::optional p; - if (!fails_at(at, [&] { p.emplace(fg, m.sources, m.destinations, m.cfg, nullptr, with_cache ? &cache : nullptr); })) { - EXPECT_EQ(LedgerAccess::bound_owners(fg).size(), 1U); - break; + const std::string w = m.name + variant + " construction allocation " + std::to_string(at); + // Everything an attempt allocates (its ledger, cache, failed construction, then the clean + // construction and its placement) is freed when the attempt ends: the thread's scratch is + // already grown by the reference, so an allocation left live is a leak of the failed + // construction (a cache's kept entry is freed with the cache). + const long live = g_live.load(); + { + FlowLedger fg(g); + DagCache cache(*g); + const LedgerState before = ledger_state(fg); + std::optional p; + if (!fails_at(at, [&] { p.emplace(fg, m.sources, m.destinations, m.cfg, elig, with_cache ? &cache : nullptr); })) { + EXPECT_EQ(LedgerAccess::bound_owners(fg).size(), 1U); + break; + } + ++injected; + EXPECT_FALSE(p.has_value()) << w; + expect_same(ledger_state(fg), before, w); + // A supplied cache holds whole entries only: none, or exactly the entries and bytes a + // clean construction leaves in a fresh cache; no policy holds a value any more. + const auto s = cache.stats(); + EXPECT_LE(fresh.entries, 1) << w; + if (s.entries == 0) { + EXPECT_EQ(s.bytes, 0) << w; + ++empty; + } else { + EXPECT_EQ(s.entries, fresh.entries) << w; + EXPECT_EQ(s.bytes, fresh.bytes) << w; + ++whole; + } + EXPECT_EQ(s.live_values, s.entries) << w; + // A clean construction works, leaves the fresh cache's entries and places like the reference. + p.emplace(fg, m.sources, m.destinations, m.cfg, elig, with_cache ? &cache : nullptr); + EXPECT_EQ(cache.stats().entries, fresh.entries) << w; + EXPECT_EQ(cache.stats().bytes, fresh.bytes) << w; + expect_same(p->place(2), want, w); + expect_same(ledger_state(fg), placed, w); + expect_balanced(fg, w); } - const std::string w = m.name + (with_cache ? " cached" : "") + " construction allocation " + std::to_string(at); - EXPECT_FALSE(p.has_value()) << w; - expect_same(ledger_state(fg), before, w); - // The cache holds whole entries only: its statistics agree with a fresh lookup. - const auto s = cache.stats(); - EXPECT_EQ(s.live_values, s.entries) << w; - EXPECT_LE(s.entries, 1) << w; - // A clean construction works and places like the reference. - p.emplace(fg, m.sources, m.destinations, m.cfg, nullptr, with_cache ? &cache : nullptr); - EXPECT_GE(p->place(2).placed, 0) << w; - expect_balanced(fg, w); + EXPECT_EQ(g_live.load(), live) << w << ": a leak"; + if (::testing::Test::HasFailure()) return; + } + EXPECT_GT(injected, 0) << m.name << variant; // not vacuous + // A mode that reads the supplied cache fails both before its entry exists and after. + if (fresh.entries == 1) { + EXPECT_GT(empty, 0) << m.name; + EXPECT_GT(whole, 0) << m.name; } } } + // Every CostOnly mode reads the supplied cache (an entry after a clean construction), the + // other modes none: the memo outcomes above are checked for every CostOnly mode. + for (const auto& m : modes(*g)) { + FlowLedger fg(g); + DagCache cache(*g); + const FlowPolicy p(fg, m.sources, m.destinations, m.cfg, nullptr, &cache); + EXPECT_EQ(cache.stats().entries, std::holds_alternative(m.cfg.routing) ? 1 : 0) << m.name; + } } TEST(PolicyFaults, DagCacheInsertionIsAllOrNothing) { const auto g = graph(); const std::vector roots = {5, 6}; + { // clean searches first grow this thread's search scratch, which outlives the attempts + DagCache warm(*g); + (void)warm.lookup(*g, std::vector {5}, Direction::Reverse, nullptr); + (void)warm.lookup(*g, roots, Direction::Reverse, nullptr); + } + long injected = 0; for (long at = 0;; ++at) { - DagCache cache(*g); - (void)cache.lookup(*g, std::vector {5}, Direction::Reverse, nullptr); // one entry before - const auto before = cache.stats(); - std::shared_ptr got; - if (!fails_at(at, [&] { got = cache.lookup(*g, roots, Direction::Reverse, nullptr); })) { - EXPECT_EQ(cache.stats().entries, 2); + const std::string w = "lookup allocation " + std::to_string(at); + // Everything an attempt allocates (its cache and values, the retry) is freed when it ends, so + // an allocation left live is a leak of the failed lookup. + const long live = g_live.load(); + bool failed = false; + { + DagCache cache(*g); + (void)cache.lookup(*g, std::vector {5}, Direction::Reverse, nullptr); // one entry before + const auto before = cache.stats(); + std::shared_ptr got; + failed = fails_at(at, [&] { got = cache.lookup(*g, roots, Direction::Reverse, nullptr); }); + if (!failed) { + EXPECT_EQ(cache.stats().entries, 2); + } else { + ++injected; + const auto s = cache.stats(); + EXPECT_EQ(s.entries, before.entries) << w; + EXPECT_EQ(s.bytes, before.bytes) << w; + EXPECT_EQ(s.live_values, before.live_values) << w; + EXPECT_EQ(s.hits, before.hits) << w; + // The key is the lookup's first allocation: its failure counts nothing; a failed search (or + // insertion) after it counts its miss. + EXPECT_EQ(s.misses, before.misses + (at == 0 ? 0 : 1)) << w; + const auto v = cache.lookup(*g, roots, Direction::Reverse, nullptr); // a clean retry inserts + EXPECT_EQ(cache.stats().entries, 2) << w; + EXPECT_TRUE(*v == detail::static_support(*g, roots, Direction::Reverse, nullptr)) << w; + } + } + EXPECT_EQ(g_live.load(), live) << w << ": a leak"; + if (!failed) break; + } + EXPECT_GE(injected, 2); // the key (allocation 0) and at least one allocation after it +} + +// A failed DagCache construction (its allocations: the counter of live values that every value's +// deleter shares, and on MSVC the map's own) builds nothing and leaks nothing; clear() and +// freeze()/thaw() make no allocation. +TEST(PolicyFaults, DagCacheConstructionFailsWithoutALeakAndClearNeverAllocates) { + const auto g = graph(); + long injected = 0; + for (long at = 0;; ++at) { + std::optional cache; + const long live = g_live.load(); + if (!fails_at(at, [&] { cache.emplace(*g); })) { + ASSERT_TRUE(cache.has_value()); + EXPECT_EQ(cache->stats().entries, 0); break; } - const auto s = cache.stats(); - const std::string w = "lookup allocation " + std::to_string(at); - EXPECT_EQ(s.entries, before.entries) << w; - EXPECT_EQ(s.bytes, before.bytes) << w; - EXPECT_EQ(s.live_values, before.live_values) << w; - EXPECT_EQ(s.hits, before.hits) << w; - // The key is the lookup's first allocation: its failure counts nothing; a failed search (or - // insertion) after it counts its miss. - EXPECT_EQ(s.misses, before.misses + (at == 0 ? 0 : 1)) << w; - const auto v = cache.lookup(*g, roots, Direction::Reverse, nullptr); // a clean retry inserts - EXPECT_EQ(cache.stats().entries, 2) << w; - EXPECT_TRUE(*v == detail::static_support(*g, roots, Direction::Reverse, nullptr)) << w; + ++injected; + EXPECT_FALSE(cache.has_value()) << "allocation " << at; + EXPECT_EQ(g_live.load(), live) << "allocation " << at << ": a leak"; } + EXPECT_GE(injected, 1); // the counter (MSVC's map adds its own) + DagCache cache(*g); + const auto held = cache.lookup(*g, std::vector {5}, Direction::Reverse, nullptr); + (void)cache.lookup(*g, std::vector {5, 6}, Direction::Reverse, nullptr); + ASSERT_EQ(cache.stats().entries, 2); + EXPECT_FALSE(fails_at(0, [&] { + cache.freeze(); + cache.thaw(); + cache.clear(); + })); + const auto s = cache.stats(); + EXPECT_EQ(s.entries, 0); + EXPECT_EQ(s.bytes, 0); + EXPECT_EQ(s.live_values, 1); // the value held here outlives its entry } namespace { // The evaluators under a failure on `workers` threads: anywhere in the process (every `step`-th -// allocation: the evaluators allocate a lot) or, with several workers, in the worker threads only -// (their setup and their tasks; every third worker allocation, from the first). The failure -// propagates as std::bad_alloc after every worker has joined, never terminating the process -// (review-1c F1: a worker's cache allocation escaped its thread), and a clean retry gives the -// reference. Returns the number of injected failures. +// allocation: 1 sweeps them all) or, with several workers, in the worker threads only (their +// setup and their tasks; every third worker allocation, from the first). `setup` runs before each +// attempt and `teardown` after it, both outside the failure; teardown frees what setup made (a +// supplied building cache, whose kept entries are no leak). The failure propagates as +// std::bad_alloc after every worker has joined, never terminating the process, and a clean retry +// gives the reference. Each attempt leaves no allocation live: the workers' scratch ends with +// their threads, and the caller computed the reference on its own thread first, which grew that +// thread's scratch. Returns the number of injected failures. long sweep_evaluator(const std::string& name, std::int32_t workers, bool in_workers, long step, - const std::function& call, const std::function& retry) { + const std::function& call, const std::function& retry, + const std::function& setup = [] {}, const std::function& teardown = [] {}) { long failures = 0; for (long at = 0;; at += in_workers ? 3 : step) { + const std::string w = name + " workers " + std::to_string(workers) + (in_workers ? " (worker threads)" : "") + + " allocation " + std::to_string(at); + const long live = g_live.load(); + setup(); const auto fn = [&] { call(workers); }; - if (!(in_workers ? fails_in_workers_at(at, fn) : fails_at(at, fn))) break; - ++failures; - retry(workers); + const bool failed = in_workers ? fails_in_workers_at(at, fn) : fails_at(at, fn); + if (failed) { + ++failures; + retry(workers); + } + teardown(); + EXPECT_EQ(g_live.load(), live) << w << ": a leak"; + if (!failed) break; if (::testing::Test::HasFailure()) { - ADD_FAILURE() << name << " workers " << workers << (in_workers ? " (worker threads)" : "") << " allocation " << at; + ADD_FAILURE() << w; break; } } return failures; } -void sweep_batch(const Mode& m, const Graph& g, std::span workers_set) { +// The options of an evaluator sweep: none, a static Eligibility (for batch_capacity also a +// per-pair value, so each pair's intersection is a new value), or a supplied building cache (one +// worker; a fresh cache before each attempt). A building cache is a memo: after a failed call it +// holds whole entries only (no value alive outside it), some failed calls keep the entries of the +// lookups that completed before the failure, and the retry leaves the entries of a clean call. +struct Options { + std::string name; + const Eligibility* elig {nullptr}; + const Eligibility* per_pair {nullptr}; + bool cache {false}; +}; + +std::vector option_sets(const Eligibility& no_bypass, const Eligibility& no_cross) { + return {{"", nullptr, nullptr, false}, {" eligibility", &no_bypass, &no_cross, false}, {" building cache", nullptr, nullptr, true}}; +} + +// A supplied building cache after a failed call: whole entries only, no more than a clean call +// leaves. +void expect_whole_entries(const DagCache::Stats& s, std::int64_t clean, const std::string& where) { + EXPECT_LE(s.entries, clean) << where; + EXPECT_EQ(s.live_values, s.entries) << where; + if (s.entries == 0) EXPECT_EQ(s.bytes, 0) << where; +} + +void expect_whole_entries(const std::optional& cache, std::int64_t clean, const std::string& where) { + if (cache) expect_whole_entries(cache->stats(), clean, where); +} + +void sweep_batch(const Mode& m, const Graph& g, std::span workers_set, long step, + const Options& o = {}) { const std::vector, std::vector>> pairs = { {m.sources, m.destinations}, {{1}, {6}}, {{0}, {5}}}; - const auto want = batch_capacity(g, pairs, CapacityOptions {m.cfg}, {}, 1); + const std::vector per_pair(o.per_pair != nullptr ? pairs.size() : 0, o.per_pair); + std::optional cache; + const auto opts = [&] { return CapacityOptions {m.cfg, o.elig, cache ? &*cache : nullptr}; }; + if (o.cache) cache.emplace(g); + const auto want = batch_capacity(g, pairs, opts(), per_pair, 1); + const std::int64_t clean = cache ? cache->stats().entries : 0; + cache.reset(); // each attempt gets a fresh one (setup) + const std::string name = m.name + " batch" + o.name; + long kept = 0; // failed calls whose cache kept the entries of the lookups before the failure for (const std::int32_t workers : workers_set) { for (const bool in_workers : {false, true}) { if (in_workers && workers == 1) continue; // one worker runs on the calling thread const long failures = sweep_evaluator( - m.name + " batch", workers, in_workers, 7, - [&](std::int32_t w) { (void)batch_capacity(g, pairs, CapacityOptions {m.cfg}, {}, w); }, + name, workers, in_workers, step, [&](std::int32_t w) { (void)batch_capacity(g, pairs, opts(), per_pair, w); }, [&](std::int32_t w) { - const auto b = batch_capacity(g, pairs, CapacityOptions {m.cfg}, {}, w); + expect_whole_entries(cache, clean, name + " failed call"); + if (cache && cache->stats().entries > 0) ++kept; + const auto b = batch_capacity(g, pairs, opts(), per_pair, w); ASSERT_EQ(b.size(), want.size()); - for (std::size_t i = 0; i < b.size(); ++i) expect_same(b[i], want[i], m.name + " batch retry"); - }); - EXPECT_GT(failures, 2) << m.name << " batch workers " << workers; // not vacuous + for (std::size_t i = 0; i < b.size(); ++i) expect_same(b[i], want[i], name + " retry"); + if (cache) EXPECT_EQ(cache->stats().entries, clean) << name << " retry"; + }, + [&] { + if (o.cache) cache.emplace(g); + }, + [&] { cache.reset(); }); + EXPECT_GT(failures, 2) << name << " workers " << workers; // not vacuous } } + if (o.cache) EXPECT_GT(kept, 0) << name; } -void sweep_sensitivity(const Mode& m, const Graph& g, std::span workers_set) { - const SensitivityOptions opts {m.cfg}; - const auto want = sensitivity(g, m.sources, m.destinations, opts, 1); +void sweep_sensitivity(const Mode& m, const Graph& g, std::span workers_set, long step, + const Options& o = {}) { + std::optional cache; + const auto opts = [&] { return SensitivityOptions {m.cfg, o.elig, cache ? &*cache : nullptr}; }; + if (o.cache) cache.emplace(g); + const auto want = sensitivity(g, m.sources, m.destinations, opts(), 1); + const std::int64_t clean = cache ? cache->stats().entries : 0; + cache.reset(); // each attempt gets a fresh one (setup) + const std::string name = m.name + " sensitivity" + o.name; + long kept = 0; // failed calls whose cache kept the entries of the lookups before the failure for (const std::int32_t workers : workers_set) { for (const bool in_workers : {false, true}) { if (in_workers && workers == 1) continue; const long failures = sweep_evaluator( - m.name + " sensitivity", workers, in_workers, 11, - [&](std::int32_t w) { (void)sensitivity(g, m.sources, m.destinations, opts, w); }, + name, workers, in_workers, step, + [&](std::int32_t w) { (void)sensitivity(g, m.sources, m.destinations, opts(), w); }, [&](std::int32_t w) { - const auto s = sensitivity(g, m.sources, m.destinations, opts, w); + expect_whole_entries(cache, clean, name + " failed call"); + if (cache && cache->stats().entries > 0) ++kept; + const auto s = sensitivity(g, m.sources, m.destinations, opts(), w); + expect_same(s.baseline, want.baseline, name + " retry"); ASSERT_EQ(s.entries.size(), want.entries.size()); for (std::size_t i = 0; i < s.entries.size(); ++i) { - EXPECT_EQ(s.entries[i].edge, want.entries[i].edge) << m.name; - EXPECT_EQ(s.entries[i].loss, want.entries[i].loss) << m.name; + EXPECT_EQ(s.entries[i].edge, want.entries[i].edge) << name; + EXPECT_EQ(s.entries[i].loss, want.entries[i].loss) << name; } - }); - EXPECT_GT(failures, 2) << m.name << " sensitivity workers " << workers; + }, + [&] { + if (o.cache) cache.emplace(g); + }, + [&] { cache.reset(); }); + EXPECT_GT(failures, 2) << name << " workers " << workers; } } + if (o.cache) EXPECT_GT(kept, 0) << name; } } // namespace +// The hook's worker-only mode, which every sweep of the evaluators with several workers relies on: +// it counts and fails only the allocations of threads other than the caller's, so an allocation +// of the calling thread neither fails nor advances the countdown (were the thread filter lost, +// those sweeps would quietly fail any thread's allocations). reaches_in_workers_at accepts an +// injected failure that reaches the caller or loses to another exception that fn handles, and +// reports a std::bad_alloc that it did not inject. +TEST(FaultHook, WorkerOnlyModeFailsOnlyWorkerAllocations) { + const auto caller_allocates = [] { ::operator delete(::operator new(8)); }; + // Three allocations of the calling thread, then one of a worker, whose exception the caller + // rethrows after the join (as the evaluators do). + const auto worker_allocates = [](bool handle) { + for (int i = 0; i < 3; ++i) ::operator delete(::operator new(8)); + std::exception_ptr error; + std::thread([&] { + try { + ::operator delete(::operator new(8)); + } catch (...) { + error = std::current_exception(); + } + }).join(); + if (error && !handle) std::rethrow_exception(error); + }; + EXPECT_EQ(failures_of([&] { EXPECT_FALSE(fails_in_workers_at(0, caller_allocates)); }), 0); + EXPECT_EQ(failures_of([&] { EXPECT_TRUE(fails_in_workers_at(0, [&] { worker_allocates(false); })); }), 0); + EXPECT_EQ(failures_of([&] { EXPECT_FALSE(fails_in_workers_at(1, [&] { worker_allocates(false); })); }), 0); + bool bad_alloc = false; + EXPECT_EQ(failures_of([&] { EXPECT_TRUE(reaches_in_workers_at(0, [&] { worker_allocates(false); }, bad_alloc)); }), 0); + EXPECT_TRUE(bad_alloc); + EXPECT_EQ(failures_of([&] { EXPECT_TRUE(reaches_in_workers_at(0, [&] { worker_allocates(true); }, bad_alloc)); }), 0); + EXPECT_FALSE(bad_alloc); // lost to an outcome fn handled + EXPECT_EQ(failures_of([&] { EXPECT_FALSE(reaches_in_workers_at(0, caller_allocates, bad_alloc)); }), 0); + // A std::bad_alloc of fn's own while the armed worker failure is never reached. + EXPECT_EQ(failures_of([&] { (void)reaches_in_workers_at(1000, [] { throw std::bad_alloc(); }, bad_alloc); }), 1); +} + +// Every allocation of capacity and batch_capacity on one worker fails once, for every mode, without +// options, with a static Eligibility and (for CostOnly, the one routing model that reads a cache) +// with a supplied building cache. sensitivity repeats capacity's work once per candidate edge, so +// its exhaustive sweep runs on one mode per routing model with the same options (every allocation +// site of its own code: the deletion values, the intersections, the CostOnly skip, the per- +// candidate cache clear); the other modes fail at every 11th allocation. TEST(PolicyFaults, EvaluatorsLeaveNoPoisonedScratch) { const auto g = graph(); + const Eligibility no_bypass = without_bypass(*g); + const Eligibility no_cross = without_cross(*g); const std::int32_t one[] = {1}; + const std::vector exhaustive_sensitivity = {"costonly eb", "capacityaware weighted diminishing", "global", + "pinned eb cohort"}; + int swept_sensitivity = 0; for (const auto& m : modes(*g)) { - const CapacityOptions copts {m.cfg, nullptr, nullptr, OutputOptions {true, true, true}}; - const auto want = capacity(*g, m.sources, m.destinations, copts); - for (long at = 0;; ++at) { - PlacementResult got; - if (!fails_at(at, [&] { got = capacity(*g, m.sources, m.destinations, copts); })) { - expect_same(got, want, m.name + " capacity (no failure)"); - break; + const bool reads_cache = std::holds_alternative(m.cfg.routing); + const bool every_sensitivity_allocation = + std::find(exhaustive_sensitivity.begin(), exhaustive_sensitivity.end(), m.name) != exhaustive_sensitivity.end(); + for (const auto& o : option_sets(no_bypass, no_cross)) { + if (o.cache && !reads_cache) continue; + const std::string name = m.name + " capacity" + o.name; + std::optional cache; + const auto copts = [&] { + return CapacityOptions {m.cfg, o.elig, cache ? &*cache : nullptr, OutputOptions {true, true, true}}; + }; + if (o.cache) cache.emplace(*g); + const auto want = capacity(*g, m.sources, m.destinations, copts()); + const std::int64_t clean = cache ? cache->stats().entries : 0; + if (o.cache) EXPECT_GT(clean, 0) << name; // the cache is read + cache.reset(); // each attempt supplies a fresh one + long injected = 0; + long kept = 0; // failed calls whose cache kept the entry of the lookup before the failure + long empty = 0; + for (long at = 0;; ++at) { + const std::string w = name + " allocation " + std::to_string(at); + // Everything an attempt allocates is freed when it ends, the supplied cache with it (its + // kept entries are no leak), so an allocation left live is a leak of the failed call. + const long live = g_live.load(); + if (o.cache) cache.emplace(*g); + bool failed = false; + { + PlacementResult got; + failed = fails_at(at, [&] { got = capacity(*g, m.sources, m.destinations, copts()); }); + if (!failed) { + expect_same(got, want, name + " (no failure)"); + } else { + ++injected; + expect_whole_entries(cache, clean, w); + if (cache) { + // The memo keeps a completed lookup's entry and its statistics: one miss per entry. + const auto s = cache->stats(); + ++(s.entries > 0 ? kept : empty); + if (s.entries > 0) { + EXPECT_EQ(s.misses, s.entries) << w; + EXPECT_EQ(s.hits, 0) << w; + } + } + expect_same(capacity(*g, m.sources, m.destinations, copts()), want, w + " (retry)"); + if (cache) EXPECT_EQ(cache->stats().entries, clean) << w; + } + } + cache.reset(); + EXPECT_EQ(g_live.load(), live) << w << ": a leak"; + if (!failed) break; + } + EXPECT_GT(injected, 0) << name; // not vacuous + if (o.cache) { + EXPECT_GT(kept, 0) << name; // failures after the lookup, and before it + EXPECT_GT(empty, 0) << name; } - expect_same(capacity(*g, m.sources, m.destinations, copts), want, m.name + " capacity retry " + std::to_string(at)); + if (!std::holds_alternative(m.cfg.routing)) sweep_batch(m, *g, one, 1, o); // batch rejects Pinned + if (every_sensitivity_allocation) { + sweep_sensitivity(m, *g, one, 1, o); + ++swept_sensitivity; + } else if (o.elig == nullptr && !o.cache) { + sweep_sensitivity(m, *g, one, 11, o); + } + if (::testing::Test::HasFailure()) return; } - if (std::holds_alternative(m.cfg.routing)) continue; - sweep_batch(m, *g, one); - sweep_sensitivity(m, *g, one); - if (::testing::Test::HasFailure()) return; } + EXPECT_EQ(swept_sensitivity, 4 * 2 + 1); // four modes with two option sets, CostOnly's with three } TEST(PolicyFaults, BatchWorkersFailWithoutTerminating) { @@ -410,7 +635,7 @@ TEST(PolicyFaults, BatchWorkersFailWithoutTerminating) { const std::int32_t several[] = {2, 4}; for (const auto& m : modes(*g)) { if (std::holds_alternative(m.cfg.routing)) continue; - sweep_batch(m, *g, several); + sweep_batch(m, *g, several, 7); if (::testing::Test::HasFailure()) return; } } @@ -419,8 +644,7 @@ TEST(PolicyFaults, SensitivityWorkersFailWithoutTerminating) { const auto g = graph(); const std::int32_t several[] = {2, 4}; for (const auto& m : modes(*g)) { - if (std::holds_alternative(m.cfg.routing)) continue; - sweep_sensitivity(m, *g, several); + sweep_sensitivity(m, *g, several, 11); if (::testing::Test::HasFailure()) return; } } @@ -430,51 +654,81 @@ TEST(PolicyFaults, RemovalAndReleaseNeverAllocate) { for (const auto& m : modes(*g)) { FlowLedger fg(g); auto p = std::make_unique(fg, m.sources, m.destinations, m.cfg); - (void)p->place(5); - EXPECT_FALSE(fails_at(0, [&] { p->remove(); })) << m.name; - EXPECT_TRUE(fg.records().empty()) << m.name; - (void)p->place(3); - const auto held = fg.allocated(); - EXPECT_FALSE(fails_at(0, [&] { p.reset(); })) << m.name; - EXPECT_EQ(fg.allocated(), held) << m.name; - EXPECT_TRUE(LedgerAccess::bound_owners(fg).empty()) << m.name; + expect_removal_and_release_allocate_nothing(fg, p, m.name); } } -// The working-residual overlay and the per-source scratch grow with the graph: every position -// fails on a graph larger than any before, so the failure lands while they grow; the failed call -// changes nothing and a clean retry on the same graph works (ASan reports any access outside a -// partly grown array). +// The working-residual overlay and the per-source scratch grow with the graph. Each attempt runs +// on a new thread (its scratch empty) that first places on a 3-node graph, then the swept call on +// a larger one, so every allocation of that call fails once while its scratch grows from the +// small size; the failed call changes nothing, and a clean retry on the same graph (or the call +// itself, once no failure is reached) gives the reference call's result, every output included, +// ledger and epoch (ASan reports any access outside a partly grown array). The same call on a new +// thread whose scratch has first grown to the large graph allocates less: the sweep reached the +// growth. TEST(PolicyFaults, ScratchGrowthFailureLeavesNothingHalfGrown) { - int failures = 0; - std::int32_t n = 400; - for (long at = 0;; ++at, n += 300) { - std::vector chain; - for (NodeId u = 0; u + 1 < n; ++u) chain.push_back({u, u + 1, 5, 1}); - auto g = std::make_shared(make_graph(n, chain)); - FlowLedger fg(g); - FlowPolicy p(fg, std::vector {0}, std::vector {n - 1}, - FlowPolicyConfig {CostOnly {}, FlowPlacement::EqualBalanced, Admission::Lossless, - PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity)}); - const LedgerState before = ledger_state(fg); - PlacementResult got; - const bool failed = fails_at(at, [&] { got = p.place(2, OutputOptions {true, true, true}); }); - if (failed) { - ++failures; - expect_same(ledger_state(fg), before, "growth allocation " + std::to_string(at)); - got = p.place(2, OutputOptions {true, true, true}); - } - EXPECT_EQ(got.placed, 2) << "growth allocation " << at; - expect_balanced(fg, "growth allocation " + std::to_string(at)); - if (!failed) break; + const FlowPolicyConfig cfg = cost_only_config(); + const OutputOptions all {true, true, true}; + const auto small = chain_graph(3, 5); + const auto large = chain_graph(3000, 5); + const std::int32_t n = large->num_nodes(); + // The reference: the call on a fresh ledger without a failure. + PlacementResult want; + LedgerState want_state; + Epoch want_epoch; + { + FlowLedger fg(large); + FlowPolicy p(fg, std::vector {0}, std::vector {n - 1}, cfg); + want = p.place(2, all); + want_state = ledger_state(fg); + want_epoch = epoch(p); } - EXPECT_GT(failures, 4); + ASSERT_EQ(want.placed, 2); // the whole chain carries the request: the comparison is not of zeros + sweep_scratch_growth( + "placement", + [&] { + FlowLedger fg(small); + FlowPolicy p(fg, std::vector {0}, std::vector {2}, cfg); + EXPECT_EQ(p.place(2, all).placed, 2); + }, + [&](long at, const std::string& w) { + FlowLedger fg(large); + FlowPolicy p(fg, std::vector {0}, std::vector {n - 1}, cfg); + const LedgerState before = ledger_state(fg); + const Epoch before_epoch = epoch(p); + PlacementResult got; + const bool failed = fails_at(at, [&] { got = p.place(2, all); }); + if (failed) { + expect_same(ledger_state(fg), before, w); + expect_same(epoch(p), before_epoch, w); + got = p.place(2, all); + } + expect_same(got, want, w); + expect_same(ledger_state(fg), want_state, w); + expect_same(epoch(p), want_epoch, w); + expect_balanced(fg, w); + return failed; + }, + [&] { + FlowLedger fg(large); + FlowPolicy p(fg, std::vector {0}, std::vector {n - 1}, cfg); + (void)p.place(2, all); // the scratch grows to the large graph + p.remove(); + long warm = 0; + while (fails_at(warm, [&] { + (void)p.place(2, all); + p.remove(); + })) { + ++warm; + } + return warm; + }); } TEST(PolicyFaults, FrozenLookupsFailWithoutCounting) { - // A frozen hit allocates only the key of roots that are not already a sorted set (roots that are - // one are borrowed: nothing; task 1d-msd), a frozen miss its key and the DagCacheMiss message, - // all before counting: a failure there counts nothing and changes no entry; a clean retry counts. + // A frozen lookup allocates a key only for roots that are not already a sorted set (a sorted set + // is borrowed: nothing), and a frozen miss builds the DagCacheMiss message, all before counting: + // a failure there counts nothing and changes no entry; a clean retry counts. const auto g = graph(); DagCache cache(*g); const std::vector unsorted = {6, 5}; @@ -487,6 +741,8 @@ TEST(PolicyFaults, FrozenLookupsFailWithoutCounting) { const auto& present = kind == 0 ? unsorted : sorted; long failures = 0; for (long at = 0;; ++at) { + const std::string w = std::string(hit ? "hit" : "miss") + " allocation " + std::to_string(at); + const long live = g_live.load(); const auto before = cache.stats(); bool missed = false; const auto call = [&] { @@ -497,8 +753,8 @@ TEST(PolicyFaults, FrozenLookupsFailWithoutCounting) { } }; const bool failed = fails_at(at, call); + EXPECT_EQ(g_live.load(), live) << w << ": a leak"; // the key and the miss's message are gone const auto s = cache.stats(); - const std::string w = std::string(hit ? "hit" : "miss") + " allocation " + std::to_string(at); EXPECT_EQ(s.entries, 1) << w; EXPECT_EQ(s.bytes, before.bytes) << w; EXPECT_EQ(s.live_values, before.live_values) << w; @@ -516,7 +772,7 @@ TEST(PolicyFaults, FrozenLookupsFailWithoutCounting) { if (kind == 1) { EXPECT_EQ(failures, 0) << "hit on a sorted set"; // borrowed: no allocation at all } else { - EXPECT_GE(failures, hit ? 1 : 2) << (hit ? "hit" : "miss"); // the key; the message (and a key) + EXPECT_GE(failures, hit ? 1 : 2) << (hit ? "hit" : "miss"); // the key; the message (sorted roots) } } } @@ -524,22 +780,26 @@ TEST(PolicyFaults, FrozenLookupsFailWithoutCounting) { namespace { void expect_stats(const DagCache::Stats& got, const DagCache::Stats& want, const std::string& where) { - EXPECT_EQ(got.hits, want.hits) << where; - EXPECT_EQ(got.misses, want.misses) << where; - EXPECT_EQ(got.entries, want.entries) << where; - EXPECT_EQ(got.bytes, want.bytes) << where; - EXPECT_EQ(got.live_values, want.live_values) << where; + EXPECT_EQ(stats_of(got), stats_of(want)) << where << " (hits, misses, entries, bytes, live values)"; } // One public call reading a supplied frozen cache, under every injected failure (every `step`-th // allocation in any thread, or every third in the worker threads only): a call failing with -// std::bad_alloc leaves all five statistics as they were; the call that completes adds exactly -// `hits` hits, or, when its outcome is the DagCacheMiss (`miss`), exactly one miss and no hit -// (NOTES R27). Returns the number of injected failures. +// std::bad_alloc leaves all five statistics as they were and no allocation live; the call that +// completes adds exactly `hits` hits, or, when its outcome is the DagCacheMiss (`miss`), exactly +// one miss and no hit. A clean call before the sweep grows the calling thread's scratch, which +// outlives the attempts (the workers' scratch ends with their threads). Returns the number of +// injected failures. long sweep_frozen_stats(const std::string& name, const DagCache& cache, bool in_workers, long step, std::int64_t hits, bool miss, const std::function& call) { + try { + call(); + } catch (const DagCacheMiss&) { + } long failures = 0; for (long at = 0;; at += in_workers ? 3 : step) { + const std::string w = name + " allocation " + std::to_string(at) + (in_workers ? " (worker threads)" : ""); + const long live = g_live.load(); const auto before = cache.stats(); bool missed = false; const auto fn = [&] { @@ -550,7 +810,7 @@ long sweep_frozen_stats(const std::string& name, const DagCache& cache, bool in_ } }; const bool failed = in_workers ? fails_in_workers_at(at, fn) : fails_at(at, fn); - const std::string w = name + " allocation " + std::to_string(at) + (in_workers ? " (worker threads)" : ""); + EXPECT_EQ(g_live.load(), live) << w << ": a leak"; auto want = before; if (failed) { ++failures; @@ -571,12 +831,36 @@ long sweep_frozen_stats(const std::string& name, const DagCache& cache, bool in_ } // namespace +// The cache comparisons of the sweeps report each defect they exist for: expect_stats any one of +// the five statistics changed; expect_whole_entries more entries than a clean call leaves, a +// value alive outside the cache, and payload bytes without an entry. +TEST(PolicyFaults, TheCacheComparatorsReportEveryDefect) { + const DagCache::Stats s {3, 2, 1, 40, 1}; + EXPECT_EQ(failures_of([&] { expect_stats(s, s, "same"); }), 0); + const std::vector> fields = { + [](auto& x) { ++x.hits; }, [](auto& x) { ++x.misses; }, [](auto& x) { ++x.entries; }, + [](auto& x) { ++x.bytes; }, [](auto& x) { ++x.live_values; }, + }; + for (std::size_t k = 0; k < fields.size(); ++k) { + DagCache::Stats x = s; + fields[k](x); + EXPECT_GT(failures_of([&] { expect_stats(x, s, "stats"); }), 0) << "statistic " << k; + } + EXPECT_EQ(failures_of([&] { expect_whole_entries(s, 1, "whole"); }), 0); + EXPECT_EQ(failures_of([&] { expect_whole_entries(DagCache::Stats {0, 0, 0, 0, 0}, 1, "empty"); }), 0); + EXPECT_GT(failures_of([&] { expect_whole_entries(s, 0, "more entries than a clean call"); }), 0); + EXPECT_GT(failures_of([&] { expect_whole_entries(DagCache::Stats {3, 2, 1, 40, 2}, 1, "a value outside"); }), 0); + EXPECT_GT(failures_of([&] { expect_whole_entries(DagCache::Stats {3, 2, 0, 40, 0}, 1, "bytes, no entry"); }), 0); +} + TEST(PolicyFaults, FrozenStatisticsFollowThePublicCall) { - // review-1d F1: a frozen cache's statistics are observable state (APPROACH 13; the building - // cache's limit 2 does not cover them). Every public call that can read a supplied frozen cache - // (the policy constructor in every mode, capacity in every mode, batch_capacity and sensitivity - // at 1, 2 and 4 workers, in any thread and in the worker threads only) is swept: hits, misses, - // entries, bytes and live values after a failed call equal those before it. + // A frozen cache's statistics are observable state (the memo allowance of a building cache + // does not cover them). Every public call that can read a supplied frozen cache is swept: the + // policy constructor and capacity in every mode; batch_capacity at 1, 2 and 4 workers, in any + // thread and in the worker threads only, with and without a miss; CostOnly sensitivity without + // a re-evaluated deletion at 1, 2 and 4 workers in any thread; and CostOnly sensitivity whose + // deletions miss at 1 worker in any thread and at 2 and 4 workers in the worker threads only. + // Hits, misses, entries, bytes and live values after a failed call equal those before it. const auto g = graph(); const auto all = modes(*g); DagCache cache(*g); @@ -639,19 +923,55 @@ TEST(PolicyFaults, FrozenStatisticsFollowThePublicCall) { } // CostOnly sensitivity whose deletions miss (a fresh uid per deletion): the baseline's hit is // dropped with the failed call, and the miss counts once. - EXPECT_GT(sweep_frozen_stats("frozen costonly sensitivity miss", cache, false, 1, 0, true, - [&] { - (void)sensitivity(*g, eb.sources, eb.destinations, - SensitivityOptions {eb.cfg, nullptr, &cache}, 1); - }), + const auto costonly_miss = [&](std::int32_t workers) { + (void)sensitivity(*g, eb.sources, eb.destinations, SensitivityOptions {eb.cfg, nullptr, &cache}, workers); + }; + EXPECT_GT(sweep_frozen_stats("frozen costonly sensitivity miss", cache, false, 1, 0, true, [&] { costonly_miss(1); }), 2); + // The same with the deletions evaluated by 2 and 4 workers, every third allocation of the + // worker threads failing. The tasks start in increasing index and the lowest failing index's + // exception is raised, so an injected failure either is raised (nothing counted) or loses to a + // lower deletion's miss (the miss counted once, no hit). + for (const std::int32_t workers : {2, 4}) { + const std::string w = "frozen costonly sensitivity miss, worker threads, workers " + std::to_string(workers); + long reached = 0, surfaced = 0; + for (long at = 0;; at += 3) { + const std::string where = w + " allocation " + std::to_string(at); + const long live = g_live.load(); + const auto before = cache.stats(); + bool missed = false; + bool bad_alloc = false; + const bool hit = reaches_in_workers_at( + at, + [&] { + try { + costonly_miss(workers); + } catch (const DagCacheMiss&) { + missed = true; + } + }, + bad_alloc); + EXPECT_EQ(g_live.load(), live) << where << ": a leak"; + EXPECT_NE(missed, bad_alloc) << where; // one outcome or the other + surfaced += hit && bad_alloc ? 1 : 0; + auto want = before; + if (missed) want.misses += 1; + expect_stats(cache.stats(), want, where); + if (!hit || ::testing::Test::HasFailure()) break; + ++reached; + } + EXPECT_GT(reached, 2) << w; + EXPECT_GT(surfaced, 0) << w; + } EXPECT_EQ(cache.stats().entries, 3); } TEST(PolicyFaults, WorkersSharingAFrozenCacheFailCleanly) { - // batch_capacity and CostOnly sensitivity with a frozen cache shared by two and four workers: - // a failure anywhere, or in the worker threads only, propagates as std::bad_alloc after the - // join, the cache keeps its entries, and a clean retry gives the reference. + // batch_capacity with a frozen cache shared by two and four workers, a failure anywhere or in + // the worker threads only, then CostOnly sensitivity with that cache on one worker (its + // statistics with two and four workers: the test above), and Global sensitivity, which reads no + // cache, on two and four workers: the failure propagates as std::bad_alloc after the join, the + // cache keeps its entries, and a clean retry gives the reference. const auto g = graph(); const auto all = modes(*g); const auto& m = all[1]; // costonly eb: reads the cache @@ -714,8 +1034,8 @@ TEST(PolicyFaults, WorkersSharingAFrozenCacheFailCleanly) { } }); EXPECT_GT(failures, 2) << "frozen sensitivity"; - // Global (deletion-mode) sensitivity with the frozen cache shared by every worker: it reads no - // cache, and fails and retries as without one. + // Global sensitivity (each deletion repaired from the baseline's state) with the frozen cache + // shared by every worker: it reads no cache, and fails and retries as without one. const auto git = std::find_if(all.begin(), all.end(), [](const Mode& x) { return x.name == "global"; }); ASSERT_NE(git, all.end()); const auto& gm = *git; diff --git a/tests/cpp/flow_policy_fixture_tests.cpp b/tests/cpp/flow_policy_fixture_tests.cpp index f0806b1..3bcae22 100644 --- a/tests/cpp/flow_policy_fixture_tests.cpp +++ b/tests/cpp/flow_policy_fixture_tests.cpp @@ -1,17 +1,21 @@ -// Prototype 2's 59 fixtures replayed through FlowPolicy (APPROACH 8, 9). +// The 59 fixtures of the independent reference replayed through FlowPolicy. // -// tests/data/policy_fixtures.json was exported from the reference, prototype 2 of the design tree -// (its "source" names it; the design tree is untracked by design; its maintained exporter is -// equivalence/export_policy_fixtures.py there): every ledger, policy and call -// of every fixture in order, with the prototype's values after each operation. The replay checks +// tests/data/policy_fixtures.json was exported from the independent reference (its +// frozen copy, the exporter, the regeneration check and the meaning of the data's historical +// "source" string: tests/reference/policy/README.md): every ledger, policy and call of every +// fixture in order, with the reference's values after each operation. The replay checks // every one of them on the engine: each call's result (placed, remaining, termination, local -// outcomes, flags, drops, the cost curve, the requested outputs), its trace (prototype 2's keys), +// outcomes, flags, drops, the cost curve, the requested outputs), its trace (the reference's keys), // the ledger after it (allocations, residuals, every record field, next FlowId, owners) and the // policy state (roster, requested, +inf, placed demand, usable bundles, per source the flows' -// ids, routes, T, deliveries, allocations, members and weights). An operation the prototype -// rejects must throw here too (the messages differ). Normalised on both sides: the T_max probe -// counts, the flow core's operation counters and ledger uids. The replay needs the private hooks -// (the trace and the creation cap; NETGRAPH_CORE_PRIVATE_HOOKS) and is skipped without them. +// ids, routes, T, deliveries, allocations, members and weights). An operation the reference +// rejects must throw std::invalid_argument here too (the messages differ); any other exception +// is a failure. Normalised on both sides: the T_max probe +// counts, the flow core's operation counters and ledger uids. The trace and the creation cap need +// the private hooks (NETGRAPH_CORE_PRIVATE_HOOKS): without them, in the shipped configuration, the +// replay compares everything but the trace on the 58 fixtures that keep the default cap, and the +// corruption test corrupts what that replay compares: every section but the trace, in those 58 +// fixtures. #include #include @@ -21,8 +25,11 @@ #include #include #include +#include #include #include +#include +#include #include #include #include @@ -44,10 +51,10 @@ #error "NETGRAPH_CORE_TEST_DATA (the tests/data directory) must be defined by the build" #endif -#if NETGRAPH_CORE_PRIVATE_HOOKS - using namespace netgraph::core; using detail::LedgerAccess; +using test::Int128; +using test::UInt128; namespace { @@ -57,7 +64,7 @@ struct J { enum class K { Null, Bool, Int, Dbl, Str, Arr, Obj }; K k {K::Null}; bool b {false}; - __int128 i {0}; + Int128 i {0}; double d {0.0}; std::string s {}; std::vector a {}; @@ -70,7 +77,7 @@ struct J { j.b = v; return j; } - static J integer(__int128 v) { + static J integer(Int128 v) { J j; j.k = K::Int; j.i = v; @@ -92,6 +99,7 @@ struct J { j.k = K::Obj; return j; } + // Builders for the replay. J& set(const std::string& key, J v) { k = K::Obj; o.emplace_back(key, std::move(v)); @@ -113,6 +121,7 @@ struct J { if (p == nullptr) throw std::runtime_error("missing key " + key); return *p; } + // Readers for the replay. [[nodiscard]] bool is_null() const { return k == K::Null; } [[nodiscard]] std::int64_t int64() const { if (k != K::Int) throw std::runtime_error("not an integer"); @@ -120,44 +129,6 @@ struct J { } }; -std::string int128_text(__int128 v) { - if (v == 0) return "0"; - const bool neg = v < 0; - unsigned __int128 u = neg ? static_cast(-(v + 1)) + 1 : static_cast(v); - std::string out; - while (u != 0) { - out.push_back(static_cast('0' + static_cast(u % 10))); - u /= 10; - } - if (neg) out.push_back('-'); - return {out.rbegin(), out.rend()}; -} - -std::string dump(const J& j) { - switch (j.k) { - case J::K::Null: return "null"; - case J::K::Bool: return j.b ? "true" : "false"; - case J::K::Int: return int128_text(j.i); - case J::K::Dbl: { - std::ostringstream os; - os << j.d; - return os.str(); - } - case J::K::Str: return "\"" + j.s + "\""; - case J::K::Arr: { - std::string out = "["; - for (std::size_t n = 0; n < j.a.size(); ++n) out += (n ? "," : "") + dump(j.a[n]); - return out + "]"; - } - case J::K::Obj: { - std::string out = "{"; - for (std::size_t n = 0; n < j.o.size(); ++n) out += (n ? "," : "") + ("\"" + j.o[n].first + "\":") + dump(j.o[n].second); - return out + "}"; - } - } - return "?"; -} - class Parser { public: explicit Parser(const std::string& text) : t_(text) {} @@ -285,7 +256,7 @@ class Parser { j.d = std::strtod(num.c_str(), nullptr); return j; } - __int128 v = 0; + Int128 v = 0; const bool neg = num[0] == '-'; for (std::size_t n = neg ? 1 : 0; n < num.size(); ++n) v = v * 10 + (num[n] - '0'); return J::integer(neg ? -v : v); @@ -308,6 +279,54 @@ const J& data() { return d; } +} // namespace + +TEST(PolicyFixtures, TheDataHoldsEveryReferenceFixture) { + const J& d = data(); + EXPECT_EQ(d.at("format").s, "p2-fixtures/1"); + EXPECT_EQ(d.at("fixtures").a.size(), 59U); +} + +namespace { + +std::string int128_text(Int128 v) { + if (v == 0) return "0"; + const bool neg = v < 0; + UInt128 u = neg ? static_cast(-(v + 1)) + 1 : static_cast(v); + std::string out; + while (u != 0) { + out.push_back(static_cast('0' + static_cast(u % 10))); + u /= 10; + } + if (neg) out.push_back('-'); + return {out.rbegin(), out.rend()}; +} + +std::string dump(const J& j) { + switch (j.k) { + case J::K::Null: return "null"; + case J::K::Bool: return j.b ? "true" : "false"; + case J::K::Int: return int128_text(j.i); + case J::K::Dbl: { + std::ostringstream os; + os << j.d; + return os.str(); + } + case J::K::Str: return "\"" + j.s + "\""; + case J::K::Arr: { + std::string out = "["; + for (std::size_t n = 0; n < j.a.size(); ++n) out += (n ? "," : "") + dump(j.a[n]); + return out + "]"; + } + case J::K::Obj: { + std::string out = "{"; + for (std::size_t n = 0; n < j.o.size(); ++n) out += (n ? "," : "") + ("\"" + j.o[n].first + "\":") + dump(j.o[n].second); + return out + "}"; + } + } + return "?"; +} + // Structural equality; objects by key set, arrays in order. `why` names the first difference. bool same(const J& a, const J& b, const std::string& path, std::string& why) { if (a.k != b.k) { @@ -364,7 +383,7 @@ bool same(const J& a, const J& b, const std::string& path, std::string& why) { template J ints(const T& v) { J j = J::arr(); - for (const auto& x : v) j.push(J::integer(static_cast<__int128>(x))); + for (const auto& x : v) j.push(J::integer(static_cast(x))); return j; } @@ -373,15 +392,15 @@ J pairs(const std::vector>& v) { J j = J::arr(); for (const auto& [x, y] : v) { J p = J::arr(); - p.push(J::integer(static_cast<__int128>(x))); - p.push(J::integer(static_cast<__int128>(y))); + p.push(J::integer(static_cast(x))); + p.push(J::integer(static_cast(y))); j.push(std::move(p)); } return j; } -__int128 wide(const Counter128& c) { - return (static_cast<__int128>(c.hi) << 64) | static_cast<__int128>(c.lo); +Int128 wide(const Counter128& c) { + return test::int128_of(static_cast(c.hi), c.lo); } const char* placement_name(FlowPlacement p) { @@ -567,21 +586,19 @@ std::vector endpoints(const J& j) { return int32s(j); } -std::shared_ptr dag_of(const Graph& g, std::vector edges) { - std::sort(edges.begin(), edges.end()); - std::vector> per(static_cast(g.num_nodes())); - for (EdgeId e : edges) per[static_cast(g.edge_dst()[static_cast(e)])].push_back(e); - std::vector offsets {0}; - std::vector parents; - std::vector via; - for (const auto& entries : per) { - for (EdgeId e : entries) { - parents.push_back(g.edge_src()[static_cast(e)]); - via.push_back(e); - } - offsets.push_back(static_cast(via.size())); - } - return std::make_shared(PredDAG::from_csr(g, offsets, parents, via)); +// A name in the data that the replay does not know: the data and the replay disagree, which is a +// failure of the replay, never a rejection by the engine. +struct UnknownName : std::runtime_error { + using std::runtime_error::runtime_error; +}; + +// The value the data names `name` among `values`; any other name throws UnknownName. +template +T named(const std::string& what, const std::string& name, std::initializer_list> values) { + for (const auto& [n, v] : values) { + if (n == name) return v; + } + throw UnknownName("the data names an unknown " + what + " (" + name + ")"); } FlowPolicyConfig config_of(const Graph& g, const J& c) { @@ -592,7 +609,7 @@ FlowPolicyConfig config_of(const Graph& g, const J& c) { routing = Global {}; } else if (kind == "Pinned") { Pinned p; - for (const auto& b : r.at("bundles").a) p.bundles.push_back(dag_of(g, int32s(b))); + for (const auto& b : r.at("bundles").a) p.bundles.push_back(test::dag_of(g, int32s(b))); routing = std::move(p); } else if (kind == "CapacityAware") { CapacityAware ca; @@ -602,8 +619,10 @@ FlowPolicyConfig config_of(const Graph& g, const J& c) { const J& mc = r.at("max_path_cost"); if (mc.a[0].s == "Absolute") { ca.max_path_cost = Absolute {mc.a[1].int64()}; - } else { + } else if (mc.a[0].s == "Stretch") { ca.max_path_cost = Stretch {mc.a[1].k == J::K::Dbl ? mc.a[1].d : static_cast(mc.a[1].int64())}; + } else { + throw UnknownName("the data names an unknown cost ceiling (" + mc.a[0].s + ")"); } } ca.min_flow_bandwidth = r.at("min_flow_bandwidth").int64(); @@ -614,34 +633,61 @@ FlowPolicyConfig config_of(const Graph& g, const J& c) { d.a[1].k == J::K::Dbl ? d.a[1].d : static_cast(d.a[1].int64())}; } routing = ca; + } else if (kind != "CostOnly") { + throw UnknownName("the data names an unknown routing (" + kind + ")"); } - const std::string placement = c.at("placement").s; - const FlowPlacement fp = placement == "MaxFlow" ? FlowPlacement::MaxFlow - : placement == "Weighted" ? FlowPlacement::Weighted - : FlowPlacement::EqualBalanced; - const Admission adm = c.at("admission").s == "Lossy" ? Admission::Lossy : Admission::Lossless; + const FlowPlacement fp = named("placement", c.at("placement").s, + {{"MaxFlow", FlowPlacement::MaxFlow}, + {"Weighted", FlowPlacement::Weighted}, + {"EqualBalanced", FlowPlacement::EqualBalanced}}); + const Admission adm = named("admission", c.at("admission").s, + {{"Lossless", Admission::Lossless}, {"Lossy", Admission::Lossy}}); const J& sel = c.at("selection"); - const EdgeTieBreak tie = - sel.at("tie_break").s == "Deterministic" ? EdgeTieBreak::Deterministic : EdgeTieBreak::PreferHigherPriority; - const std::string pr = sel.at("priority").s; - const EdgePriority prio = pr == "Residual" ? EdgePriority::Residual - : pr == "Capacity" ? EdgePriority::Capacity - : EdgePriority::Headroom; + const EdgeTieBreak tie = named( + "tie break", sel.at("tie_break").s, + {{"Deterministic", EdgeTieBreak::Deterministic}, {"PreferHigherPriority", EdgeTieBreak::PreferHigherPriority}}); + const EdgePriority prio = named("priority", sel.at("priority").s, + {{"Residual", EdgePriority::Residual}, + {"Capacity", EdgePriority::Capacity}, + {"Headroom", EdgePriority::Headroom}}); return FlowPolicyConfig {std::move(routing), fp, adm, PathSelection(tie, prio, sel.at("multipath").b, sel.at("multi_edge").b)}; } +#if NETGRAPH_CORE_PRIVATE_HOOKS // Restores the creation cap whatever happens. struct CapGuard { std::int64_t old {detail::max_flows_per_call().load()}; ~CapGuard() { detail::max_flows_per_call().store(old); } }; +#endif -// Replays one fixture; returns the failures (empty when every value matches). -std::vector replay(const J& fx) { +// Whether every call of the fixture keeps the default creation cap (without the hooks the cap +// cannot be lowered, so a fixture that lowers it replays only with them). +bool default_cap(const J& fx) { + return std::all_of(fx.at("ops").a.begin(), fx.at("ops").a.end(), [](const J& op) { + const J* cap = op.get("cap"); + return cap == nullptr || cap->int64() == kMaxFlowsPerCall; + }); +} + +// Replays one fixture; returns the failures (empty when every value matches). `compared`, when +// given, counts the expectations compared. `after_rejected_call`, when given, runs with the +// operation's index and its policy right after each call the reference rejects, before the policy +// is compared with its state before the call (the negative control of that comparison changes the +// policy there). `inside_call`, when given, runs with the operation's index inside the guarded +// part of every ledger, copy, reset, policy and placement operation, before the engine's call (the +// negative control of the rejection rule throws there). The engine rejects an operation with +// std::invalid_argument only; any other exception (an internal invariant's std::logic_error, +// std::bad_alloc, a reader of the data) is a failure, also where the reference rejects. +std::vector replay(const J& fx, std::size_t* compared = nullptr, + const std::function& after_rejected_call = {}, + const std::function& inside_call = {}) { std::vector fails; const std::string name = fx.at("name").s; +#if NETGRAPH_CORE_PRIVATE_HOOKS CapGuard guard; +#endif std::vector> graphs; for (const auto& spec : fx.at("graphs").a) { std::vector edges; @@ -665,11 +711,19 @@ std::vector replay(const J& fx) { std::deque eligs; std::vector> policies; std::vector policy_ledger; - std::vector curves; // per policy: the cumulative Global curve of the epoch (F5 normalisation) + std::vector curves; // per policy: the cumulative Global curve of the epoch (zero-call check below) auto check = [&](const std::string& where, const char* what, const J& expected, const J& actual) { + if (compared != nullptr) ++*compared; std::string why; if (!same(expected, actual, what, why)) fails.push_back(name + " " + where + ": " + why); }; + auto not_a_rejection = [&](const std::string& where, const std::exception& e) { + fails.push_back(name + " " + where + ": throws an exception other than a rejection: " + e.what()); + return fails; + }; + auto planted = [&](std::size_t n) { + if (inside_call) inside_call(n); + }; const auto& ops = fx.at("ops").a; for (std::size_t n = 0; n < ops.size(); ++n) { const J& op = ops[n]; @@ -680,15 +734,21 @@ std::vector replay(const J& fx) { if (kind == "ledger") { const auto& g = graphs[static_cast(op.at("graph").int64())]; try { + planted(n); if (op.at("baseline").is_null()) { ledgers.emplace_back(g); } else { ledgers.emplace_back(g, amounts(op.at("baseline"))); } - if (error) fails.push_back(name + " " + where + ": accepted, the prototype rejects it"); + if (error) fails.push_back(name + " " + where + ": accepted, the reference rejects it"); ledger_of_id.push_back(&ledgers.back()); + } catch (const std::invalid_argument& e) { + if (!error) { + fails.push_back(name + " " + where + ": throws " + e.what()); + return fails; // the later operations name a ledger that does not exist + } } catch (const std::exception& e) { - if (!error) fails.push_back(name + " " + where + ": throws " + e.what()); + return not_a_rejection(where, e); } continue; } @@ -696,6 +756,7 @@ std::vector replay(const J& fx) { FlowLedger& fg = *ledger_of_id[static_cast(op.at("ledger").int64())]; bool threw = false; try { + planted(n); if (kind == "copy") { ledgers.emplace_back(fg); ledger_of_id.push_back(&ledgers.back()); @@ -704,11 +765,13 @@ std::vector replay(const J& fx) { } else { fg.reset(amounts(op.at("baseline"))); } - } catch (const std::exception&) { + } catch (const std::invalid_argument&) { threw = true; + } catch (const std::exception& e) { + return not_a_rejection(where, e); } if (threw != error) { - fails.push_back(name + " " + where + (error ? ": accepted, the prototype rejects it" : ": throws")); + fails.push_back(name + " " + where + (error ? ": accepted, the reference rejects it" : ": throws")); return fails; } const FlowLedger& shown = (kind == "copy" && !error) ? ledgers.back() : fg; @@ -720,6 +783,7 @@ std::vector replay(const J& fx) { const auto& g = *fg.graph(); std::unique_ptr pol; try { + planted(n); const Eligibility* elig = nullptr; if (!op.at("elig").is_null()) { eligs.push_back(g.eligibility(int32s(op.at("elig").at("nodes")), int32s(op.at("elig").at("edges")))); @@ -728,12 +792,20 @@ std::vector replay(const J& fx) { auto cfg = config_of(g, op.at("config")); pol = std::make_unique(fg, endpoints(op.at("sources")), endpoints(op.at("destinations")), std::move(cfg), elig); - } catch (const std::exception& e) { - if (!error) fails.push_back(name + " " + where + ": throws " + e.what()); + } catch (const UnknownName& e) { + fails.push_back(name + " " + where + ": " + e.what()); + return fails; + } catch (const std::invalid_argument& e) { + if (!error) { + fails.push_back(name + " " + where + ": throws " + e.what()); + return fails; // the later operations name a policy that does not exist + } continue; + } catch (const std::exception& e) { + return not_a_rejection(where, e); } if (error) { - fails.push_back(name + " " + where + ": accepted, the prototype rejects it"); + fails.push_back(name + " " + where + ": accepted, the reference rejects it"); return fails; } policies.push_back(std::move(pol)); @@ -744,6 +816,11 @@ std::vector replay(const J& fx) { } const auto pi = static_cast(op.at("policy").int64()); const FlowLedger& fg = *policy_ledger[pi]; + if ((kind == "destroy" || kind == "remove") && error) { + // Neither can fail here, so a reference that rejects one is a difference. + fails.push_back(name + " " + where + ": accepted, the reference rejects it"); + return fails; + } if (kind == "destroy") { policies[pi] = nullptr; // the destruction law: the records stay as orphans check(where, "ledger", expect->at("ledger"), ledger_json(fg)); @@ -757,7 +834,10 @@ std::vector replay(const J& fx) { check(where, "policy", expect->at("policy"), policy_json(pol)); continue; } - // place + if (kind != "place") { + fails.push_back(name + " " + where + ": an unknown operation"); + return fails; + } std::optional volume; if (op.at("volume").k == J::K::Int) volume = op.at("volume").int64(); if (op.at("volume").k == J::K::Str && op.at("volume").s != "inf") { @@ -765,8 +845,8 @@ std::vector replay(const J& fx) { continue; } if (op.at("volume").k == J::K::Dbl) { - // A non-integer request: the prototype rejects it, as the bindings do; C++ has no such value. - if (!error) fails.push_back(name + " " + where + ": a non-integer request accepted by the prototype"); + // A non-integer request: the reference rejects it, as the bindings do; C++ has no such value. + if (!error) fails.push_back(name + " " + where + ": a non-integer request accepted by the reference"); continue; } OutputOptions outputs; @@ -774,28 +854,45 @@ std::vector replay(const J& fx) { const auto& o = op.at("outputs").a; outputs = OutputOptions {o[0].b, o[1].b, o[2].b}; } +#if NETGRAPH_CORE_PRIVATE_HOOKS detail::max_flows_per_call().store(op.at("cap").int64()); +#endif + const J policy_before = error ? policy_json(pol) : J::null(); detail::Trace trace; PlacementResult r; bool threw = false; try { + planted(n); +#if NETGRAPH_CORE_PRIVATE_HOOKS r = detail::place_traced(pol, volume, outputs, trace); - } catch (const std::exception&) { +#else + r = pol.place(volume, outputs); +#endif + } catch (const std::invalid_argument&) { threw = true; + } catch (const std::exception& e) { + return not_a_rejection(where, e); } if (threw != error) { - fails.push_back(name + " " + where + (error ? ": accepted, the prototype rejects it" : ": throws")); + fails.push_back(name + " " + where + (error ? ": accepted, the reference rejects it" : ": throws")); return fails; } if (error) { + if (after_rejected_call) after_rejected_call(n, pol); + // A rejected call leaves the ledger as the reference has it and the policy as it was (the + // data holds no policy section for it, so this comparison is not an expectation). check(where, "ledger", expect->at("ledger"), ledger_json(fg)); + std::string why; + if (!same(policy_before, policy_json(pol), "policy", why)) { + fails.push_back(name + " " + where + ": the rejected call changed the policy: " + why); + } continue; } J actual = result_json(r); const J& expected_result = expect->at("result"); if (std::holds_alternative(pol.config().routing)) { - // Contract over prototype (1c NOTES P3, review-1c F5): a Global zero call reports the - // policy's cumulative curve (9.1), which prototype 2 omits; it must equal the curve of the + // Where the engine reports more than the reference: a Global zero call reports the + // policy's cumulative curve, which the reference omits; it must equal the curve of the // epoch's last positive call ([] before any), and only then is it set aside. const J* got = actual.get("cost_distribution"); if (volume.has_value() && *volume == 0 && expected_result.get("cost_distribution") == nullptr && got != nullptr) { @@ -808,7 +905,7 @@ std::vector replay(const J& fx) { if (const J* c = expected_result.get("cost_distribution"); c != nullptr) curves[pi] = *c; } check(where, "result", expected_result, actual); - check(where, "trace", expect->at("trace"), trace_json(trace)); + if (detail::kTraced) check(where, "trace", expect->at("trace"), trace_json(trace)); check(where, "ledger", expect->at("ledger"), ledger_json(fg)); check(where, "policy", expect->at("policy"), policy_json(pol)); } @@ -817,75 +914,358 @@ std::vector replay(const J& fx) { } // namespace -TEST(PolicyFixtures, TheDataHoldsEveryPrototypeFixture) { +TEST(PolicyFixtures, EveryFixtureReplaysIdentically) { const J& d = data(); - EXPECT_EQ(d.at("format").s, "p2-fixtures/1"); - EXPECT_EQ(d.at("fixtures").a.size(), 59U); + std::size_t sections = 0; // the expectations the data holds (without the hooks: no trace) + std::size_t compared = 0; // the expectations the replay compared + std::size_t replayed = 0; + for (const auto& fx : d.at("fixtures").a) { + if (!detail::kTraced && !default_cap(fx)) continue; + ++replayed; + for (const auto& op : fx.at("ops").a) { + if (const J* expect = op.get("expect"); expect != nullptr) { + sections += expect->o.size() - (!detail::kTraced && expect->get("trace") != nullptr ? 1 : 0); + } + } + const auto fails = replay(fx, &compared); + for (const auto& f : fails) ADD_FAILURE() << f; + } + // With the hooks every fixture; without them all but the one that lowers the creation cap. + EXPECT_EQ(replayed, detail::kTraced ? 59U : 58U); + EXPECT_GT(sections, 1000U); + EXPECT_EQ(compared, sections); // every expectation was compared, none skipped } -TEST(PolicyFixtures, EveryFixtureReplaysIdentically) { +namespace { + +// One leaf changed: a null becomes 0, a boolean flips, a number grows by one, a string gains a +// character. Returns its kind: 0 integer, 1 boolean, 2 string, 3 other. +int corrupt(J& leaf) { + switch (leaf.k) { + case J::K::Null: leaf = J::integer(0); return 3; + case J::K::Bool: leaf.b = !leaf.b; return 1; + case J::K::Int: leaf.i += 1; return 0; + case J::K::Dbl: leaf.d += 1.0; return 3; + case J::K::Str: leaf.s += "#"; return 2; + default: return 3; + } +} + +// The member `key` of an object, for writing. +J& member(J& x, const std::string& key) { + for (auto& [k, v] : x.o) { + if (k == key) return v; + } + throw std::out_of_range("no member " + key); +} + +// The first leaf of x in document order (an empty container has none); nullptr when x has none. +J* first_leaf(J& x) { + if (x.k == J::K::Arr) { + for (auto& y : x.a) { + if (J* l = first_leaf(y); l != nullptr) return l; + } + return nullptr; + } + if (x.k == J::K::Obj) { + for (auto& [k, y] : x.o) { + if (J* l = first_leaf(y); l != nullptr) return l; + } + return nullptr; + } + return &x; +} + +// Whether the replay of fx reports a failure that starts with `prefix`. +bool reported(const J& fx, const std::string& prefix) { + return test::has_failure(replay(fx), prefix); +} + +// The replay's other failure clauses, each reported at the operation it concerns: the first leaf +// of every section of every other operation's expectations (failed placements, policies, copies, +// resets, removals, destructions); the error flag of the first operation of every kind and +// outcome in each fixture; the input order of the first graph; an unknown operation; a ledger the +// data accepts and the engine rejects; an unknown and a non-integer volume; the curve a Global +// zero call reports, against the remembered one; a policy changed by a call the data rejects; +// an exception other than a rejection; and a configuration name the replay does not know. +// Without the hooks the fixture that lowers the creation cap is left out, as in the replay test; +// it holds no operation kind or outcome the others lack, so the expected sets are the same. +void expect_every_other_clause_reported() { const J& d = data(); - std::size_t ops = 0; + std::set sections; // "kind error key" combinations corrupted + std::set flags; // "kind error" combinations flipped + std::size_t exercised = 0; // fixtures whose clauses were exercised for (const auto& fx : d.at("fixtures").a) { - ops += fx.at("ops").a.size(); - const auto fails = replay(fx); - for (const auto& f : fails) ADD_FAILURE() << f; + if (!detail::kTraced && !default_cap(fx)) continue; + ++exercised; + const std::string name = fx.at("name").s; + ASSERT_TRUE(replay(fx).empty()) << name; + std::set flipped; + for (std::size_t n = 0; n < fx.at("ops").a.size(); ++n) { + const J& op = fx.at("ops").a[n]; + const std::string kind = op.at("op").s; + const bool error = op.at("error").b; + const std::string combination = kind + (error ? " failed" : " ok"); + const std::string at = name + " op " + std::to_string(n) + " (" + kind + ")"; + if (const J* expect = op.get("expect"); expect != nullptr && (kind != "place" || error)) { + for (const auto& [key, section] : expect->o) { + J copy = fx; + J* leaf = first_leaf(member(member(member(copy, "ops").a[n], "expect"), key)); + ASSERT_NE(leaf, nullptr) << at << " " << key; + corrupt(*leaf); + EXPECT_TRUE(reported(copy, at + ": " + key)) << "undetected: " << at << " " << key; + sections.insert(combination + " " + key); + } + } + if (flipped.insert(combination).second) { + J copy = fx; + auto& flag = member(member(copy, "ops").a[n], "error"); + flag.b = !flag.b; + EXPECT_TRUE(reported(copy, at + ": ")) << "undetected error flag: " << at; + flags.insert(combination); + } + } + J reordered = fx; + J* first = first_leaf(member(member(reordered, "graphs").a[0], "input_index")); + ASSERT_NE(first, nullptr) << name; + corrupt(*first); + EXPECT_TRUE(reported(reordered, name + ": the engine ranks the edges differently")) << name; } - EXPECT_GT(ops, 600U); + EXPECT_EQ(exercised, detail::kTraced ? 59U : 58U); + // Every section of every other operation kind and outcome in the data, and every flag. + EXPECT_EQ(sections, (std::set {"copy failed ledger", "destroy ok ledger", "place failed ledger", + "policy ok policy", "remove ok ledger", "remove ok policy", + "reset failed ledger", "reset ok ledger"})); + EXPECT_EQ(flags, (std::set {"copy failed", "destroy ok", "ledger ok", "place failed", "place ok", + "policy failed", "policy ok", "remove ok", "reset failed", "reset ok"})); + // Op 3 of global_epochs renamed: an unknown operation. + const auto& fixtures = d.at("fixtures").a; + const auto& epochs = *std::find_if(fixtures.begin(), fixtures.end(), + [](const J& fx) { return fx.at("name").s == "global_epochs"; }); + ASSERT_EQ(epochs.at("ops").a[3].at("op").s, "place"); + J renamed = epochs; + member(member(renamed, "ops").a[3], "op") = J::str("move"); + EXPECT_TRUE(reported(renamed, "global_epochs op 3 (move): an unknown operation")); + // A ledger the data accepts and the engine rejects: op 0's baseline, one entry for two edges. + ASSERT_EQ(epochs.at("ops").a[0].at("op").s, "ledger"); + J rejected = epochs; + auto& baseline = member(member(rejected, "ops").a[0], "baseline"); + baseline = J::arr(); + baseline.a.push_back(J::integer(-1)); + EXPECT_TRUE(reported(rejected, "global_epochs op 0 (ledger): throws ")); + // The volume of op 3 of global_epochs: neither "inf" nor an integer, then a non-integer number + // the data says is accepted. + J unknown = epochs; + member(member(unknown, "ops").a[3], "volume") = J::str("many"); + EXPECT_TRUE(reported(unknown, "global_epochs op 3 (place): unexpected volume")); + J fractional = epochs; + auto& volume = member(member(fractional, "ops").a[3], "volume"); + volume.k = J::K::Dbl; + volume.d = 0.5; + EXPECT_TRUE(reported(fractional, "global_epochs op 3 (place): a non-integer request accepted")); + // Op 2 of global_epochs is a Global zero call on a fresh policy, op 3 a positive call with the + // curve [[1, 1]]. With op 3 moved before op 2, the zero call reports [[1, 1]], the remembered + // curve (the zero call's other sections, recorded before the positive call, differ); with op + // 3's curve changed as well, the two curves differ too. + const std::string zero_call = "global_epochs op 3 (place): the zero call's curve is not the cumulative one"; + ASSERT_EQ(dump(epochs.at("ops").a[2].at("volume")), "0"); + J swapped = epochs; + auto& ops = member(swapped, "ops").a; + std::swap(ops[2], ops[3]); + ASSERT_FALSE(reported(swapped, zero_call)); + J* cost = first_leaf(member(member(member(ops[2], "expect"), "result"), "cost_distribution")); + ASSERT_NE(cost, nullptr); + corrupt(*cost); + EXPECT_TRUE(reported(swapped, "global_epochs op 2 (place): result")); + EXPECT_TRUE(reported(swapped, zero_call)); + // Every call the data rejects, with the policy changed after it (its +inf flag flipped; after + // that call only): the comparison with the policy before the call reports it. + std::size_t rejected_calls = 0; + for (const auto& fx : fixtures) { + for (std::size_t n = 0; n < fx.at("ops").a.size(); ++n) { + const J& op = fx.at("ops").a[n]; + if (op.at("op").s != "place" || !op.at("error").b) continue; + ++rejected_calls; + const auto fails = replay(fx, nullptr, [n](std::size_t at, FlowPolicy& p) { + if (at != n) return; + auto& engine = detail::PolicyAccess::engine(p); + engine.infinite = !engine.infinite; + }); + const std::string at = fx.at("name").s + " op " + std::to_string(n) + " (place)"; + EXPECT_TRUE(test::has_failure(fails, at + ": the rejected call changed the policy")) << at; + } + } + EXPECT_EQ(rejected_calls, 4U); + // An exception other than std::invalid_argument (an internal invariant's std::logic_error) + // thrown inside the first operation of each kind, the first one the data rejects where there + // is one: a failure at that operation, never taken for the expected rejection. + std::set planted; + for (const std::string kind : {"ledger", "copy", "reset", "policy", "place"}) { + const J* chosen = nullptr; + std::size_t at = 0; + for (const auto& fx : fixtures) { + for (std::size_t n = 0; n < fx.at("ops").a.size(); ++n) { + const J& op = fx.at("ops").a[n]; + if (op.at("op").s != kind) continue; + if (chosen == nullptr || (op.at("error").b && !chosen->at("ops").a[at].at("error").b)) { + chosen = &fx; + at = n; + } + } + } + ASSERT_NE(chosen, nullptr) << kind; + const bool error = chosen->at("ops").a[at].at("error").b; + planted.insert(kind + (error ? " failed" : " ok")); + const auto fails = replay(*chosen, nullptr, {}, [at](std::size_t n) { + if (n == at) throw std::logic_error("planted"); + }); + const std::string where = chosen->at("name").s + " op " + std::to_string(at) + " (" + kind + ")"; + EXPECT_TRUE(test::has_failure(fails, where + ": throws an exception other than a rejection: planted")) << where; + } + EXPECT_EQ(planted, + (std::set {"copy failed", "ledger ok", "place failed", "policy failed", "reset failed"})); + // A name the replay does not know, in the configuration of global_epochs' policy (op 1) and, + // for the cost ceiling, of creation_fewer_routes' CapacityAware policy (op 1): a failure of the + // replay, never taken for the engine rejecting the policy. + ASSERT_EQ(epochs.at("ops").a[1].at("op").s, "policy"); + for (const std::string path : + {"routing kind", "placement", "admission", "selection tie_break", "selection priority"}) { + J copy = epochs; + J* config = &member(member(copy, "ops").a[1], "config"); + std::string key = path; + if (const auto space = path.find(' '); space != std::string::npos) { + config = &member(*config, path.substr(0, space)); + key = path.substr(space + 1); + } + member(*config, key) = J::str("Unknown"); + EXPECT_TRUE(reported(copy, "global_epochs op 1 (policy): the data names an unknown ")) << path; + } + const auto& fewer = *std::find_if(fixtures.begin(), fixtures.end(), + [](const J& fx) { return fx.at("name").s == "creation_fewer_routes"; }); + J ceiling = fewer; + auto& routing = member(member(member(ceiling, "ops").a[1], "config"), "routing"); + ASSERT_EQ(routing.at("kind").s, "CapacityAware"); + J bound = J::arr(); + bound.push(J::str("Unknown")); + bound.push(J::integer(5)); + member(routing, "max_path_cost") = std::move(bound); + EXPECT_TRUE( + reported(ceiling, "creation_fewer_routes op 1 (policy): the data names an unknown cost ceiling (Unknown)")); +} + +// The comparison's structural clauses, which the leaf corruptions above never reach: equal +// values pass, and an array of another length, a key the engine lacks, a key only the engine has +// and a value of another kind each fail with their own message. +void expect_the_comparison_reports_shape_differences() { + auto diff = [](const std::string& expected, const std::string& engine) { + std::string why; + return same(Parser(expected).parse(), Parser(engine).parse(), "x", why) ? std::string("same") : why; + }; + EXPECT_EQ(diff(R"({"a": [1, 2], "b": null})", R"({"b": null, "a": [1, 2]})"), "same"); + EXPECT_EQ(diff("[1, 2]", "[1]"), "x: length 2 (expected) != 1 (engine): [1,2] vs [1]"); + EXPECT_EQ(diff("[1]", "[1, 2]"), "x: length 1 (expected) != 2 (engine): [1] vs [1,2]"); + EXPECT_EQ(diff(R"({"a": 1, "b": 2})", R"({"a": 1})"), "x.b: missing in the engine's value"); + EXPECT_EQ(diff(R"({"a": 1})", R"({"a": 1, "c": 3})"), "x.c: only in the engine's value (3)"); + EXPECT_EQ(diff(R"({"a": [1]})", R"({"a": 1})"), "x.a: [1] (expected) != 1 (engine)"); + EXPECT_EQ(diff("[[1, 2]]", "[[1, 3]]"), "x[0][1]: 2 (expected) != 3 (engine)"); } -// The comparison is not vacuous: a corrupted expectation is reported. +} // namespace + +// The comparison is not vacuous: a corrupted expectation is reported at the operation and by the +// section it changes. In the first successful placement of every fixture, each section (result, +// trace, ledger, policy) gets several single corruptions: its first and last integer, boolean +// and string leaves, and one leaf drawn at random. Then the replay's other clauses and the +// comparison's structural clauses (above). Without the hooks the replay compares no trace and +// leaves out the fixture that lowers the creation cap, so neither is corrupted; the counts below +// are those of each configuration (the random draws come from std::mt19937_64, whose sequence the +// standard fixes, so they are the same with every standard library). TEST(PolicyFixtures, ACorruptedExpectationFails) { const J& d = data(); + std::mt19937_64 rng(837); std::size_t corrupted = 0; std::size_t detected = 0; + std::size_t fixtures = 0; // fixtures whose first successful placement was corrupted + std::size_t kinds[4] = {0, 0, 0, 0}; // integers, booleans, strings, others for (const auto& fx : d.at("fixtures").a) { - for (std::size_t n = 0; n < fx.at("ops").a.size() && corrupted < 200; ++n) { + if (!detail::kTraced && !default_cap(fx)) continue; + for (std::size_t n = 0; n < fx.at("ops").a.size(); ++n) { const J& op = fx.at("ops").a[n]; if (op.at("op").s != "place" || op.at("error").b) continue; - for (const char* key : {"result", "trace", "ledger", "policy"}) { - J copy = fx; - J* ops = nullptr; - for (auto& [k, v] : copy.o) { - if (k == "ops") ops = &v; - } - J* exp = nullptr; - for (auto& [k, v] : ops->a[n].o) { - if (k == "expect") exp = &v; - } - for (auto& [k, v] : exp->o) { - if (k != key) continue; - // Corrupt the first integer field of this value. - std::function corrupt = [&](J& x) { - if (x.k == J::K::Int) { - x.i += 1; - return true; - } - for (auto& y : x.a) { - if (corrupt(y)) return true; - } - for (auto& [kk, y] : x.o) { - if (corrupt(y)) return true; + ++fixtures; + for (const std::string key : {"result", "trace", "ledger", "policy"}) { + if (!detail::kTraced && key == "trace") continue; + // The leaves of the section in document order, as paths of child indices. + std::vector> leaves; + std::vector path; + std::function walk = [&](const J& x) { + if (x.k == J::K::Arr || x.k == J::K::Obj) { + const std::size_t count = x.k == J::K::Arr ? x.a.size() : x.o.size(); + for (std::size_t i = 0; i < count; ++i) { + path.push_back(i); + walk(x.k == J::K::Arr ? x.a[i] : x.o[i].second); + path.pop_back(); } - return false; - }; - if (!corrupt(v)) continue; + return; + } + leaves.push_back(path); + }; + const J& section = op.at("expect").at(key); + walk(section); + auto leaf = [](auto& root, const std::vector& at) -> auto& { + auto* x = &root; + for (std::size_t i : at) x = x->k == J::K::Arr ? &x->a[i] : &x->o[i].second; + return *x; + }; + std::set> picks; + for (const J::K kind : {J::K::Int, J::K::Bool, J::K::Str}) { + std::vector> of_kind; + for (const auto& at : leaves) { + if (leaf(section, at).k == kind) of_kind.push_back(at); + } + if (!of_kind.empty()) { + picks.insert(of_kind.front()); + picks.insert(of_kind.back()); + } + } + if (!leaves.empty()) picks.insert(leaves[rng() % leaves.size()]); + for (const auto& at : picks) { + J copy = fx; + J* ops = nullptr; + for (auto& [k, v] : copy.o) { + if (k == "ops") ops = &v; + } + J* exp = nullptr; + for (auto& [k, v] : ops->a[n].o) { + if (k == "expect") exp = &v; + } + J* target = nullptr; + for (auto& [k, v] : exp->o) { + if (k == key) target = &leaf(v, at); + } + ASSERT_NE(target->k, J::K::Arr) << "a container is not a leaf"; + ASSERT_NE(target->k, J::K::Obj) << "a container is not a leaf"; + ++kinds[corrupt(*target)]; ++corrupted; - if (!replay(copy).empty()) ++detected; + // Reported at this op, by the comparison of this section. + const std::string where = fx.at("name").s + " op " + std::to_string(n) + " (place): " + key; + if (reported(copy, where)) { + ++detected; + } else { + ADD_FAILURE() << "undetected: " << where; + } } } break; // one placement per fixture } } - EXPECT_GT(corrupted, 100U); + RecordProperty("corruptions", static_cast(corrupted)); + EXPECT_EQ(fixtures, detail::kTraced ? 59U : 58U); + EXPECT_EQ(corrupted, detail::kTraced ? 1132U : 858U); + EXPECT_GT(kinds[0], 200U); + EXPECT_GT(kinds[1], 50U); + EXPECT_GT(kinds[2], 50U); EXPECT_EQ(detected, corrupted); + expect_every_other_clause_reported(); + expect_the_comparison_reports_shape_differences(); } - -#else - -// The replay reads the trace and sets the creation cap: without the private hooks it is skipped. -TEST(PolicyFixtures, TheDataHoldsEveryPrototypeFixture) { GTEST_SKIP() << "needs the private hooks"; } -TEST(PolicyFixtures, EveryFixtureReplaysIdentically) { GTEST_SKIP() << "needs the private hooks"; } -TEST(PolicyFixtures, ACorruptedExpectationFails) { GTEST_SKIP() << "needs the private hooks"; } - -#endif diff --git a/tests/cpp/flow_policy_tests.cpp b/tests/cpp/flow_policy_tests.cpp index 0f83ccc..20b1423 100644 --- a/tests/cpp/flow_policy_tests.cpp +++ b/tests/cpp/flow_policy_tests.cpp @@ -1,29 +1,46 @@ -// The policy engine beyond prototype 2's fixtures (those replay in flow_policy_fixture_tests.cpp): -// configuration validation, the outputs, Global's empty roster, record bundles (sparse storage, -// sharing), the DagCache in building mode and the evaluators (capacity, batch_capacity, -// sensitivity) with workers (APPROACH 8 to 11). +// The policy engine beyond the reference's fixtures (those replay in flow_policy_fixture_tests.cpp): +// configuration validation, the configuration a policy keeps and the selection fields Global and +// Pinned read, request validation (a rejected request changes nothing) and the per-source round +// robin of the cumulative request, the outputs (the bottleneck under the policy's Eligibility), +// Global's empty roster, its roster without flow and its zero call's cumulative curve, record +// bundles (sparse storage, sharing, pruning), records in FlowId order, one flow to several nearest +// destinations, the reduction of the sources' outcomes, CapacityAware's diminishing guard, path-cost +// ceiling (exact at large costs and factors), stretch baseline and bandwidth floor, and the +// evaluators (capacity, batch_capacity, sensitivity) with workers and their default from the +// environment. The DagCache's own modes and key are tested in dag_cache_tests.cpp. #include +#include +#include +#include #include #include #include #include +#include #include #include +#include #include #include +#include +#include +#include +#include #include #include "netgraph/core/dag_cache.hpp" +#include "netgraph/core/detail/evaluator_checks.hpp" #include "netgraph/core/detail/kernels.hpp" #include "netgraph/core/detail/ledger_access.hpp" #include "netgraph/core/detail/policy_engine.hpp" #include "netgraph/core/detail/pred_dag_builder.hpp" -#include "netgraph/core/detail/private_hooks.hpp" +#include "netgraph/core/detail/traversal.hpp" #include "netgraph/core/evaluators.hpp" #include "netgraph/core/flow_ledger.hpp" #include "netgraph/core/flow_policy.hpp" #include "netgraph/core/search.hpp" +#include "state_comparators.hpp" #include "test_utils.hpp" using namespace netgraph::core; @@ -31,14 +48,6 @@ using namespace netgraph::core::test; namespace { -std::shared_ptr shared(Graph g) { - return std::make_shared(std::move(g)); -} - -std::shared_ptr dag_of(const Graph& g, std::vector edges) { - return std::make_shared(detail::PredDAGBuilder::of_edges(g, edges)); -} - PathSelection sel(EdgeTieBreak t = EdgeTieBreak::Deterministic, EdgePriority p = EdgePriority::Capacity, bool multipath = true, bool multi_edge = true) { return PathSelection(t, p, multipath, multi_edge); @@ -48,27 +57,7 @@ FlowPolicyConfig cfg(Routing r, FlowPlacement k, Admission a = Admission::Lossle return FlowPolicyConfig {std::move(r), k, a, s}; } -FlowPolicyConfig global_cfg() { return cfg(Global {}, FlowPlacement::MaxFlow); } - -std::string error_of(const std::function& fn) { - try { - fn(); - } catch (const std::invalid_argument& e) { - return e.what(); - } - return ""; -} - -bool contains(const std::string& s, const std::string& part) { return s.find(part) != std::string::npos; } - -std::vector edges_of(const PredDAG& dag) { - const auto e = detail::PredDAGBuilder::ascending(dag); - return {e.begin(), e.end()}; -} - -std::vector v(std::initializer_list x) { return x; } - -// Two disjoint routes 0 -> 1 -> 3 (caps a, cost 1) and 0 -> 2 -> 3 (caps b, cost c). B' ids: +// Two disjoint routes 0 -> 1 -> 3 (caps a, cost 1) and 0 -> 2 -> 3 (caps b, cost c). EdgeIds: // 0 = 0->1, 1 = 0->2, 2 = 1->3, 3 = 2->3. std::shared_ptr diamond(Amount a, Amount b, Cost c = 1) { return shared(make_graph(4, {{0, 1, a, 1}, {1, 3, a, 1}, {0, 2, b, c}, {2, 3, b, c}})); @@ -79,77 +68,108 @@ std::shared_ptr diamond(Amount a, Amount b, Cost c = 1) { TEST(FlowPolicyConfig, EveryRejectedCombinationBeforeBinding) { auto g = diamond(5, 5); FlowLedger fg(g); - auto rejects = [&](const FlowPolicyConfig& c, const std::string& part, std::vector s = {0}, + // Each combination is refused with its whole message, and no policy is bound afterwards. + auto rejects = [&](const FlowPolicyConfig& c, const std::string& message, std::vector s = {0}, std::vector d = {3}) { - const auto msg = error_of([&] { FlowPolicy p(fg, s, d, c); }); - EXPECT_TRUE(contains(msg, part)) << "expected '" << part << "', got '" << msg << "'"; + EXPECT_EQ(error_of([&] { FlowPolicy p(fg, s, d, c); }), message); EXPECT_TRUE(detail::LedgerAccess::bound_owners(fg).empty()); }; - rejects(cfg(CostOnly {}, FlowPlacement::MaxFlow, Admission::Lossy), "Lossy"); + const std::string lossy_hop_by_hop = + "Lossy admission is rejected with Global and CapacityAware routing (hop-by-hop forwarding without admission " + "control neither grows nor reroutes)"; + rejects(cfg(CostOnly {}, FlowPlacement::MaxFlow, Admission::Lossy), + "Lossy admission is rejected with the MaxFlow kernel: a controller never drops (EqualBalanced or Weighted only)"); rejects(cfg(CostOnly {}, FlowPlacement::Weighted, Admission::Lossless, sel(EdgeTieBreak::Deterministic, EdgePriority::Headroom)), - "Headroom"); + "the Weighted kernel is rejected with priority Headroom (weights are units)"); rejects(cfg(Global {}, FlowPlacement::EqualBalanced), "Global routing requires placement = MaxFlow"); - rejects(cfg(Global {}, FlowPlacement::EqualBalanced, Admission::Lossy), "Lossy"); - rejects(cfg(CapacityAware {}, FlowPlacement::EqualBalanced, Admission::Lossy), "Lossy"); + rejects(cfg(Global {}, FlowPlacement::EqualBalanced, Admission::Lossy), lossy_hop_by_hop); + rejects(cfg(CapacityAware {}, FlowPlacement::EqualBalanced, Admission::Lossy), lossy_hop_by_hop); CapacityAware ca; ca.flow_count = 0; - rejects(cfg(ca, FlowPlacement::MaxFlow), "flow_count"); + rejects(cfg(ca, FlowPlacement::MaxFlow), "CapacityAware flow_count must be >= 1 (got 0)"); ca = {}; ca.flow_count = 3; ca.max_flow_count = 2; - rejects(cfg(ca, FlowPlacement::MaxFlow), "max_flow_count"); + rejects(cfg(ca, FlowPlacement::MaxFlow), "CapacityAware max_flow_count (2) must be >= flow_count (3)"); ca = {}; ca.min_flow_bandwidth = -1; - rejects(cfg(ca, FlowPlacement::MaxFlow), "min_flow_bandwidth"); + rejects(cfg(ca, FlowPlacement::MaxFlow), "CapacityAware min_flow_bandwidth must be in [0, 2^62) units (got -1)"); + // The upper bound: 2^62 - 1 units is a valid floor, 2^62 is not. + ca.min_flow_bandwidth = kDomainLimit; + rejects(cfg(ca, FlowPlacement::MaxFlow), + "CapacityAware min_flow_bandwidth must be in [0, 2^62) units (got 4611686018427387904)"); + ca.min_flow_bandwidth = kDomainLimit - 1; + EXPECT_EQ(error_of([&] { FlowPolicy p(fg, v({0}), v({3}), cfg(ca, FlowPlacement::MaxFlow)); }), ""); ca = {}; ca.max_path_cost = Absolute {-1}; - rejects(cfg(ca, FlowPlacement::MaxFlow), "Absolute"); - for (double f : {0.5, std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity()}) { + rejects(cfg(ca, FlowPlacement::MaxFlow), "max_path_cost Absolute cost must be >= 0 (got -1)"); + for (const auto& [f, shown] : {std::pair {0.5, "0.500000"}, std::pair {std::numeric_limits::quiet_NaN(), "nan"}, + std::pair {std::numeric_limits::infinity(), "inf"}}) { ca = {}; ca.max_path_cost = Stretch {f}; - rejects(cfg(ca, FlowPlacement::MaxFlow), "Stretch"); + rejects(cfg(ca, FlowPlacement::MaxFlow), + std::string("max_path_cost Stretch factor must be finite and in [1, inf) (got ") + shown + ")"); } ca = {}; ca.diminishing = DiminishingReturns {0, 0.5}; - rejects(cfg(ca, FlowPlacement::MaxFlow), "window"); - for (double f : {0.0, 1.0, std::numeric_limits::quiet_NaN()}) { + rejects(cfg(ca, FlowPlacement::MaxFlow), "diminishing window must be >= 1 (got 0)"); + for (const auto& [f, shown] : {std::pair {0.0, "0.000000"}, std::pair {1.0, "1.000000"}, + std::pair {std::numeric_limits::quiet_NaN(), "nan"}}) { ca = {}; ca.diminishing = DiminishingReturns {1, f}; - rejects(cfg(ca, FlowPlacement::MaxFlow), "fraction"); + rejects(cfg(ca, FlowPlacement::MaxFlow), std::string("diminishing fraction must be in (0, 1) (got ") + shown + ")"); } Pinned none; - rejects(cfg(none, FlowPlacement::MaxFlow), "empty bundle list"); + rejects(cfg(none, FlowPlacement::MaxFlow), "Pinned: an empty bundle list is rejected"); + // A null bundle, first or after a valid one: refused before it is read. + Pinned null_first; + null_first.bundles = {nullptr}; + rejects(cfg(null_first, FlowPlacement::MaxFlow), "Pinned: bundle 0 is null"); + Pinned null_second; + null_second.bundles = {dag_of(*g, {0, 2}), nullptr}; + rejects(cfg(null_second, FlowPlacement::MaxFlow), "Pinned: bundle 1 is null"); Pinned dup; dup.bundles = {dag_of(*g, {0, 2}), dag_of(*g, {0, 2})}; - rejects(cfg(dup, FlowPlacement::MaxFlow), "duplicate bundle"); + rejects(cfg(dup, FlowPlacement::MaxFlow), "Pinned: duplicate bundle 1 (equal to bundle 0 before pruning)"); Pinned malformed; malformed.bundles = {dag_of(*g, {0, 3})}; - rejects(cfg(malformed, FlowPlacement::MaxFlow), "malformed bundle"); + rejects(cfg(malformed, FlowPlacement::MaxFlow), "Pinned: malformed bundle 0, no src -> dst walk (src 0, dst 3)"); Pinned ok; ok.bundles = {dag_of(*g, {0, 2})}; - rejects(cfg(ok, FlowPlacement::MaxFlow), "single endpoints", {0, 1}, {3}); + rejects(cfg(ok, FlowPlacement::MaxFlow), + "Pinned routing requires single endpoints (bundles bind one source and one destination)", {0, 1}, {3}); auto other = diamond(5, 5); Pinned foreign; foreign.bundles = {dag_of(*other, {0, 2})}; - rejects(cfg(foreign, FlowPlacement::MaxFlow), "foreign PredDAG"); - rejects(cfg(CostOnly {}, FlowPlacement::MaxFlow), "both endpoint sets", {0, 3}, {3}); - rejects(cfg(CostOnly {}, FlowPlacement::MaxFlow), "empty", {}, {3}); - rejects(cfg(CostOnly {}, FlowPlacement::MaxFlow), "out of node range", {9}, {3}); + rejects(cfg(foreign, FlowPlacement::MaxFlow), "foreign PredDAG: bundle built for another graph"); + rejects(cfg(CostOnly {}, FlowPlacement::MaxFlow), + "node 3 is in both endpoint sets: sources and destinations must not overlap", {0, 3}, {3}); + rejects(cfg(CostOnly {}, FlowPlacement::MaxFlow), "sources: the node set is empty", {}, {3}); + rejects(cfg(CostOnly {}, FlowPlacement::MaxFlow), "sources: node 9 out of node range [0, 4)", {9}, {3}); + // A routing variant left without a mode (valueless) by an exception while a mode was emplaced. + struct ThrowingConversion { + operator Pinned() const { throw std::runtime_error("conversion failed"); } + }; + auto valueless = cfg(CostOnly {}, FlowPlacement::MaxFlow); + EXPECT_THROW(valueless.routing.emplace(ThrowingConversion {}), std::runtime_error); + ASSERT_TRUE(valueless.routing.valueless_by_exception()); + rejects(valueless, "routing: the variant holds no routing mode"); const auto el = other->eligibility({}, std::vector {0}); - EXPECT_TRUE(contains(error_of([&] { FlowPolicy p(fg, v({0}), v({3}), cfg(CostOnly {}, FlowPlacement::MaxFlow), &el); }), - "foreign Eligibility")); + EXPECT_EQ(error_of([&] { FlowPolicy p(fg, v({0}), v({3}), cfg(CostOnly {}, FlowPlacement::MaxFlow), &el); }), + "foreign Eligibility: built for another graph"); DagCache foreign_cache(*other); - EXPECT_TRUE(contains(error_of([&] { - FlowPolicy p(fg, v({0}), v({3}), cfg(CostOnly {}, FlowPlacement::MaxFlow), nullptr, &foreign_cache); - }), - "foreign DagCache")); + EXPECT_EQ(error_of([&] { + FlowPolicy p(fg, v({0}), v({3}), cfg(CostOnly {}, FlowPlacement::MaxFlow), nullptr, &foreign_cache); + }), + "foreign DagCache: bound to another graph"); EXPECT_TRUE(detail::LedgerAccess::bound_owners(fg).empty()); } -// Rule 13 at construction: PathSelection and FlowPolicyConfig reject a value outside its -// enumeration (here 99, reachable by a cast) with std::invalid_argument naming the field, in +// Enumeration checks at construction: PathSelection and FlowPolicyConfig reject a value outside its +// enumeration (here 97 to 99, reachable by a cast) with std::invalid_argument naming the field, in // validate_config's order; a selection assigned out of its domain after its own construction is -// rejected by the configuration. Combinations are the consumers' (the next test). +// rejected by the configuration. Combinations are the consumers' (EveryRejectedCombinationBefore- +// Binding above and SeveralDefectsReportTheFirstInTheContractOrder below). TEST(FlowPolicyConfig, ConstructorsRejectEnumValuesOutsideTheirEnumeration) { EXPECT_EQ(error_of([] { (void)PathSelection(static_cast(99), EdgePriority::Capacity); }), "tie_break: 99 is not a value of EdgeTieBreak"); @@ -198,7 +218,7 @@ TEST(FlowPolicyConfig, ConstructorsRejectEnumValuesOutsideTheirEnumeration) { } } -// Rule 13 at every consuming entry: the fields stay assignable after construction, so a value +// Enumeration checks at every consuming entry: the fields stay assignable after construction, so a value // outside its enumeration is a domain error named by its field, before any combination check, // at every entry that takes the field; it never reaches a record. TEST(FlowPolicyConfig, EnumValuesOutsideTheirEnumerationAreRejected) { @@ -275,17 +295,340 @@ TEST(FlowPolicyConfig, EnumValuesOutsideTheirEnumerationAreRejected) { EXPECT_EQ(st.hits + st.misses + st.entries, 0); } -TEST(FlowPolicyRequests, ValidationPrecedesTheZeroShortcut) { +// validate_config with several defects at once: one error, the first in the order +// placement, admission, the selection's tie_break and priority (the fields assigned out of their +// enumerations after construction), then the kernel combinations (Lossy with MaxFlow, Weighted +// with Headroom), then the routing rules (Lossy with Global or CapacityAware, Global without +// MaxFlow, CapacityAware's counts, Pinned's bundles). Exact texts: the selection's priority is +// named "selection.priority", never the kernel check's "priority". The same first error at +// construction and at the evaluators; nothing is bound. +TEST(FlowPolicyConfig, SeveralDefectsReportTheFirstInTheContractOrder) { auto g = diamond(5, 5); FlowLedger fg(g); - FlowPolicy p(fg, v({0}), v({3}), cfg(CostOnly {}, FlowPlacement::EqualBalanced)); - EXPECT_TRUE(contains(error_of([&] { (void)p.place(-1); }), "request")); - EXPECT_TRUE(contains(error_of([&] { (void)p.place(kDomainLimit); }), "request")); - EXPECT_EQ(p.place(std::nullopt).placed, 10); - EXPECT_TRUE(contains(error_of([&] { (void)p.place(1); }), "after +inf")); - EXPECT_TRUE(contains(error_of([&] { (void)p.place(0); }), "after +inf")); - p.remove(); - EXPECT_EQ(p.place(1).placed, 1); + const auto bad_placement = static_cast(99); + const auto bad_admission = static_cast(98); + const auto bad_tie = static_cast(97); + const auto bad_priority = static_cast(96); + CapacityAware zero; + zero.flow_count = 0; + struct Case { + FlowPolicyConfig c; + std::string error; + }; + const auto with = [](FlowPolicyConfig c, std::optional k, std::optional a, + std::optional t, std::optional p) { + if (k) c.placement = *k; + if (a) c.admission = *a; + auto& selection = c.selection; + if (t) selection.tie_break = *t; + if (p) selection.priority = *p; + return c; + }; + const std::string lossy_routing = + "Lossy admission is rejected with Global and CapacityAware routing (hop-by-hop forwarding without admission " + "control neither grows nor reroutes)"; + const std::vector cases = { + {with(cfg(Global {}, FlowPlacement::MaxFlow, Admission::Lossy), bad_placement, bad_admission, bad_tie, bad_priority), + "placement: 99 is not a value of FlowPlacement"}, + {with(cfg(Global {}, FlowPlacement::Weighted), std::nullopt, bad_admission, bad_tie, bad_priority), + "admission: 98 is not a value of Admission"}, + {with(cfg(zero, FlowPlacement::MaxFlow, Admission::Lossy), std::nullopt, std::nullopt, bad_tie, bad_priority), + "selection.tie_break: 97 is not a value of EdgeTieBreak"}, + {with(cfg(zero, FlowPlacement::MaxFlow, Admission::Lossy), std::nullopt, std::nullopt, std::nullopt, bad_priority), + "selection.priority: 96 is not a value of EdgePriority"}, + {with(cfg(Global {}, FlowPlacement::Weighted), std::nullopt, std::nullopt, std::nullopt, bad_priority), + "selection.priority: 96 is not a value of EdgePriority"}, + {cfg(Global {}, FlowPlacement::MaxFlow, Admission::Lossy), kLossyKernelError}, + {cfg(zero, FlowPlacement::MaxFlow, Admission::Lossy), kLossyKernelError}, + {cfg(Global {}, FlowPlacement::Weighted, Admission::Lossless, sel(EdgeTieBreak::Deterministic, EdgePriority::Headroom)), + kHeadroomKernelError}, + {cfg(zero, FlowPlacement::Weighted, Admission::Lossy, sel(EdgeTieBreak::Deterministic, EdgePriority::Headroom)), + kHeadroomKernelError}, + {cfg(Pinned {}, FlowPlacement::MaxFlow, Admission::Lossy), kLossyKernelError}, + {cfg(zero, FlowPlacement::EqualBalanced, Admission::Lossy), lossy_routing}, + {cfg(Global {}, FlowPlacement::Weighted, Admission::Lossy), lossy_routing}, + }; + for (const auto& k : cases) { + EXPECT_EQ(error_of([&] { FlowPolicy p(fg, v({0}), v({3}), k.c); }), k.error); + EXPECT_TRUE(detail::LedgerAccess::bound_owners(fg).empty()); + EXPECT_TRUE(fg.records().empty()); + CapacityOptions co {k.c}; + EXPECT_EQ(error_of([&] { (void)capacity(*g, v({0}), v({3}), co); }), k.error); + EXPECT_EQ(error_of([&] { (void)sensitivity(*g, v({0}), v({3}), SensitivityOptions {k.c}, 1); }), k.error); + if (std::holds_alternative(k.c.routing)) continue; // batch_capacity rejects Pinned first + const std::vector, std::vector>> pairs = {{v({0}), v({3})}}; + EXPECT_EQ(error_of([&] { (void)batch_capacity(*g, pairs, co, {}, 1); }), k.error); + } +} + +// The four axes are required: a configuration has no default. +static_assert(!std::is_default_constructible_v); + +// config() is the copy taken at construction: every field as given, and a later change of the +// caller's object changes nothing. +TEST(FlowPolicyConfig, ConfigIsTheCopyTakenAtConstruction) { + auto g = diamond(5, 5); + FlowLedger fg(g); + CapacityAware ca; + ca.flow_count = 2; + ca.max_flow_count = 3; + ca.max_path_cost = Absolute {9}; + ca.min_flow_bandwidth = 1; + ca.reoptimize = true; + ca.diminishing = DiminishingReturns {2, 0.25}; + const auto selection = sel(EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual, false, false); + auto c = cfg(ca, FlowPlacement::EqualBalanced, Admission::Lossless, selection); + FlowPolicy p(fg, v({0}), v({3}), c); + c.placement = FlowPlacement::MaxFlow; + c.selection = sel(); + std::get(c.routing).flow_count = 7; + const auto& got = p.config(); + EXPECT_NE(&got, &c); + EXPECT_EQ(got.placement, FlowPlacement::EqualBalanced); + EXPECT_EQ(got.admission, Admission::Lossless); + EXPECT_TRUE(got.selection == selection); + ASSERT_TRUE(std::holds_alternative(got.routing)); + const auto& r = std::get(got.routing); + EXPECT_EQ(r.flow_count, 2); + EXPECT_EQ(r.max_flow_count, std::optional {3}); + EXPECT_TRUE(r.max_path_cost == std::optional {Absolute {9}}); + EXPECT_EQ(r.min_flow_bandwidth, 1); + EXPECT_TRUE(r.reoptimize); + ASSERT_TRUE(r.diminishing.has_value()); + EXPECT_EQ(r.diminishing->window, 2); + EXPECT_EQ(r.diminishing->fraction, 0.25); + // Pinned: the supplied bundles themselves, in supply order. + Pinned pin; + pin.bundles = {dag_of(*g, {1, 3}), dag_of(*g, {0, 2})}; + FlowLedger fg2(g); + FlowPolicy q(fg2, v({0}), v({3}), cfg(pin, FlowPlacement::MaxFlow)); + ASSERT_TRUE(std::holds_alternative(q.config().routing)); + const auto& kept = std::get(q.config().routing).bundles; + ASSERT_EQ(kept.size(), 2U); + EXPECT_EQ(kept[0].get(), pin.bundles[0].get()); + EXPECT_EQ(kept[1].get(), pin.bundles[1].get()); +} + +// Global and Pinned read only the selection's priority, and only with the Weighted kernel (its +// membership and weights): every other selection gives the same placement. Two parallel edges +// 0 -> 1 of 4 units (EdgeIds 0 and 1), a third party holding 3 units of edge 0. Pinned Weighted +// on both edges, request 5: with priority Capacity the weights are 4 : 4, so the flow is at most +// 1 + 1 = 2 (edge 0 has 1 unit left); with priority Residual they are the residuals 1 : 4 at +// creation, so it carries 1 + 4 = 5. Global (MaxFlow only), Pinned MaxFlow and Pinned +// EqualBalanced place the same under every selection. Control: CostOnly reads the selection (a +// single path, multipath = false, carries less than the two-edge DAG). +TEST(FlowPolicyConfig, GlobalAndPinnedReadOnlyTheWeightedPriority) { + auto g = shared(make_graph(2, {{0, 1, 4, 1}, {0, 1, 4, 1}})); + std::vector all; + for (auto t : {EdgeTieBreak::Deterministic, EdgeTieBreak::PreferHigherPriority}) { + for (auto p : {EdgePriority::Residual, EdgePriority::Capacity, EdgePriority::Headroom}) { + for (bool mp : {true, false}) { + for (bool me : {true, false}) all.push_back(sel(t, p, mp, me)); + } + } + } + using Edges = std::vector>; + // The placement of request 5 after the third party: (placed, the policy's records' edges). + auto run = [&](const Routing& routing, FlowPlacement k, const PathSelection& s) { + FlowLedger fg(g); + Pinned third; + third.bundles = {dag_of(*g, {0})}; + FlowPolicy other(fg, v({0}), v({1}), cfg(third, FlowPlacement::MaxFlow)); + EXPECT_EQ(other.place(3).placed, 3); + FlowPolicy p(fg, v({0}), v({1}), cfg(routing, k, Admission::Lossless, s)); + const auto r = p.place(5); + std::vector edges; + for (const auto& rec : p.records()) edges.push_back(rec.edges); + return std::pair {r.placed, edges}; + }; + Pinned both; + both.bundles = {dag_of(*g, {0, 1})}; + for (const auto& [name, routing, k] : std::vector> { + {"Global", Global {}, FlowPlacement::MaxFlow}, + {"Pinned MaxFlow", both, FlowPlacement::MaxFlow}, + {"Pinned EqualBalanced", both, FlowPlacement::EqualBalanced}}) { + const auto want = run(routing, k, all.front()); + EXPECT_EQ(want.first, k == FlowPlacement::EqualBalanced ? 2 : 5) << name; + for (const auto& s : all) EXPECT_EQ(run(routing, k, s), want) << name; + } + for (const auto& s : all) { + if (s.priority == EdgePriority::Headroom) continue; // rejected with Weighted + const bool residual = s.priority == EdgePriority::Residual; + const auto got = run(both, FlowPlacement::Weighted, s); + EXPECT_EQ(got.first, residual ? 5 : 2); + EXPECT_EQ(got.second, (std::vector {residual ? Edges {{0, 1}, {1, 4}} : Edges {{0, 1}, {1, 1}}})); + } + EXPECT_EQ(run(CostOnly {}, FlowPlacement::MaxFlow, sel()).first, 5); + EXPECT_EQ(run(CostOnly {}, FlowPlacement::MaxFlow, sel(EdgeTieBreak::Deterministic, EdgePriority::Capacity, false)).first, + 1); +} + +namespace { + +// What a rejected call must leave as it was: the policy's epoch state (the requested total, the +// +inf flag, the placed demand, the Global curve and each roster source's flows with their routes) +// and the ledger (every record field, the allocations, the residual, the next FlowId and the +// owners). A flow's source is its roster source's, and its record's sources are compared. +struct PolicyState { + Epoch epoch; + LedgerState ledger; +}; + +PolicyState state_of(const FlowPolicy& p, const FlowLedger& fg) { return {test::epoch(p), ledger_state(fg)}; } + +void expect_same(const PolicyState& a, const PolicyState& b, const std::string& where) { + expect_same(a.epoch, b.epoch, where); + expect_same(a.ledger, b.ledger, where); +} + +// Each source's delivered total, by the records' single source (a source with a flow and no +// delivery included). +std::map delivered_by_source(const FlowPolicy& p) { + std::map out; + for (const auto& r : p.records()) out[r.sources.at(0)] += r.delivered; + return out; +} + +} // namespace + +// A request rejected by validation (out of [0, 2^62), finite after +inf, a cumulative total +// that would reach 2^127) changes neither the policy's epoch state nor the ledger, under every +// routing model; validation precedes the shortcut of a zero request (place(0) after +inf is +// rejected too), and remove() ends the epoch, after which a finite request places again. The +// 2^127 case starts from a requested total set just below the limit. +TEST(FlowPolicyRequests, RejectedRequestsLeaveThePolicyAndTheLedger) { + auto g = diamond(5, 3, 2); + Pinned pinned; + pinned.bundles = {dag_of(*g, {0, 2}), dag_of(*g, {1, 3})}; + CapacityAware ca; + ca.max_path_cost = Absolute {4}; + const std::vector> configs = { + {"CostOnly", cost_only_config()}, + {"CapacityAware", cfg(ca, FlowPlacement::EqualBalanced)}, + {"Global", global_config()}, + {"Pinned", cfg(pinned, FlowPlacement::MaxFlow)}, + {"CostOnly Lossy", cfg(CostOnly {}, FlowPlacement::EqualBalanced, Admission::Lossy)}}; + const std::string range_error = " is outside [0, 2^62) units (requests are integer units or +inf)"; + const std::string after_inf = + "a finite request after +inf is rejected within an epoch (the cumulative finite request is undefined)"; + for (const auto& [name, config] : configs) { + FlowLedger fg(g); + FlowPolicy p(fg, v({0}), v({3}), config); + ASSERT_GT(p.place(4).placed, 0) << name; + const auto finite = state_of(p, fg); + EXPECT_EQ(error_of([&] { (void)p.place(-1); }), "request -1" + range_error) << name; + EXPECT_EQ(error_of([&] { (void)p.place(kDomainLimit); }), "request 4611686018427387904" + range_error) << name; + expect_same(state_of(p, fg), finite, name); + // Just below 2^127: 5 more would reach it, 4 would not. + auto& requested = detail::PolicyAccess::engine(p).requested; + const Counter128 saved = requested; + requested = Counter128 {(std::uint64_t {1} << 63) - 1, std::numeric_limits::max() - 4}; + const auto near_limit = state_of(p, fg); + EXPECT_EQ(error_of([&] { (void)p.place(5); }), "the cumulative request counter would reach 2^127") << name; + expect_same(state_of(p, fg), near_limit, name); + requested = saved; + (void)p.place(std::nullopt); + const auto infinite = state_of(p, fg); + ASSERT_TRUE(infinite.epoch.infinite) << name; + EXPECT_EQ(error_of([&] { (void)p.place(1); }), after_inf) << name; + EXPECT_EQ(error_of([&] { (void)p.place(0); }), after_inf) << name; + EXPECT_EQ(error_of([&] { (void)p.place(-1); }), "request -1" + range_error) << name; + expect_same(state_of(p, fg), infinite, name); + p.remove(); + EXPECT_EQ(p.place(1).placed, 1) << name; + } +} + +// The Lossy counters' 2^127 limit. Finite calls bring a flow's offered total up to the request +// total (below 2^127) and a +inf call then adds its injection, so both checks are reachable; the +// state about 2^65 calls would build is set here through the ledger's commit (a drop counter +// never above its flow's offered counter). On the path 0 -> 1 -> 2 with capacities 10 and 4, +// place(1) leaves residuals 9 and 3; place(+inf) then offers 9 (its injection on edge 0), edge 1 +// carries 3 and drops 6. From L = 2^127: an offered total of L - 10 reaches L - 1 (accepted), L - 9 +// would reach L (rejected); a drop counter of L - 7 reaches L - 1 and passes, so the offered +// check rejects the call; L - 6 would reach L (rejected by the drop check, which runs first). +// A rejected call leaves the policy and the ledger as they were. +TEST(FlowPolicyRequests, LossyCountersStopBelow2To127) { + auto g = shared(make_graph(3, {{0, 1, 10, 1}, {1, 2, 4, 1}})); + const auto below = [](std::uint64_t x) { + return Counter128 {(std::uint64_t {1} << 63) - 1, std::numeric_limits::max() - (x - 1)}; + }; + const std::string offered_error = "the cumulative offered counter would reach 2^127"; + const std::string drop_error = "a cumulative drop counter would reach 2^127"; + struct Case { + std::uint64_t offered_below; + std::optional drop_below; + std::optional error; + }; + for (const auto& c : {Case {10, std::nullopt, std::nullopt}, Case {9, std::nullopt, offered_error}, + Case {7, 7, offered_error}, Case {6, 6, drop_error}}) { + const std::string name = "L - " + std::to_string(c.offered_below); + FlowLedger fg(g); + FlowPolicy p(fg, v({0}), v({2}), cfg(CostOnly {}, FlowPlacement::EqualBalanced, Admission::Lossy)); + ASSERT_EQ(p.place(1).placed, 1) << name; + std::vector drafts(1); + drafts[0].record = p.records().at(0); + drafts[0].id = drafts[0].record.flow_id.second; + drafts[0].record.offered = below(c.offered_below); + if (c.drop_below.has_value()) drafts[0].record.drops = {{1, below(*c.drop_below)}}; + (void)detail::LedgerAccess::commit(fg, detail::PolicyAccess::engine(p).owner, drafts); + const auto before = state_of(p, fg); + if (c.error.has_value()) { + EXPECT_EQ(error_of([&] { (void)p.place(std::nullopt); }), *c.error) << name; + expect_same(state_of(p, fg), before, name); + continue; + } + const auto r = p.place(std::nullopt); + EXPECT_EQ(r.placed, 3) << name; + EXPECT_EQ(r.drops, (std::vector> {{1, 6}})) << name; + const auto records = p.records(); + ASSERT_EQ(records.size(), 1U) << name; + EXPECT_EQ(records[0].offered, below(1)) << name; + EXPECT_EQ(records[0].drops, (std::vector> {{1, Counter128 {0, 6}}})) << name; + EXPECT_EQ(records[0].delivered, 4) << name; + } +} + +// The per-source shares of the cumulative request R (round robin from the lowest source): after +// R units source i of k has floor(R / k) + [i < R mod k]. Sources 0 .. k - 1 with their own +// 100-unit edge to k. k = 3: place(2) from R = 0 offers [1, 1, 0]; the next place(2) (R = 2) +// offers [1, 0, 1], which a rejected call that changed R would move. k = 7, where 2^64 = 2 mod 7: +// from R = 2^64 place(1) offers its unit to source 2, from R = 2^64 + 5 (= 0 mod 7) to source 0, +// so the high word of R counts with its exact weight. +TEST(FlowPolicyRequests, RoundRobinSharesFollowTheCumulativeRequest) { + auto star = [](std::int32_t k) { + std::vector rows; + for (std::int32_t s = 0; s < k; ++s) rows.push_back({s, k, 100, 1}); + return shared(make_graph(k + 1, rows)); + }; + auto sources = [](std::int32_t k) { + std::vector out(static_cast(k)); + std::iota(out.begin(), out.end(), 0); + return out; + }; + using Shares = std::map; + { + auto g = star(3); + FlowLedger fg(g); + FlowPolicy p(fg, sources(3), v({3}), cost_only_config()); + EXPECT_EQ(p.place(2).placed, 2); + EXPECT_EQ(delivered_by_source(p), (Shares {{0, 1}, {1, 1}, {2, 0}})); + EXPECT_EQ(error_of([&] { (void)p.place(-1); }), + "request -1 is outside [0, 2^62) units (requests are integer units or +inf)"); + EXPECT_EQ(error_of([&] { (void)p.place(kDomainLimit); }), + "request 4611686018427387904 is outside [0, 2^62) units (requests are integer units or +inf)"); + EXPECT_EQ(p.place(2).placed, 2); + EXPECT_EQ(delivered_by_source(p), (Shares {{0, 2}, {1, 1}, {2, 1}})); + } + auto g = star(7); + for (const auto& [requested, to] : {std::pair {Counter128 {1, 0}, NodeId {2}}, std::pair {Counter128 {1, 5}, NodeId {0}}}) { + FlowLedger fg(g); + FlowPolicy p(fg, sources(7), v({7}), cost_only_config()); + detail::PolicyAccess::engine(p).requested = requested; + EXPECT_EQ(p.place(1).placed, 1) << requested.lo; + for (const auto& [s, x] : delivered_by_source(p)) EXPECT_EQ(x, s == to ? 1 : 0) << requested.lo << " source " << s; + EXPECT_EQ(delivered_by_source(p).at(to), 1) << requested.lo; + } } TEST(FlowPolicyOutputs, OwnAllocationLedgerResidualBottleneckAndGlobalCut) { @@ -296,9 +639,13 @@ TEST(FlowPolicyOutputs, OwnAllocationLedgerResidualBottleneckAndGlobalCut) { third.bundles = {dag_of(*g, {0, 2})}; FlowPolicy other(fg, v({0}), v({3}), cfg(third, FlowPlacement::MaxFlow)); ASSERT_EQ(other.place(1).placed, 1); - FlowPolicy p(fg, v({0}), v({3}), cfg(CostOnly {}, FlowPlacement::EqualBalanced)); + FlowPolicy p(fg, v({0}), v({3}), cost_only_config()); const OutputOptions all {true, true, true}; auto r = p.place(4, all); + // Both routes cost 2: EqualBalanced puts 2 units on each, so every edge carries 2 of this + // policy's units and the residuals are 5 - 1 - 2 on route 0 -> 1 -> 3 and 3 - 2 on the other. + // Every edge keeps a residual unit, so the sources reach everything: no saturated edge. + EXPECT_EQ(r.placed, 4); ASSERT_TRUE(r.edge_flows && r.residual && r.bottleneck); EXPECT_FALSE(r.min_cut.has_value()); // not Global EXPECT_FALSE(r.cost_distribution.has_value()); @@ -306,8 +653,11 @@ TEST(FlowPolicyOutputs, OwnAllocationLedgerResidualBottleneckAndGlobalCut) { for (const auto& rec : p.records()) { for (const auto& [e, x] : rec.edges) own[static_cast(e)] += x; } + EXPECT_EQ(*r.edge_flows, (std::vector {2, 2, 2, 2})); EXPECT_EQ(*r.edge_flows, own); + EXPECT_EQ(*r.residual, (std::vector {2, 1, 2, 1})); EXPECT_EQ(*r.residual, fg.residual()); + EXPECT_EQ(*r.bottleneck, std::vector {}); EXPECT_EQ(*r.bottleneck, fg.bottleneck(v({0}))); // Nothing requested: absent. r = p.place(1); @@ -315,23 +665,53 @@ TEST(FlowPolicyOutputs, OwnAllocationLedgerResidualBottleneckAndGlobalCut) { // Global: the curve always, min_cut with bottleneck when the solve is maximal (at +inf; none // under a cap of 2 below the maximum 8, which leaves a residual path). FlowLedger fg2(g); - FlowPolicy gl(fg2, v({0}), v({3}), global_cfg()); + FlowPolicy gl(fg2, v({0}), v({3}), global_config()); r = gl.place(2, OutputOptions {false, false, true}); + EXPECT_EQ(r.placed, 2); ASSERT_TRUE(r.cost_distribution.has_value()); EXPECT_EQ(*r.cost_distribution, curve({{2, 2}})); - EXPECT_LT(gl.placed_demand(), 8); + EXPECT_EQ(gl.placed_demand(), 2); EXPECT_FALSE(r.min_cut.has_value()); + // At +inf the remaining 6 of the maximum 5 + 3: both edges out of node 0 saturate, so the + // closure is {0} and the cut is 0 -> 1 and 0 -> 2 (EdgeIds 0 and 1). r = gl.place(std::nullopt, OutputOptions {false, false, true}); EXPECT_EQ(r.placed, 6); + EXPECT_EQ(gl.placed_demand(), 8); ASSERT_TRUE(r.min_cut.has_value()); - EXPECT_EQ(r.min_cut->size(), 2U); + EXPECT_EQ(*r.min_cut, (std::vector {0, 1})); +} + +// The bottleneck output is the ledger's saturation report for the policy's sources under the +// policy's Eligibility. Edges (input order): a = 0 -> 3 (2 units), x = 0 -> 2 (5), y = 2 -> 3 (1). A +// third party carries 1 unit on x and y, which saturates y. The policy excludes x and carries 2 +// units on a, which saturates a. Under the Eligibility the sources reach only node 0: the report is +// [a]. Without it node 2 is reachable too and y is reported as well. +TEST(FlowPolicyOutputs, BottleneckIsTheLedgersForThePolicysSourcesAndEligibility) { + auto g = shared(make_graph(4, {{0, 3, 2, 1}, {0, 2, 5, 1}, {2, 3, 1, 1}})); + const auto id = id_of_input(*g); + const EdgeId a = id[0], x = id[1], y = id[2]; + FlowLedger fg(g); + Pinned third; + third.bundles = {dag_of(*g, {x, y})}; + FlowPolicy other(fg, v({0}), v({3}), cfg(third, FlowPlacement::MaxFlow)); + ASSERT_EQ(other.place(1).placed, 1); + const auto el = g->eligibility({}, std::vector {x}); + FlowPolicy p(fg, v({0}), v({3}), cost_only_config(), &el); + const auto r = p.place(2, OutputOptions {false, false, true}); + EXPECT_EQ(r.placed, 2); + ASSERT_TRUE(r.bottleneck.has_value()); + EXPECT_EQ(*r.bottleneck, std::vector {a}); + EXPECT_EQ(*r.bottleneck, fg.bottleneck(v({0}), &el)); + auto without = std::vector {a, y}; + std::sort(without.begin(), without.end()); + EXPECT_EQ(fg.bottleneck(v({0})), without); // the Eligibility matters here } TEST(FlowPolicyGlobal, EmptyRosterReportsAnEmptyCurveAndAnEmptyCut) { - // No source reaches the destination: the maximum flow is 0 and maximal (APPROACH 2.2). + // No source reaches the destination: the maximum flow is 0 and maximal. auto g = shared(make_graph(3, {{0, 1, 5, 1}})); FlowLedger fg(g); - FlowPolicy p(fg, v({0}), v({2}), global_cfg()); + FlowPolicy p(fg, v({0}), v({2}), global_config()); auto r = p.place(std::nullopt, OutputOptions {false, false, true}); EXPECT_EQ(r.termination, Termination::NoRoute); ASSERT_TRUE(r.cost_distribution.has_value()); @@ -340,21 +720,57 @@ TEST(FlowPolicyGlobal, EmptyRosterReportsAnEmptyCurveAndAnEmptyCut) { EXPECT_TRUE(r.min_cut->empty()); EXPECT_TRUE(p.records().empty()); // The zero request is a no-op: no solve, no cut; the cumulative curve is empty. - FlowPolicy q(fg, v({1}), v({2}), global_cfg()); + FlowPolicy q(fg, v({1}), v({2}), global_config()); r = q.place(0, OutputOptions {false, false, true}); EXPECT_FALSE(r.min_cut.has_value()); ASSERT_TRUE(r.cost_distribution.has_value()); EXPECT_TRUE(r.cost_distribution->empty()); } -// A Global zero call reports the cumulative curve (9.1: its amounts sum to placed_demand(); +// Global with a roster whose maximum flow is 0: NoRoute while nothing was ever placed, Exhausted +// once something was. One edge 0 -> 1 of 2 units that a third party fills: the source statically +// reaches the destination (it is on the roster), and place(1) finds no flow: NoRoute for the call +// and the source, its record created empty (no bundle, no route cost, nothing delivered). After the +// third party leaves, place(1) delivers 1; a third party then takes the other unit, and the next +// place(1) re-solves 1 + 1 on a view of 1: nothing more, Exhausted. +TEST(FlowPolicyGlobal, ARosterWithoutFlowIsNoRouteUntilSomethingWasPlaced) { + auto g = shared(make_graph(2, {{0, 1, 2, 1}})); + FlowLedger fg(g); + Pinned edge; + edge.bundles = {dag_of(*g, {0})}; + auto third = std::make_unique(fg, v({0}), v({1}), cfg(edge, FlowPlacement::MaxFlow)); + ASSERT_EQ(third->place(2).placed, 2); + FlowPolicy p(fg, v({0}), v({1}), global_config()); + auto r = p.place(1); + EXPECT_EQ(r.placed, 0); + EXPECT_EQ(r.termination, Termination::NoRoute); + EXPECT_EQ(r.local_outcomes, (std::vector> {{0, Termination::NoRoute}})); + ASSERT_TRUE(r.cost_distribution.has_value()); + EXPECT_TRUE(r.cost_distribution->empty()); + ASSERT_EQ(p.records().size(), 1U); + EXPECT_TRUE(ascending_edges(*p.records()[0].bundle).empty()); + EXPECT_EQ(p.records()[0].route_cost, kUnreachable); + EXPECT_EQ(p.records()[0].delivered, 0); + third->remove(); + r = p.place(1); + EXPECT_EQ(r.placed, 1); + EXPECT_EQ(r.termination, Termination::Complete); + FlowPolicy fourth(fg, v({0}), v({1}), cfg(edge, FlowPlacement::MaxFlow)); + ASSERT_EQ(fourth.place(1).placed, 1); + r = p.place(1); + EXPECT_EQ(r.placed, 0); + EXPECT_EQ(r.termination, Termination::Exhausted); + EXPECT_EQ(r.local_outcomes, (std::vector> {{0, Termination::Exhausted}})); +} + +// A Global zero call reports the cumulative curve (its amounts sum to placed_demand(); // empty before the first placement and after remove()) and changes nothing; it solves nothing, -// so it claims no cut (review-1c F5). +// so it claims no cut. TEST(FlowPolicyGlobal, ZeroCallReportsTheCumulativeCurve) { using Curve = std::vector>; auto g = shared(make_graph(3, {{0, 1, 3, 7}, {0, 2, 4, 1}, {2, 1, 4, 1}})); FlowLedger fg(g); - FlowPolicy p(fg, v({0}), v({1}), global_cfg()); + FlowPolicy p(fg, v({0}), v({1}), global_config()); auto z = p.place(0); ASSERT_TRUE(z.cost_distribution.has_value()); EXPECT_TRUE(z.cost_distribution->empty()); @@ -381,7 +797,264 @@ TEST(FlowPolicyGlobal, ZeroCallReportsTheCumulativeCurve) { EXPECT_TRUE(z.cost_distribution->empty()); } -TEST(FlowPolicyRecords, BundlesAreSharedOrSparse) { +// The Global layer on random graphs (nonuniform costs 1 to 9 times a factor of 1, 1,000,003 or +// 2^35, zero capacities, a node or edge mask, another owner's occupancy W): each call re-solves the policy's cumulative amount on the view +// W + own. After each of up to two capped calls and after place(+inf), the policy's own flow is +// feasible on the view, conserved, carries the cumulative amount at the minimum cost of that +// amount (the independent Bellman-Ford curve, not the flow core), its records sum to it, and its +// cumulative curve is the optimal curve; at +inf the reported cut separates the sources from the +// destinations with a capacity equal to the flow. capacity() on a fresh ledger reaches the same +// maximum and curve, and batch_capacity() agrees with capacity() per pair. +TEST(FlowPolicyGlobal, RandomCumulativeSolvesAreOptimalWithACertifiedCut) { + std::mt19937_64 rng(20261010); + const OutputOptions all {true, true, true}; + auto below = [&](std::int64_t k) { return static_cast(rng() % static_cast(k)); }; + int capped = 0, masked = 0, occupied = 0, with_flow = 0; + int evaluated = 0; // trials whose evaluators are compared (no other owner) + for (int trial = 0; trial < 400; ++trial) { + const std::int32_t n = 4 + static_cast(rng() % 5); + std::vector rows; + const int m = 6 + static_cast(rng() % 11); + // The cost factor of the trial, outside the random stream: 1, 1,000,003 or 2^35. + const Cost factor = trial % 3 == 0 ? 1 : trial % 3 == 1 ? 1000003 : Cost {1} << 35; + for (int i = 0; i < m; ++i) { + const auto u = static_cast(rng() % static_cast(n)); + auto w = static_cast(rng() % static_cast(n - 1)); + if (w >= u) ++w; + rows.push_back({u, w, static_cast(rng() % 7), static_cast(1 + rng() % 9) * factor}); + } + auto g = shared(make_graph(n, rows)); + const std::string where = "trial " + std::to_string(trial); + // Sources 0 and maybe 1, destinations n - 1 and maybe n - 2. + std::vector srcs = {0}, dsts = {n - 1}; + if (rng() % 2 == 0) srcs.push_back(1); + if (rng() % 2 == 0) dsts.push_back(n - 2); + std::optional el; + switch (rng() % 3) { + case 0: el = g->eligibility(std::vector {below(n)}, {}); break; + case 1: el = g->eligibility({}, std::vector {below(m)}); break; + default: break; + } + const Eligibility* elig = el ? &*el : nullptr; + masked += elig != nullptr ? 1 : 0; + FlowLedger fg(g); + // Another owner: a Global policy between other endpoints (when the graph allows them) takes + // part of the capacity first. + std::optional other; + if (n >= 6 && rng() % 2 == 0) { + other.emplace(fg, v({2}), v({3}), global_config()); + occupied += other->place(static_cast(rng() % 5)).placed > 0 ? 1 : 0; + } + const std::vector view = fg.residual(); // W: the policy's own flow is 0 so far + const auto curve_pts = independent_curve(*g, view, srcs, dsts, elig); + const Amount max_flow = curve_pts.back().first; + FlowPolicy p(fg, srcs, dsts, global_config(), elig); + // The policy's own per-edge flow, its cost, and the checks every call ends with. + auto check_own = [&](Amount total, const PlacementResult& r, const std::string& at) { + std::vector own(view.size(), 0); + Amount recorded = 0; + for (const auto& rec : p.records()) { + recorded += rec.delivered; + for (const auto& [e, x] : rec.edges) own[static_cast(e)] += x; + } + EXPECT_EQ(recorded, total) << at; + EXPECT_EQ(p.placed_demand(), total) << at; + ASSERT_TRUE(r.edge_flows.has_value() && r.residual.has_value()) << at; + EXPECT_EQ(*r.edge_flows, own) << at; + EXPECT_EQ(*r.residual, fg.residual()) << at; + // The flow certificates' flow clauses (test_utils.hpp): within the view, nothing on a masked + // edge, conserved, the cumulative amount out of the sources and into the destinations. + std::vector fails; + (void)flow_clauses(*g, view, own, srcs, dsts, elig, total, fails); + const Int128 cost = flow_cost(*g, own); + EXPECT_TRUE(cost == curve_at(curve_pts, total)) << at; + // The cumulative curve, by the flow certificates' clauses: positive volumes at strictly + // increasing costs, its total the cumulative amount and its moment the flow's cost, and equal + // to the optimal curve at every breakpoint of either up to the total. + ASSERT_TRUE(r.cost_distribution.has_value()) << at; + const auto pts = curve_shape_clauses(*r.cost_distribution, total, cost, fails); + curve_optimum_clause(pts, curve_pts, total, "the optimal curve", fails); + for (const auto& f : fails) ADD_FAILURE() << at << ": " << f; + }; + // Up to two capped calls below the maximum (each re-solves the cumulative amount), then +inf. + Amount total = 0; + for (int call = 0; call < 2; ++call) { + const Amount x = max_flow - total > 1 ? below(max_flow - total) : 0; + if (x == 0) continue; + ++capped; + total += x; + const auto r = p.place(x, all); + EXPECT_EQ(r.placed, x) << where; + EXPECT_FALSE(r.min_cut.has_value()) << where; // stopped by the cap + check_own(total, r, where + " capped call " + std::to_string(call)); + } + const auto r = p.place(std::nullopt, all); + EXPECT_EQ(r.placed, max_flow - total) << where; + with_flow += max_flow > 0 ? 1 : 0; + check_own(max_flow, r, where + " +inf"); + // The cut, as the flow certificates define it (residual_closure and closure_cut, + // test_utils.hpp): the residual closure of the allowed sources is the nodes they still reach + // over allowed edges, forward where the policy's own flow leaves room in the view and backward + // where it can be undone; no destination is in it, and the cut is every allowed edge of + // positive view that leaves it, in ascending EdgeId, with a capacity equal to the maximum flow. + ASSERT_TRUE(r.min_cut.has_value() && r.edge_flows.has_value()) << where; + Amount cut_capacity = 0; + for (const EdgeId e : *r.min_cut) cut_capacity += view[static_cast(e)]; + EXPECT_EQ(cut_capacity, max_flow) << where; + const auto closure = residual_closure(*g, view, *r.edge_flows, srcs, elig); + for (const NodeId d : dsts) EXPECT_EQ(closure[static_cast(d)], 0) << where << " destination " << d; + EXPECT_EQ(*r.min_cut, closure_cut(*g, view, closure, elig)) << where; + // The evaluators on a fresh ledger (static capacities): the same maximum and cost. + if (other.has_value()) continue; + ++evaluated; + const CapacityOptions co {global_config(), elig, nullptr, all}; + const auto cr = capacity(*g, srcs, dsts, co); + EXPECT_EQ(cr.placed, max_flow) << where; + ASSERT_TRUE(cr.cost_distribution.has_value()) << where; + EXPECT_EQ(*cr.cost_distribution, *r.cost_distribution) << where; + const std::vector, std::vector>> pairs = {{srcs, dsts}, {v({0}), v({n - 1})}}; + const auto batch = batch_capacity(*g, pairs, co, {}, 1); + ASSERT_EQ(batch.size(), 2U) << where; + for (std::size_t i = 0; i < pairs.size(); ++i) { + const auto one = capacity(*g, pairs[i].first, pairs[i].second, co); + EXPECT_EQ(batch[i].placed, one.placed) << where << " pair " << i; + EXPECT_EQ(batch[i].cost_distribution, one.cost_distribution) << where << " pair " << i; + EXPECT_EQ(batch[i].min_cut, one.min_cut) << where << " pair " << i; + } + } + // The population reaches every case (this seed: 289 capped calls, 257 masks, 44 occupied + // ledgers, 241 positive maxima of 400 trials). + EXPECT_GT(capped, 220); + EXPECT_GT(masked, 200); + EXPECT_GT(occupied, 33); + EXPECT_GT(with_flow, 180); + EXPECT_GT(evaluated, 100); +} + +// CostOnly and CapacityAware placements on random graphs (costs 1 to 9 times a factor of 1, +// 1,000,003 or 2^35, zero capacities, a node or edge mask, one or two sources and destinations, +// another owner's flow first), in the kernels and admissions these models take: after each of two +// capped calls and a call of +inf, each of the policy's records is a flow of its own from the +// policy's sources to its destinations (expect_conserved, test_utils.hpp: positive amounts in +// ascending EdgeId, every transit node passing on what it receives less its drops, delivered the +// destinations' net inflow and, Lossless, the sources' net outflow), carrying and dropping nothing +// on an edge the Eligibility excludes; a Lossless policy's records deliver its placed demand, and +// the ledger's aggregate is the sum of its records (expect_balanced). These models choose routes +// by cost, not by the minimum-cost flow, so no optimality certificate applies here. +TEST(FlowPolicyRecords, RandomCostOnlyAndCapacityAwareRecordsAreFlows) { + const auto tunnel = sel(EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual, false, false); + const auto res = sel(EdgeTieBreak::Deterministic, EdgePriority::Residual); + CapacityAware grow; + grow.flow_count = 1; + grow.max_flow_count = 4; + CapacityAware reopt; + reopt.flow_count = 2; + reopt.reoptimize = true; + reopt.max_path_cost = Stretch {2.0}; + CapacityAware dim; + dim.flow_count = 1; + dim.diminishing = DiminishingReturns {1, 0.5}; + dim.min_flow_bandwidth = 1; + const std::vector configs = { + cost_only_config(), + cfg(CostOnly {}, FlowPlacement::Weighted), + cfg(CostOnly {}, FlowPlacement::MaxFlow), + cfg(CostOnly {}, FlowPlacement::EqualBalanced, Admission::Lossless, tunnel), + cfg(CostOnly {}, FlowPlacement::EqualBalanced, Admission::Lossy), + cfg(CostOnly {}, FlowPlacement::Weighted, Admission::Lossy, res), + cfg(grow, FlowPlacement::EqualBalanced, Admission::Lossless, tunnel), + cfg(reopt, FlowPlacement::MaxFlow), + cfg(dim, FlowPlacement::Weighted, Admission::Lossless, res), + }; + std::mt19937_64 rng(20261011); + auto below = [&](std::int64_t k) { return static_cast(rng() % static_cast(k)); }; + int carrying_cost_only = 0; // record checks of a CostOnly record that carries something + int carrying_capacity_aware = 0; // the same for CapacityAware + int dropping = 0; // record checks of a Lossy record with drops + int masked = 0; // record checks of a carrying record under a mask + int scaled = 0; // record checks of a carrying record with costs times 1,000,003 or 2^35 + int several = 0; // calls that delivered from two sources or into two destinations + for (int trial = 0; trial < 600; ++trial) { + const FlowPolicyConfig& config = configs[static_cast(trial) % configs.size()]; + const bool cost_only = std::holds_alternative(config.routing); + const std::int32_t n = 4 + static_cast(rng() % 5); + const int m = 6 + static_cast(rng() % 11); + const Cost factor = trial % 3 == 0 ? 1 : trial % 3 == 1 ? 1000003 : Cost {1} << 35; + std::vector rows; + for (int i = 0; i < m; ++i) { + const auto u = static_cast(rng() % static_cast(n)); + auto w = static_cast(rng() % static_cast(n - 1)); + if (w >= u) ++w; + rows.push_back({u, w, static_cast(rng() % 7), static_cast(1 + rng() % 9) * factor}); + } + auto g = shared(make_graph(n, rows)); + std::vector srcs = {0}, dsts = {n - 1}; + if (rng() % 2 == 0) srcs.push_back(1); + if (rng() % 2 == 0) dsts.push_back(n - 2); + std::optional el; + switch (rng() % 3) { + case 0: el = g->eligibility(std::vector {below(n)}, {}); break; + case 1: el = g->eligibility({}, std::vector {below(m)}); break; + default: break; + } + const Eligibility* elig = el ? &*el : nullptr; + FlowLedger fg(g); + std::optional other; + if (n >= 6 && rng() % 2 == 0) { + other.emplace(fg, v({2}), v({3}), cost_only_config()); + (void)other->place(static_cast(rng() % 5)); + } + FlowPolicy p(fg, srcs, dsts, config, elig); + const std::vector> calls = {static_cast(rng() % 6), static_cast(rng() % 6), + std::nullopt}; + for (std::size_t call = 0; call < calls.size(); ++call) { + const std::string where = "trial " + std::to_string(trial) + " call " + std::to_string(call); + (void)p.place(calls[call]); + expect_balanced(fg, where); + Amount delivered = 0; + std::set from, into; + for (const auto& r : p.records()) { + const std::string at = where + " record " + std::to_string(r.flow_id.second); + for (NodeId x : r.sources) { + EXPECT_TRUE(std::find(srcs.begin(), srcs.end(), x) != srcs.end()) << at << " source " << x; + } + for (NodeId x : r.destinations) { + EXPECT_TRUE(std::find(dsts.begin(), dsts.end(), x) != dsts.end()) << at << " destination " << x; + } + const bool carries = expect_conserved(*g, r, at); + for (const auto& [e, x] : r.edges) { + EXPECT_TRUE(elig == nullptr || elig->edge_ok(e)) << at << " carries " << x << " on excluded edge " << e; + } + for (const auto& [e, x] : r.drops) { + EXPECT_TRUE(elig == nullptr || elig->edge_ok(e)) << at << " drops on excluded edge " << e; + } + delivered += r.delivered; + if (r.delivered > 0) { + from.insert(r.sources.begin(), r.sources.end()); + for (const auto& [e, x] : r.edges) { + const NodeId head = g->edge_dst()[static_cast(e)]; + if (std::find(dsts.begin(), dsts.end(), head) != dsts.end()) into.insert(head); + } + } + (cost_only ? carrying_cost_only : carrying_capacity_aware) += carries ? 1 : 0; + dropping += r.drops.empty() ? 0 : 1; + masked += carries && elig != nullptr ? 1 : 0; + scaled += carries && factor > 1 ? 1 : 0; + } + if (config.admission == Admission::Lossless) EXPECT_EQ(delivered, p.placed_demand()) << where; + several += from.size() > 1 || into.size() > 1 ? 1 : 0; + ASSERT_FALSE(::testing::Test::HasFailure()) << where; + } + } + EXPECT_GT(carrying_cost_only, 0); + EXPECT_GT(carrying_capacity_aware, 0); + EXPECT_GT(dropping, 0); + EXPECT_GT(masked, 0); + EXPECT_GT(scaled, 0); + EXPECT_GT(several, 0); +} + +TEST(FlowPolicyRecords, BundlesAreSharedOrPruned) { auto g = diamond(5, 5); FlowLedger fg(g); const auto whole = dag_of(*g, {0, 1, 2, 3}); @@ -398,17 +1071,355 @@ TEST(FlowPolicyRecords, BundlesAreSharedOrSparse) { (void)q.place(2); const auto rec = q.records().front(); EXPECT_NE(rec.bundle.get(), whole.get()); - EXPECT_EQ(edges_of(*rec.bundle), (std::vector {1, 3})); + EXPECT_EQ(ascending_edges(*rec.bundle), (std::vector {1, 3})); EXPECT_EQ(q.usable_bundles(), 1); // CostOnly: a source whose route is the whole cached support shares it. DagCache cache(*g); FlowLedger fg3(g); - FlowPolicy c(fg3, v({0}), v({3}), cfg(CostOnly {}, FlowPlacement::EqualBalanced), nullptr, &cache); + FlowPolicy c(fg3, v({0}), v({3}), cost_only_config(), nullptr, &cache); (void)c.place(2); const auto support = cache.lookup(*g, v({3}), Direction::Reverse, nullptr); EXPECT_EQ(c.records().front().bundle.get(), support.get()); } +// records() lists the live records in FlowId order, also when the sources' flows interleave. +// Sources 0 and 1, each with two parallel edges of 3 and 5 units to node 2; CapacityAware single +// paths, up to two flows per source. place(2) offers 1 to each source: flows 0 (source 0) and 1 +// (source 1). place(14) offers 7 to each: each source fills its flow to 3 and grows a second flow +// on its other edge (5 units), FlowIds 2 (source 0) and 3 (source 1). Per source the flows are +// [0, 2] and [1, 3]; records() gives 0, 1, 2, 3. +TEST(FlowPolicyRecords, RecordsAreInFlowIdOrderAcrossSources) { + auto g = shared(make_graph(3, {{0, 2, 3, 1}, {0, 2, 5, 1}, {1, 2, 3, 1}, {1, 2, 5, 1}})); + FlowLedger fg(g); + CapacityAware ca; + ca.max_flow_count = 2; + FlowPolicy p(fg, v({0, 1}), v({2}), cfg(ca, FlowPlacement::MaxFlow, Admission::Lossless, + sel(EdgeTieBreak::Deterministic, EdgePriority::Capacity, false))); + ASSERT_EQ(p.place(2).placed, 2); + ASSERT_EQ(p.place(14).placed, 14); + std::vector ids; + std::vector sources; + for (const auto& r : p.records()) { + ids.push_back(r.flow_id.second); + sources.push_back(r.sources.at(0)); + } + EXPECT_EQ(ids, (std::vector {0, 1, 2, 3})); + EXPECT_EQ(sources, (std::vector {0, 1, 0, 1})); +} + +// CostOnly and CapacityAware route to the nearest destinations, and one flow may deliver to +// several of them: edges (input order) 0 -> 1 (10 units, cost 1), 1 -> 2 (2, cost 1), 1 -> 3 (3, +// cost 1) and 0 -> 3 (5, cost 5); destinations 2 and 3, both at cost 2 through node 1. The one +// flow carries 2 to node 2 and 3 to node 3 (the edges entering each destination), 5 in all; the +// costlier edge 0 -> 3 is no part of the route. +TEST(FlowPolicyRecords, OneFlowDeliversToTheNearestDestinations) { + auto g = shared(make_graph(4, {{0, 1, 10, 1}, {1, 2, 2, 1}, {1, 3, 3, 1}, {0, 3, 5, 5}})); + const auto id = id_of_input(*g); + CapacityAware fixed; + fixed.max_flow_count = 1; + for (const auto& [name, routing] : std::vector> {{"CostOnly", CostOnly {}}, + {"CapacityAware", fixed}}) { + FlowLedger fg(g); + FlowPolicy p(fg, v({0}), v({2, 3}), cfg(routing, FlowPlacement::MaxFlow)); + const auto r = p.place(std::nullopt); + EXPECT_EQ(r.placed, 5) << name; + const auto records = p.records(); + ASSERT_EQ(records.size(), 1U) << name; + const auto& rec = records[0]; + EXPECT_EQ(rec.delivered, 5) << name; + EXPECT_EQ(rec.route_cost, 2) << name; + EXPECT_EQ(rec.destinations, (std::vector {2, 3})) << name; + std::map into; + for (const auto& [e, x] : rec.edges) into[g->edge_dst()[static_cast(e)]] += x; + EXPECT_EQ(into, (std::map {{1, 5}, {2, 2}, {3, 3}})) << name; + EXPECT_EQ(ascending_edges(*rec.bundle), (std::vector {id[0], id[1], id[2]})) << name; + } +} + +// The call's termination is the highest local outcome among the unserved sources, LimitFlows > +// LimitDiminishing > Exhausted > NoRoute, here around LimitDiminishing. Sources 0 and 1 with +// parallel edges to node 2; CapacityAware single paths (the lowest EdgeId first), MaxFlow, one flow +// at creation, the guard DiminishingReturns(1, 0.5); request 40 offers 20 to each source, so a +// growth step that gains below 10 stops growth. Source 1 has edges of 5, 3 and 10 units: it creates +// 5, grows 3 and stops (LimitDiminishing, 8 placed). Source 0: +// edges 5, 10, 10 and at most 2 flows: 5, then 10 (not below 10), then the ceiling of 2 flows +// above flow_count 1 with the offer unmet: LimitFlows (15), and the call is LimitFlows; +// edges 5 and 10, no ceiling: 5, then 10, then no route is left: Exhausted (15), and the call +// is LimitDiminishing; +// one edge of 5 that a third party fills: no flow (NoRoute), and the call is LimitDiminishing. +TEST(FlowPolicyOutcomes, TheCallTakesTheHighestUnservedOutcome) { + using Outcomes = std::vector>; + struct Case { + const char* name; + std::vector source0; + std::optional max_flow_count; + bool occupied; + Amount placed; + Termination first, call; + }; + const std::vector cases = { + {"LimitFlows over LimitDiminishing", {5, 10, 10}, 2, false, 23, Termination::LimitFlows, Termination::LimitFlows}, + {"LimitDiminishing over Exhausted", {5, 10}, std::nullopt, false, 23, Termination::Exhausted, + Termination::LimitDiminishing}, + {"LimitDiminishing over NoRoute", {5}, std::nullopt, true, 8, Termination::NoRoute, Termination::LimitDiminishing}, + }; + for (const auto& c : cases) { + std::vector rows; + for (const Amount x : c.source0) rows.push_back({0, 2, x, 1}); + for (const Amount x : {5, 3, 10}) rows.push_back({1, 2, x, 1}); + auto g = shared(make_graph(3, rows)); + FlowLedger fg(g); + std::optional third; + if (c.occupied) { + Pinned edge; + edge.bundles = {dag_of(*g, {0})}; + third.emplace(fg, v({0}), v({2}), cfg(edge, FlowPlacement::MaxFlow)); + ASSERT_EQ(third->place(5).placed, 5) << c.name; + } + CapacityAware ca; + ca.max_flow_count = c.max_flow_count; + ca.diminishing = DiminishingReturns {1, 0.5}; + FlowPolicy p(fg, v({0, 1}), v({2}), cfg(ca, FlowPlacement::MaxFlow, Admission::Lossless, + sel(EdgeTieBreak::Deterministic, EdgePriority::Capacity, false))); + const auto r = p.place(40); + EXPECT_EQ(r.placed, c.placed) << c.name; + EXPECT_EQ(r.local_outcomes, (Outcomes {{0, c.first}, {1, Termination::LimitDiminishing}})) << c.name; + EXPECT_EQ(r.termination, c.call) << c.name; + } +} + +namespace { + +// Three disjoint routes from 0 to 4 (input order): A = 0 -> 1 -> 4 of cost 1 + 1 = 2, B = 0 -> 2 +// -> 4 of cost 2 + 1 = 3, C = 0 -> 3 -> 4 of cost 2 + 2 = 4, capacities a, b and c on both +// edges of each route. +std::shared_ptr three_routes(Amount a, Amount b, Amount c) { + return shared(make_graph(5, {{0, 1, a, 1}, {1, 4, a, 1}, {0, 2, b, 2}, {2, 4, b, 1}, {0, 3, c, 2}, {3, 4, c, 2}})); +} + +// A policy's records as (route cost, delivered, ascending bundle edges, per-edge allocation). +using RouteRow = std::tuple, std::vector>>; + +std::vector routes_of(const FlowPolicy& p) { + std::vector out; + for (const auto& r : p.records()) out.emplace_back(r.route_cost, r.delivered, ascending_edges(*r.bundle), r.edges); + return out; +} + +// The route of the input edges i and j carrying x units on each. +RouteRow route(const Graph& g, Cost cost, Amount x, std::size_t i, std::size_t j) { + const auto inv = id_of_input(g); + std::vector e = {inv[i], inv[j]}; + std::sort(e.begin(), e.end()); + return {cost, x, e, {{e[0], x}, {e[1], x}}}; +} + +CapacityAware ceiling(std::optional c, Amount floor = 0) { + CapacityAware ca; + ca.max_path_cost = c; + ca.min_flow_bandwidth = floor; + return ca; +} + +} // namespace + +// The CapacityAware path-cost ceiling (inclusive, Absolute(c) a budget, Stretch(f) f times the +// static cost-only shortest cost), MaxFlow and EqualBalanced, one source, place(10) with +// flow_count 1 and no max_flow_count. Derivation (the stages of flow_policy.cpp: 1 creation, +// 3 amounts, 4 growth) on three_routes(2, 2, 2), A, B, C of cost 2, 3, 4 and 2 units each: +// MaxFlow: stage 1 creates a flow on the shortest admitted route A; stage 3 fills it to +// T_max = 2; growth searches W (A now has residual 0) and adds B (cost 3) filled to 2, then +// searches again: only C is left. Under Absolute(3) or Stretch(1.5) (1.5 * 2 = 3) C's cost 4 +// is above the ceiling: no candidate, stop, placed 4, `exhausted` (growth found no +// candidate). Under Absolute(4) or Stretch(2) (ceiling 4, inclusive) C is added: placed 6. +// EqualBalanced: stage 3 sets T = T_max_cohort({A}) = 2; growth adds B with T' = min(0 + 10, +// T_max_cohort({A, B})) = 4 split [2, 2] (accepted, gain 2); the next search finds only C: +// the same ceilings give placed 4, or 6 with C (cohort [2, 2, 2]). +// Absolute(1): no route is admitted at all, stage 1 creates nothing: placed 0, `no_route`. +TEST(CapacityAwareGates, PathCostCeilingExcludesACostlierRoute) { + auto g = three_routes(2, 2, 2); + const auto A = route(*g, 2, 2, 0, 1); + const auto B = route(*g, 3, 2, 2, 3); + const auto C = route(*g, 4, 2, 4, 5); + for (const auto kernel : {FlowPlacement::MaxFlow, FlowPlacement::EqualBalanced}) { + struct Case { + const char* name; + std::optional c; + Amount placed; + Termination term; + std::vector routes; + }; + const std::vector cases = { + {"Absolute(3)", Absolute {3}, 4, Termination::Exhausted, {A, B}}, + {"Stretch(1.5)", Stretch {1.5}, 4, Termination::Exhausted, {A, B}}, + {"Absolute(4)", Absolute {4}, 6, Termination::Exhausted, {A, B, C}}, + {"Stretch(2)", Stretch {2.0}, 6, Termination::Exhausted, {A, B, C}}, + {"none", std::nullopt, 6, Termination::Exhausted, {A, B, C}}, + {"Absolute(1)", Absolute {1}, 0, Termination::NoRoute, {}}, + }; + for (const auto& c : cases) { + const std::string where = + std::string(kernel == FlowPlacement::MaxFlow ? "MaxFlow" : "EqualBalanced") + " ceiling " + c.name; + FlowLedger fg(g); + FlowPolicy p(fg, v({0}), v({4}), cfg(ceiling(c.c), kernel)); + const auto r = p.place(10); + EXPECT_EQ(r.placed, c.placed) << where; + EXPECT_EQ(r.remaining, 10 - c.placed) << where; + EXPECT_EQ(r.termination, c.term) << where; + EXPECT_EQ(r.local_outcomes, (std::vector> {{0, c.term}})) << where; + EXPECT_EQ(r.routes_changed, !c.routes.empty()) << where; + EXPECT_FALSE(r.weights_changed) << where; + EXPECT_EQ(routes_of(p), c.routes) << where; + } + } +} + +// The Stretch baseline is the static shortest cost, residual ignored. A third party holds +// both units of A before the policy is built: the static shortest cost stays 2, so Stretch(1.5) +// admits cost 3 and not 4. Stage 1 creates the flow on B (A has residual 0), stage 3 fills it to +// 2, growth finds no admitted route: placed 2, `exhausted`. A baseline taken on the residual +// (3, from B) would admit C under 1.5 * 3 = 4.5 and place 4; Stretch(2) admits C (2 * 2 = 4) +// and places 4. +TEST(CapacityAwareGates, StretchBaselineIsTheStaticShortestCost) { + auto g = three_routes(2, 2, 2); + const auto inv = id_of_input(*g); + const auto B = route(*g, 3, 2, 2, 3); + const auto C = route(*g, 4, 2, 4, 5); + for (const auto kernel : {FlowPlacement::MaxFlow, FlowPlacement::EqualBalanced}) { + for (const double f : {1.5, 2.0}) { + const std::string where = std::string(kernel == FlowPlacement::MaxFlow ? "MaxFlow" : "EqualBalanced") + + " Stretch " + std::to_string(f); + FlowLedger fg(g); + Pinned third; + third.bundles = {dag_of(*g, {inv[0], inv[1]})}; + FlowPolicy other(fg, v({0}), v({4}), cfg(third, FlowPlacement::MaxFlow)); + ASSERT_EQ(other.place(2).placed, 2) << where; + FlowPolicy p(fg, v({0}), v({4}), cfg(ceiling(Stretch {f}), kernel)); + const auto r = p.place(10); + const bool with_c = f == 2.0; + EXPECT_EQ(r.placed, with_c ? 4 : 2) << where; + EXPECT_EQ(r.termination, Termination::Exhausted) << where; + EXPECT_EQ(routes_of(p), with_c ? (std::vector {B, C}) : (std::vector {B})) << where; + } + } + // Under an Eligibility the baseline is the shortest eligible cost: with A's first edge excluded + // it is B's 3, so Stretch(1.5) admits up to 4.5, C included: B and C, 4 placed (a baseline that + // ignored the Eligibility, 2, would admit up to 3: B alone). + const auto el = g->eligibility({}, std::vector {inv[0]}); + for (const auto kernel : {FlowPlacement::MaxFlow, FlowPlacement::EqualBalanced}) { + FlowLedger fg(g); + FlowPolicy p(fg, v({0}), v({4}), cfg(ceiling(Stretch {1.5}), kernel), &el); + const auto r = p.place(10); + EXPECT_EQ(r.placed, 4); + EXPECT_EQ(r.termination, Termination::Exhausted); + EXPECT_EQ(routes_of(p), (std::vector {B, C})); + } +} + +// The policy's Stretch ceiling is exact at large costs and factors: route A 0-1-4 of cost +// 2^60 and route B 0-2-4 of cost 2^60 + 1, 2 units each, MaxFlow, place(10). Stretch(1) admits +// A only (in a double both costs round to 2^60): placed 2. Stretch(1 + 2^-52) admits B +// (2^60 + 2^8 >= 2^60 + 1), and so does Stretch(2^70), whose product with 2^60 needs more than +// 128 bits unless the bit lengths are compared first: placed 4. +TEST(CapacityAwareGates, StretchCeilingIsExactForLargeCostsAndFactors) { + const Cost half = Cost {1} << 59; + auto g = shared(make_graph(5, {{0, 1, 2, half}, {1, 4, 2, half}, {0, 2, 2, half}, {2, 4, 2, half + 1}})); + const auto A = route(*g, 2 * half, 2, 0, 1); + const auto B = route(*g, 2 * half + 1, 2, 2, 3); + for (const double f : {1.0, std::nextafter(1.0, 2.0), std::ldexp(1.0, 70)}) { + const std::string where = "Stretch " + std::to_string(f); + FlowLedger fg(g); + FlowPolicy p(fg, v({0}), v({4}), cfg(ceiling(Stretch {f}), FlowPlacement::MaxFlow)); + const auto r = p.place(10); + const bool with_b = f != 1.0; + EXPECT_EQ(r.placed, with_b ? 4 : 2) << where; + EXPECT_EQ(r.termination, Termination::Exhausted) << where; + EXPECT_EQ(routes_of(p), with_b ? (std::vector {A, B}) : (std::vector {A})) << where; + } +} + +// min_flow_bandwidth gates every CapacityAware search at residual >= max(1, floor), on +// three_routes(4, 2, 0): A of 4 units at cost 2, B of 2 units at cost 3, C of 0 units, place(10). +// Floor 3, MaxFlow: stage 1 creates on A (residual 4 >= 3), stage 3 fills 4; growth: A has +// residual 0 and B 2 < 3, no route: stop, placed 4, `exhausted`. Floor 2: growth adds B (2): +// placed 6. EqualBalanced: T = T_max_cohort({A}) = 4; floor 3 stops growth there (placed 4); +// floor 2 adds B with T' = T_max_cohort({A, B}) = 5 split [3, 2] by round robin in FlowId +// order (6 would need 3 on B), gain 1; the next search sees A at residual 1 < 2: placed 5. +// Floor 5 is above every residual: stage 1 finds no route, placed 0, `no_route`. +TEST(CapacityAwareGates, MinFlowBandwidthStopsGrowthBelowTheFloor) { + auto g = three_routes(4, 2, 0); + struct Case { + FlowPlacement kernel; + Amount floor; + Amount placed; + Termination term; + std::vector routes; + }; + const std::vector cases = { + {FlowPlacement::MaxFlow, 3, 4, Termination::Exhausted, {route(*g, 2, 4, 0, 1)}}, + {FlowPlacement::MaxFlow, 2, 6, Termination::Exhausted, {route(*g, 2, 4, 0, 1), route(*g, 3, 2, 2, 3)}}, + {FlowPlacement::MaxFlow, 5, 0, Termination::NoRoute, {}}, + {FlowPlacement::EqualBalanced, 3, 4, Termination::Exhausted, {route(*g, 2, 4, 0, 1)}}, + {FlowPlacement::EqualBalanced, 2, 5, Termination::Exhausted, {route(*g, 2, 3, 0, 1), route(*g, 3, 2, 2, 3)}}, + {FlowPlacement::EqualBalanced, 5, 0, Termination::NoRoute, {}}, + }; + for (const auto& c : cases) { + const std::string where = std::string(c.kernel == FlowPlacement::MaxFlow ? "MaxFlow" : "EqualBalanced") + + " floor " + std::to_string(c.floor); + FlowLedger fg(g); + FlowPolicy p(fg, v({0}), v({4}), cfg(ceiling(std::nullopt, c.floor), c.kernel)); + const auto r = p.place(10); + EXPECT_EQ(r.placed, c.placed) << where; + EXPECT_EQ(r.termination, c.term) << where; + EXPECT_EQ(r.routes_changed, !c.routes.empty()) << where; + EXPECT_EQ(routes_of(p), c.routes) << where; + } +} + +// The diminishing guard stops growth once at least `window` growth steps were accepted in the +// call and the last `window` gains sum strictly below fraction times the source's offer. One source, +// parallel edges 0 -> 1 taken in EdgeId order (CapacityAware single paths, MaxFlow, one flow at +// creation), DiminishingReturns(window, 0.5): +// edges 2, 6, 1, 4, offer 12, window 1 (threshold 6): creation 2; growth gains 6 (equal to the +// threshold, not below: continue), then 1 (below, with 3 of the offer left): stop, +// LimitDiminishing, 9 placed. Only the last gain counts: 6 + 1 would not stop it. +// edges 2, 1, 4, offer 8, window 1 (threshold 4): creation 2; the first growth step gains 1: +// stop at once, 3 placed. +// edges 2, 1, 4, offer 8, window 2: the first step is not checked alone; 1 + 4 = 5 is not +// below 4; no edge is left: Exhausted, 7 placed. +TEST(CapacityAwareGates, DiminishingStopsWhenTheLastWindowGainsFallBelowTheFraction) { + struct Case { + std::vector caps; + Amount offer; + std::int32_t window; + Amount placed; + Termination term; + std::vector flows; + }; + const std::vector cases = { + {{2, 6, 1, 4}, 12, 1, 9, Termination::LimitDiminishing, {2, 6, 1}}, + {{2, 1, 4}, 8, 1, 3, Termination::LimitDiminishing, {2, 1}}, + {{2, 1, 4}, 8, 2, 7, Termination::Exhausted, {2, 1, 4}}, + }; + for (const auto& c : cases) { + const std::string where = "window " + std::to_string(c.window) + " offer " + std::to_string(c.offer); + std::vector rows; + for (const Amount x : c.caps) rows.push_back({0, 1, x, 1}); + auto g = shared(make_graph(2, rows)); + FlowLedger fg(g); + CapacityAware ca; + ca.diminishing = DiminishingReturns {c.window, 0.5}; + FlowPolicy p(fg, v({0}), v({1}), cfg(ca, FlowPlacement::MaxFlow, Admission::Lossless, + sel(EdgeTieBreak::Deterministic, EdgePriority::Capacity, false))); + const auto r = p.place(c.offer); + EXPECT_EQ(r.placed, c.placed) << where; + EXPECT_EQ(r.termination, c.term) << where; + std::vector flows; + for (const auto& rec : p.records()) flows.push_back(rec.delivered); + EXPECT_EQ(flows, c.flows) << where; + } +} + TEST(PredDAGStorage, ABundleCostsItsEntriesNotTheGraph) { const std::int32_t n = 1'000'000; auto g = shared(make_graph(n, {{n - 3, n - 2, 4, 1}, {n - 2, n - 1, 4, 1}})); @@ -426,89 +1437,43 @@ TEST(PredDAGStorage, ABundleCostsItsEntriesNotTheGraph) { FlowPolicy p(fg, v({n - 3}), v({n - 1}), cfg(pin, FlowPlacement::MaxFlow)); EXPECT_EQ(p.place(3).placed, 3); EXPECT_LT(detail::PredDAGBuilder::payload_bytes(*p.records().front().bundle), 128U); -} - -TEST(DagCache, KeyStatisticsAndTheBuildingTransitions) { - auto g = diamond(5, 5); - DagCache cache(*g); - EXPECT_EQ(cache.stats().entries, 0); - // Two CostOnly policies differing only in selection share one entry (selection is a - // projection at creation, never part of the key). - FlowLedger fg(g); - FlowPolicy a(fg, v({0}), v({3}), cfg(CostOnly {}, FlowPlacement::EqualBalanced), nullptr, &cache); - FlowPolicy b(fg, v({0}), v({3}), - cfg(CostOnly {}, FlowPlacement::EqualBalanced, Admission::Lossless, - sel(EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual, false, false)), - nullptr, &cache); - auto s = cache.stats(); - EXPECT_EQ(s.misses, 1); - EXPECT_EQ(s.hits, 1); - EXPECT_EQ(s.entries, 1); - EXPECT_GT(s.bytes, 0); - // The key: roots as a set, the direction, the Eligibility uid. - const auto x = cache.lookup(*g, v({3, 3}), Direction::Reverse, nullptr); - EXPECT_EQ(cache.stats().hits, 2); - (void)cache.lookup(*g, v({3}), Direction::Forward, nullptr); - const auto el = g->eligibility({}, std::vector {0}); - const auto y = cache.lookup(*g, v({3}), Direction::Reverse, &el); - s = cache.stats(); - EXPECT_EQ(s.misses, 3); - EXPECT_EQ(s.entries, 3); - EXPECT_EQ(edges_of(*y), (std::vector {1, 2, 3})); // the static support under el (no 0 -> 1) - EXPECT_EQ(edges_of(*x), (std::vector {0, 1, 2, 3})); - // Invalid queries throw and count nothing. - auto other = diamond(5, 5); - EXPECT_THROW((void)cache.lookup(*other, v({3}), Direction::Reverse, nullptr), std::invalid_argument); - const auto foreign = other->eligibility({}, std::vector {0}); - EXPECT_THROW((void)cache.lookup(*g, v({3}), Direction::Reverse, &foreign), std::invalid_argument); - EXPECT_THROW((void)cache.lookup(*g, v({9}), Direction::Reverse, nullptr), std::invalid_argument); - EXPECT_THROW((void)cache.lookup(*g, std::vector {}, Direction::Reverse, nullptr), std::invalid_argument); - EXPECT_EQ(cache.stats().misses, 3); - EXPECT_EQ(cache.stats().hits, 2); - // clear(): empties, counters persist, held values stay valid and alive. - const auto held = x; - EXPECT_EQ(cache.stats().live_values, 3); - cache.clear(); - s = cache.stats(); - EXPECT_EQ(s.entries, 0); - EXPECT_EQ(s.bytes, 0); - EXPECT_EQ(s.hits, 2); - EXPECT_EQ(s.misses, 3); - EXPECT_EQ(s.live_values, 2); // x (= held) and y; the Forward entry died with the clear - EXPECT_EQ(edges_of(*held), (std::vector {0, 1, 2, 3})); - // After clear() a lookup misses and inserts again. - (void)cache.lookup(*g, v({3}), Direction::Reverse, nullptr); - EXPECT_EQ(cache.stats().misses, 4); -} - -TEST(DagCache, ValuesOutliveTheCache) { - auto g = diamond(5, 5); - std::shared_ptr v1; - { - DagCache cache(*g); - v1 = cache.lookup(*g, v({3}), Direction::Reverse, nullptr); + // The record bundles the engine builds itself are sparse too: a supplied bundle pruned by an + // Eligibility (the bypass n - 3 -> n - 1 excluded), and the CostOnly and Global routes. + auto h = shared(make_graph(n, {{n - 3, n - 2, 4, 1}, {n - 2, n - 1, 4, 1}, {n - 3, n - 1, 4, 5}})); + const auto inv = id_of_input(*h); + Pinned all; + all.bundles = {dag_of(*h, {inv[0], inv[1], inv[2]})}; + const auto el = h->eligibility({}, std::vector {inv[2]}); + const std::vector> built = { + {"pruned Pinned", cfg(all, FlowPlacement::MaxFlow)}, {"CostOnly", cost_only_config()}, {"Global", global_config()}}; + for (const auto& [name, c] : built) { + FlowLedger fh(h); + FlowPolicy q(fh, v({n - 3}), v({n - 1}), c, std::holds_alternative(c.routing) ? &el : nullptr); + EXPECT_EQ(q.place(3).placed, 3) << name; + const auto bundle = q.records().front().bundle; + EXPECT_NE(bundle.get(), all.bundles[0].get()) << name; + EXPECT_EQ(ascending_edges(*bundle), (std::vector {inv[0], inv[1]})) << name; + EXPECT_LT(detail::PredDAGBuilder::payload_bytes(*bundle), 128U) << name; } - EXPECT_EQ(edges_of(*v1), (std::vector {0, 1, 2, 3})); } TEST(Evaluators, CapacityIsPlaceInfOnAFreshLedger) { + // Every field of the result, in every routing model (Pinned's bundle joins the endpoints). auto g = diamond(5, 3, 2); - for (const auto& c : {global_cfg(), cfg(CostOnly {}, FlowPlacement::EqualBalanced), - cfg(CapacityAware {.flow_count = 2, .max_flow_count = 4}, FlowPlacement::MaxFlow)}) { + Pinned pin; + pin.bundles = {dag_of(*g, {0, 2}), dag_of(*g, {1, 3})}; + for (const auto& c : {global_config(), cost_only_config(), + cfg(CapacityAware {.flow_count = 2, .max_flow_count = 4}, FlowPlacement::MaxFlow), + cfg(pin, FlowPlacement::EqualBalanced)}) { const auto r = capacity(*g, v({0}), v({3}), CapacityOptions {c, nullptr, nullptr, OutputOptions {true, true, true}}); FlowLedger fg(g); FlowPolicy p(fg, v({0}), v({3}), c); const auto want = p.place(std::nullopt, OutputOptions {true, true, true}); - EXPECT_EQ(r.placed, want.placed); - EXPECT_EQ(r.termination, want.termination); - EXPECT_EQ(r.edge_flows, want.edge_flows); - EXPECT_EQ(r.residual, want.residual); - EXPECT_EQ(r.bottleneck, want.bottleneck); - EXPECT_EQ(r.min_cut, want.min_cut); - EXPECT_EQ(r.cost_distribution, want.cost_distribution); + EXPECT_GT(want.placed, 0); // two empty results would agree too + expect_same(r, want, "capacity against place(+inf)"); } // The exact maximum flow: Global's curve (cheapest walks first). - const auto exact = capacity(*g, v({0}), v({3}), CapacityOptions {global_cfg()}); + const auto exact = capacity(*g, v({0}), v({3}), CapacityOptions {global_config()}); EXPECT_EQ(exact.placed, 8); EXPECT_EQ(*exact.cost_distribution, curve({{2, 5}, {4, 3}})); } @@ -520,17 +1485,22 @@ TEST(Evaluators, BatchOrderIntersectionsExceptionsAndWorkers) { std::vector, std::vector>> pairs = { {{0}, {3}}, {{0}, {3}}, {{1}, {3}}, {{0}, {1, 2}}}; const std::vector per = {nullptr, &first_route, nullptr, nullptr}; - const CapacityOptions opts {global_cfg(), &common}; + const CapacityOptions opts {global_config(), &common, nullptr, OutputOptions {true, true, true}}; const auto r1 = batch_capacity(*g, pairs, opts, per, 1); ASSERT_EQ(r1.size(), 4U); EXPECT_EQ(r1[0].placed, 5); // common only: route 1 EXPECT_EQ(r1[1].placed, 0); // common and the pair's: both routes cut EXPECT_EQ(r1[2].placed, 5); EXPECT_EQ(r1[3].placed, 8); // destination set {1, 2}: both first edges (edge 3 excluded is past 2) + EXPECT_EQ(*r1[3].cost_distribution, curve({{1, 8}})); + EXPECT_EQ(*r1[3].min_cut, (std::vector {0, 1})); + // Every field of every result is the same for any worker count. for (std::int32_t w : {2, 3, 8}) { const auto rw = batch_capacity(*g, pairs, opts, per, w); ASSERT_EQ(rw.size(), 4U); - for (std::size_t i = 0; i < 4; ++i) EXPECT_EQ(rw[i].placed, r1[i].placed) << "workers " << w << " pair " << i; + for (std::size_t i = 0; i < 4; ++i) { + expect_same(rw[i], r1[i], "workers " + std::to_string(w) + " pair " + std::to_string(i)); + } } EXPECT_TRUE(batch_capacity(*g, {}, opts, {}, 4).empty()); // Validation before any worker: endpoints, lengths, foreign values, Pinned, workers, the @@ -552,12 +1522,42 @@ TEST(Evaluators, BatchOrderIntersectionsExceptionsAndWorkers) { "Pinned")); EXPECT_TRUE(contains(error_of([&] { (void)batch_capacity(*g, pairs, opts, per, 0); }), "workers")); DagCache cache(*g); - const CapacityOptions with_cache {cfg(CostOnly {}, FlowPlacement::EqualBalanced), nullptr, &cache}; + const CapacityOptions with_cache {cost_only_config(), nullptr, &cache}; EXPECT_TRUE(contains(error_of([&] { (void)batch_capacity(*g, pairs, with_cache, {}, 2); }), "workers == 1")); const auto cached = batch_capacity(*g, pairs, with_cache, {}, 1); EXPECT_EQ(cache.stats().misses, 2); // destination sets {3} and {1, 2} EXPECT_EQ(cache.stats().hits, 2); EXPECT_EQ(cached[0].placed, 7); // CostOnly EB over routes of 5 and 3: [4, 3] (odd unit to the lower id) + // The argument checks in their documented order, all before the pairs: with every defect + // present the first is reported, and with that one removed the next. Pinned also carries a + // configuration error (Lossy admission with the MaxFlow kernel); pair 2 has a destination out of + // range. The building cache is untouched: no lookup ran. + DagCache foreign_cache(*other); + DagCache building(*g); + Pinned lossy_pin; + lossy_pin.bundles = {dag_of(*g, {0, 2})}; + const FlowPolicyConfig pinned_bad = cfg(lossy_pin, FlowPlacement::MaxFlow, Admission::Lossy); + const FlowPolicyConfig config_bad = cfg(CostOnly {}, FlowPlacement::MaxFlow, Admission::Lossy); + bad = pairs; + bad[2].second = {9}; + const std::vector short_per = {nullptr}; + auto first_error = [&](const FlowPolicyConfig& c, const Eligibility* elig, DagCache* with, + const std::vector& per_pair, std::int32_t workers) { + return error_of([&] { (void)batch_capacity(*g, bad, CapacityOptions {c, elig, with}, per_pair, workers); }); + }; + EXPECT_EQ(first_error(pinned_bad, &foreign, &foreign_cache, short_per, 0), "workers must be >= 1 (got 0)"); + EXPECT_EQ(first_error(pinned_bad, &foreign, &foreign_cache, short_per, 2), + "foreign Eligibility: built for another graph"); + EXPECT_EQ(first_error(pinned_bad, nullptr, &foreign_cache, short_per, 2), "foreign DagCache: bound to another graph"); + EXPECT_EQ(first_error(pinned_bad, nullptr, &building, short_per, 2), + "a supplied building DagCache has one writer: it is rejected unless workers == 1 (got workers = 2; a " + "frozen cache may be shared by every worker)"); + EXPECT_EQ(first_error(pinned_bad, nullptr, &building, short_per, 1), + "Pinned routing is rejected in batch_capacity (bundles bind their endpoints)"); + EXPECT_EQ(first_error(config_bad, nullptr, &building, short_per, 1), kLossyKernelError); + EXPECT_EQ(first_error(global_config(), nullptr, &building, short_per, 1), "per_pair_elig has 1 entries for 4 pairs"); + EXPECT_EQ(first_error(global_config(), nullptr, &building, per, 1), "destinations: node 9 out of node range [0, 4)"); + EXPECT_EQ(stats_of(building.stats()), stats_of(DagCache(*g).stats())); } TEST(Evaluators, BatchValidatesInPairOrderBeforeAnyWorker) { @@ -565,19 +1565,37 @@ TEST(Evaluators, BatchValidatesInPairOrderBeforeAnyWorker) { auto g = diamond(5, 3); std::vector, std::vector>> pairs = { {{0}, {3}}, {{0}, {7}}, {{0}, {3}}, {{0}, {8}}}; - const auto msg = error_of([&] { (void)batch_capacity(*g, pairs, CapacityOptions {global_cfg()}, {}, 4); }); + const auto msg = error_of([&] { (void)batch_capacity(*g, pairs, CapacityOptions {global_config()}, {}, 4); }); EXPECT_TRUE(contains(msg, "node 7")) << msg; + // Within one pair: the sources, then the destinations, then their overlap, then the pair's + // Eligibility's graph. + const auto elsewhere = diamond(5, 3); + const auto foreign = elsewhere->eligibility({}, std::vector {0}); + const std::vector per = {&foreign}; + const auto pair_error = [&](std::vector s, std::vector d) { + const std::vector, std::vector>> one = {{s, d}}; + return error_of([&] { (void)batch_capacity(*g, one, CapacityOptions {global_config()}, per, 1); }); + }; + EXPECT_EQ(pair_error({}, {9}), "sources: the node set is empty"); + EXPECT_EQ(pair_error({0, 3}, {3, 9}), "destinations: node 9 out of node range [0, 4)"); + EXPECT_EQ(pair_error({0, 3}, {3}), "node 3 is in both endpoint sets: sources and destinations must not overlap"); + EXPECT_EQ(pair_error({0}, {3}), "foreign Eligibility: per_pair_elig[0] built for another graph"); } // The sensitivity shortcuts (Global from the baseline's maximum flow, CostOnly without a -// re-evaluation of the edges outside the baseline's routes) give the losses of the definition, -// the baseline's placed amount minus capacity() with the edge excluded, on random graphs with -// parallel edges, self-loops, dispersed costs, zero capacities, endpoint sets, eligibility -// masks and every CostOnly kernel, admission and selection. +// re-evaluation of the edges outside the static shortest-path support reachable from the sources) +// and the plain re-evaluations (CapacityAware, Pinned) give the losses of the definition, the +// baseline's placed amount minus capacity() with the edge excluded, on random graphs with parallel +// edges, self-loops, dispersed costs, zero capacities, endpoint sets and eligibility masks; every +// CostOnly kernel with every tie break, multipath and multi_edge setting under the Capacity +// priority, both admissions, and one Residual single-path selection (no Headroom); two +// CapacityAware configurations (growth, and reoptimization under a Stretch ceiling); and Pinned +// with two bundles (the static support toward the destination and the second shortest path) on +// single endpoints. An edge without an entry has loss 0 by the same definition. TEST(Evaluators, SensitivityEqualsTheDefinitionOnRandomGraphs) { std::mt19937 rng(20261005); auto pick = [&](int n) { return static_cast(rng() % static_cast(n)); }; - std::vector configs = {global_cfg()}; + std::vector configs = {global_config()}; for (auto k : {FlowPlacement::MaxFlow, FlowPlacement::EqualBalanced, FlowPlacement::Weighted}) { for (bool multipath : {true, false}) { for (bool multi_edge : {true, false}) { @@ -589,10 +1607,18 @@ TEST(Evaluators, SensitivityEqualsTheDefinitionOnRandomGraphs) { } configs.push_back(cfg(CostOnly {}, FlowPlacement::EqualBalanced, Admission::Lossy)); configs.push_back(cfg(CostOnly {}, FlowPlacement::Weighted, Admission::Lossy, sel(EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual, false, false))); + configs.push_back(cfg(CapacityAware {.flow_count = 1, .max_flow_count = 3}, FlowPlacement::EqualBalanced, + Admission::Lossless, sel(EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual, false, false))); + configs.push_back(cfg(CapacityAware {.flow_count = 2, .max_path_cost = Stretch {2.0}, .reoptimize = true}, + FlowPlacement::MaxFlow)); int entries = 0; int global_losses = 0; int policy_losses = 0; int policy_zero = 0; + int aware_losses = 0; + int pinned_losses = 0; + int pinned_trials = 0; + int absent = 0; for (int trial = 0; trial < 60; ++trial) { const int n = 4 + pick(7); std::vector es; @@ -610,42 +1636,84 @@ TEST(Evaluators, SensitivityEqualsTheDefinitionOnRandomGraphs) { if (pick(2) == 0) xe.push_back(static_cast(pick(static_cast(es.size())))); const auto mask = g->eligibility(xn, xe); const Eligibility* elig = pick(2) == 0 ? &mask : nullptr; - for (const auto& c : configs) { + // Pinned on the single endpoints 0 and n - 1, when 0 reaches n - 1 statically. + auto trial_configs = configs; + const NodeId t0 = n - 1; + const auto support = std::make_shared( + detail::static_support(*g, std::vector {t0}, Direction::Reverse, nullptr)); + const auto paths = ksp(*g, 0, t0, KspOptions(2, sel())); + if (paths.size() == 2) { + Pinned pin; + pin.bundles = {support, std::make_shared(paths[1].second.to_dag())}; + trial_configs.push_back(cfg(pin, FlowPlacement::Weighted, Admission::Lossless, sel())); + ++pinned_trials; + } + for (const auto& c : trial_configs) { const bool global = std::holds_alternative(c.routing); - const auto r = sensitivity(*g, s, t, SensitivityOptions {c, elig}, 1 + pick(3)); - for (const auto& e : r.entries) { - ASSERT_EQ(e.status, SensitivityStatus::Evaluated); - const EdgeId one[1] = {e.edge}; + const bool cost_only = std::holds_alternative(c.routing); + const bool pinned = std::holds_alternative(c.routing); + const std::vector cs = pinned ? std::vector {0} : s; + const std::vector ct = pinned ? std::vector {t0} : t; + const auto r = sensitivity(*g, cs, ct, SensitivityOptions {c, elig}, 1 + pick(3)); + // The baseline is capacity() itself, and the entries are the candidates in ascending order: + // Global, the baseline's positive-flow edges; CostOnly, every allowed edge of at least one + // unit of capacity. + const auto base = capacity(*g, cs, ct, CapacityOptions {c, elig, nullptr, OutputOptions {true, false, false}}); + ASSERT_EQ(r.baseline.placed, base.placed) << "trial " << trial; + std::vector candidates; + for (EdgeId e = 0; e < g->num_edges(); ++e) { + const auto ei = static_cast(e); + if (global ? (*base.edge_flows)[ei] > 0 : (elig == nullptr || elig->edge_ok(e)) && g->capacity_units()[ei] >= 1) { + candidates.push_back(e); + } + } + const std::string model = global ? " Global" : cost_only ? " CostOnly" : pinned ? " Pinned" : " CapacityAware"; + std::vector listed; + for (const auto& e : r.entries) listed.push_back(e.edge); + ASSERT_EQ(listed, candidates) << "trial " << trial << model; + // The loss of deleting `e`, by the definition. + const auto loss = [&](EdgeId e) { + const EdgeId one[1] = {e}; auto deleted = g->eligibility({}, one); if (elig != nullptr) deleted = Eligibility::intersect(*elig, deleted); - const Amount want = r.baseline.placed - capacity(*g, s, t, CapacityOptions {c, &deleted}).placed; - ASSERT_EQ(*e.loss, want) << "trial " << trial << " edge " << e.edge << (global ? " Global" : " CostOnly"); + return base.placed - capacity(*g, cs, ct, CapacityOptions {c, &deleted}).placed; + }; + for (const auto& e : r.entries) { + const Amount want = loss(e.edge); + ASSERT_EQ(e.loss, want) << "trial " << trial << " edge " << e.edge << model; ++entries; - if (want != 0) ++(global ? global_losses : policy_losses); - if (!global && want == 0) ++policy_zero; + if (want != 0) ++(global ? global_losses : cost_only ? policy_losses : pinned ? pinned_losses : aware_losses); + if (cost_only && want == 0) ++policy_zero; + } + for (EdgeId e = 0; e < g->num_edges(); ++e) { + if (std::find(listed.begin(), listed.end(), e) != listed.end()) continue; + EXPECT_EQ(loss(e), 0) << "trial " << trial << " edge " << e << model << ": an edge without an entry"; + ++absent; } } } - // Not vacuous: many entries, losses under both models and zero-loss CostOnly deletions. + // Not vacuous: many entries, losses under every model, zero-loss CostOnly deletions, Pinned + // trials, and edges without an entry. EXPECT_GT(entries, 3000); EXPECT_GT(global_losses, 100); EXPECT_GT(policy_losses, 500); EXPECT_GT(policy_zero, 500); + EXPECT_GT(aware_losses, 100); + EXPECT_GT(pinned_losses, 30); + EXPECT_GT(pinned_trials, 20); + EXPECT_GT(absent, 500); } -TEST(Evaluators, SensitivityCandidatesSignedLossesAndUnevaluable) { +TEST(Evaluators, SensitivityCandidatesAndSignedLosses) { // Global: candidates are the baseline's positive-flow edges; an unsaturated critical edge - // appears (rule 1): 0 -> 1 of capacity 9 carries 4 (its tail 1 -> 2 holds 4) and its deletion - // loses 4; the saturated-only candidate set of 0.12 missed it. + // appears: 0 -> 1 of capacity 9 carries 4 (its tail 1 -> 2 holds 4) and its deletion + // loses 4; a candidate set of saturated edges only would miss it. auto g = shared(make_graph(3, {{0, 1, 9, 1}, {1, 2, 4, 1}, {0, 2, 2, 5}})); - auto s = sensitivity(*g, v({0}), v({2}), SensitivityOptions {global_cfg()}, 1); + auto s = sensitivity(*g, v({0}), v({2}), SensitivityOptions {global_config()}, 1); EXPECT_EQ(s.baseline.placed, 6); EXPECT_FALSE(s.baseline.edge_flows.has_value()); // requested internally, not returned std::map losses; - for (const auto& e : s.entries) { - EXPECT_EQ(e.status, SensitivityStatus::Evaluated); - losses[e.edge] = *e.loss; - } + for (const auto& e : s.entries) losses[e.edge] = e.loss; const auto inv = id_of_input(*g); EXPECT_EQ(losses.at(inv[0]), 4); // unsaturated (4 of 9) and critical EXPECT_EQ(losses.at(inv[1]), 4); @@ -654,19 +1722,21 @@ TEST(Evaluators, SensitivityCandidatesSignedLossesAndUnevaluable) { // A policy model: every allowed edge of >= 1 unit; a loss can be negative (EqualBalanced over // parallel links of 1 and 10 delivers 2; without the thin link it delivers 10). auto p = shared(make_graph(2, {{0, 1, 1, 1}, {0, 1, 10, 1}, {0, 1, 0, 1}})); - const auto opts = SensitivityOptions {cfg(CostOnly {}, FlowPlacement::EqualBalanced)}; + const auto opts = SensitivityOptions {cost_only_config()}; s = sensitivity(*p, v({0}), v({1}), opts, 1); EXPECT_EQ(s.baseline.placed, 2); - ASSERT_EQ(s.entries.size(), 2U); // the zero-capacity link is absent (loss zero) + // The exact (edge, loss) list in ascending EdgeId: the zero-capacity link is absent (loss zero). const auto pin = id_of_input(*p); - for (const auto& e : s.entries) { - if (e.edge == pin[0]) EXPECT_EQ(*e.loss, -8); - if (e.edge == pin[1]) EXPECT_EQ(*e.loss, 1); - } + std::vector> got; + for (const auto& e : s.entries) got.emplace_back(e.edge, e.loss); + std::vector> want = {{pin[0], -8}, {pin[1], 1}}; + std::sort(want.begin(), want.end()); + EXPECT_EQ(got, want); // Workers give the same entries; an excluded edge is no candidate. const auto s4 = sensitivity(*p, v({0}), v({1}), opts, 4); - ASSERT_EQ(s4.entries.size(), s.entries.size()); - for (std::size_t i = 0; i < s.entries.size(); ++i) EXPECT_EQ(s4.entries[i].loss, s.entries[i].loss); + std::vector> got4; + for (const auto& e : s4.entries) got4.emplace_back(e.edge, e.loss); + EXPECT_EQ(got4, want); const auto el = p->eligibility({}, std::vector {pin[0]}); const auto se = sensitivity(*p, v({0}), v({1}), SensitivityOptions {opts.cfg, &el}, 1); ASSERT_EQ(se.entries.size(), 1U); @@ -679,27 +1749,170 @@ TEST(Evaluators, SensitivityCandidatesSignedLossesAndUnevaluable) { EXPECT_EQ(sensitivity(*p, v({0}), v({1}), SensitivityOptions {opts.cfg, nullptr, &cache}, 1).entries.size(), 2U); } -// A deletion raising a model exception is unevaluable with its message, not raised (rule 1). No -// valid configuration raises under a deletion, so the private hook injects one. -TEST(Evaluators, SensitivityReportsAModelExceptionAsUnevaluable) { -#if NETGRAPH_CORE_PRIVATE_HOOKS - auto p = shared(make_graph(2, {{0, 1, 1, 1}, {0, 1, 10, 1}, {0, 1, 0, 1}})); - const auto opts = SensitivityOptions {cfg(CostOnly {}, FlowPlacement::EqualBalanced)}; - const auto pin = id_of_input(*p); - detail::sensitivity_fail_edge().store(pin[1]); - const auto su = sensitivity(*p, v({0}), v({1}), opts, 2); - detail::sensitivity_fail_edge().store(-1); - ASSERT_EQ(su.entries.size(), 2U); - for (const auto& e : su.entries) { - if (e.edge == pin[1]) { - EXPECT_EQ(e.status, SensitivityStatus::Unevaluable); - EXPECT_FALSE(e.loss.has_value()); - EXPECT_TRUE(contains(e.reason, "injected model exception")); - } else { - EXPECT_EQ(e.status, SensitivityStatus::Evaluated); +// The baseline is capacity() with the requested outputs, in every routing model; without a +// request it carries none. +TEST(Evaluators, SensitivityBaselineIsCapacityWithTheRequestedOutputs) { + auto g = diamond(5, 3, 2); + Pinned pin; + pin.bundles = {dag_of(*g, {0, 2}), dag_of(*g, {1, 3})}; + const OutputOptions all {true, true, true}; + for (const auto& c : {global_config(), cost_only_config(), + cfg(CapacityAware {.flow_count = 2, .max_flow_count = 4}, FlowPlacement::MaxFlow), + cfg(pin, FlowPlacement::EqualBalanced)}) { + const auto s = sensitivity(*g, v({0}), v({3}), SensitivityOptions {c, nullptr, nullptr, all}, 1); + const auto want = capacity(*g, v({0}), v({3}), CapacityOptions {c, nullptr, nullptr, all}); + EXPECT_GT(want.placed, 0); + ASSERT_TRUE(want.edge_flows.has_value() && want.residual.has_value() && want.bottleneck.has_value()); + expect_same(s.baseline, want, "sensitivity baseline with every output"); + const auto plain = sensitivity(*g, v({0}), v({3}), SensitivityOptions {c}, 1); + expect_same(plain.baseline, capacity(*g, v({0}), v({3}), CapacityOptions {c}), "sensitivity baseline, no output"); + } +} + +// CostOnly re-evaluates a deletion exactly when the edge lies in the static shortest-path support +// (every edge on a shortest path to the destinations) reachable from a roster source; every other +// candidate gets loss 0 without a placement. A supplied building cache shows which deletions were +// placed: each placed deletion looks up its own fresh Eligibility uid (one miss and one entry), +// after the baseline's one. Edges 0->2, 1->2, 2->3, 2->4, 3->5, 4->5 of 10 units at cost 1: all six +// form the support toward 5. From source 0 the single path is 0->2->3->5, but 2->4 and 4->5, on no +// baseline route, are in its reachable support and are re-evaluated (loss 0); 1->2 is not and is +// skipped. From sources 0 and 1 the second source reaches 1->2 too. +TEST(Evaluators, CostOnlySensitivityReevaluatesExactlyTheReachableSupport) { + auto g = shared(make_graph(6, {{0, 2, 10, 1}, {1, 2, 10, 1}, {2, 3, 10, 1}, {2, 4, 10, 1}, {3, 5, 10, 1}, {4, 5, 10, 1}})); + const auto in = id_of_input(*g); + const auto c = cfg(CostOnly {}, FlowPlacement::MaxFlow, Admission::Lossless, + sel(EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual, false, true)); + const auto losses_of = [&](const SensitivityResult& r) { + std::map out; // by input position + for (const auto& e : r.entries) { + out[static_cast(std::find(in.begin(), in.end(), e.edge) - in.begin())] = e.loss; + } + return out; + }; + DagCache one(*g); + const auto r1 = sensitivity(*g, v({0}), v({5}), SensitivityOptions {c, nullptr, &one}, 1); + EXPECT_EQ(r1.baseline.placed, 10); + EXPECT_EQ(losses_of(r1), (std::map {{0, 10}, {1, 0}, {2, 0}, {3, 0}, {4, 0}, {5, 0}})); + EXPECT_EQ(one.stats().hits, 0); + EXPECT_EQ(one.stats().misses, 1 + 5); // the baseline, then every candidate but 1->2 + EXPECT_EQ(one.stats().entries, 1 + 5); + DagCache two(*g); + const auto r2 = sensitivity(*g, v({0, 1}), v({5}), SensitivityOptions {c, nullptr, &two}, 1); + EXPECT_EQ(r2.baseline.placed, 20); + EXPECT_EQ(losses_of(r2), (std::map {{0, 10}, {1, 10}, {2, 10}, {3, 10}, {4, 10}, {5, 10}})); + EXPECT_EQ(two.stats().misses, 1 + 6); // every candidate + EXPECT_EQ(two.stats().entries, 1 + 6); +} + +// sensitivity's worker and cache rules are batch_capacity's, checked in its order before any work: +// workers >= 1, the Eligibility's graph, the cache's graph, a supplied building cache only with +// one worker; a frozen cache is shared by every worker. +TEST(Evaluators, SensitivityChecksWorkersAndCachesAsTheBatchDoes) { + auto g = diamond(5, 3); + auto other = diamond(5, 3); + const auto foreign = other->eligibility({}, std::vector {0}); + DagCache foreign_cache(*other); + DagCache building(*g); + const auto first_error = [&](const Eligibility* elig, DagCache* cache, std::int32_t workers) { + return error_of([&] { (void)sensitivity(*g, v({0}), v({3}), SensitivityOptions {global_config(), elig, cache}, workers); }); + }; + EXPECT_EQ(first_error(&foreign, &foreign_cache, 0), "workers must be >= 1 (got 0)"); + EXPECT_EQ(first_error(&foreign, &foreign_cache, 2), "foreign Eligibility: built for another graph"); + EXPECT_EQ(first_error(nullptr, &foreign_cache, 2), "foreign DagCache: bound to another graph"); + EXPECT_EQ(first_error(nullptr, &building, 2), + "a supplied building DagCache has one writer: it is rejected unless workers == 1 (got workers = 2; a " + "frozen cache may be shared by every worker)"); + EXPECT_EQ(stats_of(building.stats()), stats_of(DagCache(*g).stats())); // no lookup ran + const auto want = sensitivity(*g, v({0}), v({3}), SensitivityOptions {global_config()}, 1); + building.freeze(); + for (const std::int32_t workers : {1, 2, 4}) { + const auto r = sensitivity(*g, v({0}), v({3}), SensitivityOptions {global_config(), nullptr, &building}, workers); + ASSERT_EQ(r.entries.size(), want.entries.size()); + for (std::size_t i = 0; i < r.entries.size(); ++i) { + EXPECT_EQ(r.entries[i].edge, want.entries[i].edge); + EXPECT_EQ(r.entries[i].loss, want.entries[i].loss); + } + } +} + +// Only CostOnly reads the cache: through an empty frozen cache Global, CapacityAware and Pinned +// sensitivity give the entries of a call without a cache and count nothing; CostOnly's baseline +// raises the miss. +TEST(Evaluators, SensitivityReadsTheCacheOnlyUnderCostOnly) { + auto g = diamond(5, 3, 2); + Pinned pin; + pin.bundles = {dag_of(*g, {0, 2}), dag_of(*g, {1, 3})}; + DagCache empty(*g); + empty.freeze(); + for (const auto& c : {global_config(), cfg(CapacityAware {.flow_count = 2, .max_flow_count = 4}, FlowPlacement::MaxFlow), + cfg(pin, FlowPlacement::EqualBalanced)}) { + const auto want = sensitivity(*g, v({0}), v({3}), SensitivityOptions {c}, 1); + ASSERT_FALSE(want.entries.empty()); + for (const std::int32_t workers : {1, 3}) { + const auto r = sensitivity(*g, v({0}), v({3}), SensitivityOptions {c, nullptr, &empty}, workers); + expect_same(r.baseline, want.baseline, "through an empty frozen cache"); + ASSERT_EQ(r.entries.size(), want.entries.size()); + for (std::size_t i = 0; i < r.entries.size(); ++i) { + EXPECT_EQ(r.entries[i].edge, want.entries[i].edge); + EXPECT_EQ(r.entries[i].loss, want.entries[i].loss); + } } } + EXPECT_EQ(stats_of(empty.stats()), stats_of(DagCache(*g).stats())); + EXPECT_THROW((void)sensitivity(*g, v({0}), v({3}), SensitivityOptions {cost_only_config(), nullptr, &empty}, 1), + DagCacheMiss); + EXPECT_EQ(empty.stats().misses, 1); +} + +namespace { + +// Sets (value) or removes (nullptr) an environment variable of this process. +void set_env(const char* name, const char* value) { +#ifdef _WIN32 + (void)_putenv_s(name, value == nullptr ? "" : value); // an empty value removes it on Windows #else - GTEST_SKIP() << "needs the private hooks (NETGRAPH_CORE_PRIVATE_HOOKS)"; + if (value == nullptr) { + (void)unsetenv(name); + } else { + (void)setenv(name, value, 1); + } #endif } + +} // namespace + +// The evaluators' default worker counts (README "Environment variables", evaluator_checks.hpp): +// NGRAPH_CORE_BATCH_THREADS for batch_capacity and NGRAPH_CORE_SENSITIVITY_THREADS for +// sensitivity, each an integer in [1, 4096] read independently of the other; 1 when unset or +// invalid. " 4" reads as 4 (leading blanks are part of the integer syntax, as for strtol and +// Python's int()); trailing characters, a sign alone and values outside the range +// give 1. +TEST(Evaluators, DefaultWorkersParseTheEnvironment) { + const char* names[] = {"NGRAPH_CORE_BATCH_THREADS", "NGRAPH_CORE_SENSITIVITY_THREADS"}; + std::optional saved[2]; + for (int i = 0; i < 2; ++i) { + if (const char* x = std::getenv(names[i])) saved[i] = x; + } + struct Restore { + const char* const* names; + std::optional* saved; + ~Restore() { + for (int i = 0; i < 2; ++i) set_env(names[i], saved[i] ? saved[i]->c_str() : nullptr); + } + } restore {names, saved}; + const std::vector> cases = { + {nullptr, 1}, {"1", 1}, {"4", 4}, {"4096", 4096}, {"4097", 1}, {"0", 1}, {"-4", 1}, + {"abc", 1}, {"4x", 1}, {"4 ", 1}, {" 4", 4}, {"+", 1}, {"99999999999999999999", 1}, + }; + for (const bool sensitivity : {false, true}) { + const int self = sensitivity ? 1 : 0; + for (const auto& [value, want] : cases) { + const std::string where = + std::string(names[self]) + "=" + (value == nullptr ? "(unset)" : "'" + std::string(value) + "'"); + set_env(names[self], value); + set_env(names[1 - self], "7"); // the other variable never applies + EXPECT_EQ(detail::default_workers(sensitivity), want) << where; + EXPECT_EQ(detail::default_workers(!sensitivity), 7) << where; + } + } +} diff --git a/tests/cpp/graph_concurrency_tests.cpp b/tests/cpp/graph_concurrency_tests.cpp index ea71a71..887bc68 100644 --- a/tests/cpp/graph_concurrency_tests.cpp +++ b/tests/cpp/graph_concurrency_tests.cpp @@ -1,68 +1,42 @@ -// The graph under concurrency (APPROACH 13 threading; task 1e), run in the ThreadSanitizer suite: -// independent constructions at the same time (no shared hash or counting scratch), and full -// forward and reverse searches through both queues on one published immutable graph and on its -// copy, each thread with its own options, compared with a serial reference; every array of the -// graph is unchanged after. One batch path builds its own reverse support in its workers (no -// prepopulated frozen cache), so the reverse-source copy is read by worker threads. +// The graph under concurrency, run in the ThreadSanitizer suite: +// independent constructions at the same time (no shared hash or counting scratch; distinct uids +// from the one counter), and full forward and reverse searches through both queues on one +// published immutable graph and on its copy, each thread with its own options, compared with a +// serial reference; every array of the graph is unchanged after. One batch path builds its own +// reverse support in its workers (no prepopulated frozen cache), so the reverse-source copy is read +// by worker threads. One PredDAG and one Path are read by every thread. The tests' own threads +// start their work together once all of them run (a start latch), so the calls overlap as far as +// the scheduler allows; ThreadSanitizer reports a race between unordered accesses whether or not +// they happened to overlap in time. #include +#include +#include #include +#include +#include #include #include #include #include -#include "graph_reference_1d.hpp" -#include "netgraph/core/detail/graph_access.hpp" +#include "graph_inputs.hpp" +#include "graph_reference.hpp" +#include "netgraph/core/detail/pred_dag_builder.hpp" #include "netgraph/core/evaluators.hpp" #include "netgraph/core/graph.hpp" +#include "netgraph/core/pred_dag.hpp" #include "netgraph/core/search.hpp" +#include "test_utils.hpp" using namespace netgraph::core; using namespace netgraph::core::test; namespace { -struct Input { - std::int32_t n {0}; - std::vector src {}, dst {}; - std::vector cap {}; - std::vector cost {}; -}; - +// m random edges of 1 to 4 units (no zero capacity). Input random_input(std::int32_t n, std::size_t m, Cost max_cost, std::uint64_t seed) { - std::mt19937_64 rng(seed); - Input in {n}; - for (std::size_t i = 0; i < m; ++i) { - in.src.push_back(static_cast(rng() % static_cast(n))); - in.dst.push_back(static_cast(rng() % static_cast(n))); - in.cap.push_back(static_cast(1 + rng() % 4)); - in.cost.push_back(static_cast(1 + rng() % static_cast(max_cost))); - } - return in; -} - -// Every array of a graph, for the before/after comparison. -struct Snapshot { - std::vector capacity; - std::vector cost; - std::vector src, dst, in_col; - std::vector input_index, row_offsets, in_row_offsets; - std::vector in_adj; - bool operator==(const Snapshot&) const = default; -}; - -Snapshot snapshot(const Graph& g) { - const auto col = detail::GraphAccess::in_col_indices(g); - return {{g.capacity_units().begin(), g.capacity_units().end()}, - {g.cost().begin(), g.cost().end()}, - {g.edge_src().begin(), g.edge_src().end()}, - {g.edge_dst().begin(), g.edge_dst().end()}, - {col.begin(), col.end()}, - {g.input_index().begin(), g.input_index().end()}, - {g.row_offsets().begin(), g.row_offsets().end()}, - {g.in_row_offsets().begin(), g.in_row_offsets().end()}, - {g.in_adj_edge_index().begin(), g.in_adj_edge_index().end()}}; + return test::random_input("", n, m, max_cost, seed, 1, 4); } } // namespace @@ -74,21 +48,35 @@ TEST(GraphConcurrency, IndependentConstructionsAtTheSameTime) { // Alternate the hash path and the fallback (K > 65,536) across threads. inputs.push_back(t % 2 == 0 ? random_input(2000, 20000, 31, static_cast(t)) : random_input(4096, 70000, Cost{1} << 40, static_cast(t))); + if (t % 2 == 0) { + ASSERT_LE(distinct(inputs.back().cost), 65536) << t; + } else { + ASSERT_GT(distinct(inputs.back().cost), 65536) << t; + } } std::vector> got(kThreads), got_rev(kThreads); + std::vector uids(kThreads); std::vector threads; + std::latch start(kThreads); // the threads start together, once all of them are running for (int t = 0; t < kThreads; ++t) { threads.emplace_back([&, t] { const auto& in = inputs[static_cast(t)]; - const Graph g(in.n, in.src, in.dst, in.cap, in.cost, 1.0); + start.arrive_and_wait(); + const Graph g = build(in); got[static_cast(t)].assign(g.input_index().begin(), g.input_index().end()); got_rev[static_cast(t)].assign(g.in_adj_edge_index().begin(), g.in_adj_edge_index().end()); + uids[static_cast(t)] = g.uid(); }); } for (auto& th : threads) th.join(); + // One process-wide uid counter: the graphs of different threads never share a uid, even after + // their threads have destroyed them. + std::sort(uids.begin(), uids.end()); + EXPECT_GT(uids.front(), 0U); + EXPECT_EQ(std::adjacent_find(uids.begin(), uids.end()), uids.end()); for (int t = 0; t < kThreads; ++t) { const auto& in = inputs[static_cast(t)]; - const auto r = reference_1d(in.n, in.src, in.dst, in.cap, in.cost, 1.0); + const auto r = reference_graph(in.n, in.src, in.dst, in.cap, in.cost, 1.0); EXPECT_EQ(got[static_cast(t)], r.input_index) << t; EXPECT_EQ(got_rev[static_cast(t)], r.in_adj_edge_index) << t; } @@ -96,9 +84,9 @@ TEST(GraphConcurrency, IndependentConstructionsAtTheSameTime) { TEST(GraphConcurrency, SharedGraphSearchesBothDirectionsBothQueues) { const auto in = random_input(3000, 30000, 20, 99); - const Graph g(in.n, in.src, in.dst, in.cap, in.cost, 1.0); + const Graph g = build(in); const Graph copy(g); - const auto before = snapshot(g); + const auto before = arrays_of(g); struct Job { const Graph* graph; Direction direction; @@ -121,36 +109,134 @@ TEST(GraphConcurrency, SharedGraphSearchesBothDirectionsBothQueues) { }; std::vector serial; for (const auto& j : jobs) serial.push_back(run(j)); + // Not vacuous: every search reaches beyond its root. + for (std::size_t i = 0; i < jobs.size(); ++i) EXPECT_FALSE(serial[i].dag.via_edges().empty()) << i; std::vector> parallel(jobs.size()); std::vector threads; - for (std::size_t i = 0; i < jobs.size(); ++i) threads.emplace_back([&, i] { parallel[i] = run(jobs[i]); }); + // The searches start together, once all threads run. + std::latch start(static_cast(jobs.size())); + for (std::size_t i = 0; i < jobs.size(); ++i) { + threads.emplace_back([&, i] { + start.arrive_and_wait(); + parallel[i] = run(jobs[i]); + }); + } for (auto& th : threads) th.join(); for (std::size_t i = 0; i < jobs.size(); ++i) { ASSERT_TRUE(parallel[i].has_value()); EXPECT_EQ(parallel[i]->costs, serial[i].costs) << i; EXPECT_TRUE(parallel[i]->dag == serial[i].dag) << i; } - EXPECT_TRUE(snapshot(g) == before); - EXPECT_TRUE(snapshot(copy) == before); + EXPECT_TRUE(arrays_of(g) == before); + EXPECT_TRUE(arrays_of(copy) == before); +} + +// The immutable route values shared by threads: every thread reads every member of one PredDAG +// (its sparse storage and its ascending edge list too, through the internal read access) and of +// one Path (its DAG included) 20 times, and compares each read with the values read before the +// threads started (the DAG also with a copy taken then). ThreadSanitizer reports any write racing +// with these reads (a lazily cached member, for example). +TEST(GraphConcurrency, SharedRouteValuesAreReadByEveryThread) { + const auto in = random_input(200, 2000, 9, 7); + const Graph g = build(in); + const std::vector roots {0}; + const auto search = spf(g, roots, SpfOptions(Direction::Forward, PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity))); + const PredDAG& dag = search.dag; + const PredDAG dag_before = dag; + ASSERT_FALSE(dag.via_edges().empty()); + // A walk of single-member hops from the root: the first entry of each node, followed back. + NodeId v = static_cast(g.edge_dst()[static_cast(dag.via_edges().back())]); + std::vector> hops; + const auto offsets = dag.parent_offsets(); + while (v != 0) { + const auto k = static_cast(offsets[static_cast(v)]); + hops.insert(hops.begin(), std::vector {dag.via_edges()[k]}); + v = dag.parents()[k]; + } + ASSERT_FALSE(hops.empty()); + const Path path(g, hops); + using Builder = detail::PredDAGBuilder; + struct Read { + std::uint64_t graph_uid; + std::vector offsets; + std::vector parents; + std::vector via; + std::vector member_nodes; + std::vector member_offsets; + std::vector ascending; + std::uint64_t path_graph_uid; + std::vector> hops; + NodeId src; + NodeId dst; + Cost cost; + bool dag_equal; + bool path_dag_equal; + bool operator==(const Read&) const = default; + }; + const PredDAG path_dag = path.to_dag(); + auto read = [&] { + const auto nodes = Builder::member_nodes(dag); + const auto node_offsets = Builder::member_offsets(dag); + const auto ascending = Builder::ascending(dag); + return Read {dag.graph_uid(), + dag.parent_offsets(), + {dag.parents().begin(), dag.parents().end()}, + {dag.via_edges().begin(), dag.via_edges().end()}, + {nodes.begin(), nodes.end()}, + {node_offsets.begin(), node_offsets.end()}, + {ascending.begin(), ascending.end()}, + path.graph_uid(), + path.hops(), + path.src(), + path.dst(), + path.cost(), + dag == dag_before, + path.to_dag() == path_dag}; + }; + const Read serial = read(); + EXPECT_TRUE(serial.dag_equal && serial.path_dag_equal); + constexpr int kThreads = 8; + constexpr int kRounds = 20; + std::vector wrong(kThreads, 0); // per thread, the reads that differ from the serial one + std::latch start(kThreads); + std::vector threads; + for (int t = 0; t < kThreads; ++t) { + threads.emplace_back([&, t] { + start.arrive_and_wait(); + for (int i = 0; i < kRounds; ++i) { + if (!(read() == serial)) ++wrong[static_cast(t)]; + } + }); + } + for (auto& th : threads) th.join(); + for (int t = 0; t < kThreads; ++t) EXPECT_EQ(wrong[static_cast(t)], 0) << t; } // Workers of batch_capacity without a supplied cache each build their own reverse static support // (the CostOnly policy's DAG toward the destinations) on the shared graph. TEST(GraphConcurrency, BatchWorkersBuildTheirOwnReverseSupport) { const auto in = random_input(400, 4000, 9, 5); - const Graph g(in.n, in.src, in.dst, in.cap, in.cost, 1.0); - const auto before = snapshot(g); + const Graph g = build(in); + const auto before = arrays_of(g); std::vector, std::vector>> pairs; for (NodeId s = 0; s < 32; ++s) pairs.push_back({{s}, {static_cast(399 - s)}}); // CostOnly routing: each policy's DAG is the reverse static support toward its destination. - const CapacityOptions opts {FlowPolicyConfig(CostOnly {}, FlowPlacement::EqualBalanced, Admission::Lossless, - PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity))}; + // Every output is requested, so the per-edge flows and the residuals are compared too. + const CapacityOptions opts {cost_only_config(), nullptr, nullptr, OutputOptions {true, true, true}}; const auto serial = batch_capacity(g, pairs, opts, {}, 1); const auto parallel = batch_capacity(g, pairs, opts, {}, 4); - ASSERT_EQ(serial.size(), parallel.size()); + ASSERT_EQ(serial.size(), pairs.size()); + ASSERT_EQ(parallel.size(), pairs.size()); + int placing = 0; for (std::size_t i = 0; i < serial.size(); ++i) { + ASSERT_TRUE(serial[i].edge_flows.has_value() && parallel[i].edge_flows.has_value()) << i; + ASSERT_TRUE(serial[i].residual.has_value() && parallel[i].residual.has_value()) << i; EXPECT_EQ(serial[i].placed, parallel[i].placed) << i; EXPECT_EQ(serial[i].edge_flows, parallel[i].edge_flows) << i; + EXPECT_EQ(serial[i].residual, parallel[i].residual) << i; + EXPECT_EQ(serial[i].bottleneck, parallel[i].bottleneck) << i; + placing += serial[i].placed > 0 ? 1 : 0; } - EXPECT_TRUE(snapshot(g) == before); + EXPECT_GT(placing, 16); // not vacuous: most pairs place something + EXPECT_TRUE(arrays_of(g) == before); } diff --git a/tests/cpp/graph_fault_tests.cpp b/tests/cpp/graph_fault_tests.cpp index c5b50d9..e98bfcc 100644 --- a/tests/cpp/graph_fault_tests.cpp +++ b/tests/cpp/graph_fault_tests.cpp @@ -1,111 +1,61 @@ -// Allocation-failure atomicity of the graph's construction and copy, and the allocation-free -// move (APPROACH 13; task 1e). Runs in the fault-injection executable (fault_injection.hpp), whose +// Allocation-failure atomicity of the graph's construction and copy, the allocation-free move, +// and the calls that build a new Eligibility, PredDAG or Path from const inputs. Runs in the +// fault-injection executable (fault_injection.hpp), whose // replacement operator new fails the chosen allocation and counts the live allocations -// (g_live, in flow_ledger_fault_tests.cpp's replacement functions). +// (g_live, in fault_injection.cpp's replacement functions). // // Every allocation of every selected construction path fails once: the constructor raises -// std::bad_alloc, the caller's input arrays are unchanged, no allocation stays live (leaks are -// counted, not left to ASan, whose leak detection is off on macOS), and a clean retry gives the -// reference arrays and passes the complete layout checker. The paths: an empty graph, isolated -// nodes, the hash path from creation through growth and collisions, the K-limit transition into -// the fallback, the fallback from the start, the probe-budget fallbacks during collection and -// during a table growth, and the copy constructor. +// std::bad_alloc and constructs nothing, no allocation stays live (leaks are counted, not left to +// ASan, whose leak detection is off on macOS), and a clean retry, like the construction without a +// failure, gives the reference arrays and passes the complete layout checker. The inputs are built for these paths +// (graph_inputs.hpp): an empty graph, isolated nodes, the hash path from creation through growth +// and collisions, the K-limit transition into the fallback, the probe-budget fallbacks during +// collection and during a table growth; and the copy constructor. Each factory call (the +// Eligibility of a graph and of an intersection, PredDAG::from_csr and parent_offsets, a Path and +// its PredDAG) fails at every allocation without building anything or leaking, and the completing +// call equals the reference. +// No branch counter is read here: graph_layout_tests.cpp observes the probe-budget paths of the +// same shapes. #include +#include #include #include +#include #include #include #include "fault_injection.hpp" -#include "graph_reference_1d.hpp" +#include "graph_inputs.hpp" +#include "graph_reference.hpp" #include "layout_checker.hpp" #include "netgraph/core/detail/graph_access.hpp" #include "netgraph/core/graph.hpp" - - +#include "netgraph/core/pred_dag.hpp" using namespace netgraph::core; using namespace netgraph::core::test; -using detail::GraphAccess; namespace { -struct Input { - std::string name; - std::int32_t n {0}; - std::vector src {}, dst {}; - std::vector cap {}; - std::vector cost {}; -}; - -Input exact_k(std::int64_t k, std::size_t extra, std::int32_t n) { - Input in {"K=" + std::to_string(k), n}; - for (std::int64_t c = 1; c <= k; ++c) in.cost.push_back(c * 5 + 2); - for (std::size_t j = 0; j < extra; ++j) in.cost.push_back(in.cost[(j * 7919) % in.cost.size()]); - for (std::size_t i = 0; i < in.cost.size(); ++i) { - in.src.push_back(static_cast((i * 31) % static_cast(n))); - in.dst.push_back(static_cast((i * 17 + 3) % static_cast(n))); - in.cap.push_back(static_cast(i % 9)); - } - return in; -} - -Input colliding(std::size_t count) { - Input in {"collisions", 8}; - for (Cost c = 1; in.cost.size() < count; ++c) { - if ((detail::cost_hash(c) & 63) == 9) in.cost.push_back(c); - } - for (std::size_t i = 0; i < in.cost.size(); ++i) { - in.src.push_back(static_cast(i % 8)); - in.dst.push_back(static_cast((i + 3) % 8)); - in.cap.push_back(1); - } - return in; -} - -// The probe-budget fallbacks (closed-form shapes of GraphLayout.ProbeBudgetFallbackInCollectionAndGrowth): -// 64 slot-zero costs plus D duplicates alternating the 63rd and 64th, optionally a 65th one. -Input budget_shape(const std::string& name, std::size_t dups, bool tail) { - std::vector z; - for (Cost c = 1; z.size() < 65; ++c) { - if ((detail::cost_hash(c) & 2047) == 0) z.push_back(c); - } - Input in {name, 7}; - std::vector c(z.begin(), z.begin() + 64); - for (std::size_t i = 0; i < dups; ++i) c.push_back(z[i % 2 == 0 ? 62 : 63]); - if (tail) c.push_back(z[64]); - for (std::size_t i = 0; i < c.size(); ++i) { - in.src.push_back(static_cast(i % 7)); - in.dst.push_back(static_cast((i * 3) % 7)); - in.cap.push_back(static_cast(i % 11)); - in.cost.push_back(c[i]); - } - return in; -} - std::vector paths() { return {Input {"empty", 0}, Input {"isolated", 64}, - exact_k(1, 10, 4), - exact_k(31, 200, 16), - exact_k(500, 100, 32), // table growths + exact_k_strided(1, 10, 4), + exact_k_strided(31, 200, 16), + exact_k_strided(500, 100, 32), // table growths colliding(40), - exact_k(65537, 50, 4096), // the K-limit transition after a full table - exact_k(70000, 0, 4096), + exact_k_strided(65537, 50, 4096), // the K-limit transition, then repeats budget_shape("budget exhausted in collection", 10000, false), budget_shape("budget exhausted in growth", 9380, true)}; } void expect_built(const Graph& g, const Input& in, const std::string& where) { - const auto r = reference_1d(in.n, in.src, in.dst, in.cap, in.cost, 1.0); + const auto r = reference_graph(in.n, in.src, in.dst, in.cap, in.cost, 1.0); EXPECT_EQ(std::vector(g.input_index().begin(), g.input_index().end()), r.input_index) << where; EXPECT_EQ(std::vector(g.in_adj_edge_index().begin(), g.in_adj_edge_index().end()), r.in_adj_edge_index) << where; - const auto bad = check_layout({g.num_nodes(), g.num_edges(), g.capacity_units(), g.cost(), g.edge_src(), g.edge_dst(), - g.input_index(), g.row_offsets(), g.in_row_offsets(), g.in_adj_edge_index(), - GraphAccess::in_col_indices(g), g.max_cost(), g.cost_gcd()}, - {in.n, in.src, in.dst, in.cap, in.cost}); + const auto bad = check_layout(spans_of(g), view(in)); EXPECT_TRUE(bad.empty()) << where << ": " << (bad.empty() ? "" : bad.front()); } @@ -113,19 +63,32 @@ void expect_built(const Graph& g, const Input& in, const std::string& where) { TEST(GraphFaults, EveryConstructionAllocationFailsCleanly) { for (const auto& in : paths()) { - const Input saved = in; long k = 0; for (;; ++k) { + std::optional g; const long live = g_live.load(); - const bool failed = fails_at(k, [&] { const Graph g(in.n, in.src, in.dst, in.cap, in.cost, 1.0); }); + const bool failed = fails_at(k, [&] { g.emplace(in.n, in.src, in.dst, in.cap, in.cost, 1.0); }); + if (!failed) { + expect_built(*g, in, in.name + " without a failure"); + // The graph holds its retained arrays and nothing else: nine with edges, the two offset + // arrays without (counted by the allocator, not by the graph's own list of its arrays). + const long held = g_live.load() - live; + EXPECT_EQ(held, in.src.empty() ? 2 : 9) << in.name << ": allocations the graph holds"; + // With edges every retained array is allocated, so the graph's own list of its arrays + // (detail::GraphAccess::retained, which the payload checks of graph_layout_tests.cpp + // read) must name every allocation it holds. + if (!in.src.empty()) { + EXPECT_EQ(static_cast(detail::GraphAccess::retained(*g).arrays), held) + << in.name << ": an array left off the list"; + } + break; + } + EXPECT_FALSE(g.has_value()) << in.name << " allocation " << k; EXPECT_EQ(g_live.load(), live) << in.name << " allocation " << k << ": a leak"; - EXPECT_TRUE(in.src == saved.src && in.dst == saved.dst && in.cap == saved.cap && in.cost == saved.cost) - << in.name << " allocation " << k << ": the input changed"; - if (!failed) break; - const Graph retry(in.n, in.src, in.dst, in.cap, in.cost, 1.0); + const Graph retry = build(in); expect_built(retry, in, in.name + " retry after allocation " + std::to_string(k)); } - // The allocation count of each path is recorded for NOTES.md (the observed list). + // The allocation count of each path is recorded as a test property. RecordProperty(in.name + " allocations", static_cast(k)); // An edgeless graph allocates its two offset arrays only; with edges, the nine retained // arrays at least. @@ -139,17 +102,31 @@ TEST(GraphFaults, EveryConstructionAllocationFailsCleanly) { TEST(GraphFaults, EveryCopyAllocationFailsCleanly) { for (const auto& in : paths()) { - const Graph g(in.n, in.src, in.dst, in.cap, in.cost, 1.0); - for (long k = 0;; ++k) { + const Graph g = build(in); + long k = 0; + for (;; ++k) { + std::optional c; const long live = g_live.load(); - const bool failed = fails_at(k, [&] { const Graph c(g); }); + const bool failed = fails_at(k, [&] { c.emplace(g); }); + if (!failed) { + expect_built(*c, in, in.name + " copy without a failure"); + EXPECT_EQ(g_live.load() - live, in.src.empty() ? 2 : 9) << in.name << ": allocations the copy holds"; + EXPECT_NE(c->uid(), g.uid()); + break; + } + EXPECT_FALSE(c.has_value()) << in.name << " allocation " << k; EXPECT_EQ(g_live.load(), live) << in.name << " allocation " << k << ": a leak"; - expect_built(g, in, in.name + " copied-from after allocation " + std::to_string(k)); - if (!failed) break; const Graph retry(g); expect_built(retry, in, in.name + " copy retry"); EXPECT_NE(retry.uid(), g.uid()); } + // k allocations failed before the copy succeeded: as in the construction sweep, the two offset + // arrays of an edgeless graph, at least the nine retained arrays with edges. + if (in.src.empty()) { + EXPECT_EQ(k, 2) << in.name; + } else { + EXPECT_GE(k, 9) << in.name; + } } } @@ -158,10 +135,10 @@ TEST(GraphFaults, EveryCopyAllocationFailsCleanly) { // which the parent reports as a failure (death-test subprocess, so the suite survives). TEST(GraphFaults, MoveIsAllocationFreeUnderAnArmedFailure) { GTEST_FLAG_SET(death_test_style, "threadsafe"); - const auto in = exact_k(31, 200, 16); + const auto in = exact_k_strided(31, 200, 16); EXPECT_EXIT( { - Graph g(in.n, in.src, in.dst, in.cap, in.cost, 1.0); + Graph g = build(in); const auto uid = g.uid(); const auto edges = g.num_edges(); const long live = g_live.load(); @@ -176,3 +153,84 @@ TEST(GraphFaults, MoveIsAllocationFreeUnderAnArmedFailure) { }, ::testing::ExitedWithCode(0), ""); } + +namespace { + +// Every allocation of make() fails once: nothing is built and nothing stays allocated; the +// completing call equals the reference (the first call, which also sizes any per-thread scratch). +template +void sweep_factory(const std::string& name, Make make, Same same) { + const T want = make(); + long k = 0; + for (;; ++k) { + std::optional got; + const long live = g_live.load(); + if (!fails_at(k, [&] { got.emplace(make()); })) { + ASSERT_TRUE(got.has_value()) << name; + EXPECT_TRUE(same(*got, want)) << name; + break; + } + EXPECT_FALSE(got.has_value()) << name << " allocation " << k; + EXPECT_EQ(g_live.load(), live) << name << " allocation " << k << ": a leak"; + } + EXPECT_GT(k, 0) << name; // not vacuous: every factory allocates +} + +} // namespace + +TEST(GraphFaults, EveryFactoryAllocationFailsWithoutALeak) { + // 0 -> 1 -> 2 -> 3 with a parallel pair 1 -> 2 and a bypass 0 -> 3. + const std::vector src = {0, 1, 1, 2, 0}; + const std::vector dst = {1, 2, 2, 3, 3}; + const std::vector cap = {5, 5, 3, 5, 1}; + const std::vector cost = {1, 1, 2, 1, 9}; + const Graph g(4, src, dst, cap, cost, 1.0); + const auto edge = [&](NodeId u, NodeId v, Cost c) { + for (EdgeId e = 0; e < g.num_edges(); ++e) { + const auto i = static_cast(e); + if (g.edge_src()[i] == u && g.edge_dst()[i] == v && g.cost()[i] == c) return e; + } + ADD_FAILURE() << "no edge " << u << " -> " << v; + return EdgeId {0}; + }; + const EdgeId bypass[] = {edge(0, 3, 9)}; + const EdgeId costly[] = {edge(1, 2, 2)}; + const NodeId none[] = {0}; + const auto same_elig = [](const Eligibility& a, const Eligibility& b) { + return std::ranges::equal(a.node_allowed(), b.node_allowed()) && std::ranges::equal(a.edge_allowed(), b.edge_allowed()); + }; + // Controls: an isolated node changes only the node flags; an edge changes only edge flags. + const auto isolated = make_graph(3, {{0, 1, 1, 1}}); + const auto all = isolated.eligibility(); + EXPECT_TRUE(same_elig(all, all)); + EXPECT_FALSE(same_elig(all, isolated.eligibility(std::vector {2}, {}))); + EXPECT_FALSE(same_elig(all, isolated.eligibility({}, std::vector {0}))); + sweep_factory("Graph::eligibility", [&] { return g.eligibility(none, bypass); }, same_elig); + const Eligibility no_bypass = g.eligibility({}, bypass); + const Eligibility no_costly = g.eligibility({}, costly); + sweep_factory("Eligibility::intersect", [&] { return Eligibility::intersect(no_bypass, no_costly); }, + same_elig); + const std::vector> hops = {{edge(0, 1, 1)}, {edge(1, 2, 1), edge(1, 2, 2)}, {edge(2, 3, 1)}}; + const auto same = [](const auto& a, const auto& b) { return a == b; }; + sweep_factory("Path", [&] { return Path(g, hops); }, same); + const Path path(g, hops); + sweep_factory("Path::to_dag", [&] { return path.to_dag(); }, same); + const PredDAG dag = path.to_dag(); + sweep_factory>("PredDAG::parent_offsets", [&] { return dag.parent_offsets(); }, same); + const auto offsets = dag.parent_offsets(); + sweep_factory("PredDAG::from_csr", [&] { return PredDAG::from_csr(g, offsets, dag.parents(), dag.via_edges()); }, + same); +} + +TEST(GraphFaults, ConstructionComparisonRejectsDifferentInputs) { + const Input in {"comparison", 4, {0, 1, 0, 2}, {1, 3, 2, 3}, {2, 3, 4, 5}, {2, 4, 6, 8}}; + const auto g = build(in); + EXPECT_EQ(failures_of([&] { expect_built(g, in, "same"); }), 0); + for (int kind = 0; kind < 3; ++kind) { + auto bad = in; + if (kind == 0) std::swap(bad.cost[0], bad.cost[2]); + if (kind == 1) bad.dst[3] = 0; + if (kind == 2) ++bad.cap.back(); + EXPECT_GT(failures_of([&] { expect_built(g, bad, "different"); }), 0) << kind; + } +} diff --git a/tests/cpp/graph_inputs.hpp b/tests/cpp/graph_inputs.hpp new file mode 100644 index 0000000..cb1f3bd --- /dev/null +++ b/tests/cpp/graph_inputs.hpp @@ -0,0 +1,166 @@ +// The graph inputs of the three graph test files (graph_layout_tests.cpp, graph_fault_tests.cpp, +// graph_concurrency_tests.cpp): one input in input coordinates, its graph and its view for the +// layout checker, its count of distinct costs, and its generators. Each generator +// is deterministic (seeded or closed-form), so a test file and its readers see the same inputs on +// every run. random_input draws general inputs; each other generator is built for the construction +// path named at it (hash table, growths, collisions, the K-limit and probe-budget fallbacks). The +// branch counters of graph_layout_tests.cpp (hooks builds) are read on exact_k's boundary inputs, +// budget_shape's clusters, a colliding_costs sequence that grows the table, and random_input's +// inputs (whether the fused path ran). +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "layout_checker.hpp" +#include "netgraph/core/detail/graph_access.hpp" +#include "netgraph/core/graph.hpp" + +namespace netgraph::core::test { + +// A graph input as the constructor takes it. +struct Input { + std::string name; + std::int32_t n {0}; + std::vector src {}, dst {}; + std::vector cap {}; + std::vector cost {}; + double q {1.0}; +}; + +// The graph of an input, at its resolution. +inline Graph build(const Input& in) { return Graph(in.n, in.src, in.dst, in.cap, in.cost, in.q); } + +// An input and a graph's stored layout as the layout checker reads them. +inline LayoutInput view(const Input& in) { return {in.n, in.src, in.dst, in.cap, in.cost}; } +inline LayoutSpans spans_of(const Graph& g) { + return {g.num_nodes(), g.num_edges(), g.capacity_units(), g.cost(), g.edge_src(), g.edge_dst(), g.input_index(), + g.row_offsets(), g.in_row_offsets(), g.in_adj_edge_index(), detail::GraphAccess::in_col_indices(g), + g.max_cost(), g.cost_gcd()}; +} + +// The number of distinct costs of an input (K), which decides its construction path. +inline std::int64_t distinct(std::vector c) { + std::sort(c.begin(), c.end()); + return std::unique(c.begin(), c.end()) - c.begin(); +} + +// A copy of every stored array of a graph and of its two cost values: the before/after comparison +// of graph_concurrency_tests.cpp, and the copy that the layout checker's corruption controls edit +// (graph_layout_tests.cpp). +struct GraphArrays { + std::vector capacity; + std::vector cost; + std::vector src, dst; + std::vector input_index, row_offsets, in_row_offsets; + std::vector in_adj; + std::vector in_col; + Cost max_cost {0}, cost_gcd {0}; + bool operator==(const GraphArrays&) const = default; +}; + +inline GraphArrays arrays_of(const Graph& g) { + const auto col = detail::GraphAccess::in_col_indices(g); + return {{g.capacity_units().begin(), g.capacity_units().end()}, + {g.cost().begin(), g.cost().end()}, + {g.edge_src().begin(), g.edge_src().end()}, + {g.edge_dst().begin(), g.edge_dst().end()}, + {g.input_index().begin(), g.input_index().end()}, + {g.row_offsets().begin(), g.row_offsets().end()}, + {g.in_row_offsets().begin(), g.in_row_offsets().end()}, + {g.in_adj_edge_index().begin(), g.in_adj_edge_index().end()}, + {col.begin(), col.end()}, + g.max_cost(), + g.cost_gcd()}; +} + +// m random edges over n nodes, costs 1..max_cost, capacities cap_low .. cap_low + cap_span - 1. +inline Input random_input(std::string name, std::int32_t n, std::size_t m, Cost max_cost, std::uint64_t seed, + Amount cap_low = 0, Amount cap_span = 5) { + std::mt19937_64 rng(seed); + Input in {std::move(name), n}; + for (std::size_t i = 0; i < m; ++i) { + in.src.push_back(static_cast(rng() % static_cast(n))); + in.dst.push_back(static_cast(rng() % static_cast(n))); + in.cap.push_back(cap_low + static_cast(rng() % static_cast(cap_span))); + in.cost.push_back(static_cast(1 + rng() % static_cast(max_cost))); + } + return in; +} + +// Exactly k distinct costs (3 c + 1), each present, plus `extra` repeats, shuffled by the seed; +// distinct capacities 1, 2, ... +inline Input exact_k(std::int64_t k, std::size_t extra, std::int32_t n, std::uint64_t seed) { + std::mt19937_64 rng(seed); + Input in {"K=" + std::to_string(k), n}; + for (std::int64_t c = 1; c <= k; ++c) in.cost.push_back(3 * c + 1); + for (std::size_t j = 0; j < extra; ++j) in.cost.push_back(in.cost[rng() % in.cost.size()]); + std::shuffle(in.cost.begin(), in.cost.end(), rng); + for (std::size_t i = 0; i < in.cost.size(); ++i) { + in.src.push_back(static_cast(rng() % static_cast(n))); + in.dst.push_back(static_cast(rng() % static_cast(n))); + in.cap.push_back(static_cast(1 + i)); + } + return in; +} + +// Exactly k distinct costs (c * 5 + 2) in ascending order of first appearance, plus `extra` +// repeats, by closed-form strides without a generator; capacities 0 to 8 (zero included). +inline Input exact_k_strided(std::int64_t k, std::size_t extra, std::int32_t n) { + Input in {"K=" + std::to_string(k), n}; + for (std::int64_t c = 1; c <= k; ++c) in.cost.push_back(c * 5 + 2); + for (std::size_t j = 0; j < extra; ++j) in.cost.push_back(in.cost[(j * 7919) % in.cost.size()]); + for (std::size_t i = 0; i < in.cost.size(); ++i) { + in.src.push_back(static_cast((i * 31) % static_cast(n))); + in.dst.push_back(static_cast((i * 17 + 3) % static_cast(n))); + in.cap.push_back(static_cast(i % 9)); + } + return in; +} + +// The first `count` costs whose construction hash has `residue` under `mask` (they share a table +// slot under that mask). +inline std::vector colliding_costs(std::uint64_t mask, std::size_t count, std::uint64_t residue = 5) { + std::vector out; + for (Cost c = 1; out.size() < count; ++c) { + if ((detail::cost_hash(c) & mask) == residue) out.push_back(c); + } + return out; +} + +// `count` edges over 8 nodes whose costs collide in the initial 64-slot table. +inline Input colliding(std::size_t count) { + Input in {"collisions", 8}; + in.cost = colliding_costs(63, count, 9); + for (std::size_t i = 0; i < in.cost.size(); ++i) { + in.src.push_back(static_cast(i % 8)); + in.dst.push_back(static_cast((i + 3) % 8)); + in.cap.push_back(1); + } + return in; +} + +// The probe-budget shapes (GraphLayout.ProbeBudgetFallbackInCollectionAndGrowth): the 64 first +// costs whose hash has 11 low zero bits (one cluster at slot 0 in every table up to 2,048 slots), +// then `dups` duplicates alternating the 63rd and 64th, optionally a 65th cluster cost at the end. +inline Input budget_shape(const std::string& name, std::size_t dups, bool tail) { + const std::vector z = colliding_costs(2047, 65, 0); + Input in {name, 7}; + std::vector c(z.begin(), z.begin() + 64); + for (std::size_t i = 0; i < dups; ++i) c.push_back(z[i % 2 == 0 ? 62 : 63]); + if (tail) c.push_back(z[64]); + for (std::size_t i = 0; i < c.size(); ++i) { + in.src.push_back(static_cast(i % 7)); + in.dst.push_back(static_cast((i * 3) % 7)); + in.cap.push_back(static_cast(i % 11)); + in.cost.push_back(c[i]); + } + return in; +} + +} // namespace netgraph::core::test diff --git a/tests/cpp/graph_layout_tests.cpp b/tests/cpp/graph_layout_tests.cpp index 53ac375..95de8f2 100644 --- a/tests/cpp/graph_layout_tests.cpp +++ b/tests/cpp/graph_layout_tests.cpp @@ -1,8 +1,7 @@ -// Layout B' storage and construction (APPROACH 3; task 1e): the independent layout checker on -// every graph, its corruption controls, the differential test against the pinned 1d constructor -// (every array, every error), the hash/fallback boundary, copies and moves, and the retained -// array payload. The manifest instance 22@1e.storage names 13 obligations; each test below names -// the one it covers ("manifest N"). +// The graph's stored layout and construction: the independent layout checker on every graph, its +// corruption controls, the differential test against the pinned earlier constructor +// (graph_reference.hpp; every array, every error), the hash/fallback boundary, copies and +// moves, and the retained array payload. #include #include @@ -12,13 +11,15 @@ #include #include #include +#include #include #include #include #include #include -#include "graph_reference_1d.hpp" +#include "graph_inputs.hpp" +#include "graph_reference.hpp" #include "layout_checker.hpp" #include "netgraph/core/dag_cache.hpp" #include "netgraph/core/detail/graph_access.hpp" @@ -34,34 +35,10 @@ using detail::GraphAccess; namespace { -constexpr Amount L62 = Amount{1} << 62; - -struct Input { - std::string name; - std::int32_t n {0}; - std::vector src {}, dst {}; - std::vector cap {}; - std::vector cost {}; - double q {1.0}; -}; - -LayoutInput view(const Input& in) { return {in.n, in.src, in.dst, in.cap, in.cost}; } - -LayoutSpans spans_of(const Graph& g) { - return {g.num_nodes(), g.num_edges(), g.capacity_units(), g.cost(), g.edge_src(), g.edge_dst(), g.input_index(), - g.row_offsets(), g.in_row_offsets(), g.in_adj_edge_index(), GraphAccess::in_col_indices(g), g.max_cost(), - g.cost_gcd()}; -} - -template -std::vector vec(std::span s) { - return {s.begin(), s.end()}; -} - // Every public array, its element width, the counts, the resolution and the scans equal the // reference's; the internal copy equals the reference's edge_src[in_adj_edge_index[slot]]. void expect_same_as_reference(const Graph& g, const Input& in) { - const auto r = reference_1d(in.n, in.src, in.dst, in.cap, in.cost, in.q); + const auto r = reference_graph(in.n, in.src, in.dst, in.cap, in.cost, in.q); static_assert(sizeof(decltype(g.capacity_units())::element_type) == 8); static_assert(sizeof(decltype(g.cost())::element_type) == 8); static_assert(sizeof(decltype(g.edge_src())::element_type) == 4); @@ -85,27 +62,8 @@ void expect_same_as_reference(const Graph& g, const Input& in) { EXPECT_TRUE(bad.empty()) << in.name << ": " << (bad.empty() ? "" : bad.front()); } -Graph build(const Input& in) { return Graph(in.n, in.src, in.dst, in.cap, in.cost, in.q); } - -std::int64_t distinct(std::vector c) { - std::sort(c.begin(), c.end()); - return std::unique(c.begin(), c.end()) - c.begin(); -} - // --------------------------------------------------------------------------- generators -Input random_input(std::string name, std::int32_t n, std::size_t m, Cost max_cost, std::uint64_t seed) { - std::mt19937_64 rng(seed); - Input in {std::move(name), n}; - for (std::size_t i = 0; i < m; ++i) { - in.src.push_back(static_cast(rng() % static_cast(n))); - in.dst.push_back(static_cast(rng() % static_cast(n))); - in.cap.push_back(static_cast(rng() % 5)); // zero capacities included - in.cost.push_back(static_cast(1 + rng() % static_cast(max_cost))); - } - return in; -} - Input permuted(const Input& in, const std::vector& p, const std::string& suffix) { Input out {in.name + suffix, in.n, {}, {}, {}, {}, in.q}; for (std::size_t i : p) { @@ -117,40 +75,16 @@ Input permuted(const Input& in, const std::vector& p, const std::st return out; } -// The same edges in B' order, reversed and shuffled (all property arrays permuted together). +// The same edges in EdgeId order, reversed and shuffled (all property arrays permuted together). std::vector orders(const Input& in, std::uint64_t seed) { - const auto r = reference_1d(in.n, in.src, in.dst, in.cap, in.cost, in.q); + const auto r = reference_graph(in.n, in.src, in.dst, in.cap, in.cost, in.q); std::vector sorted(r.input_index.begin(), r.input_index.end()); std::vector rev(in.src.size()); std::iota(rev.rbegin(), rev.rend(), 0); std::vector sh(in.src.size()); std::iota(sh.begin(), sh.end(), 0); std::shuffle(sh.begin(), sh.end(), std::mt19937_64(seed)); - return {in, permuted(in, sorted, " (B' order)"), permuted(in, rev, " (reversed)"), permuted(in, sh, " (shuffled)")}; -} - -// Exactly k distinct costs, each present, plus `extra` repeats; distinct capacities. -Input exact_k(std::int64_t k, std::size_t extra, std::int32_t n, std::uint64_t seed, Cost stride = 3) { - std::mt19937_64 rng(seed); - Input in {"K=" + std::to_string(k), n}; - for (std::int64_t c = 1; c <= k; ++c) in.cost.push_back(c * stride + 1); - for (std::size_t j = 0; j < extra; ++j) in.cost.push_back(in.cost[rng() % in.cost.size()]); - std::shuffle(in.cost.begin(), in.cost.end(), rng); - for (std::size_t i = 0; i < in.cost.size(); ++i) { - in.src.push_back(static_cast(rng() % static_cast(n))); - in.dst.push_back(static_cast(rng() % static_cast(n))); - in.cap.push_back(static_cast(1 + i)); - } - return in; -} - -// Costs that share a table slot under the given mask of the construction's hash. -std::vector colliding_costs(std::uint64_t mask, std::size_t count) { - std::vector out; - for (Cost c = 1; out.size() < count; ++c) { - if ((detail::cost_hash(c) & mask) == 5) out.push_back(c); - } - return out; + return {in, permuted(in, sorted, " (EdgeId order)"), permuted(in, rev, " (reversed)"), permuted(in, sh, " (shuffled)")}; } } // namespace @@ -203,6 +137,7 @@ TEST(GraphLayout, DifferentialDistinctCostCounts) { expect_same_as_reference(build(all), all); } +// Exact units above 2^53 (capacities and costs kept as int64, not through double). TEST(GraphLayout, DifferentialWideIntegers) { // Costs and units above 2^53, sums just below 2^62; separate from the large-K cases (the cost // sum bound forbids combining them). @@ -210,14 +145,22 @@ TEST(GraphLayout, DifferentialWideIntegers) { Input wide {"wide costs", 3, {0, 0, 1, 2, 0}, {1, 1, 2, 0, 1}, {(Amount{1} << 53) + 1, Amount{1} << 61, 0, 5, 3}, {big + 3, big + 1, big + 2, 1, big + 1}}; expect_same_as_reference(build(wide), wide); - Input sums {"sums just below 2^62", 2, {0, 0}, {1, 1}, {L62 / 2, L62 / 2 - 1}, {L62 / 2, L62 / 2 - 1}}; + Input sums {"sums just below 2^62", 2, {0, 0}, {1, 1}, {kL62 / 2, kL62 / 2 - 1}, {kL62 / 2, kL62 / 2 - 1}}; expect_same_as_reference(build(sums), sums); Input units {"units above 2^53", 2, {0, 1}, {1, 0}, {(Amount{1} << 53) + 1, (Amount{1} << 53) + 3}, {1, 1}}; expect_same_as_reference(build(units), units); + // Just above 2^53 (no double holds 2^53 + 1), both arrays; EdgeId 0 is the cheaper 0 -> 1. + const Amount above = (Amount{1} << 53) + 1; + const Input both {"2^53 + 1", 2, {0, 0, 1}, {1, 1, 0}, {above, above + 2, 0}, {(Cost{1} << 53) + 1, (Cost{1} << 53) + 3, 1}}; + const Graph g = build(both); + expect_same_as_reference(g, both); + EXPECT_EQ(g.capacity_units()[0], above); + EXPECT_EQ(g.cost()[0], (Cost{1} << 53) + 1); } TEST(GraphLayout, DifferentialHashCollisions) { - // Costs colliding in the initial table (64 slots) and in the full one (2^18 slots at load 1/4). + // 40 costs colliding in the initial 64-slot table (their low 6 hash bits equal), and 40 sharing + // their low 18 hash bits, so they collide in every table these inputs reach. for (std::uint64_t mask : {std::uint64_t {63}, (std::uint64_t {1} << 18) - 1}) { Input in {"collisions mask " + std::to_string(mask), 16}; in.cost = colliding_costs(mask, 40); @@ -245,14 +188,14 @@ std::pair outcome(F&& fn) { return {"", ""}; } -// The defects of APPROACH 3's validation steps, each applied to a copy of a valid input. +// The defects of the constructor's validation steps, each applied to a copy of a valid input. using Defect = std::function; std::vector> defects() { return { {"resolution", [](Input& x) { x.q = 3.0; }}, {"capacity negative at 2", [](Input& x) { x.cap[2] = -1; }}, - {"capacity 2^62 at 1", [](Input& x) { x.cap[1] = L62; }}, + {"capacity 2^62 at 1", [](Input& x) { x.cap[1] = kL62; }}, {"num_nodes negative", [](Input& x) { x.n = -1; }}, {"dst shorter", [](Input& x) { x.dst.pop_back(); }}, {"cost longer", [](Input& x) { x.cost.push_back(1); }}, @@ -260,8 +203,14 @@ std::vector> defects() { {"cost negative at 1", [](Input& x) { x.cost[1] = -4; }}, {"src out of range at 2", [](Input& x) { x.src[2] = x.n; }}, {"dst negative at 1", [](Input& x) { x.dst[1] = -1; }}, - {"capacity sum", [](Input& x) { x.cap[0] = L62 / 2, x.cap[3] = L62 / 2; }}, - {"cost sum", [](Input& x) { x.cost[1] = L62 / 2, x.cost[2] = L62 / 2; }}, + {"capacity sum", [](Input& x) { + x.cap[0] = kL62 / 2; + x.cap[3] = kL62 / 2; + }}, + {"cost sum", [](Input& x) { + x.cost[1] = kL62 / 2; + x.cost[2] = kL62 / 2; + }}, }; } @@ -270,38 +219,40 @@ std::vector> defects() { TEST(GraphLayout, DifferentialErrorsSingleAndPairwise) { const Input base {"base", 4, {0, 1, 2, 3, 0}, {1, 2, 3, 0, 2}, {1, 2, 3, 4, 5}, {1, 2, 3, 4, 5}}; const auto ds = defects(); - std::size_t checked = 0; for (std::size_t a = 0; a < ds.size(); ++a) { for (std::size_t b = a; b < ds.size(); ++b) { Input in = base; ds[a].second(in); if (b != a) ds[b].second(in); const auto got = outcome([&] { (void)build(in); }); - const auto want = outcome([&] { (void)reference_1d(in.n, in.src, in.dst, in.cap, in.cost, in.q); }); + const auto want = outcome([&] { (void)reference_graph(in.n, in.src, in.dst, in.cap, in.cost, in.q); }); ASSERT_FALSE(want.first.empty()) << ds[a].first << " + " << ds[b].first; EXPECT_EQ(got, want) << ds[a].first << " + " << ds[b].first; - ++checked; } } - EXPECT_EQ(checked, ds.size() * (ds.size() + 1) / 2); } TEST(GraphLayout, DifferentialErrorsFirstInvalidIndexAndSumOrder) { // Two defects of one kind: the lower input index is named. Input in {"two costs", 3, {0, 1, 2, 0}, {1, 2, 0, 2}, {1, 1, 1, 1}, {1, -2, 0, 1}}; auto got = outcome([&] { (void)build(in); }); - EXPECT_EQ(got, outcome([&] { (void)reference_1d(in.n, in.src, in.dst, in.cap, in.cost, in.q); })); + EXPECT_EQ(got, outcome([&] { (void)reference_graph(in.n, in.src, in.dst, in.cap, in.cost, in.q); })); EXPECT_NE(got.second.find("cost[1] = -2"), std::string::npos) << got.second; // Capacity and cost sums both over: the one whose running sum crosses first, in input order. - Input both {"sums", 2, {0, 0, 0}, {1, 1, 1}, {1, L62 / 2, L62 / 2}, {L62 / 2, L62 / 2, 1}}; + Input both {"sums", 2, {0, 0, 0}, {1, 1, 1}, {1, kL62 / 2, kL62 / 2}, {kL62 / 2, kL62 / 2, 1}}; got = outcome([&] { (void)build(both); }); - EXPECT_EQ(got, outcome([&] { (void)reference_1d(both.n, both.src, both.dst, both.cap, both.cost, both.q); })); + EXPECT_EQ(got, outcome([&] { (void)reference_graph(both.n, both.src, both.dst, both.cap, both.cost, both.q); })); EXPECT_NE(got.second.find("edge costs"), std::string::npos) << got.second; + // Both running sums reach 2^62 at the same edge: the capacity sum is reported. + Input tie {"sums at one edge", 2, {0, 0}, {1, 1}, {kL62 / 2, kL62 / 2}, {kL62 / 2, kL62 / 2}}; + got = outcome([&] { (void)build(tie); }); + EXPECT_EQ(got, outcome([&] { (void)reference_graph(tie.n, tie.src, tie.dst, tie.cap, tie.cost, tie.q); })); + EXPECT_NE(got.second.find("sum of all capacities"), std::string::npos) << got.second; } -// --------------------------------------------------------------------------- manifest obligations +// --------------------------------------------------------------------------- the layout checker -// manifest 1-8: the corruption controls of the checker, on a graph built by the constructor. +// The corruption controls of the checker, on a graph built by the constructor. // The fixture repeats (src, cost, dst) keys with distinct capacities and input positions, and its // reverse slot order differs from its id order, with different source values. TEST(GraphLayout, CheckerRejectsEveryCorruption) { @@ -309,18 +260,8 @@ TEST(GraphLayout, CheckerRejectsEveryCorruption) { {2, 1, 2, 1, 2, 1, 4, 4}}; const Graph g = build(in); ASSERT_TRUE(check_layout(spans_of(g), view(in)).empty()); - struct Copy { - std::vector capacity; - std::vector cost; - std::vector src, dst; - std::vector input_index, row_offsets, in_row_offsets; - std::vector in_adj; - std::vector in_col; - Cost max_cost, cost_gcd; - }; - const Copy clean {vec(g.capacity_units()), vec(g.cost()), vec(g.edge_src()), vec(g.edge_dst()), vec(g.input_index()), - vec(g.row_offsets()), vec(g.in_row_offsets()), vec(g.in_adj_edge_index()), - vec(GraphAccess::in_col_indices(g)), g.max_cost(), g.cost_gcd()}; + using Copy = GraphArrays; + const Copy clean = arrays_of(g); bool reverse_differs = false; for (std::size_t s = 0; s < clean.in_adj.size(); ++s) reverse_differs |= clean.in_adj[s] != static_cast(s); ASSERT_TRUE(reverse_differs); @@ -331,15 +272,15 @@ TEST(GraphLayout, CheckerRejectsEveryCorruption) { view(in)); }; const std::vector>> controls = { - // manifest 1: wrong source or row boundary - {"wrong source", "forward:membership", [](Copy& c) { c.src[0] = 1, c.input_index[0] = c.input_index[0]; }}, + // Wrong source or row boundary + {"wrong source", "forward:membership", [](Copy& c) { c.src[0] = 1; }}, {"row boundary moved", "forward:membership", [](Copy& c) { ++c.row_offsets[1]; }}, - // manifest 2: all-zero offsets (a graph with edges) + // All-zero offsets (a graph with edges) {"all-zero offsets", "row_offsets:end", [](Copy& c) { std::fill(c.row_offsets.begin(), c.row_offsets.end(), 0); }}, - // manifest 3: missing or duplicated reverse coverage + // Missing or duplicated reverse coverage {"duplicated reverse slot", "slot:permutation", [](Copy& c) { c.in_adj[1] = c.in_adj[0]; }}, {"wrong reverse destination", "reverse:membership", [](Copy& c) { ++c.in_row_offsets[1]; }}, - // manifest 4: (src, cost) substituted for (cost, src) + // (src, cost) substituted for (cost, src) {"(src, cost) reverse order", "reverse:order", [](Copy& c) { for (std::size_t v = 0; v + 1 < c.in_row_offsets.size(); ++v) { @@ -351,7 +292,7 @@ TEST(GraphLayout, CheckerRejectsEveryCorruption) { } for (std::size_t s = 0; s < c.in_col.size(); ++s) c.in_col[s] = c.src[static_cast(c.in_adj[s])]; }}, - // manifest 5: unstable equal-key members + // Unstable equal-key members {"unstable equal keys", "forward:order", [](Copy& c) { for (std::size_t e = 0; e + 1 < c.cost.size(); ++e) { @@ -363,16 +304,17 @@ TEST(GraphLayout, CheckerRejectsEveryCorruption) { } FAIL() << "the fixture has no equal-key pair"; }}, - // manifest 6: corrupted map + // Corrupted map {"map entries swapped", "property:capacity", [](Copy& c) { std::swap(c.input_index[0], c.input_index[1]); }}, - // manifest 7: property-only mispermutation + // Property-only mispermutation {"capacities swapped", "property:capacity", [](Copy& c) { std::swap(c.capacity[1], c.capacity[2]); }}, - // manifest 8: out-of-range map or slot before access + // Out-of-range map or slot before access {"negative map entry", "map:permutation", [](Copy& c) { c.input_index[1] = -3; }}, + {"duplicated map entry", "map:permutation", [](Copy& c) { c.input_index[1] = c.input_index[0]; }}, {"map entry E", "map:permutation", [](Copy& c) { c.input_index[1] = static_cast(c.input_index.size()); }}, {"slot out of range", "slot:permutation", [](Copy& c) { c.in_adj[2] = 1 << 30; }}, {"negative slot", "slot:permutation", [](Copy& c) { c.in_adj[2] = -1; }}, - // the brief's additional controls + // Additional size, offset and copied-source corruption controls. {"truncated capacity", "size:capacity", [](Copy& c) { c.capacity.pop_back(); }}, {"oversized slots", "size:in_adj_edge_index", [](Copy& c) { c.in_adj.push_back(0); }}, {"truncated offsets", "size:in_row_offsets", [](Copy& c) { c.in_row_offsets.pop_back(); }}, @@ -387,6 +329,28 @@ TEST(GraphLayout, CheckerRejectsEveryCorruption) { {"copied source out of range", "range:in_col_indices", [](Copy& c) { c.in_col[0] = 4; }}, {"changed maximum", "scan:max_cost", [](Copy& c) { ++c.max_cost; }}, {"changed GCD", "scan:cost_gcd", [](Copy& c) { c.cost_gcd = 2; }}, + // Every other code: each array's size, each offset rule of both offset arrays, the endpoint + // ranges and each permuted property. + {"truncated costs", "size:cost", [](Copy& c) { c.cost.pop_back(); }}, + {"truncated sources", "size:src", [](Copy& c) { c.src.pop_back(); }}, + {"truncated destinations", "size:dst", [](Copy& c) { c.dst.pop_back(); }}, + {"truncated map", "size:input_index", [](Copy& c) { c.input_index.pop_back(); }}, + {"truncated row offsets", "size:row_offsets", [](Copy& c) { c.row_offsets.pop_back(); }}, + {"row offsets start at 1", "row_offsets:start", [](Copy& c) { c.row_offsets[0] = 1; }}, + {"reverse offsets start at 1", "in_row_offsets:start", [](Copy& c) { c.in_row_offsets[0] = 1; }}, + {"reverse offsets end early", "in_row_offsets:end", [](Copy& c) { --c.in_row_offsets.back(); }}, + {"row offsets decrease", "row_offsets:monotone", [](Copy& c) { c.row_offsets[1] = c.row_offsets[2] + 1; }}, + {"reverse offsets decrease", "in_row_offsets:monotone", + [](Copy& c) { c.in_row_offsets[2] = c.in_row_offsets[1] - 1; }}, + {"source out of range", "range:src", [](Copy& c) { c.src[0] = 4; }}, + {"negative destination", "range:dst", [](Copy& c) { c.dst[0] = -1; }}, + // Two different values exchanged (the first one and the first that differs from it). + {"costs swapped", "property:cost", + [](Copy& c) { std::swap(c.cost[0], *std::find_if(c.cost.begin(), c.cost.end(), [&](Cost x) { return x != c.cost[0]; })); }}, + {"sources swapped", "property:src", + [](Copy& c) { std::swap(c.src[0], *std::find_if(c.src.begin(), c.src.end(), [&](NodeId x) { return x != c.src[0]; })); }}, + {"destinations swapped", "property:dst", + [](Copy& c) { std::swap(c.dst[0], *std::find_if(c.dst.begin(), c.dst.end(), [&](NodeId x) { return x != c.dst[0]; })); }}, }; for (const auto& [name, code, corrupt] : controls) { Copy c = clean; @@ -394,6 +358,26 @@ TEST(GraphLayout, CheckerRejectsEveryCorruption) { const auto bad = check(c); EXPECT_NE(std::find(bad.begin(), bad.end(), code), bad.end()) << name << ": expected " << code; } + // The counts and the caller's arrays: the node and edge counts, and each input array's size. + auto spans = spans_of(g); + ++spans.num_nodes; + EXPECT_EQ(check_layout(spans, view(in)), std::vector {"size:num_nodes"}); + spans = spans_of(g); + ++spans.num_edges; + EXPECT_EQ(check_layout(spans, view(in)), std::vector {"size:num_edges"}); + // A negative node count fails even when the caller claims the same count. + spans = spans_of(g); + spans.num_nodes = -1; + auto negative_input = view(in); + negative_input.num_nodes = -1; + EXPECT_EQ(check_layout(spans, negative_input), std::vector {"size:num_nodes"}); + for (const std::string name : {"input.dst", "input.capacity", "input.cost"}) { + auto short_input = view(in); + if (name == "input.dst") short_input.dst = short_input.dst.first(short_input.dst.size() - 1); + if (name == "input.capacity") short_input.capacity = short_input.capacity.first(short_input.capacity.size() - 1); + if (name == "input.cost") short_input.cost = short_input.cost.first(short_input.cost.size() - 1); + EXPECT_EQ(check_layout(spans_of(g), short_input), std::vector {"size:" + name}); + } // Empty graphs are positive controls: all-zero offsets are correct without edges. for (std::int32_t n : {0, 1, 1000}) { const Input e {"empty", n}; @@ -401,13 +385,15 @@ TEST(GraphLayout, CheckerRejectsEveryCorruption) { } } -// manifest 9: hash and fallback boundary at 65,536 distinct costs (the branch observation), on -// inputs that do not exhaust the probe budget. +// The hash and fallback boundary at 65,536 distinct costs (the branch observation), on +// inputs that do not exhaust the probe budget. In this test and the five after it, the arrays are +// compared with the reference in every build; only the branch counters need the private hooks, and +// without them the test reports itself skipped after the comparisons have passed. TEST(GraphLayout, HashAndFallbackBoundaryAt65536DistinctCosts) { -#if NETGRAPH_CORE_PRIVATE_HOOKS for (const auto& [k, hashed] : std::vector> {{65535, true}, {65536, true}, {65537, false}}) { const auto in = exact_k(k, 1000, 4096, static_cast(k)); ASSERT_EQ(distinct(in.cost), k); +#if NETGRAPH_CORE_PRIVATE_HOOKS auto& counts = detail::thread_construction_counts(); const auto before = counts; const Graph g = build(in); @@ -422,18 +408,20 @@ TEST(GraphLayout, HashAndFallbackBoundaryAt65536DistinctCosts) { EXPECT_LE(counts.last_table_slots, std::int64_t {1} << 18); // load <= 1/4 at K = 65,536 EXPECT_GE(counts.last_table_slots, 4 * k); } +#else + const Graph g = build(in); +#endif expect_same_as_reference(g, in); } -#else +#if !NETGRAPH_CORE_PRIVATE_HOOKS GTEST_SKIP() << "branch observations need the private hooks"; #endif } -// Proposal P4: with N K < E the passes run on combined keys; N K = E must take the five single-key -// passes. Both give the reference arrays, in every input order, and the random differential -// inputs reach both branches. +// The combined-key boundary: with N K < E the passes run on combined +// keys; N K = E must take the five single-key passes. Both give the reference arrays, in every +// input order, and the random differential inputs reach both branches. TEST(GraphLayout, CombinedKeyPassesOnlyBelowNTimesKEqualsE) { -#if NETGRAPH_CORE_PRIVATE_HOOKS auto shaped = [](std::int32_t n, std::int64_t k, std::size_t e, std::uint64_t seed) { std::mt19937_64 rng(seed); Input in {"N=" + std::to_string(n) + " K=" + std::to_string(k) + " E=" + std::to_string(e), n}; @@ -451,14 +439,19 @@ TEST(GraphLayout, CombinedKeyPassesOnlyBelowNTimesKEqualsE) { for (const auto& [e, fused] : std::vector> {{nk + 1, true}, {nk, false}}) { for (const auto& in : orders(shaped(n, k, e, nk + e), e)) { ASSERT_EQ(distinct(in.cost), k) << in.name; +#if NETGRAPH_CORE_PRIVATE_HOOKS auto& counts = detail::thread_construction_counts(); const auto before = counts.fused; const Graph g = build(in); EXPECT_EQ(counts.fused - before, fused ? 1 : 0) << in.name; +#else + const Graph g = build(in); +#endif expect_same_as_reference(g, in); } } } +#if NETGRAPH_CORE_PRIVATE_HOOKS std::int64_t fused = 0, plain = 0; for (std::uint64_t seed = 1; seed <= 200; ++seed) { const auto in = random_input("seed " + std::to_string(seed), 1 + static_cast(seed % 37), @@ -474,13 +467,12 @@ TEST(GraphLayout, CombinedKeyPassesOnlyBelowNTimesKEqualsE) { #endif } -// The fallback's 16-bit digits (review-1e-consult/REPORT-2): N = 2, two copies of [1, ..., 65,536, +// The fallback's 16-bit digits: N = 2, two copies of [1, ..., 65,536, // 2^59] (K = 65,537, E = 131,074 = N K: the single-key passes; the cost sum is below 2^62), then -// one more cost of 1 (E = N K + 1: P4). Both take the distinct-limit fallback (observed); by the +// one more cost of 1 (E = N K + 1: the combined keys). Both take the distinct-limit fallback (observed); by the // costs' values its radix sorts on the digits at 0, 16 and 48 bits and skips the one at 32 (one // digit value), with arrays equal to the reference in four input orders. -TEST(GraphLayout, RadixFallbackDigitsOnBothSidesOfP4) { -#if NETGRAPH_CORE_PRIVATE_HOOKS +TEST(GraphLayout, RadixFallbackDigitsOnBothSidesOfTheCombinedKeyBoundary) { Input base {"two copies of 1..65,536 and 2^59", 2}; for (int copy = 0; copy < 2; ++copy) { for (Cost c = 1; c <= 65536; ++c) base.cost.push_back(c); @@ -500,6 +492,7 @@ TEST(GraphLayout, RadixFallbackDigitsOnBothSidesOfP4) { for (const auto& [x, fused] : std::vector> {{&base, false}, {&plus, true}}) { ASSERT_EQ(distinct(x->cost), 65537); for (const auto& in : orders(*x, x->cost.size())) { +#if NETGRAPH_CORE_PRIVATE_HOOKS auto& counts = detail::thread_construction_counts(); const auto before = counts; const Graph g = build(in); @@ -507,10 +500,13 @@ TEST(GraphLayout, RadixFallbackDigitsOnBothSidesOfP4) { EXPECT_EQ(counts.fallback - before.fallback, 1) << in.name; EXPECT_EQ(counts.fused - before.fused, fused ? 1 : 0) << in.name; EXPECT_EQ(counts.last_distinct, 65537) << in.name; +#else + const Graph g = build(in); +#endif expect_same_as_reference(g, in); } } -#else +#if !NETGRAPH_CORE_PRIVATE_HOOKS GTEST_SKIP() << "branch observations need the private hooks"; #endif } @@ -518,7 +514,6 @@ TEST(GraphLayout, RadixFallbackDigitsOnBothSidesOfP4) { // A run of equal costs reuses the previous ordinal without a lookup: one cost repeated costs // exactly one probe; two alternating costs in different slots cost one probe per edge. TEST(GraphLayout, RunsOfEqualCostsSkipTheLookup) { -#if NETGRAPH_CORE_PRIVATE_HOOKS Cost a = 1, b = 2; while ((detail::cost_hash(a) & 63) == (detail::cost_hash(b) & 63)) ++b; for (const bool alternate : {false, true}) { @@ -530,43 +525,105 @@ TEST(GraphLayout, RunsOfEqualCostsSkipTheLookup) { in.cost.push_back(alternate && i % 2 == 1 ? b : a); } const Graph g = build(in); +#if NETGRAPH_CORE_PRIVATE_HOOKS EXPECT_EQ(detail::thread_construction_counts().last_probes, alternate ? 1000 : 1) << in.name; +#endif expect_same_as_reference(g, in); } -#else +#if !NETGRAPH_CORE_PRIVATE_HOOKS GTEST_SKIP() << "branch observations need the private hooks"; #endif } -// The cumulative probe budget B = 8E + 8 x 65,536 (APPROACH 3 revision 43). Costs whose hash has +namespace { + +enum class BudgetOutcome { Hashed, Collection, Growth }; + +struct ProbeCount { + BudgetOutcome outcome {BudgetOutcome::Hashed}; + std::int64_t probes {0}; // every examined slot: exactly the budget when it ran out + std::vector after; // the probes after each hashed edge, in input order + std::size_t distinct {0}; // the distinct costs inserted + std::size_t slots {64}; // the table's size +}; + +// The probe accounting of the cost hashing, modelled from its definition rather than from the +// constructor: linear probing from 64 slots, every examined slot counted against the budget; a +// cost equal to the previous one reuses its ordinal without a lookup; before a new cost would +// raise the load above 1/4, the table doubles, the distinct costs are reinserted in +// first-appearance order and the new cost is looked up again. A probe beyond the budget stops the +// hashing, inside an input lookup (Collection) or inside a growth, its reinsertion or the repeated +// lookup (Growth). Without a budget every cost is hashed. +ProbeCount count_probes(std::span cost, std::optional budget) { + ProbeCount out; + std::vector table(64, 0); + std::vector order; + auto locate = [&](Cost c) -> std::optional { + for (std::size_t at = detail::cost_hash(c) & (table.size() - 1);; at = (at + 1) & (table.size() - 1)) { + if (budget.has_value() && out.probes == *budget) return std::nullopt; + ++out.probes; + if (table[at] == 0 || table[at] == c) return at; + } + }; + Cost prev = 0; + for (const Cost c : cost) { + if (c != prev) { + auto at = locate(c); + if (!at) { + out.outcome = BudgetOutcome::Collection; + return out; + } + if (table[*at] == 0) { + if (4 * (order.size() + 1) > table.size()) { + table.assign(2 * table.size(), 0); + for (const Cost d : order) { + const auto slot = locate(d); + if (!slot) { + out.outcome = BudgetOutcome::Growth; + return out; + } + table[*slot] = d; + } + at = locate(c); + if (!at) { + out.outcome = BudgetOutcome::Growth; + return out; + } + } + table[*at] = c; + order.push_back(c); + } + prev = c; + } + out.after.push_back(out.probes); + } + out.distinct = order.size(); + out.slots = table.size(); + return out; +} + +// The construction's budget B = 8E + 8 x 65,536 for E edges. +std::int64_t probe_budget(std::size_t edges) { return 8 * static_cast(edges) + 8 * 65536; } + +BudgetOutcome budget_outcome(std::span cost) { return count_probes(cost, probe_budget(cost.size())).outcome; } + +} // namespace + +// The cumulative probe budget B = 8E + 8 x 65,536 over the whole construction. Costs whose hash has // 11 low zero bits start at slot 0 in every table up to 2,048 slots, so they form one cluster: // the j-th of them (from 0) is found after j + 1 probes. After the 64 cluster costs come D // duplicates alternating z[62], z[63] (a run of one cost reuses its ordinal without a lookup, so // the duplicates must alternate), each costing 63 or 64 probes while B grows by 8 per edge. The -// boundaries come from an exact model of the probe accounting (build/1e/prep/budget_model.py): +// boundaries are those of budget_outcome above (a model of the probe accounting, independent +// of the constructor), which every case checks first: // * collection: exhausted at an input lookup exactly when D >= 9,406; // * growth: with a 65th cluster cost at the end (its lookup, then the reinsertion into 512 // slots, then the lookup repeated), exhausted inside the growth for 9,366 <= D <= 9,404, not at // all for D <= 9,365, and at the 65th cost's first lookup (collection) for D = 9,405. // Exhausted constructions use exactly B probes and none more; the arrays equal the reference. TEST(GraphLayout, ProbeBudgetFallbackInCollectionAndGrowth) { -#if NETGRAPH_CORE_PRIVATE_HOOKS - std::vector z; - for (Cost c = 1; z.size() < 65; ++c) { - if ((detail::cost_hash(c) & 2047) == 0) z.push_back(c); - } - auto shape = [&](std::size_t dups, bool tail) { - Input in {"cluster D=" + std::to_string(dups) + (tail ? " + 65th" : ""), 7}; - std::vector c(z.begin(), z.begin() + 64); - for (std::size_t i = 0; i < dups; ++i) c.push_back(z[i % 2 == 0 ? 62 : 63]); - if (tail) c.push_back(z[64]); - for (std::size_t i = 0; i < c.size(); ++i) { - in.src.push_back(static_cast(i % 7)); - in.dst.push_back(static_cast((i * 3) % 7)); - in.cap.push_back(static_cast(i % 11)); - in.cost.push_back(c[i]); - } - return in; + auto shape = [](std::size_t dups, bool tail) { + return budget_shape("cluster D=" + std::to_string(dups) + (tail ? " + 65th" : ""), dups, tail); }; enum Where { None, Collection, Growth }; const std::vector> cases = { @@ -577,10 +634,13 @@ TEST(GraphLayout, ProbeBudgetFallbackInCollectionAndGrowth) { for (const auto& [dups, tail, where] : cases) { const auto in = shape(dups, tail); + const BudgetOutcome modelled[] = {BudgetOutcome::Hashed, BudgetOutcome::Collection, BudgetOutcome::Growth}; + EXPECT_EQ(budget_outcome(in.cost), modelled[where]) << in.name; +#if NETGRAPH_CORE_PRIVATE_HOOKS auto& counts = detail::thread_construction_counts(); const auto before = counts; const Graph g = build(in); - const std::int64_t budget = 8 * static_cast(in.src.size()) + 8 * 65536; + const std::int64_t budget = probe_budget(in.src.size()); EXPECT_EQ(counts.last_budget, budget) << in.name; EXPECT_LE(counts.last_probes, budget) << in.name; // no lookup examines more than B in total EXPECT_EQ(counts.exhausted_collection - before.exhausted_collection, where == Collection ? 1 : 0) << in.name; @@ -588,63 +648,46 @@ TEST(GraphLayout, ProbeBudgetFallbackInCollectionAndGrowth) { EXPECT_EQ(counts.fallback - before.fallback, where == None ? 0 : 1) << in.name; EXPECT_EQ(counts.hashed - before.hashed, where == None ? 1 : 0) << in.name; if (where != None) EXPECT_EQ(counts.last_probes, budget) << in.name; +#else + const Graph g = build(in); +#endif expect_same_as_reference(g, in); } -#else +#if !NETGRAPH_CORE_PRIVATE_HOOKS GTEST_SKIP() << "branch observations need the private hooks"; #endif } -// The budget stays cumulative across the large table's growths (review-1e): 64 costs share their +// The budget stays cumulative across the large table's growths: 64 costs share their // low 18 hash bits, so they collide in every table up to 262,144 slots; further costs grow the -// table to K = 32,769; then the last two colliding costs alternate. An independent count (linear -// probing, every examined slot counted, a growth reinserting the distinct costs in first-appearance -// order) finds the first edge count whose probes exceed B: the collection exhausts the budget -// there, and one edge fewer stays hashed with exactly the counted probes. +// table to K = 32,769; then the last two colliding costs alternate. The probe model above +// (count_probes, independent of the constructor) finds the first edge count whose probes exceed +// B: the collection exhausts the budget there, and one edge fewer stays hashed with exactly the +// counted probes. TEST(GraphLayout, ProbeBudgetIsCumulativeAcrossTheLargeTableGrowths) { -#if NETGRAPH_CORE_PRIVATE_HOOKS - std::vector z; - for (Cost c = 1; z.size() < 64; ++c) { - if ((detail::cost_hash(c) & 262143) == 0) z.push_back(c); - } + const std::vector z = colliding_costs(262143, 64, 0); std::vector seq = z; for (Cost c = 1; seq.size() < 32769; ++c) { if (!std::binary_search(z.begin(), z.end(), c)) seq.push_back(c); } - std::vector table(64, 0); - std::vector order; - std::int64_t probes = 0; - auto locate = [&](Cost c) { - for (std::size_t at = detail::cost_hash(c) & (table.size() - 1);; at = (at + 1) & (table.size() - 1)) { - ++probes; - if (table[at] == 0 || table[at] == c) return at; - } - }; - for (const Cost c : seq) { - std::size_t at = locate(c); - if (table[at] != 0) continue; - if (4 * (order.size() + 1) > table.size()) { - table.assign(2 * table.size(), 0); - for (const Cost d : order) table[locate(d)] = d; - at = locate(c); - } - table[at] = c; - order.push_back(c); + const std::size_t distinct_end = seq.size(); + for (std::size_t d = 0; d < 20000; ++d) seq.push_back(z[d % 2 == 0 ? 62 : 63]); + const ProbeCount unbounded = count_probes(seq, std::nullopt); + ASSERT_EQ(unbounded.distinct, 32769U); + ASSERT_EQ(unbounded.slots, 262144U); + std::size_t first = 0; // the first edge count whose probes exceed its own budget + for (std::size_t e = 1; e <= seq.size() && first == 0; ++e) { + if (unbounded.after[e - 1] > probe_budget(e)) first = e; } - ASSERT_EQ(order.size(), 32769U); - ASSERT_EQ(table.size(), 262144U); - std::int64_t prefix_probes = 0; // all probes of the longest prefix within its budget - for (std::size_t d = 0;; ++d) { - ASSERT_LT(d, 100000U); - const std::int64_t before = probes; - seq.push_back(z[d % 2 == 0 ? 62 : 63]); - locate(seq.back()); - if (probes > 8 * static_cast(seq.size()) + 8 * 65536) { - prefix_probes = before; - break; - } - } - EXPECT_EQ(seq.size(), 44436U); // the review's independent count (review-1e/probe.log) + ASSERT_GT(first, distinct_end); // inside the alternation + seq.resize(first); + const std::int64_t prefix_probes = unbounded.after[first - 2]; // all probes of one edge fewer + // The same model run with each length's budget stops there and not one edge earlier. + EXPECT_EQ(count_probes(seq, probe_budget(first)).outcome, BudgetOutcome::Collection); + const ProbeCount shorter = count_probes(std::span(seq).first(first - 1), probe_budget(first - 1)); + EXPECT_EQ(shorter.outcome, BudgetOutcome::Hashed); + EXPECT_EQ(shorter.probes, prefix_probes); + EXPECT_EQ(seq.size(), 44436U); // the count of the probe model auto input = [&](std::size_t e) { Input in {"late growth E=" + std::to_string(e), 31}; @@ -656,50 +699,52 @@ TEST(GraphLayout, ProbeBudgetIsCumulativeAcrossTheLargeTableGrowths) { } return in; }; - auto& counts = detail::thread_construction_counts(); for (const std::size_t e : {seq.size() - 1, seq.size()}) { - const bool exhausts = e == seq.size(); const auto in = input(e); +#if NETGRAPH_CORE_PRIVATE_HOOKS + const bool exhausts = e == seq.size(); + auto& counts = detail::thread_construction_counts(); const auto before = counts; const Graph g = build(in); - const std::int64_t budget = 8 * static_cast(e) + 8 * 65536; + const std::int64_t budget = probe_budget(e); EXPECT_EQ(counts.last_budget, budget) << in.name; EXPECT_EQ(counts.last_probes, exhausts ? budget : prefix_probes) << in.name; EXPECT_EQ(counts.exhausted_collection - before.exhausted_collection, exhausts ? 1 : 0) << in.name; EXPECT_EQ(counts.exhausted_growth - before.exhausted_growth, 0) << in.name; EXPECT_EQ(counts.hashed - before.hashed, exhausts ? 0 : 1) << in.name; if (!exhausts) EXPECT_EQ(counts.last_distinct, 32769) << in.name; +#else + (void)prefix_probes; + const Graph g = build(in); +#endif expect_same_as_reference(g, in); } -#else +#if !NETGRAPH_CORE_PRIVATE_HOOKS GTEST_SKIP() << "branch observations need the private hooks"; #endif } -// manifest 10: exact units above 2^53 (capacities and costs kept as int64, not through double). -TEST(GraphLayout, ExactUnitsAboveTwo53) { - const Amount big = (Amount{1} << 53) + 1; - const Input in {"2^53 + 1", 2, {0, 0, 1}, {1, 1, 0}, {big, big + 2, 0}, {(Cost{1} << 53) + 1, (Cost{1} << 53) + 3, 1}}; - const Graph g = build(in); - expect_same_as_reference(g, in); - EXPECT_EQ(g.capacity_units()[0], big); - EXPECT_EQ(g.cost()[0], (Cost{1} << 53) + 1); -} - -// manifest 11: a copy keeps every array and the map and draws a new uid. +// A copy keeps every array, the map and the resolution (one input at a resolution other than 1) +// and draws a new uid. TEST(GraphLayout, CopyKeepsEveryArrayAndDrawsANewUid) { - for (const auto& in : {exact_k(31, 500, 64, 1), exact_k(65537, 10, 512, 2), Input {"empty", 5}}) { + Input fine = exact_k(31, 500, 64, 6); + fine.name += " at 2^-12"; + fine.q = std::ldexp(1.0, -12); + for (const auto& in : {exact_k(31, 500, 64, 1), exact_k(65537, 10, 512, 2), Input {"empty", 5}, fine}) { const Graph g = build(in); + EXPECT_GT(g.uid(), 0U); // a constructed graph's uid is positive (0 marks a moved-from one) const Graph c(g); EXPECT_NE(c.uid(), g.uid()); EXPECT_GT(c.uid(), g.uid()); // a relation, not an absolute counter value expect_same_as_reference(c, in); EXPECT_EQ(GraphAccess::retained(c).size_bytes, GraphAccess::retained(g).size_bytes); + // A second construction of the same input is a new graph too. + EXPECT_NE(build(in).uid(), g.uid()); } } -// The move (P1, review-1e-consult): every value and the uid transferred without allocation -// (the fault executable's witness); the source keeps its resolution, has uid, N, E, maximum and +// The move: every value and the uid transferred without allocation +// (GraphFaults.MoveIsAllocationFreeUnderAnArmedFailure checks that none is made); the source keeps its resolution, has uid, N, E, maximum and // GCD zero, empty edge arrays and one zero in each offset view. Copying and moving an already // moved-from graph give the same state. TEST(GraphLayout, MoveTransfersEverythingAndLeavesAnEmptyGraph) { @@ -739,8 +784,11 @@ TEST(GraphLayout, MoveTransfersEverythingAndLeavesAnEmptyGraph) { // NOLINTEND(bugprone-use-after-move) } -// The retained array payload: 36 E + 8 (N + 1) bytes in exactly nine arrays, capacities equal to -// sizes (no slack, no retained scratch, no inverse map, no physical-capacity array). +// The retained array payload: 36 E + 8 (N + 1) bytes, capacities equal to sizes (no slack, no +// retained scratch, no inverse map, no physical-capacity array). The sizes come from the graph's +// own list of its arrays (GraphAccess::retained), whose length is fixed by that list's code and +// so not compared here; that the list is complete, nine arrays and no other allocation held, is +// GraphFaults' count of the allocations a graph holds. TEST(GraphLayout, RetainedPayloadIs36BytesPerEdgePlusOffsets) { std::vector cases = {Input {"N=0", 0}, Input {"isolated", 1000}, exact_k(31, 4000, 512, 4), exact_k(65537, 100, 4096, 5)}; @@ -753,32 +801,23 @@ TEST(GraphLayout, RetainedPayloadIs36BytesPerEdgePlusOffsets) { const std::size_t want = 36 * e + 8 * (static_cast(in.n) + 1); for (const Graph* x : {&g, &c, &m}) { const auto r = GraphAccess::retained(*x); - EXPECT_EQ(r.arrays, 9u) << in.name; EXPECT_EQ(r.size_bytes, want) << in.name; EXPECT_EQ(r.capacity_bytes, want) << in.name; } } } -// Manifest 12: graph-bound objects from another graph are rejected at their existing use sites, -// on a twin graph with identical arrays (only the uid differs, so the check is by identity, not by -// shape): the evaluators' Eligibility and DagCache checks, and a Path's DAG (to_dag keeps the -// Path's graph) in resolve_to_paths. The flow core, ledger, policy, KSP, search and cache sites -// have their own tests (NOTES section 7, item 3). +// Graph-bound objects from another graph are rejected at their existing use sites, on a twin +// graph with identical arrays (only the uid differs, so the check is by identity, not by shape): +// the evaluators' Eligibility and DagCache checks, and a Path's DAG (to_dag keeps the Path's +// graph) in resolve_to_paths. The flow core, ledger, policy, KSP, search and cache sites have +// their own tests. TEST(GraphLayout, ForeignGraphBoundObjectsAreRejectedAtTheirUseSites) { const std::vector src {0, 0, 1, 2}, dst {1, 2, 3, 3}; const std::vector cap {5, 5, 5, 5}; const std::vector cost {1, 1, 1, 1}; const Graph g(4, src, dst, cap, cost, 1.0), twin(4, src, dst, cap, cost, 1.0); ASSERT_NE(g.uid(), twin.uid()); - const auto message = [](const std::function& f) { - try { - f(); - } catch (const std::invalid_argument& e) { - return std::string(e.what()); - } - return std::string("no std::invalid_argument"); - }; const std::vector s {0}, d {3}; const FlowPolicyConfig cfg {CostOnly {}, FlowPlacement::MaxFlow, Admission::Lossless, PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity)}; @@ -786,15 +825,33 @@ TEST(GraphLayout, ForeignGraphBoundObjectsAreRejectedAtTheirUseSites) { DagCache cache(twin); const std::string elig_msg = "foreign Eligibility: built for another graph"; const std::string cache_msg = "foreign DagCache: bound to another graph"; - EXPECT_EQ(message([&] { (void)capacity(g, s, d, CapacityOptions {cfg, &el}); }), elig_msg); - EXPECT_EQ(message([&] { (void)capacity(g, s, d, CapacityOptions {cfg, nullptr, &cache}); }), cache_msg); + EXPECT_EQ(error_of([&] { (void)capacity(g, s, d, CapacityOptions {cfg, &el}); }), elig_msg); + EXPECT_EQ(error_of([&] { (void)capacity(g, s, d, CapacityOptions {cfg, nullptr, &cache}); }), cache_msg); const std::vector, std::vector>> pairs {{s, d}}; - EXPECT_EQ(message([&] { (void)batch_capacity(g, pairs, CapacityOptions {cfg, nullptr, &cache}, {}, 1); }), cache_msg); - EXPECT_EQ(message([&] { (void)sensitivity(g, s, d, SensitivityOptions {cfg, &el}, 1); }), elig_msg); - EXPECT_EQ(message([&] { (void)sensitivity(g, s, d, SensitivityOptions {cfg, nullptr, &cache}, 1); }), cache_msg); + EXPECT_EQ(error_of([&] { (void)batch_capacity(g, pairs, CapacityOptions {cfg, nullptr, &cache}, {}, 1); }), cache_msg); + EXPECT_EQ(error_of([&] { (void)sensitivity(g, s, d, SensitivityOptions {cfg, &el}, 1); }), elig_msg); + EXPECT_EQ(error_of([&] { (void)sensitivity(g, s, d, SensitivityOptions {cfg, nullptr, &cache}, 1); }), cache_msg); const Path p(twin, {{0}, {2}}); - EXPECT_EQ(message([&] { (void)resolve_to_paths(g, p.to_dag(), 0, 3); }), "foreign PredDAG: built for another graph"); + EXPECT_EQ(error_of([&] { (void)resolve_to_paths(g, p.to_dag(), 0, 3); }), "foreign PredDAG: built for another graph"); // The same calls on the owning graph succeed (the controls). EXPECT_NO_THROW((void)capacity(twin, s, d, CapacityOptions {cfg, &el, &cache})); EXPECT_EQ(resolve_to_paths(twin, p.to_dag(), 0, 3).size(), 1U); } + +TEST(GraphLayout, ReferenceComparisonRejectsDifferentInputs) { + const Input in {"comparison", 4, {0, 1, 0, 2}, {1, 3, 2, 3}, {2, 3, 4, 5}, {2, 4, 6, 8}}; + const auto g = build(in); + EXPECT_EQ(failures_of([&] { expect_same_as_reference(g, in); }), 0); + const std::vector> changes = { + [](auto& x) { ++x.n; }, [](auto& x) { x.q = 2; }, + [](auto& x) { ++x.cap.back(); }, [](auto& x) { x.cost.back() = 9; }, + [](auto& x) { x.src.back() = 1; }, [](auto& x) { x.dst.back() = 0; }, + [](auto& x) { x.src.push_back(3); x.dst.push_back(0); x.cap.push_back(1); x.cost.push_back(1); }, + [](auto& x) { std::swap(x.cost[0], x.cost[2]); }, + }; + for (std::size_t i = 0; i < changes.size(); ++i) { + auto different = in; + changes[i](different); + EXPECT_GT(failures_of([&] { expect_same_as_reference(g, different); }), 0) << i; + } +} diff --git a/tests/cpp/graph_reference_1d.hpp b/tests/cpp/graph_reference.hpp similarity index 89% rename from tests/cpp/graph_reference_1d.hpp rename to tests/cpp/graph_reference.hpp index c506e39..dde4ac4 100644 --- a/tests/cpp/graph_reference_1d.hpp +++ b/tests/cpp/graph_reference.hpp @@ -1,9 +1,10 @@ -// The 1d graph constructor kept as a test-only reference (task 1e, differential tests). +// An earlier graph constructor kept as a test-only reference for the differential tests. // -// Origin, pinned: src/graph.cpp of commit 3e1f3ea6e (sha256 c9af7b06c48a741fbac33d40283c50c2 -// 6763dcda7a5e7acf4be0195bf896ab68), the function Graph::Graph(num_nodes, src, dst, -// capacity_units, cost, resolution) at lines 91-222 and resolution_exponent at lines 42-53. -// The statements are copied unchanged; only the members became fields of Reference1d and the +// Origin: the earlier src/graph.cpp, identified by its sha256 c9af7b06c48a741fbac33d40283c50c2 +// 6763dcda7a5e7acf4be0195bf896ab68, the function Graph::Graph(num_nodes, src, dst, +// capacity_units, cost, resolution) at lines 91-222 and resolution_exponent at lines 42-53. This +// file is self-contained: nothing here needs that source. +// The statements are copied unchanged; only the members became fields of ReferenceGraph and the // uid is not drawn. Do not edit this file to follow a later constructor: it is the fixed point // the new construction is compared with (every array, every error type and message). #pragma once @@ -22,8 +23,8 @@ namespace netgraph::core::test { -// The 1d graph's state, member for member (graph.hpp at 3e1f3ea6e, without the uid). -struct Reference1d { +// The reference graph's state, member for member (graph.hpp at 3e1f3ea6e, without the uid). +struct ReferenceGraph { double resolution {1.0}; std::int32_t num_nodes {0}; Cost max_cost {0}; @@ -38,7 +39,7 @@ struct Reference1d { std::vector in_adj_edge_index; }; -namespace reference_1d_detail { +namespace reference_graph_detail { inline int resolution_exponent(double q) { if (!std::isfinite(q) || q <= 0.0) { @@ -53,16 +54,16 @@ inline int resolution_exponent(double q) { return k; } -} // namespace reference_1d_detail +} // namespace reference_graph_detail -inline Reference1d reference_1d(std::int32_t num_nodes, +inline ReferenceGraph reference_graph(std::int32_t num_nodes, std::span src, std::span dst, std::span capacity_units, std::span cost, double resolution) { - Reference1d r; - (void)reference_1d_detail::resolution_exponent(resolution); + ReferenceGraph r; + (void)reference_graph_detail::resolution_exponent(resolution); // The unit values. for (std::size_t i = 0; i < capacity_units.size(); ++i) { const Amount c = capacity_units[i]; @@ -85,7 +86,7 @@ inline Reference1d reference_1d(std::int32_t num_nodes, if (m > static_cast(std::numeric_limits::max())) { throw std::invalid_argument("the number of edges exceeds INT32_MAX (array length)"); } - // Zero or negative costs (rule 28). + // Zero or negative costs. Cost max_cost = 0; Cost cost_gcd = 0; for (std::size_t i = 0; i < m; ++i) { @@ -189,9 +190,9 @@ inline Reference1d reference_1d(std::int32_t num_nodes, return r; } -// The internal reverse-source column the 1e graph stores, derived from the reference (1d has no +// The internal reverse-source column the current graph stores, derived from the reference (it has no // stored copy): edge_src[in_adj_edge_index[slot]]. -inline std::vector reference_in_col_indices(const Reference1d& r) { +inline std::vector reference_in_col_indices(const ReferenceGraph& r) { std::vector out(r.in_adj_edge_index.size()); for (std::size_t s = 0; s < out.size(); ++s) out[s] = r.src[static_cast(r.in_adj_edge_index[s])]; return out; diff --git a/tests/cpp/graph_tests.cpp b/tests/cpp/graph_tests.cpp index 3bc9712..07ed0a2 100644 --- a/tests/cpp/graph_tests.cpp +++ b/tests/cpp/graph_tests.cpp @@ -1,13 +1,15 @@ -// The graph on integer units (APPROACH 2.1, 3): construction, validation order, layout B' -// ids, the reverse CSR, input_index, to_units and as_float. +// The graph on integer units: construction, validation order, the EdgeId order +// (src, cost, dst, input), the reverse CSR, input_index, to_units and as_float. #include #include #include #include +#include #include #include #include +#include #include #include "netgraph/core/graph.hpp" @@ -18,30 +20,17 @@ using namespace netgraph::core::test; namespace { -constexpr Amount L62 = Amount{1} << 62; - -// The message of the std::invalid_argument thrown by `fn`, or "" when nothing is thrown. -template -std::string error_of(F&& fn) { - try { - fn(); - } catch (const std::invalid_argument& e) { - return e.what(); - } - return ""; -} - +// A Graph of these arrays (resolution 1 unless given). Graph build(std::int32_t n, std::vector src, std::vector dst, std::vector cap, std::vector cost, double q = 1.0) { return Graph(n, src, dst, cap, cost, q); } -bool contains(const std::string& s, const std::string& part) { return s.find(part) != std::string::npos; } - } // namespace -TEST(Graph, BPrimeOrderInputIndexAndReverseCsr) { - // (u, v, cap, cost) in input order; prototype fixture graph_bprime_order. +TEST(Graph, EdgeIdOrderInputIndexAndReverseCsr) { + // (u, v, cap, cost) in input order. EdgeIds rank edges by source, then cost, then destination, + // then input position. const std::vector edges = {{2, 0, 5, 3}, {0, 2, 4, 2}, {0, 1, 3, 2}, {1, 2, 2, 1}, {0, 1, 1, 2}, {0, 2, 6, 1}, {2, 1, 7, 1}}; auto g = make_graph(3, edges); @@ -64,17 +53,15 @@ TEST(Graph, BPrimeOrderInputIndexAndReverseCsr) { EXPECT_EQ(g.capacity_units()[e], in.cap); EXPECT_EQ(g.cost()[e], in.cost); } - // The out-edges of u are the id range of its row, in (cost, dst, input) order. + // Row u's id range holds u's out-edges (their (cost, dst, input) order within the row is the + // input_index checked above). for (NodeId u = 0; u < 3; ++u) { for (auto e = g.row_offsets()[u]; e < g.row_offsets()[u + 1]; ++e) { EXPECT_EQ(g.edge_src()[static_cast(e)], u); } } - // input_index round trip through the caller's inverse. - const auto inv = id_of_input(g); - for (std::size_t i = 0; i < edges.size(); ++i) { - EXPECT_EQ(g.input_index()[static_cast(inv[i])], static_cast(i)); - } + // The inverse map from input positions to EdgeIds, derived by hand from the order above. + EXPECT_EQ(id_of_input(g), (std::vector {6, 3, 1, 4, 2, 0, 5})); std::vector caps_in; for (const auto& e : edges) caps_in.push_back(e.cap); EXPECT_EQ(to_input(g, g.capacity_units()), caps_in); @@ -105,7 +92,7 @@ TEST(Graph, EqualKeysKeepInputOrder) { } TEST(Graph, ReverseOrderIsCostThenSource) { - // A scan in id order would give (src, cost, input); the contract is (cost, src, input). + // A scan in id order would give (src, cost, input); the reverse order is (cost, src, input). auto g = make_graph(3, {{0, 2, 1, 5}, {1, 2, 1, 1}}); ASSERT_EQ(g.in_row_offsets()[2], 0); const EdgeId first = g.in_adj_edge_index()[0]; @@ -145,9 +132,9 @@ TEST(Graph, CostChecksAndMaximumAfterTheGcdReachesOne) { auto late = build(2, {0, 0, 0}, {1, 1, 1}, {1, 1, 1}, {4, 6, 9}); EXPECT_EQ(late.cost_gcd(), 1); // 1 only at the last edge EXPECT_EQ(late.max_cost(), 9); - auto ones = build(2, {0, 0, 0}, {1, 1, 1}, {1, 1, 1}, {1, 1, L62 / 4}); + auto ones = build(2, {0, 0, 0}, {1, 1, 1}, {1, 1, 1}, {1, 1, kL62 / 4}); EXPECT_EQ(ones.cost_gcd(), 1); - EXPECT_EQ(ones.max_cost(), L62 / 4); + EXPECT_EQ(ones.max_cost(), kL62 / 4); EXPECT_EQ(build(2, {0}, {1}, {1}, {6}).cost_gcd(), 6); EXPECT_EQ(build(2, {0, 0}, {1, 1}, {1, 1}, {6, 4}).cost_gcd(), 2); EXPECT_EQ(build(0, {}, {}, {}, {}).cost_gcd(), 0); @@ -157,31 +144,14 @@ TEST(Graph, CostChecksAndMaximumAfterTheGcdReachesOne) { const auto negative = error_of([] { (void)build(2, {0, 0, 0, 0}, {1, 1, 1, 1}, {1, 1, 1, 1}, {1, 5, -7, 0}); }); EXPECT_TRUE(contains(negative, "cost[2] = -7")) << negative; // The cost sum after the GCD reached 1. - EXPECT_TRUE(contains(error_of([] { (void)build(2, {0, 0, 0}, {1, 1, 1}, {1, 1, 1}, {1, L62 / 2, L62 / 2}); }), + EXPECT_TRUE(contains(error_of([] { (void)build(2, {0, 0, 0}, {1, 1, 1}, {1, 1, 1}, {1, kL62 / 2, kL62 / 2}); }), "sum of all edge costs")); } -TEST(Graph, ExactUnitsAboveTwo53) { - const Amount big = (Amount{1} << 53) + 1; - auto g = make_graph(2, {{0, 1, big, 1}}); - EXPECT_EQ(g.capacity_units()[0], big); - EXPECT_EQ(as_float(big, 1.0), 9007199254740992.0); // the documented export limit -} - -TEST(Graph, CopyIsANewGraph) { - auto g = make_graph(3, {{0, 2, 4, 2}, {0, 1, 3, 1}}); - Graph c(g); - EXPECT_NE(c.uid(), g.uid()); - EXPECT_EQ(std::vector(c.input_index().begin(), c.input_index().end()), - std::vector(g.input_index().begin(), g.input_index().end())); - EXPECT_EQ(std::vector(c.capacity_units().begin(), c.capacity_units().end()), - std::vector(g.capacity_units().begin(), g.capacity_units().end())); - auto h = make_graph(3, {{0, 2, 4, 2}, {0, 1, 3, 1}}); - EXPECT_NE(h.uid(), g.uid()); - EXPECT_GT(g.uid(), 0u); -} - TEST(Graph, Resolution) { + // The resolution has no default: no constructor takes the arrays without it. + static_assert(!std::is_constructible_v, std::span, + std::span, std::span>); for (double q : {std::ldexp(1.0, -32), std::ldexp(1.0, -12), 1.0, std::ldexp(1.0, 32)}) { auto g = build(2, {0}, {1}, {1}, {1}, q); EXPECT_EQ(g.resolution(), q); @@ -194,49 +164,75 @@ TEST(Graph, Resolution) { TEST(Graph, EveryValidationError) { // The unit values. - EXPECT_TRUE(contains(error_of([] { (void)build(2, {0}, {1}, {-1}, {1}); }), "capacity_units")); - EXPECT_TRUE(contains(error_of([] { (void)build(2, {0}, {1}, {L62}, {1}); }), "units domain")); + EXPECT_EQ(error_of([] { (void)build(2, {0}, {1}, {-1}, {1}); }), + "capacity_units[0] = -1 is outside the units domain [0, 2^62)"); + EXPECT_EQ(error_of([] { (void)build(2, {0}, {1}, {kL62}, {1}); }), + "capacity_units[0] = 4611686018427387904 is outside the units domain [0, 2^62)"); // The shapes. - EXPECT_TRUE(contains(error_of([] { (void)build(-1, {}, {}, {}, {}); }), "num_nodes")); - EXPECT_TRUE(contains(error_of([] { (void)build(2, {0, 1}, {1}, {1}, {1}); }), "same length")); - EXPECT_TRUE(contains(error_of([] { (void)build(2, {0}, {1}, {1, 2}, {1}); }), "same length")); - EXPECT_TRUE(contains(error_of([] { (void)build(2, {0}, {1}, {1}, {}); }), "same length")); - // Zero and negative costs (rule 28). - EXPECT_TRUE(contains(error_of([] { (void)build(2, {0}, {1}, {1}, {0}); }), "cost")); - EXPECT_TRUE(contains(error_of([] { (void)build(2, {0}, {1}, {1}, {-5}); }), "costs must be positive")); + EXPECT_EQ(error_of([] { (void)build(-1, {}, {}, {}, {}); }), "num_nodes must be >= 0 (got -1)"); + EXPECT_EQ(error_of([] { (void)build(2, {0, 1}, {1}, {1}, {1}); }), + "src, dst, capacity_units and cost must have the same length (got 2, 1, 1, 1)"); + EXPECT_EQ(error_of([] { (void)build(2, {0}, {1}, {1, 2}, {1}); }), + "src, dst, capacity_units and cost must have the same length (got 1, 1, 2, 1)"); + EXPECT_EQ(error_of([] { (void)build(2, {0}, {1}, {1}, {}); }), + "src, dst, capacity_units and cost must have the same length (got 1, 1, 1, 0)"); + // Zero and negative costs. + EXPECT_EQ(error_of([] { (void)build(2, {0}, {1}, {1}, {0}); }), "cost[0] = 0: costs must be positive (cost >= 1)"); + EXPECT_EQ(error_of([] { (void)build(2, {0}, {1}, {1}, {-5}); }), "cost[0] = -5: costs must be positive (cost >= 1)"); // The remaining bounds. - EXPECT_TRUE(contains(error_of([] { (void)build(2, {2}, {1}, {1}, {1}); }), "out of node range")); - EXPECT_TRUE(contains(error_of([] { (void)build(2, {0}, {-1}, {1}, {1}); }), "out of node range")); - EXPECT_TRUE(contains(error_of([] { (void)build(2, {0, 0}, {1, 1}, {L62 / 2, L62 / 2}, {1, 1}); }), - "sum of all capacities")); - EXPECT_TRUE(contains(error_of([] { (void)build(2, {0, 0}, {1, 1}, {1, 1}, {L62 / 2, L62 / 2}); }), - "sum of all edge costs")); + EXPECT_EQ(error_of([] { (void)build(2, {2}, {1}, {1}, {1}); }), "edge 0: endpoint (2, 1) out of node range [0, 2)"); + EXPECT_EQ(error_of([] { (void)build(2, {0}, {-1}, {1}, {1}); }), "edge 0: endpoint (0, -1) out of node range [0, 2)"); + EXPECT_EQ(error_of([] { (void)build(2, {0, 0}, {1, 1}, {kL62 / 2, kL62 / 2}, {1, 1}); }), + "the sum of all capacities reaches the bound of the units domain (2^62 units)"); + EXPECT_EQ(error_of([] { (void)build(2, {0, 0}, {1, 1}, {1, 1}, {kL62 / 2, kL62 / 2}); }), + "the sum of all edge costs reaches the bound 2^62: path costs would overflow int64"); // Just below the bounds is accepted. - auto caps = build(2, {0, 0}, {1, 1}, {L62 / 2, L62 / 2 - 1}, {1, 1}); - EXPECT_EQ(caps.capacity_units()[0] + caps.capacity_units()[1], L62 - 1); - auto costs = build(2, {0, 0}, {1, 1}, {1, 1}, {L62 / 2, L62 / 2 - 1}); - EXPECT_EQ(costs.max_cost(), L62 / 2); - auto huge_cost = build(2, {0}, {1}, {1}, {L62 - 1}); - EXPECT_EQ(huge_cost.max_cost(), L62 - 1); - EXPECT_TRUE(contains(error_of([] { (void)build(2, {0}, {1}, {1}, {std::numeric_limits::max()}); }), - "sum of all edge costs")); + auto caps = build(2, {0, 0}, {1, 1}, {kL62 / 2, kL62 / 2 - 1}, {1, 1}); + EXPECT_EQ(caps.capacity_units()[0] + caps.capacity_units()[1], kL62 - 1); + auto costs = build(2, {0, 0}, {1, 1}, {1, 1}, {kL62 / 2, kL62 / 2 - 1}); + EXPECT_EQ(costs.max_cost(), kL62 / 2); + auto huge_cost = build(2, {0}, {1}, {1}, {kL62 - 1}); + EXPECT_EQ(huge_cost.max_cost(), kL62 - 1); + EXPECT_EQ(error_of([] { (void)build(2, {0}, {1}, {1}, {std::numeric_limits::max()}); }), + "the sum of all edge costs reaches the bound 2^62: path costs would overflow int64"); } TEST(Graph, ValidationOrder) { // Resolution before the unit values. - EXPECT_TRUE(contains(error_of([] { (void)build(2, {0}, {1}, {-1}, {1}, 3.0); }), "resolution")); - // The unit values before the shapes. - EXPECT_TRUE(contains(error_of([] { (void)build(2, {0, 1}, {1}, {-1}, {1}); }), "capacity_units")); + EXPECT_EQ(error_of([] { (void)build(2, {0}, {1}, {-1}, {1}, 3.0); }), + "resolution must be a power of two in [2^-32, 2^32]"); + // The unit values before the shapes (the length message names capacity_units too). + EXPECT_EQ(error_of([] { (void)build(2, {0, 1}, {1}, {-1}, {1}); }), + "capacity_units[0] = -1 is outside the units domain [0, 2^62)"); // The shapes before the costs. - EXPECT_TRUE(contains(error_of([] { (void)build(2, {0, 1}, {1}, {1}, {0}); }), "same length")); - EXPECT_TRUE(contains(error_of([] { (void)build(-1, {0}, {1}, {1}, {0}); }), "num_nodes")); + EXPECT_EQ(error_of([] { (void)build(2, {0, 1}, {1}, {1}, {0}); }), + "src, dst, capacity_units and cost must have the same length (got 2, 1, 1, 1)"); + EXPECT_EQ(error_of([] { (void)build(-1, {0}, {1}, {1}, {0}); }), "num_nodes must be >= 0 (got -1)"); // The costs before the remaining bounds (endpoints, sums). - EXPECT_TRUE(contains(error_of([] { (void)build(2, {0}, {5}, {1}, {0}); }), "costs must be positive")); - EXPECT_TRUE(contains(error_of([] { (void)build(2, {0, 0}, {1, 1}, {L62 / 2, L62 / 2}, {1, 0}); }), - "costs must be positive")); + EXPECT_EQ(error_of([] { (void)build(2, {0}, {5}, {1}, {0}); }), "cost[0] = 0: costs must be positive (cost >= 1)"); + EXPECT_EQ(error_of([] { (void)build(2, {0, 0}, {1, 1}, {kL62 / 2, kL62 / 2}, {1, 0}); }), + "cost[1] = 0: costs must be positive (cost >= 1)"); // Endpoints before the sums. - EXPECT_TRUE(contains(error_of([] { (void)build(2, {0, 7}, {1, 1}, {L62 / 2, L62 / 2}, {1, 1}); }), - "out of node range")); + EXPECT_EQ(error_of([] { (void)build(2, {0, 7}, {1, 1}, {kL62 / 2, kL62 / 2}, {1, 1}); }), + "edge 1: endpoint (7, 1) out of node range [0, 2)"); + // Both running sums reach 2^62 at the same edge: the capacity sum is reported. + EXPECT_EQ(error_of([] { (void)build(2, {0, 0}, {1, 1}, {kL62 / 2, kL62 / 2}, {kL62 / 2, kL62 / 2}); }), + "the sum of all capacities reaches the bound of the units domain (2^62 units)"); +} + +// Uids come from one process-wide counter and are never reused: a graph built after another was +// destroyed (in the same storage) gets a larger uid, and so does a copy. +TEST(Graph, UidsAreNeverReused) { + std::uint64_t last = 0; + for (int i = 0; i < 3; ++i) { + const auto g = build(2, {0}, {1}, {1}, {1}); + EXPECT_GT(g.uid(), last) << i; + last = g.uid(); + } + const auto g = build(2, {0}, {1}, {1}, {1}); + const Graph copy(g); + EXPECT_GT(copy.uid(), g.uid()); + EXPECT_GT(g.uid(), last); } TEST(Units, HalfUpBoundaries) { @@ -259,24 +255,35 @@ TEST(Units, HalfUpBoundaries) { EXPECT_EQ(to_units(4503599627370497.5, 1.0), Amount{4503599627370498}); // 2^52 + 1.5 is not a double EXPECT_EQ(to_units(4503599627370495.5, 1.0), Amount{4503599627370496}); // 2^52 - 0.5 rounds up EXPECT_EQ(to_units(4611686018427387392.0, 1.0), Amount{4611686018427387392}); // 2^62 - 512 + // The export above 2^53 is the nearest double (the documented export limit). + EXPECT_EQ(as_float((Amount{1} << 53) + 1, 1.0), 9007199254740992.0); } TEST(Units, RejectedValues) { const double q = std::ldexp(1.0, -12); - EXPECT_TRUE(contains(error_of([&] { (void)to_units(std::numeric_limits::quiet_NaN(), q); }), "NaN")); - EXPECT_TRUE(contains(error_of([&] { (void)to_units(std::numeric_limits::infinity(), q); }), "finite")); - EXPECT_TRUE(contains(error_of([&] { (void)to_units(-std::numeric_limits::infinity(), q); }), "finite")); - EXPECT_TRUE(contains(error_of([&] { (void)to_units(-1e-300, q); }), "negative")); - EXPECT_TRUE(contains(error_of([&] { (void)to_units(4611686018427387904.0, 1.0); }), "bound")); // 2^62 - EXPECT_TRUE(contains(error_of([&] { (void)to_units(std::numeric_limits::max(), std::ldexp(1.0, 32)); }), - "bound")); - EXPECT_TRUE(contains(error_of([&] { (void)to_units(1e300, std::ldexp(1.0, -32)); }), "bound")); - EXPECT_TRUE(contains(error_of([&] { (void)to_units(1.0, 3.0); }), "resolution")); - EXPECT_TRUE(contains(error_of([&] { (void)as_float(1, 0.0); }), "resolution")); - // Admission at q = 2^-12: [1e15, 1.25e14] fits, [1e15, 1.26e14] exceeds the unit sum. + const std::string nan = "to_units: NaN has no unit value"; + const std::string infinite = "to_units: an infinite amount is not finite and has no unit value"; + const std::string bound = "to_units: the amount is at or above the bound of the units domain (2^62 units)"; + const std::string resolution = "resolution must be a power of two in [2^-32, 2^32]"; + EXPECT_EQ(error_of([&] { (void)to_units(std::numeric_limits::quiet_NaN(), q); }), nan); + EXPECT_EQ(error_of([&] { (void)to_units(std::numeric_limits::infinity(), q); }), infinite); + EXPECT_EQ(error_of([&] { (void)to_units(-std::numeric_limits::infinity(), q); }), infinite); + EXPECT_EQ(error_of([&] { (void)to_units(-1e-300, q); }), "to_units: a negative amount has no unit value"); + EXPECT_EQ(error_of([&] { (void)to_units(4611686018427387904.0, 1.0); }), bound); // 2^62 + EXPECT_EQ(error_of([&] { (void)to_units(std::numeric_limits::max(), std::ldexp(1.0, 32)); }), bound); + EXPECT_EQ(error_of([&] { (void)to_units(1e300, std::ldexp(1.0, -32)); }), bound); + // Both resolution checks: a value that is not a power of two, and a power of two out of range + // (and the values that are not positive and finite). + for (const double bad : {3.0, std::ldexp(1.0, -33), std::ldexp(1.0, 33), 0.0, -1.0, + std::numeric_limits::quiet_NaN(), std::numeric_limits::infinity()}) { + EXPECT_EQ(error_of([&] { (void)to_units(1.0, bad); }), resolution) << bad; + EXPECT_EQ(error_of([&] { (void)as_float(1, bad); }), resolution) << bad; + } + // The capacity-sum bound at q = 2^-12: [1e15, 1.25e14] fits, [1e15, 1.26e14] exceeds the unit sum. const std::vector ok = {to_units(1e15, q), to_units(1.25e14, q)}; auto g = build(2, {0, 0}, {1, 1}, ok, {1, 1}, q); EXPECT_EQ(g.capacity_units()[0] + g.capacity_units()[1], ok[0] + ok[1]); const std::vector over = {to_units(1e15, q), to_units(1.26e14, q)}; - EXPECT_TRUE(contains(error_of([&] { (void)build(2, {0, 0}, {1, 1}, over, {1, 1}, q); }), "sum of all capacities")); + EXPECT_EQ(error_of([&] { (void)build(2, {0, 0}, {1, 1}, over, {1, 1}, q); }), + "the sum of all capacities reaches the bound of the units domain (2^62 units)"); } diff --git a/tests/cpp/kernels_tests.cpp b/tests/cpp/kernels_tests.cpp index c4d9c27..15764f9 100644 --- a/tests/cpp/kernels_tests.cpp +++ b/tests/cpp/kernels_tests.cpp @@ -1,21 +1,28 @@ -// Apportionment and the placement kernels in units (APPROACH 2.1, 7, 16): the closed-form +// Apportionment and the placement kernels in units: the closed-form // highest averages against the sequential definition, house monotonicity and the Hamilton hole -// control, plans (membership by kernel and priority, split-graph pruning), alloc(T) at every -// split, exact T_max, the Lossless steps of MaxFlow (replacement on V = W + own, the -// cancellation DAG, the equal-optimum and cancel-upstream controls, the release witness, the -// mixed-cost bundle), Weighted and EqualBalanced, and the Lossy steps (drops, +inf injection). -// Expected values are the closed forms and the prototype-1 fixtures named in each test. +// control, the round-robin callback form, the lower-quota division on both sides of 2^64; the +// kernel check (the first defect in order, the combination rule alone); plans (membership by +// kernel and priority, split-graph pruning, the walk definition for every kernel), alloc(T) at +// every split, exact T_max against an independent per-node reference, the Lossless +// steps of MaxFlow (replacement on V = W + own, the cancellation DAG, the equal-optimum and +// cancel-upstream controls, the release witness, the mixed-cost bundle; randomized steps +// certified on their member subset against an exhaustive optimum or an independent solver, with +// every cost multiplied, and the step certificate's own controls), Weighted and EqualBalanced, +// and the Lossy steps (drops, +inf injection; on random DAGs against an independent per-node +// reference). Expected values are the closed forms, the hand derivations given in each test, those +// independent optima and the per-node references. #include #include #include +#include #include #include #include #include -#include #include -#include +#include +#include #include #include "netgraph/core/detail/int128.hpp" @@ -48,14 +55,116 @@ std::vector ha_slow(Amount total, const std::vector& w) { for (Amount u = 0; u < total; ++u) { std::size_t best = 0; for (std::size_t i = 1; i < w.size(); ++i) { - if (static_cast<__int128>(w[i]) * (a[best] + 1) > static_cast<__int128>(w[best]) * (a[i] + 1)) best = i; + if (static_cast(w[i]) * (a[best] + 1) > static_cast(w[best]) * (a[i] + 1)) best = i; } ++a[best]; } return a; } -// Hamilton (largest remainders, rejected): a control only. +// Count the exact rank of each quotient w[i] / k in all positive quotients w[j] / h. +// Quotients above it precede it; equal quotients with j <= i also count. The k-th unit belongs +// to i iff that rank is at most total. Binary search each member's last accepted k, using only +// integer comparisons and division, independently of the library's lower-quota allocation. +std::vector ha_by_rank(Amount total, const std::vector& w) { + std::vector out(w.size(), 0); + for (std::size_t i = 0; i < w.size(); ++i) { + Amount lo = 0, hi = total; + while (lo < hi) { + const Amount k = lo + (hi - lo + 1) / 2; + Int128 rank = 0; + for (std::size_t j = 0; j < w.size(); ++j) { + const Int128 product = static_cast(w[j]) * k; + rank += (product - 1) / w[i]; + if (product % w[i] == 0 && j <= i) ++rank; + } + if (rank <= total) lo = k; + else hi = k - 1; + } + out[i] = lo; + } + return out; +} + +// The per-node reference of alloc(T) on a DAG whose node ids are a topological order, +// independent of the library: `total` enters at node `src`; node by node in id order, each node's +// inflow is split over its arcs in the given order by the sequential definition with the arcs' +// weights. Returns each arc's amount. +std::vector per_node_reference(int n, const std::vector>& arcs, + const std::vector& weights, Amount total, NodeId src = 0, bool by_rank = false) { + std::vector inflow(static_cast(n), 0), out(arcs.size(), 0); + inflow[static_cast(src)] = total; + for (NodeId u = 0; u < n; ++u) { + std::vector mine; + std::vector w; + for (std::size_t i = 0; i < arcs.size(); ++i) { + if (arcs[i].first != u) continue; + mine.push_back(i); + w.push_back(weights[i]); + } + if (mine.empty()) continue; + const auto parts = by_rank ? ha_by_rank(inflow[static_cast(u)], w) + : ha_slow(inflow[static_cast(u)], w); + for (std::size_t j = 0; j < mine.size(); ++j) { + out[mine[j]] = parts[j]; + inflow[static_cast(arcs[mine[j]].second)] += parts[j]; + } + } + return out; +} + +// The per-node reference of a Lossy step, independent of the library: on a split graph whose node +// ids are a topological order, given as its arcs in ascending EdgeId with each arc's weight and +// residual, the offer enters at `src` (+inf: each arc out of `src` is offered its residual); node +// by node in id order, a node's inflow is split over its arcs one unit at a time (Weighted: each +// unit to the largest weight / (share + 1), ties to the earlier arc; EqualBalanced: the arcs in +// turn), each arc carries the smaller of its share and its residual and drops the rest, and what +// it carries enters its head. A destination splits nothing (no arc of a split graph leaves one). +struct LossyReference { + std::vector carried; + std::vector drops; + Amount injected {0}; + Amount delivered {0}; +}; +LossyReference lossy_reference(int n, const std::vector>& arcs, + const std::vector& weights, const std::vector& residual, bool weighted, + std::optional offer, NodeId src, const std::vector& dsts) { + LossyReference out {std::vector(arcs.size(), 0), std::vector(arcs.size(), 0)}; + std::vector inflow(static_cast(n), 0); + inflow[static_cast(src)] = offer.value_or(0); + for (NodeId u = 0; u < n; ++u) { + if (std::find(dsts.begin(), dsts.end(), u) != dsts.end()) continue; + std::vector mine; + std::vector w; + for (std::size_t i = 0; i < arcs.size(); ++i) { + if (arcs[i].first != u) continue; + mine.push_back(i); + w.push_back(weights[i]); + } + if (mine.empty()) continue; + std::vector share(mine.size(), 0); + if (u == src && !offer.has_value()) { + for (std::size_t j = 0; j < mine.size(); ++j) share[j] = residual[mine[j]]; + } else if (weighted) { + share = ha_slow(inflow[static_cast(u)], w); + } else { + for (Amount unit = 0; unit < inflow[static_cast(u)]; ++unit) { + ++share[static_cast(unit % static_cast(mine.size()))]; + } + } + for (std::size_t j = 0; j < mine.size(); ++j) { + const std::size_t i = mine[j]; + if (u == src) out.injected += share[j]; + out.carried[i] = std::min(share[j], residual[i]); + out.drops[i] = share[j] - out.carried[i]; + inflow[static_cast(arcs[i].second)] += out.carried[i]; + } + } + for (NodeId d : dsts) out.delivered += inflow[static_cast(d)]; + return out; +} + +// Hamilton (largest remainders; not the library's rule): a control only. std::vector hamilton(Amount total, const std::vector& w) { const Amount sum = std::accumulate(w.begin(), w.end(), Amount {0}); std::vector a(w.size()); @@ -71,12 +180,6 @@ std::vector hamilton(Amount total, const std::vector& w) { return a; } -// A plan's structure field (a view of the plan's block) as a vector. -template -std::vector> vec(std::span v) { - return std::vector>(v.begin(), v.end()); -} - // Member-aligned view of a dense per-edge array. std::vector on_members(const KernelPlan& plan, const std::vector& dense) { std::vector out; @@ -91,12 +194,6 @@ std::vector dense_input(const Graph& g, const KernelPlan& plan, const st return to_input(g, by_id); } -std::vector all_ids(const Graph& g) { - std::vector out(static_cast(g.num_edges())); - std::iota(out.begin(), out.end(), 0); - return out; -} - KernelPlan plan_of(const Graph& g, FlowPlacement p, NodeId src, std::vector dsts, std::vector route, const std::vector& view, EdgePriority prio = EdgePriority::Residual, Admission adm = Admission::Lossless) { @@ -123,9 +220,56 @@ LosslessStep first_step(const Graph& g, const KernelPlan& plan, const std::vecto return lossless_step(g, plan, 0, v, own, request); } +// `count` random edges of a DAG whose node ids 0 .. n - 1 are a topological order: each u -> v +// with u < v, then its capacity and its cost, drawn in that order. +template +std::vector random_dag_edges(Rng& rng, int n, int count, CapacityOf capacity, CostOf cost) { + std::vector edges; + for (int i = 0; i < count; ++i) { + const auto u = static_cast(rng() % static_cast(n - 1)); + const auto v = static_cast(u + 1 + rng() % static_cast(n - 1 - u)); + const Amount c = capacity(); + const Cost w = cost(); + edges.push_back({u, v, c, w}); + } + return edges; +} + +// The walk definition of a split graph, independent of the plan builder: the edges of `edges` +// on some walk from src to the destination set that leaves no destination (destinations are +// terminal), ascending; forward from src and backward from the destinations by fixpoint. +std::vector walk_members(const Graph& g, const std::vector& edges, NodeId src, + const std::vector& dsts) { + const auto n = static_cast(g.num_nodes()); + std::vector is_dst(n, 0), fwd(n, 0), bwd(n, 0); + for (NodeId d : dsts) is_dst[static_cast(d)] = bwd[static_cast(d)] = 1; + fwd[static_cast(src)] = 1; + std::vector open; + for (EdgeId e : edges) { + if (is_dst[static_cast(g.edge_src()[static_cast(e)])] == 0) open.push_back(e); + } + for (std::size_t round = 0; round < n; ++round) { + for (EdgeId e : open) { + const auto u = static_cast(g.edge_src()[static_cast(e)]); + const auto v = static_cast(g.edge_dst()[static_cast(e)]); + if (fwd[u] != 0) fwd[v] = 1; + if (bwd[v] != 0) bwd[u] = 1; + } + } + std::vector out; + for (EdgeId e : open) { + if (fwd[static_cast(g.edge_src()[static_cast(e)])] != 0 && + bwd[static_cast(g.edge_dst()[static_cast(e)])] != 0) { + out.push_back(e); + } + } + std::sort(out.begin(), out.end()); + return out; +} + } // namespace -TEST(Apportion, RoundRobinAndReviewValues) { +TEST(Apportion, RoundRobinAndHighestAveragesValues) { EXPECT_EQ(rr(7, 3), (std::vector{3, 2, 2})); EXPECT_EQ(rr(1, 3), (std::vector{1, 0, 0})); EXPECT_EQ(rr(0, 4), (std::vector{0, 0, 0, 0})); @@ -173,7 +317,7 @@ TEST(Apportion, RoundRobinCallbackFormEqualsSpanFormAndStopsEarly) { TEST(Apportion, ClosedFormEqualsSequentialAndIsHouseMonotone) { std::mt19937 rng(7); - int cases = 0; + int weighted = 0; // allocations that differ from round robin: the weights mattered for (int k = 1; k <= 20; ++k) { for (int trial = 0; trial < 40; ++trial) { std::vector w(static_cast(k)); @@ -184,7 +328,7 @@ TEST(Apportion, ClosedFormEqualsSequentialAndIsHouseMonotone) { ASSERT_EQ(a, ha_slow(total, w)) << "k " << k << " total " << total; for (std::size_t i = 0; i < a.size(); ++i) ASSERT_GE(a[i], prev[i]); prev = a; - ++cases; + weighted += a != rr(total, static_cast(k)) ? 1 : 0; } } } @@ -192,7 +336,7 @@ TEST(Apportion, ClosedFormEqualsSequentialAndIsHouseMonotone) { for (std::size_t k = 1; k <= 9; ++k) { for (Amount total = 0; total <= 40; ++total) EXPECT_EQ(ha(total, std::vector(k, 7)), rr(total, k)); } - EXPECT_GT(cases, 40000); + EXPECT_GT(weighted, 20000); } TEST(Apportion, HighestAveragesNearTheDomainBound) { @@ -205,8 +349,8 @@ TEST(Apportion, HighestAveragesNearTheDomainBound) { for (std::size_t i = 0; i < w.size(); ++i) { if (a[i] == 0) continue; for (std::size_t j = 0; j < w.size(); ++j) { - const __int128 left = static_cast<__int128>(w[i]) * (a[j] + 1); - const __int128 right = static_cast<__int128>(w[j]) * a[i]; + const Int128 left = static_cast(w[i]) * (a[j] + 1); + const Int128 right = static_cast(w[j]) * a[i]; EXPECT_TRUE(left > right || (left == right && i <= j)) << i << " " << j; } } @@ -218,8 +362,8 @@ TEST(Apportion, HighestAveragesNearTheDomainBound) { for (std::size_t i = 0; i < wide.size(); ++i) { for (std::size_t j = 0; j < wide.size(); ++j) { if (b[i] == 0) continue; - const __int128 left = static_cast<__int128>(wide[i]) * (b[j] + 1); - const __int128 right = static_cast<__int128>(wide[j]) * b[i]; + const Int128 left = static_cast(wide[i]) * (b[j] + 1); + const Int128 right = static_cast(wide[j]) * b[i]; EXPECT_TRUE(left > right || (left == right && i <= j)); } } @@ -228,10 +372,10 @@ TEST(Apportion, HighestAveragesNearTheDomainBound) { // The lower-quota division floor(n / d) on both sides of 2^64: dividends with a zero high word // (the 64-bit path) and with a nonzero one (the 128-bit path), divisors near 1, near the units // domain bound 2^62 (the call sites divide by a weight sum below it) and near 2^64, against the -// unsigned __int128 quotient and the defining bounds q * d <= n < (q + 1) * d. +// 128-bit quotient of the test integer (test_int128.hpp) and the defining bounds q * d <= n < (q + 1) * d. TEST(Apportion, QuotaDivisionOnBothSidesOfTheSixtyFourBitBoundary) { using u64 = std::uint64_t; - using u128 = unsigned __int128; + using u128 = UInt128; constexpr u64 kMax = std::numeric_limits::max(); const auto domain = static_cast(kDomainLimit); int narrow = 0, wide = 0; @@ -259,15 +403,14 @@ TEST(Apportion, QuotaDivisionOnBothSidesOfTheSixtyFourBitBoundary) { for (u64 q : {u64 {1}, u64 {2}, kMax / d, kMax - 1, kMax}) { const u128 at = static_cast(q) * d; for (u128 n : {at - 1, at, at + 1}) { - if (n >> 64 < d) check(static_cast(n >> 64), static_cast(n), d); + if ((n >> 64) < d) check(static_cast(n >> 64), static_cast(n), d); } } } // The call-site shape: total * w / W with total and the weights below 2^62, W the weight sum. for (u64 total : {u64 {1}, u64 {50}, domain - 2, domain - 1}) { for (u64 w : {u64 {1}, u64 {3}, domain / 3, domain - 1}) { - for (u64 sum : {w, w + 1, domain - 1}) { - if (sum < w) continue; + for (u64 sum : {w, w + 1, domain - 1}) { // every sum at least w const U128 n = mul_u64(total, w); check(n.hi, n.lo, sum); } @@ -283,6 +426,30 @@ TEST(Apportion, QuotaDivisionOnBothSidesOfTheSixtyFourBitBoundary) { EXPECT_GT(wide, 10000); } +// The exact 64 x 64 -> 128-bit product: the built-in path where the compiler has a 128-bit +// integer, the 32-bit-limb path elsewhere (MSVC), both against the test integer (test_int128.hpp, +// independent of the library) at the word boundaries and on random operands. +TEST(Apportion, MulU64IsTheExactProduct) { + using u64 = std::uint64_t; + constexpr u64 kMax = std::numeric_limits::max(); + const std::vector edges = {0, 1, 2, 3, 0xffffffffULL, 0x100000000ULL, 0x100000001ULL, (u64 {1} << 62) - 1, + u64 {1} << 62, u64 {1} << 63, kMax - 1, kMax}; + auto check = [](u64 a, u64 b) { + const U128 p = mul_u64(a, b); + const UInt128 want = static_cast(a) * static_cast(b); + ASSERT_EQ(p.hi, static_cast(want >> 64)) << a << " * " << b; + ASSERT_EQ(p.lo, static_cast(want)) << a << " * " << b; + }; + for (u64 a : edges) { + for (u64 b : edges) check(a, b); + } + std::mt19937_64 rng(1729); + for (int i = 0; i < 20000; ++i) { + const int shift_a = static_cast(rng() % 64), shift_b = static_cast(rng() % 64); + check(rng() >> shift_a, rng() >> shift_b); + } +} + TEST(Apportion, HamiltonHoleControl) { // Weights [1, 3, 3] on members with view [0, 3, 3]: highest averages is feasible on 0..4 // (T_max = 4 exactly); Hamilton's rule has the hole at 3 ({0, 1, 2, 4}). @@ -318,6 +485,68 @@ TEST(Apportion, HamiltonHoleControl) { EXPECT_EQ(dense_input(g, plan, at3), (std::vector{0, 2, 1})); } +// The kernel entries keep their complete check: with several defects at once, one +// error, the first in the order placement, admission, priority (named "priority": a kernel entry +// takes it as a field of its own), then the combinations Lossy with MaxFlow and Weighted with +// Headroom; at check_kernel and at the three entries that call it. +TEST(Plans, KernelEntriesReportTheFirstDefectInOrder) { + const auto bad_placement = static_cast(99); + const auto bad_admission = static_cast(98); + const auto bad_priority = static_cast(97); + struct Case { + FlowPlacement k; + Admission a; + EdgePriority p; + std::string error; + }; + const std::vector cases = { + {bad_placement, bad_admission, bad_priority, "placement: 99 is not a value of FlowPlacement"}, + {bad_placement, Admission::Lossy, EdgePriority::Headroom, "placement: 99 is not a value of FlowPlacement"}, + {FlowPlacement::MaxFlow, bad_admission, bad_priority, "admission: 98 is not a value of Admission"}, + {FlowPlacement::MaxFlow, Admission::Lossy, bad_priority, "priority: 97 is not a value of EdgePriority"}, + {FlowPlacement::Weighted, Admission::Lossy, bad_priority, "priority: 97 is not a value of EdgePriority"}, + {FlowPlacement::MaxFlow, Admission::Lossy, EdgePriority::Headroom, kLossyKernelError}, + {FlowPlacement::Weighted, Admission::Lossy, EdgePriority::Headroom, kHeadroomKernelError}, + {FlowPlacement::Weighted, Admission::Lossless, EdgePriority::Headroom, kHeadroomKernelError}, + }; + const auto g = make_graph(3, {{0, 1, 2, 1}, {1, 2, 2, 1}}); + const auto support = all_edges(g); + const std::vector dsts = {2}; + const PriorityValues values {&g, EdgePriority::Capacity, {}}; + for (const auto& c : cases) { + EXPECT_EQ(error_of([&] { check_kernel(c.k, c.a, c.p); }), c.error); + EXPECT_EQ(error_of([&] { (void)make_plan(g, c.k, c.a, c.p, 0, dsts, *support, {}); }), c.error); + EXPECT_EQ(error_of([&] { (void)make_route_plan(g, c.k, c.a, c.p, 0, dsts, *support, nullptr); }), c.error); + PathSelection selection(EdgeTieBreak::Deterministic, EdgePriority::Capacity); + selection.priority = c.p; + EXPECT_EQ(error_of([&] { (void)cost_only_creation(g, support, 0, dsts, selection, PriorityFn::of(values), c.k, c.a); }), + c.error); + } +} + +// validate_config's part of the kernel check: the two combinations only, +// never a domain, which the configuration has checked under its own field names just before. +TEST(Plans, TheCombinationRuleAloneChecksNoDomain) { + const auto bad_placement = static_cast(99); + const auto bad_admission = static_cast(98); + const auto bad_priority = static_cast(97); + EXPECT_EQ(error_of([&] { check_kernel_combination(FlowPlacement::MaxFlow, Admission::Lossy, bad_priority); }), + kLossyKernelError); + EXPECT_EQ(error_of([&] { check_kernel_combination(FlowPlacement::Weighted, bad_admission, EdgePriority::Headroom); }), + kHeadroomKernelError); + EXPECT_EQ(error_of([&] { check_kernel_combination(bad_placement, bad_admission, bad_priority); }), ""); + for (auto k : {FlowPlacement::MaxFlow, FlowPlacement::Weighted, FlowPlacement::EqualBalanced}) { + for (auto a : {Admission::Lossless, Admission::Lossy}) { + for (auto pr : {EdgePriority::Residual, EdgePriority::Capacity, EdgePriority::Headroom}) { + const bool rejected = (a == Admission::Lossy && k == FlowPlacement::MaxFlow) || + (k == FlowPlacement::Weighted && pr == EdgePriority::Headroom); + EXPECT_EQ(error_of([&] { check_kernel_combination(k, a, pr); }).empty(), !rejected); + EXPECT_EQ(error_of([&] { check_kernel_combination(k, a, pr); }), error_of([&] { check_kernel(k, a, pr); })); + } + } + } +} + TEST(Plans, MembershipByKernelAndPriority) { // Parallel links of capacities [4, 4, 0] with residual [0, 4, 0] on the creation view. auto g = make_graph(2, {{0, 1, 4, 1}, {0, 1, 4, 1}, {0, 1, 0, 1}}); @@ -333,15 +562,18 @@ TEST(Plans, MembershipByKernelAndPriority) { auto wc = plan_of(g, FlowPlacement::Weighted, 0, {1}, all_ids(g), view, EdgePriority::Capacity); EXPECT_EQ(wc.members, (std::vector{inv[0], inv[1]})); EXPECT_EQ(wc.weights, (std::vector{4, 4})); - EXPECT_THROW((void)plan_of(g, FlowPlacement::Weighted, 0, {1}, all_ids(g), view, EdgePriority::Headroom), - std::invalid_argument); - EXPECT_THROW( - (void)plan_of(g, FlowPlacement::MaxFlow, 0, {1}, all_ids(g), view, EdgePriority::Residual, Admission::Lossy), - std::invalid_argument); + // Lossy keeps its kernel's membership and weights. + for (const auto& [kernel, prio, lossless] : {std::tuple {FlowPlacement::EqualBalanced, EdgePriority::Residual, &eb}, + std::tuple {FlowPlacement::Weighted, EdgePriority::Residual, &wr}, + std::tuple {FlowPlacement::Weighted, EdgePriority::Capacity, &wc}}) { + const auto lossy = plan_of(g, kernel, 0, {1}, all_ids(g), view, prio, Admission::Lossy); + EXPECT_EQ(lossy.members, lossless->members) << static_cast(kernel) << " " << static_cast(prio); + EXPECT_EQ(lossy.weights, lossless->weights) << static_cast(kernel) << " " << static_cast(prio); + } } TEST(Plans, SplitGraphPruning) { - // E1a: EqualBalanced with a zero-capacity tail. 0->1, 0->2, 1->3, 2->4 (cap 0), 4->3: the + // EqualBalanced with a zero-capacity tail. 0->1, 0->2, 1->3, 2->4 (cap 0), 4->3: the // branch through 2 cannot reach 3 over members, so it is pruned (no stranded unit). auto g = make_graph(5, {{0, 1, 2, 1}, {0, 2, 2, 1}, {1, 3, 2, 1}, {2, 4, 0, 1}, {4, 3, 2, 1}}); const auto inv = id_of_input(g); @@ -374,24 +606,23 @@ TEST(Plans, SplitGraphEqualsTheWalkDefinition) { // the source to a destination that enters no destination before its end: the members equal an // independent reachability computation, the route cost equals an independent shortest walk, // the topological structure orders every member's tail before its head, and an allocation - // conserves its total. make_plan on the DAG of the usable edges gives the same plan as - // make_route_plan filtering the bundle (the two entry points of the one builder). + // conserves its total and equals the per-node reference (each node's inflow split over its + // members by the kernel's rule). make_plan on the DAG of the usable edges gives the same plan as + // make_route_plan filtering the bundle (the two entry points of the one builder), for every + // kernel. MaxFlow's make_plan keeps every route edge, with no capacity filter: on the bundle + // itself its members are the walk definition over all the bundle's edges. std::mt19937 rng(2024); int compared = 0; + int maxflow_differs = 0; // trials where MaxFlow's members include an edge below one unit + int splitting = 0; // such plans with a split, and two destinations or a source other than 0 for (int trial = 0; trial < 2000; ++trial) { const int n = 3 + static_cast(rng() % 8); - std::vector edges; - for (int i = 0; i < 2 * n + static_cast(rng() % 10); ++i) { - const NodeId u = static_cast(rng() % static_cast(n - 1)); - const NodeId v = static_cast(u + 1 + rng() % static_cast(n - 1 - u)); - const Amount cap = (rng() % 6) == 0 ? 0 : 1 + static_cast(rng() % 9); - edges.push_back({u, v, cap, 1 + static_cast(rng() % 4)}); - } - auto g = make_graph(n, edges); - std::vector all(static_cast(g.num_edges())); - std::iota(all.begin(), all.end(), 0); + auto g = make_graph(n, random_dag_edges( + rng, n, 2 * n + static_cast(rng() % 10), + [&] { return (rng() % 6) == 0 ? Amount {0} : 1 + static_cast(rng() % 9); }, + [&] { return 1 + static_cast(rng() % 4); })); std::vector in_bundle; - for (EdgeId e : all) { + for (EdgeId e : all_ids(g)) { if ((rng() % 5) != 0) in_bundle.push_back(e); } const auto bundle = PredDAGBuilder::of_edges(g, in_bundle); @@ -408,7 +639,7 @@ TEST(Plans, SplitGraphEqualsTheWalkDefinition) { std::vector xn; std::vector xe; if ((rng() % 2) == 0) xn.push_back(static_cast(rng() % static_cast(n))); - for (EdgeId e : all) { + for (EdgeId e : all_ids(g)) { if ((rng() % 7) == 0) xe.push_back(e); } elig = g.eligibility(xn, xe); @@ -421,33 +652,18 @@ TEST(Plans, SplitGraphEqualsTheWalkDefinition) { for (EdgeId e : PredDAGBuilder::ascending(bundle)) { if (capacity[static_cast(e)] >= 1 && (!elig || elig->edge_ok(e))) usable.push_back(e); } - // The definition: forward from src and backward from the destinations over the usable edges - // that leave no destination, by fixpoint; the shortest walk by Bellman-Ford over the same. + // The definition over the usable edges; the shortest walk by Bellman-Ford over those that + // leave no destination. std::vector is_dst(static_cast(n), 0); for (NodeId d : dsts) is_dst[static_cast(d)] = 1; - std::vector open; - for (EdgeId e : usable) { - if (is_dst[static_cast(edge_src[static_cast(e)])] == 0) open.push_back(e); - } - std::vector fwd(static_cast(n), 0), bwd(static_cast(n), 0); - fwd[static_cast(src)] = 1; - for (NodeId d : dsts) bwd[static_cast(d)] = 1; + const auto want = walk_members(g, usable, src, dsts); std::vector dist(static_cast(n), kUnreachable); dist[static_cast(src)] = 0; for (int round = 0; round < n; ++round) { - for (EdgeId e : open) { + for (EdgeId e : usable) { const auto u = static_cast(edge_src[static_cast(e)]); const auto v = static_cast(edge_dst[static_cast(e)]); - if (fwd[u] != 0) fwd[v] = 1; - if (bwd[v] != 0) bwd[u] = 1; - if (dist[u] != kUnreachable) dist[v] = std::min(dist[v], dist[u] + cost[static_cast(e)]); - } - } - std::vector want; - for (EdgeId e : open) { - if (fwd[static_cast(edge_src[static_cast(e)])] != 0 && - bwd[static_cast(edge_dst[static_cast(e)])] != 0) { - want.push_back(e); + if (is_dst[u] == 0 && dist[u] != kUnreachable) dist[v] = std::min(dist[v], dist[u] + cost[static_cast(e)]); } } Cost want_cost = kUnreachable; @@ -455,7 +671,14 @@ TEST(Plans, SplitGraphEqualsTheWalkDefinition) { for (NodeId d : dsts) want_cost = std::min(want_cost, dist[static_cast(d)]); } const auto usable_dag = PredDAGBuilder::of_edges(g, usable); - for (const auto kernel : {FlowPlacement::EqualBalanced, FlowPlacement::Weighted}) { + const auto bundle_edges = PredDAGBuilder::ascending(bundle); + const auto maxflow_want = walk_members(g, std::vector(bundle_edges.begin(), bundle_edges.end()), src, dsts); + EXPECT_EQ(make_plan(g, FlowPlacement::MaxFlow, Admission::Lossless, EdgePriority::Capacity, src, dsts, bundle, {}) + .members, + maxflow_want) + << "trial " << trial; + maxflow_differs += maxflow_want != want ? 1 : 0; + for (const auto kernel : {FlowPlacement::EqualBalanced, FlowPlacement::Weighted, FlowPlacement::MaxFlow}) { Cost got_cost = -1; const auto on_bundle = make_route_plan(g, kernel, Admission::Lossless, EdgePriority::Capacity, src, dsts, bundle, elig ? &*elig : nullptr, &got_cost); @@ -466,28 +689,13 @@ TEST(Plans, SplitGraphEqualsTheWalkDefinition) { Cost plan_cost = -1; const auto on_usable = make_plan(g, kernel, Admission::Lossless, EdgePriority::Capacity, src, dsts, usable_dag, {}, &plan_cost); - EXPECT_EQ(on_usable.members, on_bundle.members); - EXPECT_EQ(vec(on_usable.dsts), vec(on_bundle.dsts)); - EXPECT_EQ(vec(on_usable.topo), vec(on_bundle.topo)); - EXPECT_EQ(vec(on_usable.out_begin), vec(on_bundle.out_begin)); - EXPECT_EQ(vec(on_usable.out_end), vec(on_bundle.out_end)); - EXPECT_EQ(vec(on_usable.head_pos), vec(on_bundle.head_pos)); - EXPECT_EQ(vec(on_usable.terminal), vec(on_bundle.terminal)); - EXPECT_EQ(on_usable.weights, on_bundle.weights); - // The route cost is over the route (every usable edge), not only the members. - dist.assign(static_cast(n), kUnreachable); - dist[static_cast(src)] = 0; - for (int round = 0; round < n; ++round) { - for (EdgeId e : usable) { - const auto u = static_cast(edge_src[static_cast(e)]); - const auto v = static_cast(edge_dst[static_cast(e)]); - if (is_dst[u] == 0 && dist[u] != kUnreachable) dist[v] = std::min(dist[v], dist[u] + cost[static_cast(e)]); - } - } - Cost route_want = kUnreachable; - for (NodeId d : dsts) route_want = std::min(route_want, dist[static_cast(d)]); - EXPECT_EQ(plan_cost, route_want) << "trial " << trial; - EXPECT_EQ(route_cost(g, usable_dag, src, dsts), route_want) << "trial " << trial; // the cost pass alone + // The same plan but for its route, which is the whole usable DAG (plan_differences, + // test_utils.hpp). + EXPECT_EQ(plan_differences(on_usable, on_bundle, false), std::vector {}) << "trial " << trial; + // The plan built on the usable edges reports the same shortest-walk cost, and so does the + // cost pass alone. + EXPECT_EQ(plan_cost, want_cost) << "trial " << trial; + EXPECT_EQ(route_cost(g, usable_dag, src, dsts), want_cost) << "trial " << trial; if (on_bundle.members.empty()) continue; // Topology: the source first, every member's tail before its head, members of a node // consecutive. @@ -498,7 +706,16 @@ TEST(Plans, SplitGraphEqualsTheWalkDefinition) { EXPECT_GT(on_bundle.head_pos[static_cast(k)], static_cast(p)); } } - // Conservation: what enters the destinations is the total. + // Conservation: what enters the destinations is the total; and the per-node reference (node + // ids are a topological order; the members in ascending EdgeId, Weighted's weights the + // capacities). MaxFlow places by the flow core, not by alloc(T). + if (kernel == FlowPlacement::MaxFlow) continue; + std::vector> arcs; + std::vector weights; + for (EdgeId e : on_bundle.members) { + arcs.emplace_back(edge_src[static_cast(e)], edge_dst[static_cast(e)]); + weights.push_back(kernel == FlowPlacement::Weighted ? capacity[static_cast(e)] : 1); + } std::vector a(on_bundle.members.size()); for (Amount t : {Amount {1}, Amount {7}, Amount {50}}) { ASSERT_TRUE(allocate(on_bundle, t, a)); @@ -509,15 +726,25 @@ TEST(Plans, SplitGraphEqualsTheWalkDefinition) { } } EXPECT_EQ(delivered, t) << "trial " << trial; + EXPECT_EQ(a, per_node_reference(n, arcs, weights, t, src)) << "trial " << trial << " T " << t; + } + // What the single-destination tests below do not reach: a split at some node, with two + // destinations or a source other than node 0. + bool splits = false; + for (std::size_t p = 0; p < on_bundle.topo.size(); ++p) { + splits = splits || (on_bundle.terminal[p] == 0 && on_bundle.out_end[p] - on_bundle.out_begin[p] >= 2); } + splitting += splits && (dsts.size() > 1 || src != 0) ? 1 : 0; ++compared; } } EXPECT_GT(compared, 1000); + EXPECT_GT(maxflow_differs, 100); + EXPECT_GT(splitting, 300); } -TEST(Allocation, Review14SplitAndReconvergeEveryIntermediateValue) { - // Prototype 1 fixture apportion_house_monotone_dag: capacities 20 x weights, baseline view. +TEST(Allocation, SplitAndReconvergeMatchesThePerNodeReferenceAtEveryValue) { + // House monotonicity of the apportionment on a DAG: capacities 20 x weights, baseline view. const std::vector> dag = {{0, 1}, {0, 2}, {1, 3}, {1, 4}, {2, 3}, {2, 4}, {3, 5}, {4, 5}}; const std::vector w = {2, 7, 1, 4, 3, 2, 1, 1}; const std::vector view_in = {8, 20, 4, 9, 10, 10, 15, 15}; @@ -526,27 +753,10 @@ TEST(Allocation, Review14SplitAndReconvergeEveryIntermediateValue) { auto g = make_graph(6, edges); const auto view = to_ids(g, view_in); for (auto kernel : {FlowPlacement::Weighted, FlowPlacement::EqualBalanced}) { - // Independent per-node reference on input positions (node ids are topological). - auto ref = [&](Amount total) { - std::vector inflow(6, 0), out(dag.size(), 0); - inflow[0] = total; - for (NodeId u = 0; u < 5; ++u) { - std::vector mine; - for (std::size_t i = 0; i < dag.size(); ++i) { - if (dag[i].first == u) mine.push_back(i); - } - if (mine.empty() || inflow[static_cast(u)] == 0) continue; - std::vector ww; - for (auto i : mine) ww.push_back(w[i]); - const auto parts = kernel == FlowPlacement::Weighted ? ha_slow(inflow[static_cast(u)], ww) - : rr(inflow[static_cast(u)], mine.size()); - for (std::size_t j = 0; j < mine.size(); ++j) { - out[mine[j]] = parts[j]; - inflow[static_cast(dag[mine[j]].second)] += parts[j]; - } - } - return out; - }; + // The per-node reference on input positions (node ids are topological); EqualBalanced is the + // sequential definition with equal weights (ties to the lower index). + const auto weights = kernel == FlowPlacement::EqualBalanced ? std::vector(w.size(), 1) : w; + auto ref = [&](Amount total) { return per_node_reference(6, dag, weights, total); }; Amount brute = 0; for (Amount t = 0; t <= 100; ++t) { const auto a = ref(t); @@ -577,7 +787,7 @@ TEST(Allocation, Review14SplitAndReconvergeEveryIntermediateValue) { } TEST(Allocation, EqualBalancedOnePlusTwoEqualsThree) { - // Review-13 EB reconvergence DAG (prototype 1 apportion_house_monotone_dag, second part). + // EqualBalanced on a DAG that splits three ways at the source and reconverges at node 4. const std::vector> recon = {{0, 1}, {0, 2}, {0, 3}, {1, 4}, {2, 4}, {3, 4}, {4, 5}, {4, 6}, {5, 7}, {6, 7}}; std::vector edges; @@ -594,57 +804,79 @@ TEST(Allocation, EqualBalancedOnePlusTwoEqualsThree) { } TEST(Allocation, TMaxExactAgainstBruteForce) { + // Random DAGs from node 0 to node n - 1 (node ids topological). The reference is independent of + // the library: the members by the walk definition, alloc(T) node by node in id order, each + // node's inflow split over its members in ascending EdgeId by the sequential definition + // (capacities as Weighted's weights, equal weights for EqualBalanced), and T_max the last T + // whose allocation fits the view (the allocation is member-monotone, so the fitting T are a + // prefix). allocate() equals the reference at every T up to one past T_max, its limit answer + // agrees, and t_max returns that T. std::mt19937 rng(99); - int checked = 0; + int several = 0; // plans whose T_max is at least 2 units for (int trial = 0; trial < 400; ++trial) { const int n = 3 + static_cast(rng() % 5); - std::vector edges; - for (int i = 0; i < 2 * n + static_cast(rng() % 6); ++i) { - const NodeId u = static_cast(rng() % (n - 1)); - const NodeId v = static_cast(u + 1 + rng() % static_cast(n - 1 - u)); - edges.push_back({u, v, 1 + static_cast(rng() % 9), 1}); - } - auto g = make_graph(n, edges); + auto g = make_graph(n, random_dag_edges( + rng, n, 2 * n + static_cast(rng() % 6), + [&] { return 1 + static_cast(rng() % 9); }, [] { return Cost {1}; })); std::vector view(static_cast(g.num_edges())); for (std::size_t e = 0; e < view.size(); ++e) { view[e] = static_cast(rng() % static_cast(g.capacity_units()[e] + 1)); } const auto kernel = (rng() % 2) == 0 ? FlowPlacement::Weighted : FlowPlacement::EqualBalanced; const auto plan = plan_of(g, kernel, 0, {n - 1}, all_ids(g), caps(g), EdgePriority::Capacity); + ASSERT_EQ(plan.members, walk_members(g, all_ids(g), 0, {n - 1})) << "trial " << trial; if (plan.members.empty()) continue; + // The members as arcs in ascending EdgeId, with their weights. + std::vector> arcs; + std::vector weights; + for (EdgeId m : plan.members) { + const auto e = static_cast(m); + arcs.emplace_back(g.edge_src()[e], g.edge_dst()[e]); + weights.push_back(kernel == FlowPlacement::Weighted ? g.capacity_units()[e] : 1); + } + auto reference = [&](Amount total) { return per_node_reference(n, arcs, weights, total); }; const auto vm = on_members(plan, view); const auto tm = t_max(plan, vm); EXPECT_LE(tm.iterations, 62); - Amount brute = 0; - std::vector a(plan.members.size()); + Amount brute = -1; + std::vector a(plan.members.size()), limited(plan.members.size()); for (Amount t = 0; t <= 200; ++t) { - if (allocate(plan, t, a, vm)) brute = t; + const auto want = reference(t); + bool fits = true; + for (std::size_t k = 0; k < want.size(); ++k) fits = fits && want[k] <= vm[k]; + ASSERT_TRUE(allocate(plan, t, a)) << "trial " << trial << " T " << t; + ASSERT_EQ(a, want) << "trial " << trial << " T " << t; + ASSERT_EQ(allocate(plan, t, limited, vm), fits) << "trial " << trial << " T " << t; + if (!fits) break; + brute = t; } + ASSERT_GE(brute, 0); ASSERT_EQ(tm.value, brute) << "trial " << trial; - // At T_max some member is exactly full and the next unit overfills one. - ASSERT_TRUE(allocate(plan, brute, a, vm)); - std::vector next(plan.members.size()); - EXPECT_FALSE(allocate(plan, brute + 1, next, vm)); - ++checked; + several += tm.value >= 2 ? 1 : 0; } - EXPECT_GT(checked, 300); + EXPECT_GT(several, 60); } TEST(Allocation, TMaxSearchIsExactAndWithinTheProbeBudgetOnLargeRanges) { // Capacities and views up to 2^58 units (search ranges near 2^62 at the source): the seeded // search must return the same T as a plain bisection from 0 and probe at most 62 times, also // when its floating-point estimate is far off (a member capped near zero deep in the DAG). + // First cross-check the quotient-rank reference against the unit-by-unit definition, + // including ties. The large trials then use that reference at each node. + for (Amount total = 0; total < 50; ++total) { + for (const std::vector& weights : {std::vector {1, 1, 1}, {1, 3, 7}, {9, 2, 9, 1}}) { + ASSERT_EQ(ha_by_rank(total, weights), ha_slow(total, weights)); + } + } std::mt19937_64 rng(1234); - int checked = 0; + int large = 0; // T at least 2^40 + int far = 0; // T at most 2^-20 of the source side's capacity (the estimate far off) for (int trial = 0; trial < 300; ++trial) { const int n = 3 + static_cast(rng() % 6); - std::vector edges; - for (int i = 0; i < 2 * n + static_cast(rng() % 8); ++i) { - const NodeId u = static_cast(rng() % static_cast(n - 1)); - const NodeId v = static_cast(u + 1 + rng() % static_cast(n - 1 - u)); - edges.push_back({u, v, 1 + static_cast(rng() % (std::uint64_t {1} << 58)), 1}); - } - auto g = make_graph(n, edges); + auto g = make_graph(n, random_dag_edges( + rng, n, 2 * n + static_cast(rng() % 8), + [&] { return 1 + static_cast(rng() % (std::uint64_t {1} << 58)); }, + [] { return Cost {1}; })); std::vector view(static_cast(g.num_edges())); for (std::size_t e = 0; e < view.size(); ++e) { const Amount c = g.capacity_units()[e]; @@ -656,14 +888,26 @@ TEST(Allocation, TMaxSearchIsExactAndWithinTheProbeBudgetOnLargeRanges) { const auto vm = on_members(plan, view); const auto tm = t_max(plan, vm); EXPECT_LE(tm.iterations, 62); - // Reference: plain bisection on [0, hi] with the same exact predicate. + // Reference: each node splits by exact quotient rank, then plain bisection over feasible + // totals. No production allocation or search is used to compute an expected amount. Amount hi = 0; for (auto k = plan.src_out_begin; k < plan.src_out_end; ++k) hi += vm[static_cast(k)]; - std::vector a(plan.members.size()); + const Amount source_side = hi; + ASSERT_EQ(plan.members, walk_members(g, all_ids(g), 0, {n - 1})); + std::vector> arcs; + std::vector weights; + for (EdgeId edge : plan.members) { + const auto e = static_cast(edge); + arcs.emplace_back(g.edge_src()[e], g.edge_dst()[e]); + weights.push_back(kernel == FlowPlacement::Weighted ? g.capacity_units()[e] : 1); + } Amount lo = 0; while (lo < hi) { const Amount mid = lo + (hi - lo + 1) / 2; - if (allocate(plan, mid, a, vm)) lo = mid; else hi = mid - 1; + const auto a = per_node_reference(n, arcs, weights, mid, 0, true); + bool fits = true; + for (std::size_t j = 0; j < a.size(); ++j) fits = fits && a[j] <= vm[j]; + if (fits) lo = mid; else hi = mid - 1; } ASSERT_EQ(tm.value, lo) << "trial " << trial; // The Lossless step from T = 0 with a request beyond the maximum lands on the same T. @@ -671,14 +915,17 @@ TEST(Allocation, TMaxSearchIsExactAndWithinTheProbeBudgetOnLargeRanges) { const auto step = lossless_step(g, plan, 0, vm, zero, kDomainLimit - 1); EXPECT_EQ(step.total, lo); EXPECT_LE(step.iterations, 62); - ++checked; + large += lo >= (Amount {1} << 40) ? 1 : 0; + far += source_side >= (Amount {1} << 40) && lo <= (source_side >> 20) ? 1 : 0; } - EXPECT_GT(checked, 200); + EXPECT_GT(large, 10); + EXPECT_GT(far, 100); } TEST(MaxFlowKernel, CancellationDagGivesTwo) { - // Review-5 V3 (prototype 1 maxflow_cancellation_dag): place(1) takes 0-1-3-5, the next - // place(1) re-solves on V = W + own and returns the unique value-2 flow (1->3 goes to 0). + // A DAG where the second unit needs the first one's edge 1->3 cancelled: place(1) takes + // 0-1-3-5, the next place(1) re-solves on V = W + own and returns the unique value-2 flow (1->3 + // goes to 0). const std::vector> pairs = {{0, 1}, {0, 2}, {1, 3}, {1, 4}, {2, 3}, {3, 5}, {4, 5}}; std::vector edges; for (auto [u, v] : pairs) edges.push_back({u, v, 1, 1}); @@ -698,7 +945,7 @@ TEST(MaxFlowKernel, CancellationDagGivesTwo) { namespace { -// Review-9 witness edges (prototype 1 CANCEL_EDGES): s=0; A=1, B=2, C=3; X=4, Y=5; t=6. +// A graph whose minimum-cost flow cancels earlier flow: s=0; A=1, B=2, C=3; X=4, Y=5; t=6. std::vector cancel_edges(Cost b_to_y = 9) { return {{0, 1, 1, 1}, {0, 2, 1, 1}, {0, 3, 1, 1}, {1, 4, 1, 2}, {1, 5, 1, 9}, {2, 4, 1, 1}, {2, 5, 1, b_to_y}, {3, 4, 1, 3}, {4, 6, 2, 1}, {5, 6, 1, 1}}; @@ -731,8 +978,9 @@ TEST(MaxFlowKernel, CancelUpstreamMixedCost) { EXPECT_EQ(cost, 19); } -TEST(MaxFlowKernel, EqualOptimumControlFollowsBPrimeArcOrder) { - // Rule-20 control: with cost(B->Y) = 8 two value-3 optima cost 19; ascending B' arcs pick the +TEST(MaxFlowKernel, EqualOptimaFollowTheAscendingEdgeIdOrder) { + // Ties between equal optima follow the arc order (ascending EdgeId): + // with cost(B->Y) = 8 two value-3 optima cost 19; ascending arcs pick the // flow with Y fed by A, so a later demand on A->X still finds its unit. auto g = make_graph(7, cancel_edges(8)); const auto plan = plan_of(g, FlowPlacement::MaxFlow, 0, {6}, all_ids(g), caps(g)); @@ -758,15 +1006,230 @@ TEST(MaxFlowKernel, ReleaseWitnessAndMixedCostBundle) { auto s3 = lossless_step(g, plan, 1, v, s1.alloc, 0); EXPECT_EQ(s3.total, 1); EXPECT_EQ(dense_input(g, plan, s3.alloc), (std::vector{0, 1})); - // Review-13 D4: on a mixed-cost bundle the 1 + 1 + 1 chain fills before the cost-4 edge. + // On a mixed-cost bundle the 1 + 1 + 1 chain fills before the cost-4 edge. auto g2 = make_graph(4, {{0, 1, 1, 1}, {1, 2, 1, 1}, {2, 3, 1, 1}, {0, 3, 1, 4}}); const auto p2 = plan_of(g2, FlowPlacement::MaxFlow, 0, {3}, all_ids(g2), caps(g2)); EXPECT_EQ(dense_input(g2, p2, first_step(g2, p2, caps(g2), 1).alloc), (std::vector{1, 1, 1, 0})); EXPECT_EQ(cost_of(g2, p2), 3); } +namespace { + +// The certificate of one MaxFlow-kernel step, independent of the solver: the step's member +// allocation, as a flow on the graph with the members' view and every other edge masked, is +// feasible and conserved from the plan's source to its destinations, has the value +// min(total + request, F_max) (the maximum for a request of +inf) and the minimum cost at that +// value, and its curve has the flow's total and moment and the optimum's cost at every value up +// to it (the curve clauses shared with certify, test_utils.hpp). The optimum is the exhaustive +// one (`tier_a`, small graphs) or the independent Bellman-Ford curve (test_utils.hpp). The +// checker relies on the members lying on walks from the source to its destinations, which end +// there, so that no member enters the source or leaves a destination: the random test below +// checks each plan's members against that walk definition first. Returns the failed clauses. +std::vector certify_step(const Graph& g, const KernelPlan& plan, const std::vector& view_m, + Amount total, std::optional request, const LosslessStep& step, + bool tier_a) { + std::vector fails; + if (step.alloc.size() != plan.members.size()) { + fails.push_back("shape"); + return fails; + } + const auto m = static_cast(g.num_edges()); + const auto n = static_cast(g.num_nodes()); + std::vector view(m, 0); + std::vector flow(m, 0); + std::vector member(m, 0); + for (std::size_t k = 0; k < plan.members.size(); ++k) { + const auto e = static_cast(plan.members[k]); + member[e] = 1; + view[e] = view_m[k]; + flow[e] = step.alloc[k]; + if (step.alloc[k] < 0 || step.alloc[k] > view_m[k]) fails.push_back("feasibility at member " + std::to_string(k)); + } + std::vector outside; + for (std::size_t e = 0; e < m; ++e) { + if (member[e] == 0) outside.push_back(static_cast(e)); + } + const auto mask = g.eligibility({}, outside); + std::vector bal(n, 0); + for (std::size_t e = 0; e < m; ++e) { + bal[static_cast(g.edge_src()[e])] -= flow[e]; + bal[static_cast(g.edge_dst()[e])] += flow[e]; + } + const Int128 moment = flow_cost(g, flow); + const std::vector src = {plan.src}; + const std::vector dsts(plan.dsts.begin(), plan.dsts.end()); + Int128 inflow = 0; + for (std::size_t v = 0; v < n; ++v) { + if (static_cast(v) == plan.src) continue; + if (std::find(dsts.begin(), dsts.end(), static_cast(v)) != dsts.end()) { + inflow += bal[v]; + } else if (bal[v] != 0) { + fails.push_back("conservation at node " + std::to_string(v)); + } + } + if (-bal[static_cast(plan.src)] != step.total || inflow != step.total) { + fails.push_back("value differs from supply or inflow"); + } + // The optimum's cost at every value up to F_max, as breakpoints. + std::vector> optimum; + if (tier_a) { + for (const auto& [x, c] : exhaustive_costs(g, view, src, dsts, &mask)) optimum.emplace_back(x, c); + } else { + optimum = independent_curve(g, view, src, dsts, &mask); + } + const Amount fmax = optimum.back().first; + const Amount top = request.has_value() ? std::min(total + *request, fmax) : fmax; + if (step.total != top) fails.push_back("placed differs from min(cap, F_max)"); + if (step.total >= 0 && step.total <= fmax && moment != curve_at(optimum, step.total)) { + fails.push_back("cost differs from the optimum"); + } + const auto pts = curve_shape_clauses(step.cost_distribution, step.total, moment, fails); + curve_optimum_clause(pts, optimum, std::min(step.total, fmax), "the optimum", fails); + return fails; +} + +} // namespace + +TEST(MaxFlowKernel, StepCertificateRejectsMutatedSteps) { + // The step certificate is not vacuous: each clean step certifies, then each mutation is rejected + // by the clause it breaks. + using Fails = std::vector; + auto mutated = [](const Graph& g, const KernelPlan& plan, std::optional request, + const std::function& mutate) { + const auto vm = on_members(plan, caps(g)); + auto step = first_step(g, plan, caps(g), request); + EXPECT_TRUE(certify_step(g, plan, vm, 0, request, step, true).empty()); + mutate(step); + return certify_step(g, plan, vm, 0, request, step, true); + }; + // The chain 0 -> 1 -> 2 of capacity 2: two units, members in chain order. + const auto chain = make_graph(3, {{0, 1, 2, 1}, {1, 2, 2, 1}}); + const auto cp = plan_of(chain, FlowPlacement::MaxFlow, 0, {2}, all_ids(chain), caps(chain)); + ASSERT_EQ(cp.members.size(), 2U); + EXPECT_EQ(mutated(chain, cp, std::nullopt, [](auto& s) { s.alloc.push_back(0); }), Fails {"shape"}); + EXPECT_TRUE(has_failure(mutated(chain, cp, std::nullopt, [](auto& s) { s.alloc = {3, 3}; }), "feasibility at member 0")); + EXPECT_TRUE(has_failure(mutated(chain, cp, std::nullopt, [](auto& s) { s.alloc = {-1, -1}; }), "feasibility at member 0")); + // One unit less into node 1 lowers the supply only, one unit less out of it the inflow only. + for (const std::size_t k : {std::size_t {0}, std::size_t {1}}) { + const auto f = mutated(chain, cp, std::nullopt, [&](auto& s) { s.alloc[k] = 1; }); + EXPECT_TRUE(has_failure(f, "conservation at node 1")) << k; + EXPECT_TRUE(has_failure(f, "value differs from supply or inflow")) << k; + } + // A capped step (request 1) certified as if the request were 2: the value alone differs. + { + const auto vm = on_members(cp, caps(chain)); + const auto step = first_step(chain, cp, caps(chain), 1); + EXPECT_EQ(certify_step(chain, cp, vm, 0, 2, step, true), Fails {"placed differs from min(cap, F_max)"}); + } + // Parallel members of cost 1 and 3 and capacity 1: the first unit on the cheap one, the curve + // [(1, 1), (3, 1)] for both. The unit moved to the costly member keeps the value; the curve's + // last volume raised breaks its total; its first cost raised breaks its moment; the one tier + // [(2, 2)] keeps the total and the moment but not the optimum at 1. + const auto par = make_graph(2, {{0, 1, 1, 1}, {0, 1, 1, 3}}); + const auto pp = plan_of(par, FlowPlacement::MaxFlow, 0, {1}, all_ids(par), caps(par)); + ASSERT_EQ(pp.members.size(), 2U); + EXPECT_TRUE(has_failure(mutated(par, pp, 1, [](auto& s) { s.alloc = {0, 1}; }), "cost differs from the optimum")); + EXPECT_TRUE(has_failure(mutated(par, pp, std::nullopt, [](auto& s) { s.cost_distribution = {{1, 1}, {3, 2}}; }), + "curve total differs from placed")); + EXPECT_TRUE(has_failure(mutated(par, pp, std::nullopt, [](auto& s) { s.cost_distribution = {{2, 1}, {3, 1}}; }), + "curve moment differs from the flow cost")); + EXPECT_EQ(mutated(par, pp, std::nullopt, [](auto& s) { s.cost_distribution = {{2, 2}}; }), + Fails {"curve differs from the optimum at 1"}); + // The curve's shape, each the only failure: an entry of volume 0 between the two (total, moment + // and the curve's values unchanged), and the chain's one tier [(2, 2)] split in two at its cost. + EXPECT_EQ(mutated(par, pp, std::nullopt, [](auto& s) { s.cost_distribution = {{1, 1}, {2, 0}, {3, 1}}; }), + Fails {"curve volume not positive"}); + EXPECT_EQ(mutated(chain, cp, std::nullopt, [](auto& s) { s.cost_distribution = {{2, 1}, {2, 1}}; }), + Fails {"curve costs not strictly increasing"}); + // The guard of the replacement solve: a committed total beyond what the view can carry. + const auto vm = on_members(pp, caps(par)); + const std::vector own(pp.members.size(), 0); + try { + (void)lossless_step(par, pp, 3, vm, own, 1); + ADD_FAILURE() << "no exception"; + } catch (const std::logic_error& e) { + EXPECT_STREQ(e.what(), "MaxFlow kernel: the replacement solve lost committed flow"); + } +} + +TEST(MaxFlowKernel, RandomStepsAreCertifiedOnTheirSubset) { + // Random DAG plans (nonuniform costs, parallel edges, a route that is a random subset of the + // edges, one or two destinations), each placed in three steps on a changing view V = W + own (W + // drawn below the capacity left beside the flow's own allocation) with a request of +inf, 0 or + // a few units: every step certified on its member subset (the other edges masked), the small + // graphs against the exhaustive optimum, the others against the independent solver. The same + // steps with every cost multiplied give the same allocations and the curve's costs multiplied. + std::mt19937_64 rng(5005); + int positive = 0; // steps that place + int capped = 0; // steps stopped by their request below the maximum + int cancelled = 0; // steps that take back part of the flow's own allocation + for (int trial = 0; trial < 400; ++trial) { + const bool tier_a = trial % 2 == 0; + const int n = tier_a ? 3 + static_cast(rng() % 4) : 6 + static_cast(rng() % 9); + const int m = tier_a ? 3 + static_cast(rng() % 5) : n + static_cast(rng() % (2 * n)); + const Amount max_cap = tier_a ? 2 : 40; + const Cost max_cost = tier_a ? 6 : 40; + const auto edges = random_dag_edges( + rng, n, m, [&] { return static_cast(rng() % static_cast(max_cap + 1)); }, + [&] { return static_cast(1 + rng() % static_cast(max_cost)); }); + const auto g = make_graph(n, edges); + auto scaled_edges = edges; + for (auto& e : scaled_edges) e.cost *= 1000003; + const auto scaled = make_graph(n, scaled_edges); + std::vector route; + for (EdgeId e = 0; e < g.num_edges(); ++e) { + if (rng() % 5 != 0) route.push_back(e); + } + std::vector dsts = {n - 1}; + if (rng() % 2 == 0) dsts.insert(dsts.begin(), n - 2); + const auto plan = plan_of(g, FlowPlacement::MaxFlow, 0, dsts, route, caps(g)); + ASSERT_EQ(plan.members, walk_members(g, route, 0, dsts)) << "trial " << trial; // every route edge on a walk + const auto scaled_plan = plan_of(scaled, FlowPlacement::MaxFlow, 0, dsts, route, caps(scaled)); + ASSERT_EQ(scaled_plan.members, plan.members) << "trial " << trial; // scaling keeps the EdgeId order + std::vector own(plan.members.size(), 0); + Amount total = 0; + for (int k = 0; k < 3; ++k) { + std::vector vm(plan.members.size()); + for (std::size_t j = 0; j < vm.size(); ++j) { + const Amount cap = g.capacity_units()[static_cast(plan.members[j])]; + vm[j] = own[j] + static_cast(rng() % static_cast(cap - own[j] + 1)); + } + const std::uint64_t kind = rng() % 4; + const std::optional request = + kind == 0 ? std::nullopt : std::optional(static_cast(kind == 1 ? 0 : rng() % 4)); + const auto step = lossless_step(g, plan, total, vm, own, request); + const std::string where = "trial " + std::to_string(trial) + " step " + std::to_string(k); + const auto fails = certify_step(g, plan, vm, total, request, step, tier_a); + ASSERT_TRUE(fails.empty()) << where << ": " << fails.front(); + const auto other = lossless_step(scaled, scaled_plan, total, vm, own, request); + ASSERT_EQ(other.total, step.total) << where; + ASSERT_EQ(other.alloc, step.alloc) << where; + ASSERT_EQ(other.cost_distribution.size(), step.cost_distribution.size()) << where; + for (std::size_t i = 0; i < step.cost_distribution.size(); ++i) { + ASSERT_EQ(other.cost_distribution[i].first, step.cost_distribution[i].first * 1000003) << where; + ASSERT_EQ(other.cost_distribution[i].second, step.cost_distribution[i].second) << where; + } + positive += step.total > 0 ? 1 : 0; + const Amount fmax = independent_curve(g, [&] { + std::vector view(static_cast(g.num_edges()), 0); + for (std::size_t j = 0; j < vm.size(); ++j) view[static_cast(plan.members[j])] = vm[j]; + return view; + }(), std::vector {0}, dsts, nullptr).back().first; + capped += request.has_value() && step.total < fmax ? 1 : 0; + bool took_back = false; + for (std::size_t j = 0; j < own.size(); ++j) took_back = took_back || step.alloc[j] < own[j]; + cancelled += took_back ? 1 : 0; + own = step.alloc; + total = step.total; + } + } + EXPECT_GT(positive, 240); + EXPECT_GT(capped, 150); + EXPECT_GT(cancelled, 30); +} + TEST(WeightedKernel, CapacityVersusResidualMembershipAfterOccupancy) { - // Prototype 1 weighted_capacity_vs_residual_after_occupancy: parallel links [4, 4], another + // Weighted membership by capacity versus residual after occupancy: parallel links [4, 4], another // owner holds 3 on link 0 (residual [1, 4]). auto g = make_graph(2, {{0, 1, 4, 1}, {0, 1, 4, 1}}); auto run = [&](EdgePriority prio, std::vector residual_in, Amount request) { @@ -793,7 +1256,7 @@ TEST(WeightedKernel, CapacityVersusResidualMembershipAfterOccupancy) { } TEST(WeightedKernel, OnePlusFiveEqualsSix) { - // Additivity on a fixed plan (prototype 1 weighted_1_plus_5_vs_6): weights 2 and 6 at the + // Additivity on a fixed plan (place(1) then place(5) equals place(6)): weights 2 and 6 at the // source. auto g = make_graph(3, {{0, 1, 2, 1}, {0, 2, 6, 1}, {1, 2, 9, 1}}); const auto plan = plan_of(g, FlowPlacement::Weighted, 0, {2}, all_ids(g), caps(g), EdgePriority::Capacity); @@ -802,12 +1265,19 @@ TEST(WeightedKernel, OnePlusFiveEqualsSix) { auto a = lossless_step(g, plan, 0, vm, zero, 1); auto b = lossless_step(g, plan, a.total, vm, a.alloc, 5); auto c = lossless_step(g, plan, 0, vm, zero, 6); + // By hand, D'Hondt over the source weights [2, 6] (each unit to the larger w / (a + 1), ties to + // the lower index): the first unit to 0 -> 2; 6 units give [1, 5] (the third unit is the tie + // 2 / 1 = 6 / 3), and node 1 passes its unit on over 1 -> 2. Every amount fits its capacity, + // so all 6 are placed. + EXPECT_EQ(dense_input(g, plan, a.alloc), (std::vector{0, 1, 0})); + EXPECT_EQ(c.total, 6); + EXPECT_EQ(dense_input(g, plan, c.alloc), (std::vector{1, 5, 1})); EXPECT_EQ(b.total, c.total); EXPECT_EQ(b.alloc, c.alloc); } TEST(LossyKernel, DiamondDropsAndInfiniteInjection) { - // Prototype 1 lossy_diamond: source links 10/10, tails 1/10. + // A Lossy diamond: source links 10/10, tails 1/10. auto g = make_graph(4, {{0, 1, 10, 1}, {0, 2, 10, 1}, {1, 3, 1, 1}, {2, 3, 10, 1}}); for (auto kernel : {FlowPlacement::EqualBalanced, FlowPlacement::Weighted}) { const auto plan = plan_of(g, kernel, 0, {3}, all_ids(g), caps(g), EdgePriority::Residual, Admission::Lossy); @@ -825,7 +1295,7 @@ TEST(LossyKernel, DiamondDropsAndInfiniteInjection) { EXPECT_EQ(dense_input(g, plan, inf.drops), (std::vector{0, 0, 9, 0})); } // Highest averages at every node for Lossy Weighted: Capacity weights [1, 3, 3] split 7 as - // [1, 3, 3] (prototype 1 lossy_weighted_highest_averages). + // [1, 3, 3]. auto g2 = make_graph(2, {{0, 1, 1, 1}, {0, 1, 3, 1}, {0, 1, 3, 1}}); const auto p2 = plan_of(g2, FlowPlacement::Weighted, 0, {1}, all_ids(g2), caps(g2), EdgePriority::Capacity, Admission::Lossy); @@ -841,3 +1311,61 @@ TEST(LossyKernel, DiamondDropsAndInfiniteInjection) { EXPECT_EQ(s3.injected, 0); EXPECT_EQ(s3.delivered, 0); } + +// Lossy steps on random DAGs (parallel arcs, zero capacities, one or two destinations, a +// destination reached through another), both kernels, finite offers and +inf, against the +// per-node reference: every member's carried amount and drop, the injection, the delivery and +// the offer. The members are first checked against the walk definition, so the reference reads +// the same split graph. +TEST(LossyKernel, RandomStepsEqualThePerNodeReference) { + std::mt19937 rng(1915); + int weighted_transit_drops = 0; // Weighted steps that drop at a node other than the source + int balanced_transit_drops = 0; // the same for EqualBalanced + int infinite = 0; // +inf offers that injected something + int delivering = 0; // steps that delivered something + for (int trial = 0; trial < 1000; ++trial) { + const int n = 3 + static_cast(rng() % 6); + const auto edges = random_dag_edges( + rng, n, n + static_cast(rng() % 10), + [&] { return (rng() % 6) == 0 ? Amount {0} : 1 + static_cast(rng() % 6); }, + [&] { return 1 + static_cast(rng() % 3); }); + auto g = make_graph(n, edges); + std::vector dsts = {static_cast(n - 1)}; + if (rng() % 3 == 0) dsts.insert(dsts.begin(), static_cast(1 + rng() % static_cast(n - 2))); + const bool weighted = trial % 2 == 1; + const auto kernel = weighted ? FlowPlacement::Weighted : FlowPlacement::EqualBalanced; + const auto plan = plan_of(g, kernel, 0, dsts, all_ids(g), caps(g), EdgePriority::Capacity, Admission::Lossy); + std::vector usable; + for (EdgeId e : all_ids(g)) { + if (g.capacity_units()[static_cast(e)] >= 1) usable.push_back(e); + } + ASSERT_EQ(plan.members, walk_members(g, usable, 0, dsts)) << "trial " << trial; + std::vector> arcs; + std::vector weights, residual; + for (EdgeId e : plan.members) { + const auto k = static_cast(e); + arcs.emplace_back(g.edge_src()[k], g.edge_dst()[k]); + weights.push_back(g.capacity_units()[k]); + residual.push_back(static_cast(rng() % static_cast(g.capacity_units()[k] + 1))); + } + const std::optional offer = + rng() % 4 == 0 ? std::nullopt : std::optional(static_cast(rng() % 25)); + const auto s = lossy_step(plan, offer, residual); + const auto want = lossy_reference(n, arcs, weights, residual, weighted, offer, 0, dsts); + const std::string where = "trial " + std::to_string(trial); + EXPECT_EQ(s.carried, want.carried) << where; + EXPECT_EQ(s.drops, want.drops) << where; + EXPECT_EQ(s.injected, want.injected) << where; + EXPECT_EQ(s.delivered, want.delivered) << where; + EXPECT_EQ(s.offered, offer.value_or(want.injected)) << where; + bool transit_drop = false; + for (std::size_t i = 0; i < arcs.size(); ++i) transit_drop = transit_drop || (arcs[i].first != 0 && want.drops[i] > 0); + (weighted ? weighted_transit_drops : balanced_transit_drops) += transit_drop ? 1 : 0; + infinite += !offer.has_value() && want.injected > 0 ? 1 : 0; + delivering += want.delivered > 0 ? 1 : 0; + } + EXPECT_GT(weighted_transit_drops, 0); + EXPECT_GT(balanced_transit_drops, 0); + EXPECT_GT(infinite, 0); + EXPECT_GT(delivering, 0); +} diff --git a/tests/cpp/ksp_tests.cpp b/tests/cpp/ksp_tests.cpp index 9218f1b..678da32 100644 --- a/tests/cpp/ksp_tests.cpp +++ b/tests/cpp/ksp_tests.cpp @@ -1,9 +1,12 @@ -// KSP (APPROACH 6, rule 10) and resolve_to_paths (APPROACH 5): prototype 6's witnesses (the -// review-12 C2 and three-hop graphs, the review-13 priority-suffix trap, the prefix-capped spur, -// the parallel root under Expand and Collapse, the four-edge graph, the zero-capacity tie order, -// the static ceiling on chain-plus-bypasses), k = 1 against the forward and reverse single path, -// and agreement with exhaustive enumeration (search_reference.hpp) on random graphs; the -// resolver's order, splitting and truncation against an enumeration of every walk of a DAG. +// KSP and resolve_to_paths (docs/semantics.md#k-shortest-paths): the witness graphs (hand-built +// examples: the diamond and the three-hop graph, whose first path is the canonical single path +// of the forward and the reverse search; a shared bottleneck that leaves the fewest hops first; the prefix-capped spur; a +// candidate's bottleneck including its prefix; parallel members under Expand and Collapse; +// accepted paths that never return after a shared prefix; zero-capacity edges; the ceilings on +// chain-plus-bypasses and the exact Stretch ceiling at large costs and factors), the defects in the contract order, and agreement with exhaustive +// enumeration (search_reference.hpp) on random graphs; the resolver's order, splitting and +// truncation against an enumeration of every walk of a DAG, from either search direction and on +// long chains. #include #include @@ -14,7 +17,6 @@ #include #include #include -#include #include #include @@ -33,17 +35,8 @@ PathSelection sel(bool multi_edge = true, EdgeTieBreak tie = EdgeTieBreak::Deter return PathSelection(tie, prio, true, multi_edge); } -std::string error_of(const std::function& fn) { - try { - fn(); - } catch (const std::invalid_argument& e) { - return e.what(); - } - return ""; -} - -// Rows (u, v, capacity, cost) given in B' order (sorted by (src, cost, dst)), so ids are the -// input positions, as prototype 6's b() arranges. +// A graph from rows (u, v, capacity, cost), stable-sorted by (src, cost, dst) first: that is the +// EdgeId order, so each EdgeId is the row's position in the sorted list. Graph bgraph(std::int32_t n, std::vector rows) { std::stable_sort(rows.begin(), rows.end(), [](const InEdge& a, const InEdge& b) { return std::tie(a.u, a.cost, a.v) < std::tie(b.u, b.cost, b.v); @@ -87,20 +80,20 @@ std::vector costs_of(const std::vector>& r) { } // namespace -TEST(Ksp, Review12AndTheSinglePathFromBothDirections) { - auto c2 = bgraph(4, {{0, 1, 1, 9}, {0, 2, 1, 1}, {1, 3, 1, 1}, {2, 3, 1, 9}}); +TEST(Ksp, FirstPathIsTheCanonicalSinglePathOfBothDirections) { + auto diamond = bgraph(4, {{0, 1, 1, 9}, {0, 2, 1, 1}, {1, 3, 1, 1}, {2, 3, 1, 9}}); auto three = bgraph(6, {{0, 2, 1, 1}, {2, 3, 1, 1}, {3, 5, 1, 1}, {0, 1, 1, 1}, {1, 4, 1, 1}, {4, 5, 1, 1}}); struct Case { const Graph* g; NodeId t; std::vector want; }; - for (const auto& c : {Case {&c2, 3, {0, 2, 3}}, Case {&three, 5, {0, 1, 4, 5}}}) { + for (const auto& c : {Case {&diamond, 3, {0, 2, 3}}, Case {&three, 5, {0, 1, 4, 5}}}) { for (auto tie : {EdgeTieBreak::Deterministic, EdgeTieBreak::PreferHigherPriority}) { auto got = run(*c.g, 0, c.t, 2, sel(true, tie)); ASSERT_EQ(got.size(), 2U); EXPECT_EQ(nodes_of(*c.g, got[0].second), c.want); - // k = 1 is the canonical single path of the forward and the reverse search. + // The first path is the canonical single path of the forward and the reverse search. SpfOptions f(Direction::Forward, PathSelection(tie, EdgePriority::Residual, false)); f.targets = std::vector {c.t}; SpfOptions r(Direction::Reverse, PathSelection(tie, EdgePriority::Residual, false)); @@ -109,7 +102,6 @@ TEST(Ksp, Review12AndTheSinglePathFromBothDirections) { auto rv = spf(*c.g, std::vector {c.t}, r); auto w = walk_of(got[0].second); std::sort(w.begin(), w.end()); - EXPECT_EQ(std::vector(fw.dag.via_edges().begin(), fw.dag.via_edges().end()).size(), w.size()); auto fe = std::vector(fw.dag.via_edges().begin(), fw.dag.via_edges().end()); auto re = std::vector(rv.dag.via_edges().begin(), rv.dag.via_edges().end()); std::sort(fe.begin(), fe.end()); @@ -120,7 +112,7 @@ TEST(Ksp, Review12AndTheSinglePathFromBothDirections) { } } -TEST(Ksp, Review13PrioritySuffixTrap) { +TEST(Ksp, WithASharedBottleneckTheFewestHopsComeFirst) { auto g = bgraph(5, {{0, 1, 1, 1}, {1, 4, 1, 3}, {1, 2, 2, 1}, {2, 3, 2, 1}, {3, 4, 2, 1}}); auto got = run(g, 0, 4, 1, sel(true, EdgeTieBreak::PreferHigherPriority, EdgePriority::Capacity)); ASSERT_EQ(got.size(), 1U); @@ -143,7 +135,7 @@ TEST(Ksp, CandidateBottleneckIncludesThePrefix) { // 0-2-3 (bottleneck 5); at 1, the prefix 0-1 then the second 1-3 edge (the spur alone has // bottleneck 10, the candidate min(1, 10) = 1). PreferHigherPriority orders 0-2-3 first; a // candidate keyed by its spur's bottleneck alone would put the prefix's deviation first. - // Deterministic orders the two by B' ids: 0-1-3 (ids 0, 3) before 0-2-3 (ids 1, 4). + // Deterministic orders the two by EdgeIds: 0-1-3 (ids 0, 3) before 0-2-3 (ids 1, 4). auto g = bgraph(4, {{0, 1, 1, 1}, {1, 3, 10, 1}, {0, 2, 5, 1}, {2, 3, 5, 2}, {1, 3, 10, 2}}); auto prefer = run(g, 0, 3, 8, sel(true, EdgeTieBreak::PreferHigherPriority, EdgePriority::Capacity)); ASSERT_EQ(costs_of(prefer), (std::vector {2, 3, 3})); @@ -173,6 +165,77 @@ TEST(Ksp, ParallelMembersUnderExpandAndCollapse) { EXPECT_EQ(costs_of(run(fe, 0, 2, 8, sel(false))), (std::vector{3})); } +TEST(Ksp, AcceptedPathsNeverReturnAfterASharedPrefix) { + // A candidate goes into the pending set without a search of the accepted + // paths, because each accepted path with the spur's prefix has its next edge (Expand) or its + // next transition (Collapse) banned at the spur. Spurs here share their prefix with one or two + // accepted paths at once, over parallel members; every path comes once, in the derived order. + // EdgeIds: 0, 1: 0->1 (1); 2: 0->2 (3); 3: 1->2 (1); 4, 5: 1->3 (1); 6: 2->3 (1). + auto g = bgraph(4, {{0, 1, 1, 1}, {0, 1, 1, 1}, {0, 2, 1, 3}, {1, 2, 1, 1}, {1, 3, 1, 1}, {1, 3, 1, 1}, {2, 3, 1, 1}}); + auto walks = [](const std::vector>& r) { + std::vector> out; + for (const auto& [c, p] : r) out.push_back(walk_of(p)); + return out; + }; + // Expand, derived by hand from the deviations: [0, 4] first; its spurs give [1, 4] and [0, 5]. + // After [0, 5] the spur at node 1 bans 4 and 5 (both accepted paths share the prefix [0]) and + // finds [0, 3, 6]; the spur at node 0 bans 0 (twice) and finds [1, 4] again (pending: kept + // once). [1, 4] adds [2, 6] and [1, 5]; [1, 5] adds [1, 3, 6]; the rest add nothing new. + const auto expand = run(g, 0, 3, 16, sel()); + EXPECT_EQ(walks(expand), (std::vector> {{0, 4}, {0, 5}, {1, 4}, {1, 5}, {0, 3, 6}, {1, 3, 6}, {2, 6}})); + EXPECT_EQ(costs_of(expand), (std::vector {2, 2, 2, 2, 3, 3, 4})); + // Collapse: [0, 4] first; at node 1 the transition to 3 is banned, so 5 never follows [0]; at + // node 0 the transition to 1 is banned. Then [0, 3, 6] (node 1 bans the heads 3 and 2) and + // [2, 6]. + const auto collapse = run(g, 0, 3, 16, sel(false)); + EXPECT_EQ(walks(collapse), (std::vector> {{0, 4}, {0, 3, 6}, {2, 6}})); + EXPECT_EQ(costs_of(collapse), (std::vector {2, 3, 4})); + // Every k gives the prefix of the full answer. + const auto all_e = walks(expand); + const auto all_c = walks(collapse); + for (std::int64_t k = 1; k <= 8; ++k) { + EXPECT_EQ(walks(run(g, 0, 3, k, sel())), decltype(all_e)(all_e.begin(), all_e.begin() + std::min(k, 7))); + EXPECT_EQ(walks(run(g, 0, 3, k, sel(false))), decltype(all_c)(all_c.begin(), all_c.begin() + std::min(k, 3))); + } +} + +TEST(Ksp, EndpointDefectsReportTheFirstInOrder) { + // The scalar endpoints are checked as one-element sets (range, then the + // overlap), with every other argument also wrong where the order allows it; the first defect + // in the validation order is reported, for ksp and resolve_to_paths. + auto g = bgraph(3, {{0, 1, 1, 1}, {1, 2, 1, 1}}); + auto other = bgraph(3, {{0, 1, 1, 1}}); + auto foreign = other.eligibility({}, std::vector {0}); + const std::vector bad = {1, 2}; + const auto all_wrong = [&](NodeId s, NodeId t) { + return error_of([&] { (void)run(g, s, t, 0, sel(), Absolute {-1}, &bad, &foreign); }); + }; + EXPECT_EQ(all_wrong(5, -1), "src: node 5 out of node range [0, 3)"); + EXPECT_EQ(all_wrong(-1, -1), "src: node -1 out of node range [0, 3)"); + EXPECT_EQ(all_wrong(3, 3), "src: node 3 out of node range [0, 3)"); // range before the overlap + EXPECT_EQ(all_wrong(0, 3), "dst: node 3 out of node range [0, 3)"); + EXPECT_EQ(all_wrong(2, -4), "dst: node -4 out of node range [0, 3)"); + EXPECT_EQ(all_wrong(1, 1), "node 1 is in both endpoint sets: src and dst must not overlap"); + EXPECT_EQ(all_wrong(0, 2), "k must be >= 1 (got 0)"); + PathSelection selection = sel(); + selection.priority = static_cast(9); + EXPECT_EQ(error_of([&] { (void)ksp(g, 7, 7, KspOptions(0, selection)); }), + "selection.priority: 9 is not a value of EdgePriority"); + const auto dag = detail::PredDAGBuilder::of_edges(g, std::vector {0, 1}); + const auto dag_other = detail::PredDAGBuilder::of_edges(other, std::vector {0}); + EXPECT_EQ(error_of([&] { (void)resolve_to_paths(g, dag_other, 9, 9, false, 0); }), "foreign PredDAG: built for another graph"); + EXPECT_EQ(error_of([&] { (void)resolve_to_paths(g, dag, 9, -9, false, 0); }), "src: node 9 out of node range [0, 3)"); + EXPECT_EQ(error_of([&] { (void)resolve_to_paths(g, dag, 3, 3, false, 0); }), "src: node 3 out of node range [0, 3)"); + EXPECT_EQ(error_of([&] { (void)resolve_to_paths(g, dag, 0, 3, false, 0); }), "dst: node 3 out of node range [0, 3)"); + EXPECT_EQ(error_of([&] { (void)resolve_to_paths(g, dag, 2, 2, false, 0); }), + "node 2 is in both endpoint sets: src and dst must not overlap"); + EXPECT_EQ(error_of([&] { (void)resolve_to_paths(g, dag, 0, 2, false, 0); }), "max_paths must be >= 1 (got 0)"); + // Valid endpoints at both ends of the range. + EXPECT_EQ(run(g, 0, 2, 1, sel()).size(), 1U); + EXPECT_EQ(resolve_to_paths(g, dag, 0, 2).size(), 1U); + EXPECT_TRUE(run(g, 2, 0, 1, sel()).empty()); +} + TEST(Ksp, ZeroCapacityEdgesAreNeverMembers) { auto g = bgraph(4, {{0, 1, 1, 1}, {1, 3, 1, 1}, {0, 2, 1, 1}, {2, 3, 1, 1}, {0, 3, 0, 2}, {0, 3, 1, 2}}); std::vector zero; @@ -183,6 +246,9 @@ TEST(Ksp, ZeroCapacityEdgesAreNeverMembers) { auto with_zero = run(g, 0, 3, 8, sel()); auto el = g.eligibility({}, zero); auto excluded = run(g, 0, 3, 8, sel(), std::nullopt, nullptr, &el); + // The three walks of cost 2 that avoid the zero-capacity edge: 0-1-3, 0-2-3 and the direct edge + // of capacity 1. + ASSERT_EQ(costs_of(with_zero), (std::vector {2, 2, 2})); ASSERT_EQ(with_zero.size(), excluded.size()); for (std::size_t i = 0; i < with_zero.size(); ++i) { EXPECT_TRUE(with_zero[i].second == excluded[i].second); @@ -214,15 +280,35 @@ TEST(Ksp, CeilingsOnChainPlusBypasses) { EXPECT_TRUE(run(g, 0, 3, 8, sel(), Stretch {2.0}, nullptr, &el).empty()); } +// The Stretch ceiling is exact for costs and factors far past double precision: f = m * 2^x is +// compared as that dyadic rational, the bit lengths before any shift. Paths 0->1 of cost b and +// 0->2->1 of cost b + 1 (b + 2 on the second graph). b = 2^60: b + 1 rounds to b in a double, so +// Stretch(1) admitting both paths would be a double comparison; (1 + 2^-52) * 2^60 = 2^60 + 2^8 +// admits b + 1; m * 2^60 for m = 2^52 + 1 does not fit 64 bits; 2^68 * 2^60 leaves 128 bits, and +// so do the rest, which admit everything. b = 2^52: (1 + 2^-52) * b = b + 1 exactly, admitted, +// while b + 2 is not. +TEST(Ksp, StretchCeilingIsExactForLargeCostsAndFactors) { + const Cost big = Cost {1} << 60; + auto g = bgraph(3, {{0, 1, 1, big}, {0, 2, 1, 1}, {2, 1, 1, big}}); + const double just_above_one = std::nextafter(1.0, 2.0); // 1 + 2^-52 + EXPECT_EQ(costs_of(run(g, 0, 1, 8, sel(), Stretch {1.0})), (std::vector {big})); + for (const double f : {just_above_one, std::ldexp(1.0, 68), std::ldexp(1.0, 70), std::ldexp(1.0, 1000), 1e308, + std::numeric_limits::max()}) { + EXPECT_EQ(costs_of(run(g, 0, 1, 8, sel(), Stretch {f})), (std::vector {big, big + 1})) << f; + } + const Cost b52 = Cost {1} << 52; + auto h = bgraph(4, {{0, 1, 1, b52}, {0, 2, 1, 1}, {2, 1, 1, b52}, {0, 3, 1, 2}, {3, 1, 1, b52}}); + EXPECT_EQ(costs_of(run(h, 0, 1, 8, sel(), Stretch {1.0})), (std::vector {b52})); + EXPECT_EQ(costs_of(run(h, 0, 1, 8, sel(), Stretch {just_above_one})), (std::vector {b52, b52 + 1})); + EXPECT_EQ(costs_of(run(h, 0, 1, 8, sel(), Stretch {2.0})), (std::vector {b52, b52 + 1, b52 + 2})); +} + TEST(Ksp, ValidationInTheContractOrder) { auto g = bgraph(3, {{0, 1, 1, 1}, {1, 2, 1, 1}}); auto other = bgraph(3, {{0, 1, 1, 1}}); auto foreign = other.eligibility({}, std::vector {0}); const std::vector bad = {1, 2}; - EXPECT_EQ(error_of([&] { (void)run(g, 3, 1, 1, sel()); }), "src: node 3 out of node range [0, 3)"); - EXPECT_EQ(error_of([&] { (void)run(g, 0, -1, 1, sel()); }), "dst: node -1 out of node range [0, 3)"); - EXPECT_EQ(error_of([&] { (void)run(g, 1, 1, 1, sel()); }), "node 1 is in both endpoint sets: src and dst must not overlap"); - EXPECT_EQ(error_of([&] { (void)run(g, 0, 2, 0, sel()); }), "k must be >= 1 (got 0)"); + // The endpoints and k, alone and with every later argument wrong: the test above. EXPECT_EQ(error_of([&] { (void)run(g, 0, 2, 1, sel(), Absolute {-1}); }), "max_path_cost Absolute cost must be >= 0 (got -1)"); EXPECT_EQ(error_of([&] { (void)run(g, 0, 2, 1, sel(), Stretch {0.5}); }), "max_path_cost Stretch factor must be finite and in [1, inf) (got 0.500000)"); @@ -231,18 +317,33 @@ TEST(Ksp, ValidationInTheContractOrder) { EXPECT_EQ(error_of([&] { (void)run(g, 0, 2, 1, sel(), std::nullopt, &bad); }), "residual[1] = 2 is outside [0, capacity_units[1] = 1]"); EXPECT_EQ(error_of([&] { (void)run(g, 0, 2, 1, sel(), std::nullopt, nullptr, &foreign); }), "foreign Eligibility: built for another graph"); - // Order: the endpoints before k before the ceiling before the residual. - EXPECT_EQ(error_of([&] { (void)run(g, 2, 2, 0, sel(), Absolute {-1}, &bad); }), - "node 2 is in both endpoint sets: src and dst must not overlap"); - EXPECT_EQ(error_of([&] { (void)run(g, 0, 2, 0, sel(), Absolute {-1}, &bad); }), "k must be >= 1 (got 0)"); + // Order: the ceiling before the residual (the endpoints before k before the ceiling: the test + // above). EXPECT_EQ(error_of([&] { (void)run(g, 0, 2, 1, sel(), Absolute {-1}, &bad); }), "max_path_cost Absolute cost must be >= 0 (got -1)"); KspOptions o(1, sel()); o.selection.tie_break = static_cast(4); EXPECT_EQ(error_of([&] { (void)ksp(g, 0, 2, o); }), "selection.tie_break: 4 is not a value of EdgeTieBreak"); - // An excluded endpoint gives [], never an error. - const std::vector x0 = {0}; - auto el = g.eligibility(x0, {}); - EXPECT_TRUE(run(g, 0, 2, 3, sel(), std::nullopt, nullptr, &el).empty()); + // An excluded endpoint, the source or the destination, gives [], never an error. + for (const NodeId x : {0, 2}) { + const std::vector xn = {x}; + auto el = g.eligibility(xn, {}); + EXPECT_TRUE(run(g, 0, 2, 3, sel(), std::nullopt, nullptr, &el).empty()) << x; + EXPECT_TRUE(run(g, 0, 2, 3, sel(), Stretch {2.0}, nullptr, &el).empty()) << x; + } +} + +// A residual array with several defects at once (one error, from the shared edge-amount check): its +// length before any entry, then its first entry outside [0, capacity] in EdgeId order, before +// the Eligibility. +TEST(Ksp, ResidualDefectsReportTheFirstInOrder) { + const auto g = edge_amount_graph(); + const auto other = make_graph(4, {{0, 1, 2, 1}}); + const auto foreign = other.eligibility({}, std::vector {0}); + for (const auto& c : edge_amount_defects()) { + EXPECT_EQ(error_of([&] { (void)run(g, 0, 3, 2, sel(), std::nullopt, &c.amounts, &foreign); }), "residual" + c.error); + } + const auto bounds = edge_amounts_at_the_bounds(); + EXPECT_EQ(error_of([&] { (void)run(g, 0, 3, 2, sel(), std::nullopt, &bounds); }), ""); } namespace { @@ -277,8 +378,8 @@ std::vector> reference_ksp(const Graph& g, const ref::Query& const Cost base = d[static_cast(t)]; if (base == ref::kNone) return {}; // f in {1, 1.25, 1.5, 2}: c <= f * base exactly in quarters. - const auto quarters = static_cast<__int128>(std::get(*ceiling).factor * 4); - if (static_cast<__int128>(c) * 4 > quarters * base) break; + const auto quarters = static_cast(std::get(*ceiling).factor * 4); + if (static_cast(c) * 4 > static_cast(quarters) * base) break; } } out.push_back(p); @@ -289,10 +390,10 @@ std::vector> reference_ksp(const Graph& g, const ref::Query& } // namespace TEST(Ksp, RandomGraphsAgreeWithExhaustiveEnumeration) { - // The prototype-6 corpus shape (seed 6006): parallel members, residuals, masks, both tie modes, - // every priority, Expand and Collapse, k in {1, 3, 8}, Absolute and Stretch ceilings. + // Random graphs (seed 6006) checked against exhaustive enumeration: parallel members, residuals, + // masks, both tie modes, every priority, Expand and Collapse, k in {1, 3, 8}, Absolute and + // Stretch ceilings; each query again with multipath = false, which ksp ignores. std::mt19937 rng(6006); - int checked = 0; int nonempty = 0; int deep = 0; for (int gi = 0; gi < 300; ++gi) { @@ -353,12 +454,20 @@ TEST(Ksp, RandomGraphsAgreeWithExhaustiveEnumeration) { EXPECT_EQ(cost, p.cost()); } ASSERT_EQ(walks, want) << "graph " << gi << " case " << c; - ++checked; + // multipath is ignored: the same selection with multipath = false lists the same paths. + PathSelection single = s1; + single.multipath = false; + const auto same = run(g, 0, static_cast(n - 1), k, single, ceiling, use_res ? &residual : nullptr, + masks ? &el : nullptr); + ASSERT_EQ(same.size(), got.size()) << "graph " << gi << " case " << c; + for (std::size_t i = 0; i < same.size(); ++i) { + EXPECT_EQ(same[i].first, got[i].first) << "graph " << gi << " case " << c; + EXPECT_TRUE(same[i].second == got[i].second) << "graph " << gi << " case " << c; + } if (!want.empty()) ++nonempty; if (want.size() >= 3) ++deep; } } - EXPECT_EQ(checked, 7200); EXPECT_GT(nonempty, 3000); EXPECT_GT(deep, 300); } @@ -422,7 +531,7 @@ std::vector>> reference_walks(const Graph& g, co TEST(ResolveToPaths, OrderSplitAndTruncation) { // A DAG with a parallel pair, two routes of equal cost and a dearer one: - // ids (B' order): 0: 0->1 (1), 1: 0->1 (1), 2: 0->2 (1), 3: 0->3 (5), 4: 1->3 (1), 5: 2->3 (1). + // EdgeIds: 0: 0->1 (1), 1: 0->1 (1), 2: 0->2 (1), 3: 0->3 (5), 4: 1->3 (1), 5: 2->3 (1). auto g = bgraph(4, {{0, 1, 1, 1}, {0, 1, 1, 1}, {0, 2, 1, 1}, {0, 3, 1, 5}, {1, 3, 1, 1}, {2, 3, 1, 1}}); const auto dag = detail::PredDAGBuilder::of_edges(g, std::vector {0, 1, 2, 3, 4, 5}); auto hops_of = [](const std::vector& ps) { @@ -435,7 +544,7 @@ TEST(ResolveToPaths, OrderSplitAndTruncation) { EXPECT_EQ(hops_of(grouped), (std::vector>> {{{0, 1}, {4}}, {{2}, {5}}, {{3}}})); EXPECT_EQ(grouped[0].cost(), 2); EXPECT_EQ(grouped[2].cost(), 5); - // Split: one path per member combination, in the same order. + // Split: one path per member combination, sorted by its own (cost, hops, edge-id sequence). auto split = resolve_to_paths(g, dag, 0, 3, true); EXPECT_EQ(hops_of(split), (std::vector>> {{{0}, {4}}, {{1}, {4}}, {{2}, {5}}, {{3}}})); EXPECT_EQ(resolve_to_paths(g, dag, 0, 3, true, 2).size(), 2U); @@ -443,20 +552,28 @@ TEST(ResolveToPaths, OrderSplitAndTruncation) { // An endpoint the DAG does not join: []. EXPECT_TRUE(resolve_to_paths(g, dag, 3, 0).empty()); EXPECT_TRUE(resolve_to_paths(g, detail::PredDAGBuilder::of_edges(g, std::vector {4}), 0, 3).empty()); - // Validation. - auto other = bgraph(4, {{0, 1, 1, 1}}); - EXPECT_EQ(error_of([&] { (void)resolve_to_paths(other, dag, 0, 3); }), "foreign PredDAG: built for another graph"); - EXPECT_EQ(error_of([&] { (void)resolve_to_paths(g, dag, 4, 3); }), "src: node 4 out of node range [0, 4)"); - EXPECT_EQ(error_of([&] { (void)resolve_to_paths(g, dag, 0, -2); }), "dst: node -2 out of node range [0, 4)"); - EXPECT_EQ(error_of([&] { (void)resolve_to_paths(g, dag, 2, 2); }), "node 2 is in both endpoint sets: src and dst must not overlap"); - EXPECT_EQ(error_of([&] { (void)resolve_to_paths(g, dag, 0, 3, false, 0); }), "max_paths must be >= 1 (got 0)"); + // Unequal parallel costs: expanding the first grouped walk in place would put cost 4 before + // the direct cost-3 path, and truncating there would discard the wrong path. + const auto mixed = bgraph(3, {{0, 1, 1, 1}, {0, 1, 1, 3}, {1, 2, 1, 1}, {0, 2, 1, 3}}); + const auto all = detail::PredDAGBuilder::of_edges(mixed, all_ids(mixed)); + const auto grouped_mixed = resolve_to_paths(mixed, all, 0, 2); + EXPECT_EQ(hops_of(grouped_mixed), (std::vector>> {{{0, 1}, {3}}, {{2}}})); + const auto split_mixed = resolve_to_paths(mixed, all, 0, 2, true); + EXPECT_EQ(hops_of(split_mixed), (std::vector>> {{{0}, {3}}, {{2}}, {{1}, {3}}})); + ASSERT_EQ(split_mixed.size(), 3U); + EXPECT_EQ((std::vector {split_mixed[0].cost(), split_mixed[1].cost(), split_mixed[2].cost()}), + (std::vector {2, 3, 4})); + EXPECT_EQ(hops_of(resolve_to_paths(mixed, all, 0, 2, true, 2)), + (std::vector>> {{{0}, {3}}, {{2}}})); + // Validation: Ksp.EndpointDefectsReportTheFirstInOrder. } TEST(ResolveToPaths, RandomDagsAgreeWithEnumeration) { // Random DAGs (edges u -> v with u < v, parallel members, mixed costs, not necessarily tight): // the order, both forms and the truncation against the enumeration of every walk. std::mt19937 rng(55); - int checked = 0; + int nonempty = 0; // checks with at least one walk + int truncated = 0; // checks whose max_paths cut the walks short for (int trial = 0; trial < 600; ++trial) { const int n = 2 + static_cast(rng() % 7); std::vector rows; @@ -482,11 +599,15 @@ TEST(ResolveToPaths, RandomDagsAgreeWithEnumeration) { const std::size_t expect_n = mp.has_value() ? std::min(want.size(), static_cast(*mp)) : want.size(); ASSERT_EQ(got.size(), expect_n) << trial; for (std::size_t i = 0; i < got.size(); ++i) EXPECT_EQ(got[i].hops(), want[i]) << trial << " path " << i; - ++checked; + nonempty += want.empty() ? 0 : 1; + truncated += expect_n < want.size() ? 1 : 0; } } } - EXPECT_EQ(checked, 3600); + RecordProperty("nonempty", nonempty); + RecordProperty("truncated", truncated); + EXPECT_GT(nonempty, 1800); // not vacuous: most DAGs hold a walk + EXPECT_GT(truncated, 400); } TEST(ResolveToPaths, ResolvesASearchDagFromEitherDirection) { @@ -512,9 +633,7 @@ TEST(ResolveToPaths, LongChainsNeedNoRecursion) { for (int v = 0; v + 1 < n; ++v) rows.push_back({v, v + 1, 1, 1}); rows.push_back({n / 2, n / 2 + 1, 1, 1}); auto g = make_graph(n, rows); - std::vector all(static_cast(g.num_edges())); - for (std::size_t e = 0; e < all.size(); ++e) all[e] = static_cast(e); - const auto dag = detail::PredDAGBuilder::of_edges(g, all); + const auto dag = detail::PredDAGBuilder::of_edges(g, all_ids(g)); EXPECT_EQ(resolve_to_paths(g, dag, 0, n - 1, true).size(), 2U); EXPECT_EQ(resolve_to_paths(g, dag, 0, n - 1).size(), 1U); } diff --git a/tests/cpp/layout_checker.hpp b/tests/cpp/layout_checker.hpp index 6a56e55..8c3b9da 100644 --- a/tests/cpp/layout_checker.hpp +++ b/tests/cpp/layout_checker.hpp @@ -1,10 +1,11 @@ -// An independent checker of the graph's stored layout (APPROACH 3, layout B'; task 1e). +// An independent checker of the graph's stored layout (the EdgeId order and the CSR arrays that +// graph.hpp describes). // // It reads the arrays as spans and the caller's input separately, and derives every expectation -// from the contract's text, never from the constructor's helpers: +// from the layout's definition, never from the constructor's helpers: // 1. sizes first, before any element is read: N >= 0, E >= 0, the input arrays of length E, // every per-edge array and the input map of length E, both offset arrays of length N + 1, -// the reverse slots of length E, the internal reverse-source copy of length E when present; +// the reverse slots of length E, the internal reverse-source copy of length E; // 2. then ranges, before any value is used as an index: offsets in [0, E], [0] = 0, [N] = E, // non-decreasing; endpoints in [0, N); the map and the slots in [0, E) and permutations; // the copied sources in [0, N); @@ -18,7 +19,6 @@ #include #include -#include #include #include #include @@ -49,7 +49,7 @@ struct LayoutSpans { std::span row_offsets {}; std::span in_row_offsets {}; std::span in_adj_edge_index {}; - std::optional> in_col_indices {}; // the internal copy, when stored + std::span in_col_indices {}; // the internal copy of the in-edges' sources Cost max_cost {0}; Cost cost_gcd {0}; }; @@ -108,7 +108,7 @@ inline std::vector check_layout(const LayoutSpans& g, const LayoutI size_is(g.row_offsets.size(), np1, "row_offsets"); size_is(g.in_row_offsets.size(), np1, "in_row_offsets"); size_is(g.in_adj_edge_index.size(), me, "in_adj_edge_index"); - if (g.in_col_indices.has_value()) size_is(g.in_col_indices->size(), me, "in_col_indices"); + size_is(g.in_col_indices.size(), me, "in_col_indices"); if (!bad.empty()) return bad; // 2. Ranges and permutations (values are read, none is used as an index yet). @@ -128,12 +128,10 @@ inline std::vector check_layout(const LayoutSpans& g, const LayoutI } if (!permutation_of_range(g.input_index, m)) bad.emplace_back("map:permutation"); if (!permutation_of_range(g.in_adj_edge_index, m)) bad.emplace_back("slot:permutation"); - if (g.in_col_indices.has_value()) { - for (const NodeId x : *g.in_col_indices) { - if (x < 0 || x >= n) { - bad.emplace_back("range:in_col_indices"); - break; - } + for (const NodeId x : g.in_col_indices) { + if (x < 0 || x >= n) { + bad.emplace_back("range:in_col_indices"); + break; } } if (!bad.empty()) return bad; @@ -198,12 +196,10 @@ inline std::vector check_layout(const LayoutSpans& g, const LayoutI } if (!rev_member) bad.emplace_back("reverse:membership"); if (!rev_order) bad.emplace_back("reverse:order"); - if (g.in_col_indices.has_value()) { - for (std::size_t s = 0; s < me; ++s) { - if ((*g.in_col_indices)[s] != g.src[static_cast(g.in_adj_edge_index[s])]) { - bad.emplace_back("copy:source"); - break; - } + for (std::size_t s = 0; s < me; ++s) { + if (g.in_col_indices[s] != g.src[static_cast(g.in_adj_edge_index[s])]) { + bad.emplace_back("copy:source"); + break; } } // The scans, from the input (0 on an empty graph). diff --git a/tests/cpp/pinned_one.hpp b/tests/cpp/pinned_one.hpp index 8d72964..523be93 100644 --- a/tests/cpp/pinned_one.hpp +++ b/tests/cpp/pinned_one.hpp @@ -1,6 +1,6 @@ -// A manual placement as the contract defines it (APPROACH 8): a FlowPolicy with Pinned routing and -// one bundle. The ledger and kernel tests drive the ledger through it and compare the real -// PlacementResult. +// A manual placement: a FlowPolicy with Pinned routing and one bundle. The ledger tests +// (flow_ledger_tests.cpp, flow_ledger_fault_tests.cpp) drive the ledger through it and compare the +// real PlacementResult. #pragma once #include @@ -35,7 +35,6 @@ class PinnedOne { if (pe.cohorts.empty() || pe.cohorts.front().empty()) return std::nullopt; return FlowId {pe.fg.uid(), pe.cohorts.front().front().id}; } - [[nodiscard]] Amount placed_demand() const { return policy_->placed_demand(); } [[nodiscard]] int usable_bundles() const { return policy_->usable_bundles(); } [[nodiscard]] const detail::KernelPlan* plan() const { const auto& pe = engine(); @@ -44,6 +43,8 @@ class PinnedOne { } [[nodiscard]] const Counter128& requested() const { return engine().requested; } [[nodiscard]] bool infinite() const { return engine().infinite; } + // The wrapped policy (for the shared comparators of state_comparators.hpp, such as epoch()). + [[nodiscard]] FlowPolicy& policy() { return *policy_; } private: [[nodiscard]] const detail::PolicyEngine& engine() const { return detail::PolicyAccess::engine(*policy_); } diff --git a/tests/cpp/pred_dag_tests.cpp b/tests/cpp/pred_dag_tests.cpp index bef2484..c8165a2 100644 --- a/tests/cpp/pred_dag_tests.cpp +++ b/tests/cpp/pred_dag_tests.cpp @@ -1,13 +1,10 @@ -// PredDAG and Path (APPROACH 5): from_csr's complete validation in the documented order, the +// PredDAG and Path: from_csr's complete validation in the documented order, the // canonical predecessor order, structural equality, Path validation in its order, cost and // to_dag(). #include #include -#include #include -#include -#include #include #include "netgraph/core/detail/pred_dag_builder.hpp" @@ -19,30 +16,13 @@ using namespace netgraph::core::test; namespace { -std::string error_of(const std::function& fn) { - try { - fn(); - } catch (const std::invalid_argument& e) { - return e.what(); - } - return ""; -} - -bool contains(const std::string& s, const std::string& part) { return s.find(part) != std::string::npos; } - -// 0 -> 1 (two parallel members), 1 -> 2, 0 -> 2; ids follow (src, cost, dst, input). +// 0 -> 1 (two parallel members), 1 -> 2, 0 -> 2 and 2 -> 0; ids follow (src, cost, dst, input). Graph diamondish() { return make_graph(3, {{0, 1, 5, 1}, {0, 1, 5, 1}, {1, 2, 5, 1}, {0, 2, 5, 3}, {2, 0, 5, 1}}); } using V = std::vector; -// The DAG's edges in ascending EdgeId (the builder's view of the storage). -std::vector ascending(const PredDAG& dag) { - const auto e = detail::PredDAGBuilder::ascending(dag); - return {e.begin(), e.end()}; -} - } // namespace TEST(PredDAG, FromCsrRoundTripAndAccessors) { @@ -56,32 +36,37 @@ TEST(PredDAG, FromCsrRoundTripAndAccessors) { EXPECT_EQ(dag.parent_offsets(), off); EXPECT_EQ(V(dag.parents().begin(), dag.parents().end()), par); EXPECT_EQ(V(dag.via_edges().begin(), dag.via_edges().end()), via); - EXPECT_EQ(ascending(dag), (std::vector{0, 1, 2, 3})); + EXPECT_EQ(ascending_edges(dag), (std::vector{0, 1, 2, 3})); // Structural equality: the builder's value from the same edge set is equal. const std::vector edges = {3, 1, 0, 2}; EXPECT_TRUE(detail::PredDAGBuilder::of_edges(g, edges) == dag); auto empty = PredDAG::from_csr(g, V {0, 0, 0, 0}, V {}, V {}); EXPECT_FALSE(empty == dag); - EXPECT_TRUE(ascending(empty).empty()); + EXPECT_TRUE(ascending_edges(empty).empty()); + // Equality includes the graph: the same CSR on a twin graph (the same arrays, another uid) is + // another value, from from_csr and from the builder alike. + const auto twin = diamondish(); + ASSERT_NE(twin.uid(), g.uid()); + const auto there = PredDAG::from_csr(twin, off, par, via); + EXPECT_EQ(there.parent_offsets(), off); + EXPECT_EQ(V(there.via_edges().begin(), there.via_edges().end()), via); + EXPECT_FALSE(there == dag); + EXPECT_FALSE(detail::PredDAGBuilder::of_edges(twin, edges) == dag); + EXPECT_TRUE(detail::PredDAGBuilder::of_edges(twin, edges) == there); } // The builder's entries for any edge subset in any input order: children ascending, via -// ascending within a child, the parent each via's tail, and the same value as from_csr. +// ascending within a child, the parent each via's tail, and the same value as from_csr. The +// builder groups a subset of fewer than N / 8 edges by sorting (child, via) pairs and a larger one +// by a counting sort over the nodes: small graphs take mostly the counting sort, and graphs of 512 +// to 767 nodes with four to seven children of five to nine in-edges each (at most 63 edges, often +// more than a sort keeps in insertion order) take only the pair sort. Each population is guarded by +// its DAGs with a child of several entries, whose order is what differs between the two. TEST(PredDAG, BuilderGroupsAnyEdgeSubsetCanonically) { std::mt19937 rng(5); - for (int trial = 0; trial < 200; ++trial) { - const int n = 2 + static_cast(rng() % 9); - std::vector es; - for (int k = 0, m = 1 + static_cast(rng() % 30); k < m; ++k) { // u < v: every subset is a DAG - const auto u = static_cast(rng() % static_cast(n - 1)); - const auto v = static_cast(u + 1 + static_cast(rng() % static_cast(n - 1 - u))); - es.push_back({u, v, 1, static_cast(1 + rng() % 3)}); - } - const auto g = make_graph(n, es); - std::vector subset; - for (EdgeId e = 0; e < g.num_edges(); ++e) { - if (rng() % 2 == 0) subset.push_back(e); - } + // Checks the builder's value for one subset; true when a child has several entries. + const auto check = [&](const Graph& g, std::vector subset, int trial) { + const int n = g.num_nodes(); std::shuffle(subset.begin(), subset.end(), rng); const auto dag = detail::PredDAGBuilder::of_edges(g, subset); V off(static_cast(n) + 1, 0); @@ -101,8 +86,48 @@ TEST(PredDAG, BuilderGroupsAnyEdgeSubsetCanonically) { EXPECT_EQ(V(dag.via_edges().begin(), dag.via_edges().end()), via) << trial; EXPECT_TRUE(PredDAG::from_csr(g, off, par, via) == dag) << trial; std::sort(subset.begin(), subset.end()); - EXPECT_EQ(ascending(dag), subset) << trial; + EXPECT_EQ(ascending_edges(dag), subset) << trial; + const auto o = dag.parent_offsets(); + bool several = false; + for (std::size_t i = 1; i < o.size(); ++i) several = several || o[i] - o[i - 1] >= 2; + return several; + }; + int grouped = 0; // DAGs with a child of several entries (their order is checked) + for (int trial = 0; trial < 200; ++trial) { + const int n = 2 + static_cast(rng() % 9); + std::vector es; + for (int k = 0, m = 1 + static_cast(rng() % 30); k < m; ++k) { // u < v: every subset is a DAG + const auto u = static_cast(rng() % static_cast(n - 1)); + const auto v = static_cast(u + 1 + static_cast(rng() % static_cast(n - 1 - u))); + es.push_back({u, v, 1, static_cast(1 + rng() % 3)}); + } + const auto g = make_graph(n, es); + std::vector subset; + for (EdgeId e = 0; e < g.num_edges(); ++e) { + if (rng() % 2 == 0) subset.push_back(e); + } + grouped += check(g, subset, trial) ? 1 : 0; + } + EXPECT_GT(grouped, 50); + int sparse_grouped = 0; // pair-sorted DAGs with a child of several entries + for (int trial = 0; trial < 200; ++trial) { + const int n = 512 + static_cast(rng() % 256); + std::vector es; + for (int c = 0, children = 4 + static_cast(rng() % 4); c < children; ++c) { + const auto v = static_cast(1 + rng() % static_cast(n - 1)); + for (int k = 0, d = 5 + static_cast(rng() % 5); k < d; ++k) { // u < v; parallel members possible + es.push_back({static_cast(rng() % static_cast(v)), v, 1, static_cast(1 + rng() % 3)}); + } + } + const auto g = make_graph(n, es); + std::vector subset; + for (EdgeId e = 0; e < g.num_edges(); ++e) { + if (rng() % 8 != 0) subset.push_back(e); + } + ASSERT_LT(subset.size() * 8, static_cast(n)); // the pair sort + sparse_grouped += check(g, subset, 1000 + trial) ? 1 : 0; } + EXPECT_GT(sparse_grouped, 100); } TEST(PredDAG, FromCsrShapeChecksInOrder) { @@ -122,27 +147,54 @@ TEST(PredDAG, FromCsrShapeChecksInOrder) { // Shape before entries: a bad parent and a shape defect report the shape. EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, V {0, 0, 2, 3}, V {9, 0}, V {0, 1}); }), "offsets[N] = 3")); + // Every shape defect at once, then each reported one removed: each step is reported before + // all the later ones (and before the bad entry 9, 9). + EXPECT_EQ(error_of([&] { (void)PredDAG::from_csr(g, V {1, 0, 0}, V {9}, V {9}); }), + "PredDAG.from_csr: offsets has length 3, expected N + 1 = 4"); + EXPECT_EQ(error_of([&] { (void)PredDAG::from_csr(g, V {1, 0, 2, 5}, V {9}, V {9}); }), + "PredDAG.from_csr: offsets[0] = 1, expected 0"); + EXPECT_EQ(error_of([&] { (void)PredDAG::from_csr(g, V {0, 2, 1, 5}, V {9}, V {9}); }), + "PredDAG.from_csr: offsets are not monotone at 2 (2 > 1)"); + EXPECT_EQ(error_of([&] { (void)PredDAG::from_csr(g, V {0, 0, 2, 5}, V {9}, V {9}); }), + "PredDAG.from_csr: offsets[N] = 5 but parents has 1 and via has 1 entries"); } TEST(PredDAG, FromCsrEntryChecksInOrder) { auto g = diamondish(); const V off = {0, 0, 2, 2}; // parents in range, then via in range (entry by entry) - EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, off, V {3, 0}, V {0, 1}); }), "parents[0] = 3")); + EXPECT_EQ(error_of([&] { (void)PredDAG::from_csr(g, off, V {3, 0}, V {0, 1}); }), + "PredDAG.from_csr: parents[0] = 3 out of node range [0, 3)"); EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, off, V {0, 0}, V {0, 5}); }), "via[1] = 5")); - EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, off, V {-1, 0}, V {-1, 1}); }), "parents[0] = -1")); + EXPECT_EQ(error_of([&] { (void)PredDAG::from_csr(g, off, V {-1, 0}, V {-1, 1}); }), + "PredDAG.from_csr: parents[0] = -1 out of node range [0, 3)"); // via endpoints: wrong parent, wrong child EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, off, V {1, 0}, V {0, 1}); }), "joins (0, 1), not (1, 1)")); EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, V {0, 0, 0, 1}, V {0}, V {0}); }), "joins (0, 1), not (0, 2)")); // duplicate (child, via) before the order check EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, off, V {0, 0}, V {1, 1}); }), "duplicate entry")); - // a cycle (0 -> 1 -> 2 -> 0) before the order check - EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, V {0, 1, 2, 3}, V {2, 0, 1}, V {4, 0, 3}); }), + // a cycle (0 -> 1 -> 2 -> 0) before the order check: node 1's two entries descend (via 1, then 0) + EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, V {0, 1, 3, 4}, V {2, 0, 0, 1}, V {4, 1, 0, 3}); }), "cycle")); // canonical order: ascending via within a node EXPECT_TRUE(contains(error_of([&] { (void)PredDAG::from_csr(g, off, V {0, 0}, V {1, 0}); }), "not in ascending via EdgeId")); + // Every entry defect at once, then each reported one removed. Node 0 has (2, via 4), node 1 + // (0, via 1), (0, via 0), (0, via 0): a cycle 0 -> 1 -> 2 -> 0, a duplicate and a descending + // pair; node 2 has a wrong endpoint (entry 4) before an out-of-range via (entry 5), so the + // ranges of every entry come before any endpoint. + const auto first = [&](V o, V p, V w) { return error_of([&] { (void)PredDAG::from_csr(g, o, p, w); }); }; + EXPECT_EQ(first(V {0, 1, 4, 6}, V {2, 0, 0, 0, 0, 1}, V {4, 1, 0, 0, 0, 9}), + "PredDAG.from_csr: via[5] = 9 out of edge range [0, 5)"); + EXPECT_EQ(first(V {0, 1, 4, 6}, V {2, 0, 0, 0, 0, 1}, V {4, 1, 0, 0, 0, 3}), + "PredDAG.from_csr: entry 4 of node 2: via edge 0 joins (0, 1), not (0, 2)"); + EXPECT_EQ(first(V {0, 1, 4, 6}, V {2, 0, 0, 0, 0, 1}, V {4, 1, 0, 0, 2, 3}), + "PredDAG.from_csr: duplicate entry (child 1, via 0)"); + EXPECT_EQ(first(V {0, 1, 3, 5}, V {2, 0, 0, 0, 1}, V {4, 1, 0, 2, 3}), "PredDAG.from_csr: the entries contain a cycle"); + EXPECT_EQ(first(V {0, 0, 2, 4}, V {0, 0, 0, 1}, V {1, 0, 2, 3}), + "PredDAG.from_csr: the entries of node 1 are not in ascending via EdgeId (1 then 0)"); + EXPECT_EQ(first(V {0, 0, 2, 4}, V {0, 0, 0, 1}, V {0, 1, 2, 3}), ""); } TEST(PredDAG, SelfLoopIsACycle) { @@ -155,7 +207,8 @@ TEST(PredDAG, EmptyGraph) { auto g = make_graph(0, {}); auto dag = PredDAG::from_csr(g, V {0}, V {}, V {}); EXPECT_EQ(dag.parent_offsets(), V {0}); - EXPECT_FALSE(error_of([&] { (void)PredDAG::from_csr(g, V {}, V {}, V {}); }).empty()); + EXPECT_EQ(error_of([&] { (void)PredDAG::from_csr(g, V {}, V {}, V {}); }), + "PredDAG.from_csr: offsets has length 0, expected N + 1 = 1"); } TEST(Path, CostSrcDstAndToDag) { @@ -178,6 +231,11 @@ TEST(Path, CostSrcDstAndToDag) { EXPECT_EQ(Path(g2, {{0, 1}}).cost(), 2); EXPECT_TRUE(Path(g2, {{0, 1}}) == Path(g2, {{0, 1}})); EXPECT_FALSE(Path(g2, {{0, 1}}) == Path(g2, {{1, 0}})); + // Equality includes the graph: the same hops on a twin graph (the same arrays, another uid). + auto twin = make_graph(2, {{0, 1, 1, 4}, {0, 1, 1, 2}}); + ASSERT_NE(twin.uid(), g2.uid()); + EXPECT_EQ(Path(twin, {{0, 1}}).hops(), Path(g2, {{0, 1}}).hops()); + EXPECT_FALSE(Path(twin, {{0, 1}}) == Path(g2, {{0, 1}})); } TEST(Path, ValidationInOrder) { @@ -187,7 +245,7 @@ TEST(Path, ValidationInOrder) { EXPECT_TRUE(contains(error_of([&] { Path(g, {{-1}}); }), "member -1 out of edge range")); // duplicate member before an empty hop EXPECT_TRUE(contains(error_of([&] { Path(g, {{0, 0}, {}}); }), "duplicate member 0")); - EXPECT_TRUE(contains(error_of([&] { Path(g, {}); }), "hop list is empty")); + EXPECT_EQ(error_of([&] { Path(g, {}); }), "Path: the hop list is empty"); EXPECT_TRUE(contains(error_of([&] { Path(g, {{0}, {}}); }), "hop 1 is empty")); // members of a hop share (u, v) EXPECT_TRUE(contains(error_of([&] { Path(g, {{0, 2}}); }), "do not share (u, v)")); @@ -195,4 +253,15 @@ TEST(Path, ValidationInOrder) { EXPECT_TRUE(contains(error_of([&] { Path(g, {{0}, {2}}); }), "not contiguous")); // no repeated node: 0 -> 2 -> 0 EXPECT_TRUE(contains(error_of([&] { Path(g, {{2}, {4}}); }), "node 0 is repeated")); + // Several defects at once, then each reported one removed (an empty hop list has no member, so + // it is never reported with another defect). The later defect sits in an earlier hop where it + // can: the order is by step, not by position. + EXPECT_EQ(error_of([&] { Path(g, {{0, 0, 2}, {}, {7}}); }), "Path: hop 2 member 7 out of edge range [0, 5)"); + EXPECT_EQ(error_of([&] { Path(g, {{0, 0, 2}, {}, {4}}); }), "Path: duplicate member 0"); + EXPECT_EQ(error_of([&] { Path(g, {{0, 2}, {}, {4}}); }), "Path: hop 1 is empty"); + // hop 2 mixes 0 -> 2 and 0 -> 1; hops 0 and 1 do not meet (1 then 2); node 0 repeats + EXPECT_EQ(error_of([&] { Path(g, {{1}, {4}, {2, 0}}); }), + "Path: the members of hop 2 do not share (u, v): edge 0 joins (0, 1), edge 2 joins (0, 2)"); + EXPECT_EQ(error_of([&] { Path(g, {{1}, {4}, {2}}); }), "Path: hops 0 and 1 are not contiguous (1 then 2)"); + EXPECT_EQ(error_of([&] { Path(g, {{2}, {4}, {0}}); }), "Path: node 0 is repeated"); } diff --git a/tests/cpp/search_fault_tests.cpp b/tests/cpp/search_fault_tests.cpp index 177fad2..736db96 100644 --- a/tests/cpp/search_fault_tests.cpp +++ b/tests/cpp/search_fault_tests.cpp @@ -1,9 +1,10 @@ -// Allocation-failure atomicity of the search (APPROACH 13): every allocation an spf call makes +// Allocation-failure atomicity of the search: every allocation an spf call makes // (the workspace's acquisition and growth, the tight-edge pool, both queues, the pruning and the // extraction scratch, the emitted DAG and the result) fails once; the call raises std::bad_alloc, // and a clean retry and a different query afterwards give their references, so the thread's -// workspace is restored on every exit (no touched node, mark, list or queue entry survives). -// Runs in the fault-injection executable (fault_injection.hpp). +// workspace is restored on every exit (no touched node, mark, list or queue entry survives); no +// failed attempt leaves an allocation behind, on a warm workspace or from a new thread. Runs in +// the fault-injection executable (fault_injection.hpp). #include #include @@ -36,14 +37,10 @@ struct Query { SpfQueue queue; }; -Graph random_graph(int n, int m, Cost max_cost, unsigned seed) { +// test_utils.hpp's random graph from its own seed, every capacity at least 1 unit. +Graph seeded_graph(int n, int m, Cost max_cost, unsigned seed) { std::mt19937 rng(seed); - std::vector edges; - for (int i = 0; i < m; ++i) { - edges.push_back({static_cast(rng() % static_cast(n)), static_cast(rng() % static_cast(n)), - static_cast(1 + rng() % 4), static_cast(1 + rng() % static_cast(max_cost))}); - } - return make_graph(n, edges); + return random_graph(rng, n, m, max_cost, 1, 1); } SpfResult run(const Graph& g, const Query& q, const std::vector& residual) { @@ -78,6 +75,26 @@ void in_fresh_thread(const std::function& attempt, const std::function& attempt, const std::function& after, + const std::string& where) { + const long live = g_live.load(); + const bool hit = fails_at(k, [&] { in_fresh_thread(attempt, after); }); + if (hit) EXPECT_EQ(g_live.load(), live) << where << " allocation " << k << ": a leak"; + return hit; +} + +// The allocations in_fresh_thread makes itself (its thread), given functions built beforehand: +// a sweep through it counts these too, so its guard subtracts them. +long thread_allocations() { + const std::function nothing = [] {}; + long n = 0; + while (fails_at(n, [&] { in_fresh_thread(nothing, nothing); })) ++n; + return n; +} + } // namespace TEST(SearchFaults, EveryAllocationOfSpfFailsCleanly) { @@ -87,7 +104,7 @@ TEST(SearchFaults, EveryAllocationOfSpfFailsCleanly) { // below n or above 4n + 4096 nodes, so it never shrinks here): each attempt starts from a // workspace another graph used last. The acquisition of the node arrays is swept below, from // fresh threads. - const auto big = random_graph(400, 2400, 3, 99); + const auto big = seeded_graph(400, 2400, 3, 99); const auto tiny = make_graph(3, {{0, 1, 1, 1}, {1, 2, 1, 1}}); std::vector residual(static_cast(big.num_edges())); for (std::size_t e = 0; e < residual.size(); ++e) residual[e] = big.capacity_units()[e] - static_cast(e % 2); @@ -108,12 +125,16 @@ TEST(SearchFaults, EveryAllocationOfSpfFailsCleanly) { for (const auto& q : queries) want.push_back(run(big, q, residual)); const auto want_other = run(big, other, residual); const auto want_tiny = spf(tiny, std::vector {0}, SpfOptions(Direction::Forward, det)); + const long harness = thread_allocations(); for (std::size_t i = 0; i < queries.size(); ++i) { const auto& q = queries[i]; long failures = 0; for (long k = 0;; ++k) { (void)spf(tiny, std::vector {0}, SpfOptions(Direction::Forward, det)); // reuses the workspace std::optional got; + // The workspace already has the size every query needs (the references above), so a failed + // search that leaves an allocation live leaks it. + const long live = g_live.load(); const bool hit = fails_at(k, [&] { got.emplace(run(big, q, residual)); }); if (!hit) { ASSERT_TRUE(got.has_value()) << q.name; @@ -122,6 +143,7 @@ TEST(SearchFaults, EveryAllocationOfSpfFailsCleanly) { } ++failures; EXPECT_FALSE(got.has_value()) << q.name << " at " << k; + EXPECT_EQ(g_live.load(), live) << q.name << " at " << k << ": a leak"; // The retry, another query and a tiny search on this thread see a clean workspace. expect_same(run(big, q, residual), want[i], q.name + " retry after " + std::to_string(k)); expect_same(run(big, other, residual), want_other, q.name + " other after " + std::to_string(k)); @@ -130,17 +152,17 @@ TEST(SearchFaults, EveryAllocationOfSpfFailsCleanly) { } EXPECT_GE(failures, 8) << q.name; // the sweep reached the search's allocations, not only the result // The same sweep from an empty workspace (a new thread per attempt): the first acquisition of - // the node arrays, the bucket arrays and the queues' storage fail too. + // the node arrays, the bucket arrays and the queues' storage fail too, so beyond the thread's + // own allocations the attempt allocates more than on the warm workspace above. long fresh = 0; for (long k = 0;; ++k) { std::optional got; - const bool hit = fails_at(k, [&] { - in_fresh_thread([&] { got.emplace(run(big, q, residual)); }, - [&] { - expect_same(run(big, q, residual), want[i], q.name + " fresh retry after " + std::to_string(k)); - expect_same(run(big, other, residual), want_other, q.name + " fresh other after " + std::to_string(k)); - }); - }); + const std::function attempt = [&] { got.emplace(run(big, q, residual)); }; + const std::function after = [&] { + expect_same(run(big, q, residual), want[i], q.name + " fresh retry after " + std::to_string(k)); + expect_same(run(big, other, residual), want_other, q.name + " fresh other after " + std::to_string(k)); + }; + const bool hit = fails_in_fresh_thread_at(k, attempt, after, q.name + " fresh"); if (!hit) { ASSERT_TRUE(got.has_value()) << q.name; expect_same(*got, want[i], q.name + " fresh without a failure"); @@ -148,19 +170,20 @@ TEST(SearchFaults, EveryAllocationOfSpfFailsCleanly) { } ++fresh; } - EXPECT_GT(fresh, failures) << q.name; + EXPECT_GT(fresh - harness, failures) << q.name; } } TEST(SearchFaults, InternalRouteSearchesFailCleanly) { // The policies' entries (route_search, support_route, static_support, static_costs) under the // same sweep: a failure leaves nothing behind for the next search. - const auto g = random_graph(200, 1200, 2, 7); + const auto g = seeded_graph(200, 1200, 2, 7); std::vector view(static_cast(g.num_edges())); for (std::size_t e = 0; e < view.size(); ++e) view[e] = g.capacity_units()[e]; const PathSelection single(EdgeTieBreak::PreferHigherPriority, EdgePriority::Residual, false, false); const std::vector dests = {150, 199}; const auto want_route = detail::route_search(g, detail::Gate {nullptr, view, 1}, single, 3, dests); + ASSERT_TRUE(want_route.has_value()); // node 3 reaches a destination const auto support = detail::static_support(g, dests, Direction::Reverse, nullptr); const detail::PriorityValues values {&g, EdgePriority::Residual, view}; const auto prio = detail::PriorityFn::of(values); @@ -169,6 +192,7 @@ TEST(SearchFaults, InternalRouteSearchesFailCleanly) { std::vector> calls = { [&] { auto r = detail::route_search(g, detail::Gate {nullptr, view, 1}, single, 3, dests); + ASSERT_TRUE(r.has_value()); EXPECT_EQ(r->edges, want_route->edges); }, [&] { @@ -178,18 +202,18 @@ TEST(SearchFaults, InternalRouteSearchesFailCleanly) { [&] { EXPECT_TRUE(detail::static_support(g, dests, Direction::Reverse, nullptr) == support); }, [&] { EXPECT_EQ(detail::static_costs(g, nullptr, dests, std::vector {0, 1, 2, 3}), want_costs); }, }; + const std::function every = [&] { + for (auto& call : calls) call(); // every entry clean afterwards, on the same thread + }; + const long harness = thread_allocations(); for (std::size_t c = 0; c < calls.size(); ++c) { long failures = 0; for (long k = 0;; ++k) { - const bool hit = fails_at(k, [&] { - in_fresh_thread(calls[c], [&] { - for (auto& call : calls) call(); // every entry clean afterwards, on the same thread - }); - }); + const bool hit = fails_in_fresh_thread_at(k, calls[c], every, "call " + std::to_string(c)); if (!hit) break; ++failures; } - EXPECT_GE(failures, 6) << "call " << c; + EXPECT_GE(failures - harness, 10) << "call " << c; // beyond the thread's own allocations } } @@ -198,7 +222,7 @@ TEST(SearchFaults, KspAndTheResolverFailCleanly) { // (the walk back, the hops, the per-node lists and heaps, the frame stack, the Paths): every // allocation fails once, in a new thread each time (an empty workspace), and the thread's next // calls give their references. - const auto g = random_graph(60, 300, 3, 21); + const auto g = seeded_graph(60, 300, 3, 21); const PathSelection expand(EdgeTieBreak::PreferHigherPriority, EdgePriority::Capacity, true, true); const PathSelection collapse(EdgeTieBreak::Deterministic, EdgePriority::Capacity, true, false); auto ksp_of = [&](const PathSelection& s) { @@ -209,7 +233,8 @@ TEST(SearchFaults, KspAndTheResolverFailCleanly) { const auto want_expand = ksp_of(expand); const auto want_collapse = ksp_of(collapse); ASSERT_GE(want_expand.size(), 3U); - // Layers 0 -> {1..5} -> {6..10} -> 59 with a parallel pair: 26 equal shortest walks. + // Layers 0 -> {1..5} -> {6..10} -> 59 with a parallel pair 10 -> 59: 25 equal shortest node + // walks, 30 when the pair is split into its members (the resolver below splits and keeps 7). std::vector layered; for (NodeId a = 1; a <= 5; ++a) layered.push_back({0, a, 1, 1}); for (NodeId a = 1; a <= 5; ++a) { @@ -229,6 +254,16 @@ TEST(SearchFaults, KspAndTheResolverFailCleanly) { } return true; }; + EXPECT_TRUE(same(want_expand, want_expand)); + auto damaged = want_expand; + damaged.pop_back(); + EXPECT_FALSE(same(damaged, want_expand)); + damaged = want_expand; + ++damaged.back().first; + EXPECT_FALSE(same(damaged, want_expand)); + damaged = want_expand; + damaged.back().second = want_expand.front().second; + EXPECT_FALSE(same(damaged, want_expand)); std::vector> calls = { [&] { EXPECT_TRUE(same(ksp_of(expand), want_expand)); }, [&] { EXPECT_TRUE(same(ksp_of(collapse), want_collapse)); }, @@ -238,17 +273,31 @@ TEST(SearchFaults, KspAndTheResolverFailCleanly) { for (std::size_t i = 0; i < got.size(); ++i) EXPECT_TRUE(got[i] == want_paths[i]); }, }; + const std::function every = [&] { + for (auto& call : calls) call(); + }; + const long harness = thread_allocations(); for (std::size_t c = 0; c < calls.size(); ++c) { long failures = 0; for (long k = 0;; ++k) { - const bool hit = fails_at(k, [&] { - in_fresh_thread(calls[c], [&] { - for (auto& call : calls) call(); - }); - }); + const bool hit = fails_in_fresh_thread_at(k, calls[c], every, "call " + std::to_string(c)); if (!hit) break; ++failures; } - EXPECT_GE(failures, 10) << "call " << c; + EXPECT_GE(failures - harness, 60) << "call " << c; // beyond the thread's own allocations + } +} + +TEST(SearchFaults, ResultComparisonRejectsEveryField) { + const auto g = make_graph(3, {{0, 1, 1, 1}, {1, 2, 1, 1}}); + const SpfOptions opts(Direction::Forward, PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity)); + const auto good = spf(g, std::vector {0}, opts); + EXPECT_EQ(failures_of([&] { expect_same(good, good, "same"); }), 0); + for (int field = 0; field < 3; ++field) { + auto bad = good; + if (field == 0) bad.costs = std::vector {0, 1, 9}; + if (field == 1) bad.target_cost = 9; + if (field == 2) bad.dag = spf(g, std::vector {2}, opts).dag; + EXPECT_EQ(failures_of([&] { expect_same(bad, good, "different"); }), 1) << field; } } diff --git a/tests/cpp/search_reference.hpp b/tests/cpp/search_reference.hpp index fc077ce..e08ac6b 100644 --- a/tests/cpp/search_reference.hpp +++ b/tests/cpp/search_reference.hpp @@ -1,4 +1,4 @@ -// An independent reference of the search semantics (APPROACH 6) for the tests: the member graph +// An independent reference of the search semantics (docs/semantics.md#4-search) for the tests: the member graph // by an explicit per-pair selection, Bellman-Ford distances, the tight-edge DAG and the pruned // DAG by two-sided distances, and the canonical single path by enumerating every simple walk. // It shares no code with the traversal core (no queue, no lists, no extractor). @@ -15,19 +15,20 @@ #include "netgraph/core/graph.hpp" #include "netgraph/core/options.hpp" #include "netgraph/core/types.hpp" +#include "test_int128.hpp" namespace netgraph::core::test::ref { // An exact priority: residual / capacity as a rational, or an integer (den 1); `inf` above all. struct Prio { - __int128 num {0}; - __int128 den {1}; + Int128 num {0}; + Int128 den {1}; bool inf {false}; }; inline int cmp(const Prio& a, const Prio& b) { if (a.inf || b.inf) return a.inf == b.inf ? 0 : (a.inf ? 1 : -1); - const __int128 l = a.num * b.den; - const __int128 r = b.num * a.den; + const Int128 l = a.num * b.den; + const Int128 r = b.num * a.den; return l == r ? 0 : (l < r ? -1 : 1); } @@ -164,22 +165,43 @@ inline void walks(const Graph& g, const std::vector& mem, NodeId u, const } } +// Every simple walk from any start to an end over the members (walks above, from each start). +inline std::vector> all_walks(const Graph& g, const std::vector& mem, + const std::vector& starts, const std::vector& ends) { + std::vector is_end(static_cast(g.num_nodes()), 0); + for (NodeId t : ends) is_end[static_cast(t)] = 1; + std::vector> all; + for (NodeId s : starts) { + std::vector walk; + std::vector on(static_cast(g.num_nodes()), 0); + on[static_cast(s)] = 1; + walks(g, mem, s, is_end, walk, on, all); + } + return all; +} + +inline Cost walk_cost(const Graph& g, const std::vector& walk) { + Cost x = 0; + for (EdgeId e : walk) x += g.cost()[static_cast(e)]; + return x; +} + +// A walk's bottleneck: its lowest priority value (cmp order). +inline Prio bottleneck(const Query& q, const std::vector& walk) { + Prio b {0, 1, true}; + for (EdgeId e : walk) { + if (cmp(prio_of(q, e), b) < 0) b = prio_of(q, e); + } + return b; +} + // The canonical key (cost, -bottleneck under prefer, hops, edge-id sequence) as a strict order. inline bool key_less(const Query& q, const std::vector& a, const std::vector& b) { - const Graph& g = *q.g; - Cost ca = 0, cb = 0; - for (EdgeId e : a) ca += g.cost()[static_cast(e)]; - for (EdgeId e : b) cb += g.cost()[static_cast(e)]; + const Cost ca = walk_cost(*q.g, a); + const Cost cb = walk_cost(*q.g, b); if (ca != cb) return ca < cb; if (q.prefer) { - Prio ba {0, 1, true}, bb {0, 1, true}; - for (EdgeId e : a) { - if (cmp(prio_of(q, e), ba) < 0) ba = prio_of(q, e); - } - for (EdgeId e : b) { - if (cmp(prio_of(q, e), bb) < 0) bb = prio_of(q, e); - } - const int c = cmp(ba, bb); + const int c = cmp(bottleneck(q, a), bottleneck(q, b)); if (c != 0) return c > 0; } if (a.size() != b.size()) return a.size() < b.size(); @@ -189,16 +211,7 @@ inline bool key_less(const Query& q, const std::vector& a, const std::ve // The canonical single path from the start set to the end set, or nullopt. inline std::optional> canonical(const Query& q, const std::vector& mem, const std::vector& starts, const std::vector& ends) { - const Graph& g = *q.g; - std::vector is_end(static_cast(g.num_nodes()), 0); - for (NodeId t : ends) is_end[static_cast(t)] = 1; - std::vector> all; - for (NodeId s : starts) { - std::vector walk; - std::vector on(static_cast(g.num_nodes()), 0); - on[static_cast(s)] = 1; - walks(g, mem, s, is_end, walk, on, all); - } + const auto all = all_walks(*q.g, mem, starts, ends); if (all.empty()) return std::nullopt; return *std::min_element(all.begin(), all.end(), [&](const auto& a, const auto& b) { return key_less(q, a, b); }); } diff --git a/tests/cpp/search_tests.cpp b/tests/cpp/search_tests.cpp index 173a912..d93385a 100644 --- a/tests/cpp/search_tests.cpp +++ b/tests/cpp/search_tests.cpp @@ -1,23 +1,25 @@ -// The search of APPROACH 6 through the public spf and the traversal core's internal entries: the -// member graph and select_adjacency, the unique DAG in both directions, anycast pruning, the staged -// canonical single path on the review witnesses, the canonical predecessor order, both queues and +// The search through the public spf and the traversal core's internal entries: the +// member graph and select_adjacency, the unique DAG in both directions and the values that reach +// it only through the member graph, anycast pruning, the staged +// canonical single path on its witness graphs (hand-built examples), the canonical predecessor order, both queues and // their dispatch, and agreement with an independent reference (search_reference.hpp) on random -// graphs. +// graphs, one population of them built so that every rule of the single path's key decides +// some trials. #include #include #include -#include #include #include #include #include #include -#include #include +#include #include #include +#include "netgraph/core/dag_cache.hpp" #include "netgraph/core/detail/pred_dag_builder.hpp" #include "netgraph/core/detail/traversal.hpp" #include "netgraph/core/options.hpp" @@ -35,15 +37,6 @@ PathSelection sel(bool multipath = true, bool multi_edge = true, EdgeTieBreak ti return PathSelection(tie, prio, multipath, multi_edge); } -std::string error_of(const std::function& fn) { - try { - fn(); - } catch (const std::invalid_argument& e) { - return e.what(); - } - return ""; -} - SpfResult run(const Graph& g, std::vector roots, Direction dir, std::optional> targets, const PathSelection& s, const std::vector* residual = nullptr, Amount min_residual = 1, const Eligibility* elig = nullptr, SpfQueue queue = SpfQueue::Auto) { @@ -113,6 +106,8 @@ TEST(Search, CollapseBySelectAdjacency) { }; // Expand keeps every tight member; Deterministic Collapse the lowest EdgeId among the cheapest. EXPECT_EQ(member(sel()), (std::vector{0, 1, 2})); + // multipath and multi_edge default to true: a full search keeping every tight member. + EXPECT_EQ(member(PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Residual)), (std::vector{0, 1, 2})); EXPECT_EQ(member(sel(true, false)), (std::vector{0})); // PreferHigherPriority on Capacity: the highest capacity among the cheapest. EXPECT_EQ(member(sel(true, false, EdgeTieBreak::PreferHigherPriority, EdgePriority::Capacity)), (std::vector{1})); @@ -134,7 +129,7 @@ TEST(Search, CollapseBySelectAdjacency) { } TEST(Search, HeadroomRationalsBeyondDoubles) { - // review-1d-refine's witness: (a - 1) / a < a / (a + 1) for a = 2^54, while both round to the + // Headroom ratios a double cannot separate: (a - 1) / a < a / (a + 1) for a = 2^54, while both round to the // same double. Parallel 0->1 members: id 0 residual a - 1 of capacity a, id 1 residual a of // capacity a + 1; Headroom Collapse must keep id 1. const Amount a = Amount {1} << 54; @@ -157,16 +152,20 @@ TEST(Search, FullDagForwardAndReverse) { expect_canonical(g, f.dag); auto r = run(g, {3}, Direction::Reverse, std::nullopt, sel()); EXPECT_EQ(*r.costs, (std::vector{2, 1, 1, 0})); - // The reverse DAG toward 3 is the forward-oriented set of tight edges toward 3. + // The reverse DAG toward 3 is the forward-oriented set of tight edges toward 3. It differs from + // the forward DAG from 0 although 3 is a node that search reaches: each search keeps the edges + // tight in its own direction (0->2 forward only, 2->3 reverse only). EXPECT_EQ(edges_of(r.dag), (std::vector{0, 1, 3, 4})); expect_canonical(g, r.dag); + // On the same root the directions differ too: no edge enters 0, so the reverse DAG is empty. + EXPECT_TRUE(run(g, {0}, Direction::Reverse, std::nullopt, sel()).dag.via_edges().empty()); // Unreached nodes keep kUnreachable. auto u = run(g, {2}, Direction::Forward, std::nullopt, sel()); EXPECT_EQ(*u.costs, (std::vector{kUnreachable, kUnreachable, 0, 1})); } TEST(Search, PredecessorOrderIsAscendingViaIndependentlyOfDiscovery) { - // review-1d-refine's four-node witness: ids 0: 0->1 (1), 1: 0->2 (2), 2: 1->3 (2), 3: 2->3 (1). + // A four-node witness: ids 0: 0->1 (1), 1: 0->2 (2), 2: 1->3 (2), 3: 2->3 (1). // Node 3's tight parents are discovered via 2 (from node 1, settled first) and then via 3; the // list holds them newest first, [3, 2]; the DAG must list [2, 3] whatever the queue. auto g = make_graph(4, {{0, 1, 1, 1}, {0, 2, 1, 2}, {1, 3, 1, 2}, {2, 3, 1, 1}}); @@ -188,6 +187,25 @@ TEST(Search, PredecessorOrderIsAscendingViaIndependentlyOfDiscovery) { EXPECT_EQ(edges_of(rev.dag), (std::vector{0, 1, 2, 3})); } +// The canonical-form check reports each defect of a hand-built DAG: a descending via within a +// child, a wrong parent, an entry under a child its edge does not enter, an ascending list that +// is not the sorted via; the correct DAG passes. +TEST(Search, CanonicalFormCheckReportsEachDefect) { + // ids: 0: 0->1, 1: 0->1, 2: 0->2 (cost 2), 3: 1->2. + auto g = make_graph(3, {{0, 1, 1, 1}, {0, 1, 1, 1}, {0, 2, 1, 2}, {1, 2, 1, 1}}); + const auto failures = [&](std::vector nodes, std::vector offsets, std::vector parents, + std::vector via, std::vector ascending) { + const auto dag = detail::PredDAGBuilder::of_parts(g, std::move(nodes), std::move(offsets), std::move(parents), + std::move(via), std::move(ascending)); + return failures_of([&] { expect_canonical(g, dag); }); + }; + EXPECT_EQ(failures({1, 2}, {0, 2, 3}, {0, 0, 1}, {0, 1, 3}, {0, 1, 3}), 0); + EXPECT_GT(failures({1, 2}, {0, 2, 3}, {0, 0, 1}, {1, 0, 3}, {0, 1, 3}), 0) << "descending via"; + EXPECT_GT(failures({1, 2}, {0, 2, 3}, {0, 0, 0}, {0, 1, 3}, {0, 1, 3}), 0) << "wrong parent"; + EXPECT_GT(failures({1}, {0, 3}, {0, 0, 1}, {0, 1, 3}, {0, 1, 3}), 0) << "wrong child"; + EXPECT_GT(failures({1, 2}, {0, 2, 3}, {0, 0, 1}, {0, 1, 3}, {0, 1, 2}), 0) << "ascending list"; +} + TEST(Search, AnycastPruningAndDirectionIndependence) { // 0->1 (1), 1->2 (1), 0->3 (2), 3->2 (5), 1->4 (5): targets {2, 4}: nearest is 2 at cost 2. auto g = make_graph(5, {{0, 1, 1, 1}, {1, 2, 1, 1}, {0, 3, 1, 2}, {3, 2, 1, 5}, {1, 4, 1, 5}}); @@ -298,9 +316,49 @@ TEST(Search, ValidationBeforeAnyWorkInTheContractOrder) { SpfOptions good(Direction::Forward, sel()); EXPECT_EQ(error_of([&] { (void)spf(g, std::vector {0}, good, static_cast(3)); }), "queue: 3 is not a value of SpfQueue"); + // The enumerations come before every other check, in the order direction, the selection (its + // tie break, then its priority), the queue. Each call has every later defect too (empty roots, + // multipath = false without targets, a short residual, min_residual 0, a foreign Eligibility); + // repairing the reported defect moves the error to the next one. + SpfOptions every(Direction::Forward, sel(false)); + every.residual = std::span(short_res); + every.min_residual = 0; + every.elig = &foreign; + every.direction = static_cast(7); + every.selection.tie_break = static_cast(6); + every.selection.priority = static_cast(5); + const auto bad_queue = static_cast(3); + const auto staircase_error = [&] { return error_of([&] { (void)spf(g, std::vector {}, every, bad_queue); }); }; + EXPECT_EQ(staircase_error(), "direction: 7 is not a value of Direction"); + every.direction = Direction::Forward; + EXPECT_EQ(staircase_error(), "selection.tie_break: 6 is not a value of EdgeTieBreak"); + every.selection.tie_break = EdgeTieBreak::Deterministic; + EXPECT_EQ(staircase_error(), "selection.priority: 5 is not a value of EdgePriority"); + every.selection.priority = EdgePriority::Capacity; + EXPECT_EQ(staircase_error(), "queue: 3 is not a value of SpfQueue"); + EXPECT_EQ(error_of([&] { (void)spf(g, std::vector {}, every, SpfQueue::Auto); }), "roots: the node set is empty"); } -TEST(Search, StagedPathReview13D3Witness) { +// A residual array with several defects at once (one error, from the shared edge-amount check): its +// length before any entry, then its first entry outside [0, capacity] in EdgeId order, before +// min_residual and the Eligibility; in both directions, with and without targets. +TEST(Search, ResidualDefectsReportTheFirstInOrder) { + const auto g = edge_amount_graph(); + const auto other = make_graph(4, {{0, 1, 2, 1}}); + const auto foreign = other.eligibility({}, std::vector {0}); + for (const auto& c : edge_amount_defects()) { + for (auto dir : {Direction::Forward, Direction::Reverse}) { + EXPECT_EQ(error_of([&] { (void)run(g, {0}, dir, std::nullopt, sel(), &c.amounts, 0, &foreign); }), + "residual" + c.error); + EXPECT_EQ(error_of([&] { (void)run(g, {0}, dir, std::vector {3}, sel(false), &c.amounts, 0, &foreign); }), + "residual" + c.error); + } + } + const auto bounds = edge_amounts_at_the_bounds(); + EXPECT_EQ(error_of([&] { (void)run(g, {0}, Direction::Forward, std::nullopt, sel(), &bounds); }), ""); +} + +TEST(Search, StagedPathWithASharedBottleneckTakesTheFewestHops) { // s=0 -> v=1 cost 1 prio 1; v -> t=4 cost 3 prio 1; v -> a=2 -> b=3 -> t costs 1+1+1 prio 2. // Capacities carry the priority. Under PreferHigherPriority the bottleneck of every walk is 1 // (the shared s->v), so B* = 1 and the canonical walk is the two-hop s, v, t. @@ -345,7 +403,7 @@ TEST(Search, StagedPathBottleneckThenHopsThenIds) { } TEST(Search, StagedPathPrefixTrapNeedsTheFullKey) { - // review-1d-refine section 1: a cost-2 direct prefix 0->2 with bottleneck 5 against a two-edge + // A cost-2 direct prefix 0->2 with bottleneck 5 against a two-edge // cost-2 prefix 0->1->2 with bottleneck 10, then a cost-1 edge of priority 1 to the target 3. // Both complete walks cost 3 with bottleneck 1, so hops decide: [0->2, 2->3]. A search that kept // only the wider prefix would return the three-hop walk. @@ -368,11 +426,11 @@ TEST(Search, MultiRootSinglePathStartsAtTheNearestRootByHops) { EXPECT_EQ(edges_of(r.dag), (std::vector{inv[0]})); } -TEST(Search, StagedPathReview13CanonicalWitnesses) { - // review-13 contract_probes.py: the C2 diamond (0-1-3 and 0-2-3 both cost 10) takes 0-2-3, +TEST(Search, StagedPathTiesGoToTheSmallerEdgeIdSequence) { + // The diamond (0-1-3 and 0-2-3 both cost 10) takes 0-2-3, // whose edge-id sequence is smaller; the equal three-hop graph takes 0-1-4-5. Both tie modes // (all capacities 1: priority ties), both directions. - auto c2 = make_graph(4, {{0, 1, 1, 9}, {0, 2, 1, 1}, {1, 3, 1, 1}, {2, 3, 1, 9}}); + auto diamond = make_graph(4, {{0, 1, 1, 9}, {0, 2, 1, 1}, {1, 3, 1, 1}, {2, 3, 1, 9}}); auto three = make_graph(6, {{0, 2, 1, 1}, {2, 3, 1, 1}, {3, 5, 1, 1}, {0, 1, 1, 1}, {1, 4, 1, 1}, {4, 5, 1, 1}}); auto nodes_of = [](const Graph& g, const PredDAG& dag) { std::set out; @@ -385,9 +443,9 @@ TEST(Search, StagedPathReview13CanonicalWitnesses) { for (auto tie : {EdgeTieBreak::Deterministic, EdgeTieBreak::PreferHigherPriority}) { for (auto prio : {EdgePriority::Residual, EdgePriority::Capacity, EdgePriority::Headroom}) { const auto s = sel(false, true, tie, prio); - auto a = run(c2, {0}, Direction::Forward, std::vector {3}, s); - EXPECT_EQ(nodes_of(c2, a.dag), (std::set{0, 2, 3})); - auto ar = run(c2, {3}, Direction::Reverse, std::vector {0}, s); + auto a = run(diamond, {0}, Direction::Forward, std::vector {3}, s); + EXPECT_EQ(nodes_of(diamond, a.dag), (std::set{0, 2, 3})); + auto ar = run(diamond, {3}, Direction::Reverse, std::vector {0}, s); EXPECT_EQ(edges_of(ar.dag), edges_of(a.dag)); auto b = run(three, {0}, Direction::Forward, std::vector {5}, s); EXPECT_EQ(nodes_of(three, b.dag), (std::set{0, 1, 4, 5})); @@ -412,15 +470,6 @@ struct RandomCase { std::optional> targets; }; -Graph random_graph(std::mt19937& rng, int n, int m, Cost max_cost, Cost scale = 1) { - std::vector edges; - for (int i = 0; i < m; ++i) { - edges.push_back({static_cast(rng() % static_cast(n)), static_cast(rng() % static_cast(n)), - static_cast(rng() % 5), scale * static_cast(1 + rng() % static_cast(max_cost))}); - } - return make_graph(n, edges); -} - // The expected result of a query by the reference: costs, the DAG's edges, target_cost. struct Expected { std::optional> costs; @@ -428,28 +477,23 @@ struct Expected { std::optional target_cost; }; -Expected expect_of(const RandomCase& c) { - const Graph& g = c.g; +ref::Query query_of(const RandomCase& c) { ref::Query q; - q.g = &g; + q.g = &c.g; q.elig = c.elig ? &*c.elig : nullptr; q.residual = c.use_res ? &c.residual : nullptr; q.min_residual = c.min_residual; q.multi_edge = c.selection.multi_edge; q.prefer = c.selection.tie_break == EdgeTieBreak::PreferHigherPriority; q.priority = c.selection.priority; + return q; +} + +Expected expect_of(const RandomCase& c) { + const Graph& g = c.g; + const ref::Query q = query_of(c); const auto mem = ref::members(q); - auto live = [&](const std::vector& set) { - std::vector s(set.begin(), set.end()); - std::sort(s.begin(), s.end()); - s.erase(std::unique(s.begin(), s.end()), s.end()); - std::vector out; - for (NodeId v : s) { - if (q.elig == nullptr || q.elig->node_ok(v)) out.push_back(v); - } - return out; - }; - const auto roots = live(c.roots); + const auto roots = allowed_nodes(c.roots, q.elig); const bool reverse = c.direction == Direction::Reverse; Expected out; if (!c.targets.has_value()) { @@ -459,7 +503,7 @@ Expected expect_of(const RandomCase& c) { out.edges.assign(dag.begin(), dag.end()); return out; } - const auto targets = live(*c.targets); + const auto targets = allowed_nodes(*c.targets, q.elig); out.target_cost = kUnreachable; if (roots.empty() || targets.empty()) return out; const auto& starts = reverse ? targets : roots; @@ -480,8 +524,9 @@ Expected expect_of(const RandomCase& c) { TEST(Search, RandomQueriesAgreeWithTheReferenceOnBothQueues) { std::mt19937 rng(20261006); - int checked = 0; - int single = 0; + int full = 0; // full searches that keep an edge + int multipath = 0; // multipath target searches that reach a target + int single = 0; // single-path target searches that reach a target for (int trial = 0; trial < 3000; ++trial) { const int n = 2 + static_cast(rng() % 7); const int m = static_cast(rng() % 16); @@ -513,14 +558,10 @@ TEST(Search, RandomQueriesAgreeWithTheReferenceOnBothQueues) { const std::size_t nr = 1 + rng() % static_cast(std::max(1, n / 2)); c.roots.assign(perm.begin(), perm.begin() + static_cast(nr)); if (rng() % 4 == 0) c.roots.push_back(c.roots.front()); - if (with_targets) { + if (with_targets) { // never empty: nr <= n / 2 < n const std::size_t nt = 1 + rng() % static_cast(std::max(1, perm.size() - nr)); c.targets = std::vector(perm.begin() + static_cast(nr), perm.begin() + static_cast(std::min(perm.size(), nr + nt))); - if (c.targets->empty()) c.targets = std::nullopt; - } - if (!c.targets.has_value() && !c.selection.multipath) { - c.selection = PathSelection(c.selection.tie_break, c.selection.priority, true, c.selection.multi_edge); } const auto want = expect_of(c); for (auto queue : {SpfQueue::Auto, SpfQueue::Heap}) { @@ -531,17 +572,108 @@ TEST(Search, RandomQueriesAgreeWithTheReferenceOnBothQueues) { ASSERT_EQ(got.target_cost, want.target_cost) << "trial " << trial; expect_canonical(c.g, got.dag); } - ++checked; - if (c.targets.has_value() && !c.selection.multipath && want.target_cost != kUnreachable) ++single; + full += !c.targets.has_value() && !want.edges.empty() ? 1 : 0; + const bool reached = c.targets.has_value() && want.target_cost != kUnreachable; + multipath += reached && c.selection.multipath ? 1 : 0; + single += reached && !c.selection.multipath ? 1 : 0; } - EXPECT_EQ(checked, 3000); + EXPECT_GT(full, 300); + EXPECT_GT(multipath, 300); EXPECT_GT(single, 300); } +namespace { + +// Whether the hop count decides a single-path query's canonical walk: another walk ties it on +// cost (and bottleneck under PreferHigherPriority) with more hops but a smaller edge-id sequence. +bool hops_decide(const RandomCase& c) { + const ref::Query q = query_of(c); + const auto mem = ref::members(q); + const bool reverse = c.direction == Direction::Reverse; + const auto roots = allowed_nodes(c.roots, q.elig); + const auto targets = allowed_nodes(*c.targets, q.elig); + const auto& starts = reverse ? targets : roots; + const auto& ends = reverse ? roots : targets; + const auto canonical = ref::canonical(q, mem, starts, ends); + if (!canonical) return false; + const std::vector& walk = *canonical; + for (const auto& w : ref::all_walks(c.g, mem, starts, ends)) { + if (ref::walk_cost(c.g, w) == ref::walk_cost(c.g, walk) && + (!q.prefer || ref::cmp(ref::bottleneck(q, w), ref::bottleneck(q, walk)) == 0) && w.size() > walk.size() && + w < walk) { + return true; + } + } + return false; +} + +} // namespace + +// The single-path rules that the random comparison above reaches only now and then, on a +// population built for them: a few nodes joined by many parallel edges of cost 1 or 2, a residual +// on every query, one root and one target. Each rule is guarded by the trials in which it decides +// the reference's answer, against a twin query without it: the Headroom ratio (the twin reads the +// residual alone), the capacity priority (the twin reads the residual), the bottleneck key under +// PreferHigherPriority (the twin breaks ties deterministically, on Expand member graphs, where the +// tie mode decides nothing else), and the hop count (hops_decide). Both queues agree with the +// reference on every trial. +TEST(Search, RandomSinglePathsExerciseEveryKeyRule) { + std::mt19937 rng(20261010); + int headroom = 0, capacity = 0, bottleneck = 0, hops = 0; + for (int trial = 0; trial < 8000; ++trial) { + const int n = 3 + static_cast(rng() % 4); + const int m = 6 + static_cast(rng() % 14); + const EdgePriority prios[3] = {EdgePriority::Residual, EdgePriority::Capacity, EdgePriority::Headroom}; + const EdgePriority prio = prios[rng() % 3]; + const bool prefer = rng() % 4 != 0; + const bool expand = rng() % 2 == 0; + RandomCase c {random_graph(rng, n, m, 2), {}, true, 1, std::nullopt, + PathSelection(prefer ? EdgeTieBreak::PreferHigherPriority : EdgeTieBreak::Deterministic, prio, false, + expand), + (rng() % 2) == 0 ? Direction::Forward : Direction::Reverse, {}, std::nullopt}; + c.residual.resize(static_cast(c.g.num_edges())); + for (std::size_t e = 0; e < c.residual.size(); ++e) { + const Amount cap = c.g.capacity_units()[e]; + c.residual[e] = cap == 0 ? 0 : static_cast(rng() % static_cast(cap + 1)); + } + const auto root = static_cast(rng() % static_cast(n)); + c.roots = {root}; + c.targets = std::vector {static_cast((root + 1 + rng() % static_cast(n - 1)) % n)}; + const auto want = expect_of(c); + for (auto queue : {SpfQueue::Auto, SpfQueue::Heap}) { + auto got = run(c.g, c.roots, c.direction, c.targets, c.selection, &c.residual, c.min_residual, nullptr, queue); + ASSERT_EQ(edges_of(got.dag), want.edges) << "trial " << trial << " queue " << static_cast(queue); + ASSERT_EQ(got.target_cost, want.target_cost) << "trial " << trial; + } + if (want.target_cost == kUnreachable) continue; + auto differs = [&](const std::function& change) { + RandomCase twin = c; + change(twin); + return expect_of(twin).edges != want.edges ? 1 : 0; + }; + const auto residual_only = [](RandomCase& t) { t.selection.priority = EdgePriority::Residual; }; + if (prio == EdgePriority::Headroom) headroom += differs(residual_only); + if (prio == EdgePriority::Capacity) capacity += differs(residual_only); + if (prefer && expand) { + bottleneck += differs([](RandomCase& t) { t.selection.tie_break = EdgeTieBreak::Deterministic; }); + } + hops += hops_decide(c) ? 1 : 0; + } + // Each rule decides 42 to 66 of the 8,000 trials with this seed: the floors leave room for a + // generator change while staying far above the one or two trials of the comparison above. + EXPECT_GT(headroom, 20); + EXPECT_GT(capacity, 20); + EXPECT_GT(bottleneck, 20); + EXPECT_GT(hops, 20); +} + TEST(Search, DirectionsWithExchangedEndpointsDescribeTheSameWalks) { // A forward search from S with targets T and a reverse search rooted at T with targets S give - // the same pruned DAG, target cost and single path, for every selection. + // the same pruned DAG, target cost and single path, for both tie modes with and without + // multipath and multi-edge, under the capacity priority (the other priorities are compared with + // the reference in RandomQueriesAgreeWithTheReferenceOnBothQueues). std::mt19937 rng(7); + int reached = 0; // comparisons where a target is reached (a non-empty DAG) for (int trial = 0; trial < 500; ++trial) { const int n = 4 + static_cast(rng() % 8); auto g = random_graph(rng, n, 4 + static_cast(rng() % 20), 3); @@ -558,55 +690,154 @@ TEST(Search, DirectionsWithExchangedEndpointsDescribeTheSameWalks) { auto r = run(g, t, Direction::Reverse, s, s1); ASSERT_EQ(f.target_cost, r.target_cost) << trial; ASSERT_EQ(edges_of(f.dag), edges_of(r.dag)) << trial; + reached += f.target_cost != kUnreachable ? 1 : 0; } } } } + RecordProperty("reached", reached); + EXPECT_GT(reached, 1500); // not vacuous: of 4000 comparisons } TEST(Search, TieModesShareTheDagOfAFixedMemberGraph) { // Under Expand the member graph does not depend on the tie mode or the priority, so neither - // does the DAG; under Collapse the member may differ (and with it the DAG). + // does the DAG; under Collapse the member may differ (and with it the DAG). Half of the graphs, + // with and without targets, have a random residual (0 to the capacity), so the residual and + // headroom priorities read values that differ from the capacities. std::mt19937 rng(11); + int nonempty = 0; + int with_residual = 0; // nonempty DAGs under a residual for (int trial = 0; trial < 300; ++trial) { auto g = random_graph(rng, 6, 14, 2); std::optional> t; if (trial % 2 == 0) t = std::vector {5}; - const auto base = edges_of(run(g, {0}, Direction::Forward, t, sel()).dag); + std::vector res; + if (trial % 4 < 2) { + res.resize(static_cast(g.num_edges())); + for (std::size_t e = 0; e < res.size(); ++e) { + const Amount cap = g.capacity_units()[e]; + res[e] = cap == 0 ? 0 : static_cast(rng() % static_cast(cap + 1)); + } + } + const std::vector* r = res.empty() ? nullptr : &res; + const auto base = edges_of(run(g, {0}, Direction::Forward, t, sel(), r).dag); + nonempty += base.empty() ? 0 : 1; + with_residual += !base.empty() && r != nullptr ? 1 : 0; + for (auto tie : {EdgeTieBreak::Deterministic, EdgeTieBreak::PreferHigherPriority}) { + for (auto prio : {EdgePriority::Residual, EdgePriority::Capacity, EdgePriority::Headroom}) { + EXPECT_EQ(edges_of(run(g, {0}, Direction::Forward, t, sel(true, true, tie, prio), r).dag), base) << trial; + } + } + } + RecordProperty("nonempty", nonempty); + RecordProperty("with_residual", with_residual); + EXPECT_GT(nonempty, 150); // not vacuous: of 300 graphs + EXPECT_GT(with_residual, 50); // of the 150 graphs with a residual (this seed: 76) +} + +TEST(Search, CapacityAndResidualValuesActOnlyThroughTheMemberGraph) { + // Two graphs with the same edges, in EdgeId order so the ids agree, where the second raises + // every positive capacity by 1 to 5 units (a zero capacity stays zero). On half of the trials + // each gets a residual that gates the same edges at min_residual (1 or 2 units) with other + // values. The member graph is then the same, so the DAG must be: in both directions, with and + // without targets, under every tie mode and priority keeping every member, and under the + // Deterministic member rule (which reads no capacity or residual value). + std::mt19937 rng(23); + int nonempty = 0; + int gated = 0; // nonempty DAGs where the residual excluded an edge of at least 1 unit + for (int trial = 0; trial < 300; ++trial) { + auto g = random_graph(rng, 6, 14, 2); + const auto m = static_cast(g.num_edges()); + std::vector rows; + for (std::size_t e = 0; e < m; ++e) { + const Amount cap = g.capacity_units()[e]; + rows.push_back({g.edge_src()[e], g.edge_dst()[e], cap == 0 ? 0 : cap + 1 + static_cast(rng() % 5), + g.cost()[e]}); + } + auto h = make_graph(6, rows); + for (std::size_t e = 0; e < m; ++e) { + ASSERT_EQ(h.edge_src()[e], g.edge_src()[e]); + ASSERT_EQ(h.edge_dst()[e], g.edge_dst()[e]); + ASSERT_EQ(h.cost()[e], g.cost()[e]); + const Amount cg = g.capacity_units()[e]; + ASSERT_TRUE(cg == 0 ? h.capacity_units()[e] == 0 : h.capacity_units()[e] > cg); + } + std::vector rg; + std::vector rh; + const Amount minr = 1 + static_cast(rng() % 2); + bool excluded = false; + if (trial % 2 == 0) { + for (std::size_t e = 0; e < m; ++e) { + const Amount cg = g.capacity_units()[e]; + const Amount ch = h.capacity_units()[e]; + rg.push_back(cg == 0 ? 0 : static_cast(rng() % static_cast(cg + 1))); + if (rg.back() >= minr) { + rh.push_back(minr + static_cast(rng() % static_cast(ch - minr + 1))); + } else { + rh.push_back(static_cast(rng() % static_cast(std::min(minr - 1, ch) + 1))); + excluded = excluded || cg >= 1; + } + } + } + const std::vector* res_g = rg.empty() ? nullptr : &rg; + const std::vector* res_h = rh.empty() ? nullptr : &rh; + std::optional> t; + if (trial % 4 < 2) t = std::vector {5}; + std::vector sels; for (auto tie : {EdgeTieBreak::Deterministic, EdgeTieBreak::PreferHigherPriority}) { for (auto prio : {EdgePriority::Residual, EdgePriority::Capacity, EdgePriority::Headroom}) { - EXPECT_EQ(edges_of(run(g, {0}, Direction::Forward, t, sel(true, true, tie, prio)).dag), base); + sels.push_back(sel(true, true, tie, prio)); + } + } + sels.push_back(sel(true, false, EdgeTieBreak::Deterministic, EdgePriority::Capacity)); + for (const Direction dir : {Direction::Forward, Direction::Reverse}) { + for (const auto& s : sels) { + const auto want = edges_of(run(g, {0}, dir, t, s, res_g, minr).dag); + EXPECT_EQ(edges_of(run(h, {0}, dir, t, s, res_h, minr).dag), want) << trial; + nonempty += want.empty() ? 0 : 1; + gated += !want.empty() && excluded ? 1 : 0; } } } + RecordProperty("nonempty", nonempty); + RecordProperty("gated", gated); + EXPECT_GT(nonempty, 1500); // not vacuous: of 300 x 2 x 7 = 4200 comparisons (this seed: 2205) + EXPECT_GT(gated, 500); // of the 2100 comparisons under a residual (this seed: 840) } TEST(Search, QueueDispatchAtTheWidthLimit) { -#if NETGRAPH_CORE_PRIVATE_HOOKS - auto& counts = detail::thread_queue_counts(); - auto queue_of = [&](const Graph& g, SpfQueue q) { - const auto before = counts; - auto r = run(g, {0}, Direction::Forward, std::nullopt, sel(), nullptr, 1, nullptr, q); - EXPECT_EQ(*r.costs, (std::vector{0, g.cost()[0], kUnreachable})); - return counts.buckets > before.buckets ? std::string("buckets") : std::string("heap"); - }; // W = max_cost / gcd + 1: 65,535 / 1 + 1 = 65,536 buckets (the limit) and 65,536 + 1 above it; - // a large common gcd makes the width 2. + // a large common gcd makes the width 2 big / big + 1 = 3; a graph without edges has width 1. + // The widths and the dispatch rule (Auto takes the bucket queue up to the limit, Heap never) + // hold in every build; the queue each search takes is counted by a private hook. auto at = make_graph(3, {{0, 1, 1, 65535}, {1, 0, 1, 1}}); auto above = make_graph(3, {{0, 1, 1, 65536}, {1, 0, 1, 1}}); const Cost big = Cost {1} << 40; auto gcd = make_graph(3, {{0, 1, 1, big}, {1, 0, 1, 2 * big}}); + auto bare = make_graph(2, {}); EXPECT_EQ(detail::bucket_width(at), 65536); EXPECT_EQ(detail::bucket_width(above), 65537); EXPECT_EQ(detail::bucket_width(gcd), 3); + EXPECT_EQ(detail::bucket_width(bare), 1); + EXPECT_TRUE(detail::uses_buckets(at, SpfQueue::Auto)); + EXPECT_FALSE(detail::uses_buckets(above, SpfQueue::Auto)); + EXPECT_TRUE(detail::uses_buckets(gcd, SpfQueue::Auto)); + EXPECT_TRUE(detail::uses_buckets(bare, SpfQueue::Auto)); + EXPECT_FALSE(detail::uses_buckets(at, SpfQueue::Heap)); + EXPECT_FALSE(detail::uses_buckets(gcd, SpfQueue::Heap)); +#if NETGRAPH_CORE_PRIVATE_HOOKS + auto& counts = detail::thread_queue_counts(); + auto queue_of = [&](const Graph& g, SpfQueue q) { + const auto before = counts; + auto r = run(g, {0}, Direction::Forward, std::nullopt, sel(), nullptr, 1, nullptr, q); + EXPECT_EQ(*r.costs, (std::vector{0, g.cost()[0], kUnreachable})); + return counts.buckets > before.buckets ? std::string("buckets") : std::string("heap"); + }; EXPECT_EQ(queue_of(at, SpfQueue::Auto), "buckets"); EXPECT_EQ(queue_of(above, SpfQueue::Auto), "heap"); EXPECT_EQ(queue_of(gcd, SpfQueue::Auto), "buckets"); EXPECT_EQ(queue_of(at, SpfQueue::Heap), "heap"); EXPECT_EQ(queue_of(gcd, SpfQueue::Heap), "heap"); - // A graph without edges has width 1: buckets. - auto bare = make_graph(2, {}); - EXPECT_EQ(detail::bucket_width(bare), 1); #else GTEST_SKIP() << "the queue counters are a private hook"; #endif @@ -652,9 +883,10 @@ std::pair, std::vector> dijkstra(const Graph& g, const } // namespace -TEST(Search, BucketWraparoundRingsAndChains) { - // Small widths with distances many times the width: the cyclic buckets wrap around repeatedly; - // rings and chains keep a frontier of one item (the inline item and its promotion), and chords +TEST(Search, BucketWraparoundOnRings) { + // Small widths with distances many times the width: the cyclic buckets wrap around repeatedly. + // A plain ring, searched from node 0, runs like a chain with a frontier of one item (the inline + // item and its promotion); chords, shortcuts and parallel edges on the other three rings // promote it. Every result equals the heap's and the textbook Dijkstra's. std::mt19937 rng(5); for (int shape = 0; shape < 4; ++shape) { @@ -687,49 +919,72 @@ TEST(Search, EarlyStopsLeaveNothingForTheNextQuery) { auto huge = random_graph(rng, 20000, 40000, 5); const auto [d_big, t_big] = dijkstra(big, {0}); const auto [d_small, t_small] = dijkstra(small, {1}); + // The huge reverse search, first on a fresh thread (an empty workspace): each repetition below + // must give the same. + std::optional h_want; + std::thread([&] { h_want.emplace(run(huge, {5}, Direction::Reverse, std::vector {7}, sel(false))); }).join(); + ASSERT_NE(h_want->target_cost, kUnreachable); + int stopped = 0; // target searches that reached their target (and stopped there) for (int round = 0; round < 6; ++round) { for (auto q : {SpfQueue::Auto, SpfQueue::Heap}) { auto stop = run(big, {0}, Direction::Forward, std::vector {static_cast(1 + round)}, sel(), nullptr, 1, nullptr, q); EXPECT_EQ(stop.target_cost, d_big[static_cast(1 + round)]); + stopped += stop.target_cost != kUnreachable ? 1 : 0; auto s = run(small, {1}, Direction::Forward, std::nullopt, sel(), nullptr, 1, nullptr, q); EXPECT_EQ(*s.costs, d_small); EXPECT_EQ(edges_of(s.dag), t_small); - if (round % 2 == 0) (void)run(huge, {5}, Direction::Reverse, std::vector {7}, sel(false), nullptr, 1, nullptr, q); + if (round % 2 == 0) { + const auto h = run(huge, {5}, Direction::Reverse, std::vector {7}, sel(false), nullptr, 1, nullptr, q); + EXPECT_EQ(h.target_cost, h_want->target_cost); + EXPECT_EQ(edges_of(h.dag), edges_of(h_want->dag)); + } auto b = run(big, {0}, Direction::Forward, std::nullopt, sel(), nullptr, 1, nullptr, q); EXPECT_EQ(*b.costs, d_big); EXPECT_EQ(edges_of(b.dag), t_big); } } + EXPECT_EQ(stopped, 12); } TEST(Search, LargeGraphsBothQueuesIdentical) { - // Every SpfResult field identical on both queues, for full and target searches in both - // directions, on graphs where Auto takes the buckets. + // Every SpfResult field identical on both queues, on graphs where Auto takes the buckets: in + // both directions, the full multipath search and the target searches (multipath, and single + // path under each tie mode; a single-path search needs targets), with and without multi_edge + // across the shapes. std::mt19937 rng(17); + int reached = 0; for (int shape = 0; shape < 3; ++shape) { auto g = random_graph(rng, 2000, 16000, shape == 2 ? 1 : 31); ASSERT_TRUE(detail::uses_buckets(g, SpfQueue::Auto)); - for (int k = 0; k < 6; ++k) { - const auto s = sel(k % 2 == 0, k % 3 != 0, k % 2 == 0 ? EdgeTieBreak::Deterministic : EdgeTieBreak::PreferHigherPriority, - EdgePriority::Capacity); - const Direction dir = k % 2 == 0 ? Direction::Forward : Direction::Reverse; - std::optional> t; - if (k >= 2) t = std::vector {static_cast(100 + k), static_cast(900 + k)}; - if (!t.has_value() && !s.multipath) continue; - auto a = run(g, {static_cast(k)}, dir, t, s, nullptr, 1, nullptr, SpfQueue::Auto); - auto h = run(g, {static_cast(k)}, dir, t, s, nullptr, 1, nullptr, SpfQueue::Heap); - EXPECT_EQ(a.costs, h.costs); - EXPECT_EQ(a.target_cost, h.target_cost); - EXPECT_TRUE(a.dag == h.dag); + int k = 0; + for (const Direction dir : {Direction::Forward, Direction::Reverse}) { + for (int kind = 0; kind < 4; ++kind, ++k) { + const auto tie = kind == 3 ? EdgeTieBreak::PreferHigherPriority : EdgeTieBreak::Deterministic; + const auto s = sel(kind < 2, (kind + shape) % 2 == 0, tie, EdgePriority::Capacity); + std::optional> t; + if (kind > 0) t = std::vector {static_cast(100 + k), static_cast(900 + k)}; + auto a = run(g, {static_cast(k)}, dir, t, s, nullptr, 1, nullptr, SpfQueue::Auto); + auto h = run(g, {static_cast(k)}, dir, t, s, nullptr, 1, nullptr, SpfQueue::Heap); + EXPECT_EQ(a.costs, h.costs); + EXPECT_EQ(a.target_cost, h.target_cost); + EXPECT_TRUE(a.dag == h.dag); + reached += t.has_value() && a.target_cost.value_or(kUnreachable) != kUnreachable; + } } } + EXPECT_GT(reached, 12); // of the 18 target searches: not comparisons of unreached targets } TEST(Search, InternalEntriesMatchThePublicSearch) { // route_search (CapacityAware), support_route (CostOnly on the cached support) and - // static_costs (rosters) against spf on the same questions. + // static_costs (rosters) against spf on the same questions; support_route under residual + // priority views against the independent reference (search_reference.hpp). std::mt19937 rng(23); + int routed = 0; // gated route searches that reach a destination + int projected = 0; // static projections that reach a destination + int residual_views = 0; + int view_changes = 0; // the residual view selected another route than the static one for (int trial = 0; trial < 400; ++trial) { const int n = 3 + static_cast(rng() % 8); auto g = random_graph(rng, n, 3 + static_cast(rng() % 22), 3); @@ -754,6 +1009,7 @@ TEST(Search, InternalEntriesMatchThePublicSearch) { ASSERT_TRUE(got.has_value()); EXPECT_EQ(got->cost, *want.target_cost); EXPECT_EQ(got->edges, edges_of(want.dag)); + ++routed; } // CostOnly: the static support toward dests projected with static priorities equals the // static search from src (no residual: every priority reads capacities). @@ -767,6 +1023,36 @@ TEST(Search, InternalEntriesMatchThePublicSearch) { } else { EXPECT_EQ(proj.edges, edges_of(stat.dag)) << trial; EXPECT_EQ(proj.cost, *stat.target_cost); + ++projected; + } + // CostOnly creation reads its priorities on a residual view without gating on it: + // the projection of the static support under each priority read on `residual` equals + // the independent reference's walks (or canonical path) over the static member graph + // with that priority, gated at no residual (min_residual 0). + for (auto prio : {EdgePriority::Residual, EdgePriority::Headroom, EdgePriority::Capacity}) { + const auto sp = sel(mp, me, tie, prio); + const detail::PriorityValues on_residual {&g, prio, residual}; + auto rproj = detail::support_route(g, detail::PredDAGBuilder::ascending(support), src, dests, sp, + detail::PriorityFn::of(on_residual)); + ref::Query q; + q.g = &g; + q.residual = &residual; + q.min_residual = 0; + q.multi_edge = me; + q.prefer = tie == EdgeTieBreak::PreferHigherPriority; + q.priority = prio; + const auto mem = ref::members(q); + const auto [dstar, walks] = ref::pruned(g, mem, {src}, dests); + if (dstar == ref::kNone) { + EXPECT_TRUE(rproj.edges.empty()) << trial; + continue; + } + std::vector want(walks.begin(), walks.end()); + if (!mp) want = sorted(*ref::canonical(q, mem, {src}, dests)); + EXPECT_EQ(rproj.edges, want) << "trial " << trial << " priority " << static_cast(prio); + EXPECT_EQ(rproj.cost, dstar) << trial; + ++residual_views; + if (rproj.edges != proj.edges) ++view_changes; } } } @@ -775,13 +1061,18 @@ TEST(Search, InternalEntriesMatchThePublicSearch) { auto rev = run(g, dests, Direction::Reverse, std::nullopt, sel()); EXPECT_EQ(costs, (std::vector{(*rev.costs)[0], (*rev.costs)[1]})); } + EXPECT_GT(routed, 300); // not vacuous: many queries reach a destination + EXPECT_GT(projected, 1000); + EXPECT_GT(residual_views, 3000); + EXPECT_GT(view_changes, 30); // and the residual view decides some of them } TEST(Search, ReverseSupportsOrderEveryTailsEdgesAtAnyDegree) { - // Task 1d-msd: a reverse full search emits each tail's tight out-edges in ascending EdgeId, by one - // bit per out-edge id up to 64 out-edges and by sorting above: hubs with 63, 64, 65 and 129 - // out-edges, tight (cost 1, parallel copies, ties) and not (cost 2 or no capacity), against the - // reference's full DAG, with and without an Eligibility. + // A reverse full search emits each tail's tight out-edges in ascending EdgeId, by one + // bit per out-edge id up to 64 out-edges and by sorting above: hubs with 1, 63, 64, 65 and 129 + // out-edges, tight (cost 1, parallel copies, ties) and not (cost 2 or no capacity), and hubs of + // 64, 65 and 66 out-edges that are all tight, against the reference's full DAG, with and + // without an Eligibility. // Ids rank a node's costlier edges last, so the all-tight variant makes the last ids tight too. std::mt19937 rng(64); for (const auto& [degree, all_tight] : std::vector> { @@ -813,6 +1104,72 @@ TEST(Search, ReverseSupportsOrderEveryTailsEdgesAtAnyDegree) { << "degree " << degree << " all tight " << all_tight << " elig " << with_elig; if (all_tight && !with_elig) EXPECT_EQ(asc.size(), static_cast(degree + spokes)); EXPECT_TRUE(std::is_sorted(asc.begin(), asc.end())); + // The hub's own entries, whose order is the subject here: at least half its edges (none when + // the Eligibility masks the degree-1 hub's only edge). + const auto hub_entries = std::count_if(asc.begin(), asc.end(), [&](EdgeId e) { return g.edge_src()[e] == hub; }); + EXPECT_GE(hub_entries, with_elig && degree == 1 ? 0 : (degree + 1) / 2) + << "degree " << degree << " all tight " << all_tight << " elig " << with_elig; + } + } +} + +TEST(Search, StaticSupportTakesNormalisedRootsAndAllowsNone) { + // static_support and static_costs take root sets their callers normalised + // (ascending, no duplicates, in range) and filter the Eligibility themselves. On random graphs + // with masks they equal the public search on the same roots: the full support in both + // directions, the reverse costs at every node. With no allowed root the support is the empty + // DAG and every cost kUnreachable, as the public search gives (and static_costs of no + // destination at all). The public entries keep normalising: spf and a cache lookup on the + // roots unsorted and repeated give the same support. + std::mt19937 rng(813); + int masked_roots = 0; // supports with an edge although the Eligibility removes a root + for (int trial = 0; trial < 300; ++trial) { + const int n = 3 + static_cast(rng() % 9); + const auto g = random_graph(rng, n, 2 + static_cast(rng() % 30), 4); + std::vector roots; + for (NodeId v = 0; v < n; ++v) { + if (rng() % 3 == 0) roots.push_back(v); + } + if (roots.empty()) roots.push_back(static_cast(rng() % static_cast(n))); + std::vector xn; + for (NodeId v = 0; v < n; ++v) { + if (rng() % 4 == 0) xn.push_back(v); + } + if (trial % 10 == 0) xn = roots; // no root allowed + std::vector xe; + for (EdgeId e = 0; e < g.num_edges(); ++e) { + if (rng() % 6 == 0) xe.push_back(e); + } + const auto el = g.eligibility(xn, xe); + const Eligibility* elig = trial % 2 == 0 ? &el : nullptr; + const bool any_allowed = + std::any_of(roots.begin(), roots.end(), [&](NodeId r) { return elig == nullptr || elig->node_ok(r); }); + const bool masked = + elig != nullptr && std::any_of(roots.begin(), roots.end(), [&](NodeId r) { return !elig->node_ok(r); }); + std::vector all(static_cast(n)); + std::iota(all.begin(), all.end(), 0); + for (Direction dir : {Direction::Forward, Direction::Reverse}) { + const auto got = detail::static_support(g, roots, dir, elig); + const auto [want_costs, want_dag, want_cost] = run(g, roots, dir, std::nullopt, sel(), nullptr, 1, elig); + EXPECT_TRUE(got == want_dag) << "trial " << trial << " direction " << static_cast(dir); + if (!any_allowed) EXPECT_TRUE(got == detail::PredDAGBuilder::empty(g)) << trial; + if (dir == Direction::Forward && masked && !detail::PredDAGBuilder::ascending(got).empty()) ++masked_roots; } + const auto costs = detail::static_costs(g, elig, roots, all); + const auto [rev_costs, rev_dag, rev_cost] = run(g, roots, Direction::Reverse, std::nullopt, sel(), nullptr, 1, elig); + EXPECT_EQ(costs, *rev_costs) << trial; + if (!any_allowed) EXPECT_EQ(costs, std::vector(all.size(), kUnreachable)) << trial; // every tenth trial, and an even one whose exclusions remove every root + EXPECT_EQ(detail::static_costs(g, elig, std::vector {}, all), std::vector(all.size(), kUnreachable)); + // The public entries on the same roots reversed and repeated. + std::vector messy(roots.rbegin(), roots.rend()); + messy.push_back(roots.front()); + DagCache cache(g); + const auto cached = cache.lookup(g, messy, Direction::Reverse, elig); + EXPECT_TRUE(*cached == detail::static_support(g, roots, Direction::Reverse, elig)) << trial; + EXPECT_TRUE(cache.lookup(g, roots, Direction::Reverse, elig) == cached) << trial; // the same entry + EXPECT_TRUE(rev_dag == *cached) << trial; + const auto [messy_costs, messy_dag, messy_cost] = run(g, messy, Direction::Reverse, std::nullopt, sel(), nullptr, 1, elig); + EXPECT_TRUE(messy_dag == *cached) << trial; } + EXPECT_GT(masked_roots, 10); // not vacuous: the Eligibility removes some roots of real supports } diff --git a/tests/cpp/state_comparators.hpp b/tests/cpp/state_comparators.hpp new file mode 100644 index 0000000..ce98864 --- /dev/null +++ b/tests/cpp/state_comparators.hpp @@ -0,0 +1,151 @@ +// What a call leaves, as the tests compare it: the ledger (LedgerState, every record field), its +// invariant (expect_balanced), a policy's epoch state (Epoch) and a placement result; the +// comparators' negative controls intercept their failures with test_utils.hpp's failures_of. +// Shared by both test executables; the fault-injection sweeps (fault_injection.hpp) compare +// through these. +#pragma once + +#include + +#include +#include +#include +#include + +#include "netgraph/core/detail/ledger_access.hpp" +#include "netgraph/core/detail/policy_engine.hpp" +#include "netgraph/core/flow_ledger.hpp" +#include "netgraph/core/flow_policy.hpp" +#include "test_utils.hpp" + +namespace netgraph::core::test { + +// The ledger as the tests compare it: the aggregate, the residual, every record field (FlowIds +// keep their local ids: a reference sequence runs on another ledger), the next FlowId and the +// bound and orphaned owners. +struct LedgerState { + std::vector allocated; + std::vector residual; + std::vector records; + std::int64_t next_id {0}; + std::vector bound; + std::vector orphans; +}; + +// Every record's FlowId carries the ledger's uid; then the uid is cleared, so records of two +// ledgers compare by their local ids. +inline void expect_uid_then_clear(std::vector& records, std::uint64_t uid) { + for (auto& r : records) { + EXPECT_EQ(r.flow_id.first, uid); + r.flow_id.first = 0; + } +} + +inline LedgerState ledger_state(const FlowLedger& fg) { + using detail::LedgerAccess; + LedgerState s {fg.allocated(), fg.residual(), fg.records(), LedgerAccess::next_flow_id(fg), + LedgerAccess::bound_owners(fg), LedgerAccess::orphan_owners(fg)}; + expect_uid_then_clear(s.records, fg.uid()); + return s; +} + +inline bool same_record(const FlowRecord& a, const FlowRecord& b) { + return a.flow_id == b.flow_id && a.owner == b.owner && a.sources == b.sources && a.destinations == b.destinations && + a.placement == b.placement && a.admission == b.admission && a.bundle && b.bundle && *a.bundle == *b.bundle && + a.route_cost == b.route_cost && a.offered == b.offered && a.delivered == b.delivered && a.edges == b.edges && + a.drops == b.drops; +} + +inline void expect_same(const LedgerState& a, const LedgerState& b, const std::string& where) { + EXPECT_EQ(a.allocated, b.allocated) << where; + EXPECT_EQ(a.residual, b.residual) << where; + EXPECT_EQ(a.next_id, b.next_id) << where; + EXPECT_EQ(a.bound, b.bound) << where; + EXPECT_EQ(a.orphans, b.orphans) << where; + ASSERT_EQ(a.records.size(), b.records.size()) << where; + for (std::size_t i = 0; i < a.records.size(); ++i) { + EXPECT_TRUE(same_record(a.records[i], b.records[i])) << where << " record " << i; + } +} + +// The ledger invariant: the aggregate is the sum of the records, within [0, baseline] on every +// edge. +inline void expect_balanced(const FlowLedger& fg, const std::string& where) { + const std::vector allocated = fg.allocated(); + const auto baseline = detail::LedgerAccess::baseline(fg); + std::vector sum(allocated.size(), 0); + for (const auto& r : fg.records()) { + for (const auto& [e, x] : r.edges) sum[static_cast(e)] += x; + } + EXPECT_EQ(sum, allocated) << where; + ASSERT_EQ(baseline.size(), allocated.size()) << where; + for (std::size_t e = 0; e < allocated.size(); ++e) { + EXPECT_GE(allocated[e], 0) << where << " edge " << e; + EXPECT_LE(allocated[e], baseline[e]) << where << " edge " << e; + } +} + +// Every field of a placement result. +inline void expect_same(const PlacementResult& a, const PlacementResult& b, const std::string& where) { + EXPECT_EQ(a.placed, b.placed) << where; + EXPECT_EQ(a.remaining, b.remaining) << where; + EXPECT_EQ(a.termination, b.termination) << where; + EXPECT_EQ(a.local_outcomes, b.local_outcomes) << where; + EXPECT_EQ(a.routes_changed, b.routes_changed) << where; + EXPECT_EQ(a.weights_changed, b.weights_changed) << where; + EXPECT_EQ(a.drops, b.drops) << where; + EXPECT_EQ(a.cost_distribution, b.cost_distribution) << where; + EXPECT_EQ(a.edge_flows, b.edge_flows) << where; + EXPECT_EQ(a.residual, b.residual) << where; + EXPECT_EQ(a.bottleneck, b.bottleneck) << where; + EXPECT_EQ(a.min_cut, b.min_cut) << where; +} + +// One live flow as the tests compare it: its local id, its route cost and its plan's route, +// members and Weighted weights (a Global flow has no plan: its bundle's edges as route and +// members). +struct FlowSnapshot { + std::int64_t id {0}; + Cost cost {0}; + std::vector route; + std::vector members; + std::vector weights; + bool operator==(const FlowSnapshot&) const = default; +}; + +// A policy's epoch state: the call state (requested, +inf, the placed demand), Global's +// cumulative curve and per roster source its flows. +struct Epoch { + Counter128 requested; + bool infinite {false}; + Amount placed {0}; + std::vector> curve; + std::vector> cohorts; +}; + +inline Epoch epoch(const FlowPolicy& p) { + const auto& pe = detail::PolicyAccess::engine(p); + Epoch e {pe.requested, pe.infinite, p.placed_demand(), pe.curve, {}}; + for (const auto& c : pe.cohorts) { + auto& out = e.cohorts.emplace_back(); + for (const auto& f : c) { + if (f.plan) { + out.push_back({f.id, f.cost, f.plan->route, f.plan->members, f.plan->weights}); + } else { + const auto ids = ascending_edges(*f.bundle); + out.push_back({f.id, f.cost, ids, ids, {}}); + } + } + } + return e; +} + +inline void expect_same(const Epoch& a, const Epoch& b, const std::string& where) { + EXPECT_EQ(a.requested, b.requested) << where; + EXPECT_EQ(a.infinite, b.infinite) << where; + EXPECT_EQ(a.placed, b.placed) << where; + EXPECT_EQ(a.curve, b.curve) << where; + EXPECT_TRUE(a.cohorts == b.cohorts) << where; +} + +} // namespace netgraph::core::test diff --git a/tests/cpp/test_int128.hpp b/tests/cpp/test_int128.hpp new file mode 100644 index 0000000..ea46bc1 --- /dev/null +++ b/tests/cpp/test_int128.hpp @@ -0,0 +1,183 @@ +// Exact 128-bit integers for the test oracles: Int128 (signed) and UInt128 (unsigned). The +// portable classes wrap modulo 2^128; the built-in signed type must not overflow (that is +// undefined), so a test that wants a wrapped signed result forms it in UInt128 and converts back. +// Compilers with a built-in 128-bit integer (GCC, Clang) use it; others (MSVC) use the two small +// classes below, written for the tests alone and independent of the library's detail/int128.hpp, +// so an arithmetic error there cannot hide in an expected value. The CI job "C++ tests (windows, +// MSVC)" runs every test on the classes, and PortableInt128.PortableClassesMatchTheBuiltInType +// compares them with the built-in type wherever both exist. Defining +// NETGRAPH_CORE_TEST_PORTABLE_INT128 selects the classes on any compiler; no make target or CI job +// sets it. To run every test on them locally, from the repository root (a fresh build directory: +// CMake reads CXXFLAGS only when it first configures one): +// CXXFLAGS=-DNETGRAPH_CORE_TEST_PORTABLE_INT128 cmake -S . -B build/cpp-tests-portable -DNETGRAPH_CORE_BUILD_TESTS=ON -DNETGRAPH_CORE_PRIVATE_HOOKS=ON +// cmake --build build/cpp-tests-portable --parallel && ctest --test-dir build/cpp-tests-portable --no-tests=error +#pragma once + +#include +#include +#include +#include +#include + +namespace netgraph::core::test { + +// Two 64-bit words. Every operation is modulo 2^128; division by zero aborts. +class PortableUInt128 { + public: + using U = PortableUInt128; + + constexpr PortableUInt128() noexcept = default; + // Implicit, like a built-in integral conversion: a negative value wraps modulo 2^128. + template + constexpr PortableUInt128(T v) noexcept : lo_(static_cast(v)) { + if constexpr (std::is_signed_v) hi_ = v < 0 ? ~std::uint64_t {0} : 0; + } + static constexpr U of_words(std::uint64_t hi, std::uint64_t lo) noexcept { + U r; + r.hi_ = hi; + r.lo_ = lo; + return r; + } + [[nodiscard]] constexpr std::uint64_t high() const noexcept { return hi_; } + [[nodiscard]] constexpr std::uint64_t low() const noexcept { return lo_; } + // The low bits, like a built-in narrowing conversion. + template + constexpr explicit operator T() const noexcept { + return static_cast(lo_); + } + + friend constexpr bool operator==(const U&, const U&) noexcept = default; + friend constexpr std::strong_ordering operator<=>(const U& a, const U& b) noexcept { + if (a.hi_ != b.hi_) return a.hi_ <=> b.hi_; + return a.lo_ <=> b.lo_; + } + + friend constexpr U operator+(U a, U b) noexcept { + const std::uint64_t lo = a.lo_ + b.lo_; + return of_words(a.hi_ + b.hi_ + (lo < a.lo_ ? 1 : 0), lo); + } + friend constexpr U operator-(U a, U b) noexcept { + return of_words(a.hi_ - b.hi_ - (a.lo_ < b.lo_ ? 1 : 0), a.lo_ - b.lo_); + } + friend constexpr U operator*(U a, U b) noexcept { + // The full product of the low words from 32-bit halves; the products with a high word only + // reach the high word (modulo 2^128). + const std::uint64_t mask = 0xffffffffU; + const std::uint64_t a0 = a.lo_ & mask, a1 = a.lo_ >> 32; + const std::uint64_t b0 = b.lo_ & mask, b1 = b.lo_ >> 32; + const std::uint64_t p00 = a0 * b0, p01 = a0 * b1, p10 = a1 * b0, p11 = a1 * b1; + const std::uint64_t middle = (p00 >> 32) + (p01 & mask) + (p10 & mask); + const std::uint64_t lo = (middle << 32) | (p00 & mask); + const std::uint64_t hi = p11 + (p01 >> 32) + (p10 >> 32) + (middle >> 32) + a.hi_ * b.lo_ + a.lo_ * b.hi_; + return of_words(hi, lo); + } + // Long division, one quotient bit per step: n = q * d + r with r < d. + static constexpr void divide(U n, U d, U& q, U& r) noexcept { + if (d == U {}) std::abort(); + q = U {}; + r = U {}; + for (int i = 127; i >= 0; --i) { + r = (r << 1) | ((n >> i) & U {1}); + if (r >= d) { + r = r - d; + q = q | (U {1} << i); + } + } + } + friend constexpr U operator/(U a, U b) noexcept { + U q, r; + divide(a, b, q, r); + return q; + } + friend constexpr U operator%(U a, U b) noexcept { + U q, r; + divide(a, b, q, r); + return r; + } + // Shifts by 0 to 127 bits. + friend constexpr U operator<<(U a, int s) noexcept { + if (s == 0) return a; + if (s >= 64) return of_words(a.lo_ << (s - 64), 0); + return of_words((a.hi_ << s) | (a.lo_ >> (64 - s)), a.lo_ << s); + } + friend constexpr U operator>>(U a, int s) noexcept { + if (s == 0) return a; + if (s >= 64) return of_words(0, a.hi_ >> (s - 64)); + return of_words(a.hi_ >> s, (a.lo_ >> s) | (a.hi_ << (64 - s))); + } + friend constexpr U operator|(U a, U b) noexcept { return of_words(a.hi_ | b.hi_, a.lo_ | b.lo_); } + friend constexpr U operator&(U a, U b) noexcept { return of_words(a.hi_ & b.hi_, a.lo_ & b.lo_); } + constexpr U& operator+=(U b) noexcept { return *this = *this + b; } + constexpr U& operator-=(U b) noexcept { return *this = *this - b; } + constexpr U& operator*=(U b) noexcept { return *this = *this * b; } + constexpr U& operator/=(U b) noexcept { return *this = *this / b; } + + private: + std::uint64_t hi_ {0}; + std::uint64_t lo_ {0}; +}; + +// The two's-complement reading of a PortableUInt128 bit pattern: signed comparison and division +// (truncating toward zero, like the built-in type); the other operations are those of the bits. +class PortableInt128 { + public: + using I = PortableInt128; + using U = PortableUInt128; + + constexpr PortableInt128() noexcept = default; + // Implicit, like a built-in integral conversion: the value is kept. + template + constexpr PortableInt128(T v) noexcept : bits_(v) {} + constexpr explicit PortableInt128(U bits) noexcept : bits_(bits) {} + constexpr explicit operator U() const noexcept { return bits_; } + template + constexpr explicit operator T() const noexcept { + return static_cast(bits_); + } + + friend constexpr bool operator==(const I&, const I&) noexcept = default; + friend constexpr std::strong_ordering operator<=>(const I& a, const I& b) noexcept { + const auto ah = static_cast(a.bits_.high()); + const auto bh = static_cast(b.bits_.high()); + if (ah != bh) return ah <=> bh; + return a.bits_.low() <=> b.bits_.low(); + } + + friend constexpr I operator+(I a, I b) noexcept { return I(a.bits_ + b.bits_); } + friend constexpr I operator-(I a, I b) noexcept { return I(a.bits_ - b.bits_); } + friend constexpr I operator-(I a) noexcept { return I(U {} - a.bits_); } + friend constexpr I operator*(I a, I b) noexcept { return I(a.bits_ * b.bits_); } + friend constexpr I operator/(I a, I b) noexcept { + const I q(magnitude(a) / magnitude(b)); + return (a < 0) != (b < 0) ? -q : q; + } + friend constexpr I operator%(I a, I b) noexcept { + const I r(magnitude(a) % magnitude(b)); + return a < 0 ? -r : r; + } + constexpr I& operator+=(I b) noexcept { return *this = *this + b; } + constexpr I& operator-=(I b) noexcept { return *this = *this - b; } + constexpr I& operator*=(I b) noexcept { return *this = *this * b; } + + private: + static constexpr U magnitude(I x) noexcept { return x < 0 ? (-x).bits_ : x.bits_; } + U bits_ {}; +}; + +#if defined(__SIZEOF_INT128__) && !defined(NETGRAPH_CORE_TEST_PORTABLE_INT128) +using Int128 = __int128; +using UInt128 = unsigned __int128; +#else +using Int128 = PortableInt128; +using UInt128 = PortableUInt128; +#endif + +// hi * 2^64 + lo. +constexpr Int128 int128_of(std::int64_t hi, std::uint64_t lo) noexcept { + return static_cast((static_cast(static_cast(hi)) << 64) | UInt128 {lo}); +} + +// 2^127 - 1. +constexpr Int128 int128_max() noexcept { return static_cast((UInt128 {1} << 127) - UInt128 {1}); } + +} // namespace netgraph::core::test diff --git a/tests/cpp/test_utils.hpp b/tests/cpp/test_utils.hpp index 393c1b4..7c9ff51 100644 --- a/tests/cpp/test_utils.hpp +++ b/tests/cpp/test_utils.hpp @@ -1,19 +1,36 @@ #pragma once +#include #include #include #include +#include +#include #include #include +#include #include +#include #include #include +#include #include +#include +#include +#include #include +#include "netgraph/core/dag_cache.hpp" #include "netgraph/core/detail/flow_core.hpp" +#include "netgraph/core/detail/kernels.hpp" +#include "netgraph/core/detail/pred_dag_builder.hpp" +#include "netgraph/core/flow_ledger.hpp" +#include "netgraph/core/flow_policy.hpp" +#include "netgraph/core/flow_record.hpp" #include "netgraph/core/graph.hpp" +#include "netgraph/core/pred_dag.hpp" +#include "test_int128.hpp" namespace netgraph::core::test { @@ -30,6 +47,31 @@ struct InEdge { Cost cost; }; +// 2^62: the bound of capacities and of the capacity and cost sums, in units. +inline constexpr Amount kL62 = Amount {1} << 62; + +// The two policy configurations most tests use, both Lossless with deterministic ties and the +// capacity priority: the exact maximum flow (Global routing, MaxFlow) and CostOnly EqualBalanced. +inline FlowPolicyConfig global_config() { + return FlowPolicyConfig {Global {}, FlowPlacement::MaxFlow, Admission::Lossless, + PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity)}; +} +inline FlowPolicyConfig cost_only_config() { + return FlowPolicyConfig {CostOnly {}, FlowPlacement::EqualBalanced, Admission::Lossless, + PathSelection(EdgeTieBreak::Deterministic, EdgePriority::Capacity)}; +} + +// The five statistics of a DagCache, comparable as one value. +inline std::tuple stats_of( + const DagCache::Stats& s) { + return {s.hits, s.misses, s.entries, s.bytes, s.live_values}; +} + +// The kernel-combination errors of the configuration check. +inline const std::string kLossyKernelError = + "Lossy admission is rejected with the MaxFlow kernel: a controller never drops (EqualBalanced or Weighted only)"; +inline const std::string kHeadroomKernelError = "the Weighted kernel is rejected with priority Headroom (weights are units)"; + inline Graph make_graph(std::int32_t n, const std::vector& edges, double q = 1.0) { std::vector src, dst; std::vector cap; @@ -43,6 +85,136 @@ inline Graph make_graph(std::int32_t n, const std::vector& edges, double return Graph(n, src, dst, cap, cost, q); } +// m random edges on n nodes (self-loops and parallel edges allowed): capacities min_capacity to 4 +// units, costs 1 to max_cost times scale. The draws are in a braced list, so their order is fixed. +inline Graph random_graph(std::mt19937& rng, int n, int m, Cost max_cost, Cost scale = 1, Amount min_capacity = 0) { + std::vector edges; + for (int i = 0; i < m; ++i) { + edges.push_back({static_cast(rng() % static_cast(n)), static_cast(rng() % static_cast(n)), + min_capacity + static_cast(rng() % static_cast(5 - min_capacity)), + scale * static_cast(1 + rng() % static_cast(max_cost))}); + } + return make_graph(n, edges); +} + +// The message of the std::invalid_argument fn throws; "" when it throws none. +inline std::string error_of(const std::function& fn) { + try { + fn(); + } catch (const std::invalid_argument& e) { + return e.what(); + } + return ""; +} + +// The failures fn records, intercepted (they do not fail the calling test): the negative +// controls of the tests' own checks and comparators. +inline int failures_of(const std::function& fn) { + ::testing::TestPartResultArray results; + { + const ::testing::ScopedFakeTestPartResultReporter reporter( + ::testing::ScopedFakeTestPartResultReporter::INTERCEPT_ONLY_CURRENT_THREAD, &results); + fn(); + } + return results.size(); +} + +inline bool contains(const std::string& s, const std::string& part) { return s.find(part) != std::string::npos; } + +// Whether a checker's failed clauses (a list of messages) include one that starts with `clause`. +inline bool has_failure(const std::vector& fails, const std::string& clause) { + return std::any_of(fails.begin(), fails.end(), [&](const std::string& f) { return f.rfind(clause, 0) == 0; }); +} + +inline std::shared_ptr shared(Graph g) { return std::make_shared(std::move(g)); } + +// A node list written inline: v({0, 1}). +inline std::vector v(std::initializer_list x) { return x; } + +// Per-edge amount arrays with one or several defects (one error; detail::check_edge_amounts) for +// edge_amount_graph(), three edges of capacity 2, so the texts hold whatever the EdgeId order: +// the length is reported before any entry, then the first entry outside [0, 2] in EdgeId order. +// `error` follows the caller's label ("residual", "view"). +struct EdgeAmountDefect { + std::vector amounts; + std::string error; +}; + +inline Graph edge_amount_graph() { return make_graph(4, {{0, 1, 2, 1}, {1, 2, 2, 1}, {2, 3, 2, 1}}); } + +inline std::vector edge_amount_defects() { + return { + {{9, -1, 0, 0}, " has length 4, expected 3 (one entry per edge)"}, + {{-5, 9}, " has length 2, expected 3 (one entry per edge)"}, + {{}, " has length 0, expected 3 (one entry per edge)"}, + {{0, -1, 3}, "[1] = -1 is outside [0, capacity_units[1] = 2]"}, + {{0, 3, -1}, "[1] = 3 is outside [0, capacity_units[1] = 2]"}, + {{2, 0, 3}, "[2] = 3 is outside [0, capacity_units[2] = 2]"}, + {{-1, -1, -1}, "[0] = -1 is outside [0, capacity_units[0] = 2]"}, + }; +} + +// Both bounds of [0, capacity] are inside. +inline std::vector edge_amounts_at_the_bounds() { return {0, 2, 1}; } + +// A span (a public array, a plan's structure field) as a vector. +template +std::vector> vec(std::span s) { + return std::vector>(s.begin(), s.end()); +} + +// Every EdgeId of g, ascending. +inline std::vector all_ids(const Graph& g) { + std::vector out(static_cast(g.num_edges())); + for (std::size_t i = 0; i < out.size(); ++i) out[i] = static_cast(i); + return out; +} + +// The PredDAG of a set of EdgeIds (the builder's of_edges). +inline std::shared_ptr dag_of(const Graph& g, std::vector edges) { + return std::make_shared(detail::PredDAGBuilder::of_edges(g, edges)); +} + +// A DAG's edges in ascending EdgeId (the builder's view of the storage). +inline std::vector ascending_edges(const PredDAG& dag) { + const auto e = detail::PredDAGBuilder::ascending(dag); + return {e.begin(), e.end()}; +} + +// The sorted, distinct nodes of `set` that `elig` allows (all of them without an Eligibility). +inline std::vector allowed_nodes(std::vector set, const Eligibility* elig) { + std::sort(set.begin(), set.end()); + set.erase(std::unique(set.begin(), set.end()), set.end()); + std::vector out; + for (NodeId v : set) { + if (elig == nullptr || elig->node_ok(v)) out.push_back(v); + } + return out; +} + +// The bundle of every edge of g. +inline std::shared_ptr all_edges(const Graph& g) { return dag_of(g, all_ids(g)); } + +// Every operation counter of two solves (the counter struct has no ==). +inline bool same_counters(const detail::FlowCoreCounters& x, const detail::FlowCoreCounters& y) { + return x.dijkstra_runs == y.dijkstra_runs && x.dijkstra_pops == y.dijkstra_pops && x.settled == y.settled && + x.arc_scans == y.arc_scans && x.dinic_phases == y.dinic_phases && x.augmentations == y.augmentations; +} + +// Every field of a network against its copy `in`. +inline bool same_network(const detail::Network& net, const detail::Network& in) { + return net.num_nodes == in.num_nodes && net.edges == in.edges && net.tail == in.tail && net.head == in.head && + net.cost == in.cost && net.cap == in.cap && net.sources == in.sources && net.destinations == in.destinations && + net.off == in.off && net.arc_head == in.arc_head && net.rev == in.rev && net.arc_cost == in.arc_cost && + net.edge_slot == in.edge_slot && net.src_rev == in.src_rev && net.dst_fwd == in.dst_fwd; +} + +// Every field of a deletion re-solve's result, its operation counters included. +inline bool same_result(const detail::DeletionResult& a, const detail::DeletionResult& b) { + return a.value == b.value && a.potentials == b.potentials && a.cut == b.cut && a.edge_flows == b.edge_flows && + same_counters(a.counters, b.counters); +} + // The caller's inverse of input_index(): input position -> EdgeId. inline std::vector id_of_input(const Graph& g) { auto ii = g.input_index(); @@ -51,6 +223,13 @@ inline std::vector id_of_input(const Graph& g) { return inv; } +// The path 0 -> 1 -> ... -> n - 1 (cost 1 per edge) at capacity `cap`. +inline std::shared_ptr chain_graph(std::int32_t n, Amount cap) { + std::vector edges; + for (NodeId v = 0; v + 1 < n; ++v) edges.push_back({v, v + 1, cap, 1}); + return std::make_shared(make_graph(n, edges)); +} + // A per-edge array in input order, permuted to id order. template std::vector to_ids(const Graph& g, const std::vector& by_input) { @@ -92,17 +271,13 @@ inline FlowCoreResult solve(const Graph& g, std::vector s, std::vector minimum cost of every integer flow value, by enumerating every integer flow on the // eligible edges (small graphs only). Independent of the solver. -inline std::map exhaustive_costs(const Graph& g, std::span view, +inline std::map exhaustive_costs(const Graph& g, std::span view, const std::vector& srcs, const std::vector& dsts, const Eligibility* elig) { const auto m = static_cast(g.num_edges()); @@ -114,16 +289,16 @@ inline std::map exhaustive_costs(const Graph& g, std::span S, D; for (NodeId s : srcs) if (elig == nullptr || elig->node_ok(s)) S.insert(s); for (NodeId d : dsts) if (elig == nullptr || elig->node_ok(d)) D.insert(d); - std::map best; + std::map best; std::vector f(free.size(), 0); while (true) { std::vector bal(n, 0); - __int128 cost = 0; + Int128 cost = 0; for (std::size_t k = 0; k < free.size(); ++k) { const auto e = free[k]; bal[static_cast(g.edge_src()[e])] -= f[k]; bal[static_cast(g.edge_dst()[e])] += f[k]; - cost += static_cast<__int128>(g.cost()[e]) * f[k]; + cost += static_cast(g.cost()[e]) * f[k]; } bool ok = true; Amount value = 0; @@ -148,7 +323,7 @@ inline std::map exhaustive_costs(const Graph& g, std::span> independent_curve(const Graph& g, +inline std::vector> independent_curve(const Graph& g, std::span view, const std::vector& srcs, const std::vector& dsts, @@ -163,10 +338,10 @@ inline std::vector> independent_curve(const Graph& g } for (NodeId s : srcs) if (elig == nullptr || elig->node_ok(s)) arcs.push_back({S, s, kMaxFlowCap, 0, 0}); for (NodeId d : dsts) if (elig == nullptr || elig->node_ok(d)) arcs.push_back({d, T, kMaxFlowCap, 0, 0}); - std::vector> pts{{0, 0}}; + std::vector> pts{{0, 0}}; while (true) { - const __int128 inf = std::numeric_limits<__int128>::max(); - std::vector<__int128> dist(static_cast(n + 2), inf); + const Int128 inf = int128_max(); + std::vector dist(static_cast(n + 2), inf); std::vector> parent(static_cast(n + 2), {-1, 0}); dist[static_cast(S)] = 0; for (int it = 0; it < n + 2; ++it) { @@ -209,155 +384,268 @@ inline std::vector> independent_curve(const Graph& g // Cost of a piecewise-linear curve at an integer value inside its domain. Every segment of both // curves has an integer slope (the cost per unit of one tier or one augmenting path), so the -// value is exact in 128 bits: costs stay below 2^62 per unit and amounts below 2^62. -inline __int128 curve_at(const std::vector>& pts, Amount x) { +// value is exact in 128 bits: costs stay below 2^62 per unit and amounts below 2^62. An empty or +// backward segment (a payload entry of volume 0 or less, which the curve-shape clause rejects) is +// skipped rather than divided by its length. +inline Int128 curve_at(const std::vector>& pts, Amount x) { for (std::size_t i = 1; i < pts.size(); ++i) { const auto [a, ca] = pts[i - 1]; const auto [b, cb] = pts[i]; - if (a <= x && x <= b) { - const __int128 slope = (cb - ca) / (b - a); + if (a < b && a <= x && x <= b) { + const Int128 slope = (cb - ca) / (b - a); return ca + slope * (x - a); } } return pts.back().second; } -inline std::vector> payload_points(const FlowCoreResult& r) { - std::vector> pts{{0, 0}}; - for (const auto& [c, u] : r.cost_distribution) { - pts.emplace_back(pts.back().first + u, pts.back().second + static_cast<__int128>(c) * u); +// The curve clauses shared by the flow certificates (certify below, the MaxFlow kernel step's in +// kernels_tests.cpp and the Global policy's cumulative curve in flow_policy_tests.cpp). The shape: every volume positive and the costs strictly increasing; the +// curve's total equals the delivered value and its moment (the sum of cost x volume) the flow's +// cost. Returns the curve's breakpoints [(value, cost)] from (0, 0). +inline std::vector> curve_shape_clauses(const std::vector>& curve, + Amount placed, Int128 moment, + std::vector& fails) { + std::vector> pts {{0, 0}}; + for (std::size_t i = 0; i < curve.size(); ++i) { + const auto [c, u] = curve[i]; + if (u <= 0) fails.push_back("curve volume not positive"); + if (i > 0 && c <= curve[i - 1].first) fails.push_back("curve costs not strictly increasing"); + pts.emplace_back(pts.back().first + u, pts.back().second + static_cast(c) * u); } + if (pts.back().first != placed) fails.push_back("curve total differs from placed"); + if (pts.back().second != moment) fails.push_back("curve moment differs from the flow cost"); return pts; } -// The certificate of APPROACH 2.2 in two ordered branches. Returns the failed clauses. With -// `tier_a`, the complete curve is compared with the exhaustive optimum of every value; else with -// the independent Bellman-Ford curve at the union of both breakpoint sets. -inline std::vector certify(const Graph& g, std::span view, - std::vector srcs, std::vector dsts, - std::optional cap, const Eligibility* elig, - const FlowCoreResult& r, bool tier_a) { - std::vector fails; +// The curve against an optimum's breakpoints: equal at every breakpoint of either up to `upto` +// (both are piecewise linear, so equal everywhere there); the first value that differs is +// reported, naming the optimum. +inline void curve_optimum_clause(const std::vector>& pts, + const std::vector>& optimum, Amount upto, + const std::string& name, std::vector& fails) { + std::set xs; + for (const auto& [x, c] : pts) xs.insert(x); + for (const auto& [x, c] : optimum) xs.insert(x); + for (Amount x : xs) { + if (x > upto) continue; + if (curve_at(pts, x) != curve_at(optimum, x)) { + fails.push_back("curve differs from " + name + " at " + std::to_string(x)); + return; + } + } +} + +// The cost of a flow given per EdgeId: the sum of cost x flow, exact in 128 bits. +inline Int128 flow_cost(const Graph& g, std::span flows) { + Int128 total = 0; + for (std::size_t e = 0; e < flows.size(); ++e) total += static_cast(g.cost()[e]) * flows[e]; + return total; +} + +// Per node, what a flow's sources send out and what its destinations receive (net amounts; 0 at +// every other node). +struct FlowBalance { + std::vector supply; + std::vector inflow; +}; + +// The flow clauses of the flow certificates (certify below, the deletion re-solve's in +// flow_core_deletion_tests.cpp and the Global policy's in flow_policy_tests.cpp), on a flow given +// per EdgeId: every edge flow within [0, view] and zero on an edge the Eligibility excludes; a +// source absorbs nothing and a destination emits nothing (a node in both sets counts as a +// source), an excluded one carries nothing, and every other node passes on what it receives; +// what the sources send out and what the destinations receive both equal `placed`. Appends the +// failed clauses to `fails`. +inline FlowBalance flow_clauses(const Graph& g, std::span view, std::span flows, + const std::vector& srcs, const std::vector& dsts, + const Eligibility* elig, Amount placed, std::vector& fails) { const auto m = static_cast(g.num_edges()); const auto n = static_cast(g.num_nodes()); - std::sort(srcs.begin(), srcs.end()); - srcs.erase(std::unique(srcs.begin(), srcs.end()), srcs.end()); - std::sort(dsts.begin(), dsts.end()); - dsts.erase(std::unique(dsts.begin(), dsts.end()), dsts.end()); auto edge_ok = [&](std::size_t e) { return elig == nullptr || elig->edge_ok(static_cast(e)); }; auto node_ok = [&](NodeId v) { return elig == nullptr || elig->node_ok(v); }; - if (r.edge_flows.size() != m || r.potentials.size() != n) { - fails.push_back("shape"); - return fails; - } - if (cap.has_value() && *cap == 0) { - const auto& c = r.counters; - if (r.placed != 0 || !r.cost_distribution.empty() || r.min_cut.has_value() || - std::any_of(r.edge_flows.begin(), r.edge_flows.end(), [](Amount x) { return x != 0; }) || - std::any_of(r.potentials.begin(), r.potentials.end(), [](Cost p) { return p != kNeverReached; }) || - c.dijkstra_runs || c.dijkstra_pops || c.settled || c.arc_scans || c.dinic_phases || c.augmentations) { - fails.push_back("zero cap: the prescribed zero payload"); - } - return fails; - } - std::vector<__int128> bal(n, 0); - __int128 moment = 0; + std::vector bal(n, 0); // inflow minus outflow for (std::size_t e = 0; e < m; ++e) { - const Amount f = r.edge_flows[e]; + const Amount f = flows[e]; if (f < 0 || f > view[e]) fails.push_back("feasibility at edge " + std::to_string(e)); if (!edge_ok(e) && f != 0) fails.push_back("flow on an ineligible edge " + std::to_string(e)); bal[static_cast(g.edge_src()[e])] -= f; bal[static_cast(g.edge_dst()[e])] += f; - moment += static_cast<__int128>(g.cost()[e]) * f; } - std::set S(srcs.begin(), srcs.end()), D(dsts.begin(), dsts.end()); - __int128 supply = 0, inflow = 0; - std::vector<__int128> sup(n, 0), inf_d(n, 0); + const std::set S(srcs.begin(), srcs.end()), D(dsts.begin(), dsts.end()); + FlowBalance out {std::vector(n, 0), std::vector(n, 0)}; + Int128 supply = 0, inflow = 0; for (std::size_t v = 0; v < n; ++v) { const auto node = static_cast(v); if (S.count(node)) { if (bal[v] > 0) fails.push_back("source absorbs flow"); if (!node_ok(node) && bal[v] != 0) fails.push_back("excluded source supplies"); - sup[v] = -bal[v]; + out.supply[v] = -bal[v]; supply += -bal[v]; } else if (D.count(node)) { if (bal[v] < 0) fails.push_back("destination emits flow"); if (!node_ok(node) && bal[v] != 0) fails.push_back("excluded destination receives"); - inf_d[v] = bal[v]; + out.inflow[v] = bal[v]; inflow += bal[v]; } else if (bal[v] != 0) { fails.push_back("conservation at node " + std::to_string(v)); } } - if (supply != r.placed || inflow != r.placed) fails.push_back("value differs from supply or inflow"); - if (r.placed < 0 || (cap.has_value() && r.placed > *cap)) fails.push_back("placed outside [0, cap]"); - // Full residual closure seeded at the allowed real sources. - const std::size_t s_star = n, t_star = n + 1; - std::vector>> out(n + 2); + if (supply != placed || inflow != placed) fails.push_back("value differs from supply or inflow"); + return out; +} + +// The residual closure of a flow given per EdgeId: the nodes that the allowed nodes of `seeds` +// reach over allowed edges, forward along an edge whose flow leaves room in the view and backward +// along an edge that carries flow (a unit there can be undone). Marks them with 1. +inline std::vector residual_closure(const Graph& g, std::span view, + std::span flows, const std::vector& seeds, + const Eligibility* elig) { + const auto m = static_cast(g.num_edges()); + const auto n = static_cast(g.num_nodes()); + std::vector> next(n); for (std::size_t e = 0; e < m; ++e) { - if (!edge_ok(e)) continue; + if (elig != nullptr && !elig->edge_ok(static_cast(e))) continue; const auto u = static_cast(g.edge_src()[e]); const auto v = static_cast(g.edge_dst()[e]); - if (r.edge_flows[e] < view[e]) out[u].emplace_back(v, g.cost()[e]); - if (r.edge_flows[e] > 0) out[v].emplace_back(u, -g.cost()[e]); + if (flows[e] < view[e]) next[u].push_back(v); + if (flows[e] > 0) next[v].push_back(u); } - for (NodeId s : srcs) { - if (!node_ok(s)) continue; - out[s_star].emplace_back(static_cast(s), 0); - if (sup[static_cast(s)] > 0) out[static_cast(s)].emplace_back(s_star, 0); - } - for (NodeId d : dsts) { - if (!node_ok(d)) continue; - out[static_cast(d)].emplace_back(t_star, 0); - if (inf_d[static_cast(d)] > 0) out[t_star].emplace_back(static_cast(d), 0); - } - std::vector seen(n + 2, 0); + std::vector seen(n, 0); std::vector queue; - for (NodeId s : srcs) { - if (node_ok(s)) { seen[static_cast(s)] = 1; queue.push_back(static_cast(s)); } + for (NodeId s : seeds) { + const auto x = static_cast(s); + if ((elig == nullptr || elig->node_ok(s)) && seen[x] == 0) { + seen[x] = 1; + queue.push_back(x); + } } for (std::size_t qi = 0; qi < queue.size(); ++qi) { - for (auto [v, w] : out[queue[qi]]) { - (void)w; - if (!seen[v]) { seen[v] = 1; queue.push_back(v); } + for (const std::size_t v : next[queue[qi]]) { + if (seen[v] == 0) { + seen[v] = 1; + queue.push_back(v); + } + } + } + return seen; +} + +// The cut of a closure: every allowed edge of positive view from a node of the closure to a node +// outside it, ascending EdgeId. On a maximal flow, the closure of the sources holds no +// destination and this is the minimum cut the flow certifies. +inline std::vector closure_cut(const Graph& g, std::span view, const std::vector& closure, + const Eligibility* elig) { + std::vector cut; + for (std::size_t e = 0; e < static_cast(g.num_edges()); ++e) { + if (elig != nullptr && !elig->edge_ok(static_cast(e))) continue; + if (view[e] > 0 && closure[static_cast(g.edge_src()[e])] != 0 && + closure[static_cast(g.edge_dst()[e])] == 0) { + cut.push_back(static_cast(e)); + } + } + return cut; +} + +// The flow core's optimality certificate in two ordered branches. Returns the failed clauses. With +// `tier_a`, the complete curve is compared with the exhaustive optimum of every value; else with +// the independent Bellman-Ford curve at the union of both breakpoint sets. +inline std::vector certify(const Graph& g, std::span view, + std::vector srcs, std::vector dsts, + std::optional cap, const Eligibility* elig, + const FlowCoreResult& r, bool tier_a) { + std::vector fails; + const auto m = static_cast(g.num_edges()); + const auto n = static_cast(g.num_nodes()); + std::sort(srcs.begin(), srcs.end()); + srcs.erase(std::unique(srcs.begin(), srcs.end()), srcs.end()); + std::sort(dsts.begin(), dsts.end()); + dsts.erase(std::unique(dsts.begin(), dsts.end()), dsts.end()); + auto edge_ok = [&](std::size_t e) { return elig == nullptr || elig->edge_ok(static_cast(e)); }; + auto node_ok = [&](NodeId v) { return elig == nullptr || elig->node_ok(v); }; + if (r.edge_flows.size() != m || r.potentials.size() != n) { + fails.push_back("shape"); + return fails; + } + if (cap.has_value() && *cap == 0) { + if (r.placed != 0 || !r.cost_distribution.empty() || r.min_cut.has_value() || + std::any_of(r.edge_flows.begin(), r.edge_flows.end(), [](Amount x) { return x != 0; }) || + std::any_of(r.potentials.begin(), r.potentials.end(), [](Cost p) { return p != kNeverReached; }) || + !same_counters(r.counters, detail::FlowCoreCounters {})) { + fails.push_back("zero cap: the prescribed zero payload"); } + return fails; + } + const FlowBalance balance = flow_clauses(g, view, r.edge_flows, srcs, dsts, elig, r.placed, fails); + const Int128 moment = flow_cost(g, r.edge_flows); + if (r.placed < 0 || (cap.has_value() && r.placed > *cap)) fails.push_back("placed outside [0, cap]"); + // The full residual closure, with a virtual source s* (an arc to every allowed source, and one + // back from a source that sends) and a virtual sink t* (an arc from every allowed destination, + // and one back to a destination that receives): t* is in it when an allowed destination is + // (the destinations are still connected to the sources), and then so is everything a receiving + // destination reaches; s* adds no node, every allowed source being a seed. + std::vector seen = residual_closure(g, view, r.edge_flows, srcs, elig); + const bool connected = std::any_of(dsts.begin(), dsts.end(), [&](NodeId d) { + return node_ok(d) && seen[static_cast(d)] != 0; + }); + if (connected) { + std::vector seeds = srcs; + for (NodeId d : dsts) { + if (balance.inflow[static_cast(d)] > 0) seeds.push_back(d); + } + seen = residual_closure(g, view, r.edge_flows, seeds, elig); } - const bool connected = seen[t_star] != 0; if (!cap.has_value() && connected) fails.push_back("uncapped solve left the destinations connected"); if (cap.has_value() && r.placed != *cap && connected) fails.push_back("attained value: early stop"); if (connected) { if (r.min_cut.has_value()) fails.push_back("min_cut present on a connected solve"); } else { - std::vector expect; - __int128 cut_value = 0; - for (std::size_t e = 0; e < m; ++e) { - if (!edge_ok(e)) continue; - if (seen[static_cast(g.edge_src()[e])] && !seen[static_cast(g.edge_dst()[e])] && - view[e] > 0) { - expect.push_back(static_cast(e)); - cut_value += view[e]; - } - } + const std::vector expect = closure_cut(g, view, seen, elig); + Int128 cut_value = 0; + for (const EdgeId e : expect) cut_value += view[static_cast(e)]; if (!r.min_cut.has_value()) fails.push_back("min_cut missing on a maximal solve"); else if (*r.min_cut != expect) fails.push_back("min_cut differs from the closure cut"); if (cut_value != r.placed) fails.push_back("cut value differs from placed"); } + // The vertices some search settled: the first search runs on the zero flow and settles every + // vertex an allowed source reaches over eligible edges of positive view; a later search adds + // only the reverses of carrying edges, whose ends that one already settled. + std::vector settled(n, 0); + std::vector from_zero; + for (NodeId s : srcs) { + const auto x = static_cast(s); + if (node_ok(s) && !settled[x]) { settled[x] = 1; from_zero.push_back(x); } + } + for (std::size_t qi = 0; qi < from_zero.size(); ++qi) { + for (std::size_t e = 0; e < m; ++e) { + const auto u = static_cast(g.edge_src()[e]); + const auto v = static_cast(g.edge_dst()[e]); + if (u == from_zero[qi] && edge_ok(e) && view[e] > 0 && !settled[v]) { settled[v] = 1; from_zero.push_back(v); } + } + } for (std::size_t v = 0; v < n; ++v) { if (r.potentials[v] < kNeverReached) fails.push_back("potential below the sentinel"); if (seen[v] && r.potentials[v] == kNeverReached) fails.push_back("closure vertex holds NEVER_REACHED"); + if ((r.potentials[v] == kNeverReached) == (settled[v] != 0)) { + fails.push_back("NEVER_REACHED differs from reachability at zero flow at node " + std::to_string(v)); + } } for (NodeId s : srcs) { const auto p = r.potentials[static_cast(s)]; if (node_ok(s) && p != 0) fails.push_back("allowed root with potential other than 0"); } - for (std::size_t u = 0; u < n; ++u) { - if (!seen[u] || r.potentials[u] == kNeverReached) continue; - for (auto [v, w] : out[u]) { - if (v >= n || !seen[v] || r.potentials[v] == kNeverReached) continue; - if (static_cast<__int128>(w) + r.potentials[u] - r.potentials[v] < 0) { - fails.push_back("negative reduced cost inside the closure"); - } - } + // Every residual arc between two closure vertices with potentials: the forward arc of an + // allowed edge with room (its cost) and the backward arc of one that carries flow (minus it). + auto reduced_cost_clause = [&](std::size_t u, std::size_t v, Int128 w) { + if (!seen[u] || !seen[v] || r.potentials[u] == kNeverReached || r.potentials[v] == kNeverReached) return; + if (w + r.potentials[u] - r.potentials[v] < 0) fails.push_back("negative reduced cost inside the closure"); + }; + for (std::size_t e = 0; e < m; ++e) { + if (!edge_ok(e)) continue; + const auto u = static_cast(g.edge_src()[e]); + const auto v = static_cast(g.edge_dst()[e]); + if (r.edge_flows[e] < view[e]) reduced_cost_clause(u, v, g.cost()[e]); + if (r.edge_flows[e] > 0) reduced_cost_clause(v, u, -static_cast(g.cost()[e])); } std::optional pt; for (NodeId d : dsts) { @@ -368,7 +656,9 @@ inline std::vector certify(const Graph& g, std::span for (NodeId d : dsts) { const auto p = r.potentials[static_cast(d)]; if (p == kNeverReached) continue; - if (inf_d[static_cast(d)] > 0 && p != *pt) fails.push_back("inflow destination potential differs from pi(t*)"); + if (balance.inflow[static_cast(d)] > 0 && p != *pt) { + fails.push_back("inflow destination potential differs from pi(t*)"); + } } if (!r.cost_distribution.empty() && r.cost_distribution.back().first != *pt) { fails.push_back("pi(t*) differs from the last curve cost"); @@ -376,46 +666,101 @@ inline std::vector certify(const Graph& g, std::span } else if (r.placed != 0) { fails.push_back("flow delivered but no destination reached"); } - __int128 total = 0, curve_moment = 0; - for (std::size_t i = 0; i < r.cost_distribution.size(); ++i) { - const auto [c, u] = r.cost_distribution[i]; - if (u <= 0) fails.push_back("curve volume not positive"); - if (i > 0 && c <= r.cost_distribution[i - 1].first) fails.push_back("curve costs not strictly increasing"); - total += u; - curve_moment += static_cast<__int128>(c) * u; - } - if (total != r.placed) fails.push_back("curve total differs from placed"); - if (curve_moment != moment) fails.push_back("curve moment differs from the flow cost"); - const auto pts = payload_points(r); + const auto pts = curve_shape_clauses(r.cost_distribution, r.placed, moment, fails); + std::vector> optimum; if (tier_a) { - const auto best = exhaustive_costs(g, view, srcs, dsts, elig); - const Amount fmax = best.rbegin()->first; - const Amount top = cap.has_value() ? std::min(*cap, fmax) : fmax; - if (r.placed != top) fails.push_back("placed differs from min(cap, F_max)"); - for (Amount x = 0; x <= std::min(top, r.placed); ++x) { - auto it = best.find(x); - if (it == best.end() || curve_at(pts, x) != it->second) { - fails.push_back("curve differs from the exhaustive optimum at " + std::to_string(x)); - break; - } - } + for (const auto& [x, c] : exhaustive_costs(g, view, srcs, dsts, elig)) optimum.emplace_back(x, c); } else { - const auto ref = independent_curve(g, view, srcs, dsts, elig); - const Amount fmax = ref.back().first; - const Amount top = cap.has_value() ? std::min(*cap, fmax) : fmax; - if (r.placed != top) fails.push_back("placed differs from min(cap, F_max)"); - std::set xs; - for (auto [x, c] : pts) { (void)c; xs.insert(x); } - for (auto [x, c] : ref) { (void)c; xs.insert(x); } - for (Amount x : xs) { - if (x > std::min(top, r.placed)) continue; - if (curve_at(pts, x) != curve_at(ref, x)) { - fails.push_back("curve differs from the independent solver at " + std::to_string(x)); - break; - } - } + optimum = independent_curve(g, view, srcs, dsts, elig); } + const Amount fmax = optimum.back().first; + const Amount top = cap.has_value() ? std::min(*cap, fmax) : fmax; + if (r.placed != top) fails.push_back("placed differs from min(cap, F_max)"); + curve_optimum_clause(pts, optimum, std::min(top, r.placed), + tier_a ? "the exhaustive optimum" : "the independent solver", fails); return fails; } +// The fields in which two kernel plans differ, by name. `with_route` false leaves the route out: a +// plan built on a larger DAG keeps that DAG as its route but splits over the same members. +inline std::vector plan_differences(const detail::KernelPlan& got, const detail::KernelPlan& want, + bool with_route = true) { + std::vector out; + auto differ = [&](bool same, const char* name) { + if (!same) out.emplace_back(name); + }; + differ(got.placement == want.placement, "placement"); + differ(got.admission == want.admission, "admission"); + differ(got.src == want.src, "src"); + differ(vec(got.dsts) == vec(want.dsts), "dsts"); + if (with_route) differ(got.route == want.route, "route"); + differ(got.members == want.members, "members"); + differ(got.weights == want.weights, "weights"); + differ(vec(got.topo) == vec(want.topo), "topo"); + differ(vec(got.out_begin) == vec(want.out_begin), "out_begin"); + differ(vec(got.out_end) == vec(want.out_end), "out_end"); + differ(vec(got.head_pos) == vec(want.head_pos), "head_pos"); + differ(vec(got.terminal) == vec(want.terminal), "terminal"); + differ(got.src_out_begin == want.src_out_begin, "src_out_begin"); + differ(got.src_out_end == want.src_out_end, "src_out_end"); + return out; +} + +// One record on its own, from its fields alone: `edges` and `drops` hold positive amounts in +// strictly ascending EdgeId, only a Lossy record has `offered` and drops, and the edges carry +// the flow. Lossless: every node other than a source or a destination passes on what it +// receives, and `delivered` is the net inflow of the destinations (the net outflow of the +// sources). Lossy: a transit node passes on what it receives less what is dropped on the edges +// it forwards over, `delivered` is still the destinations' net inflow, and what was offered +// covers what was delivered and dropped (the rest is the source drops). Returns whether the +// record carries something (delivered or dropped). +inline bool expect_conserved(const Graph& g, const FlowRecord& r, const std::string& where) { + const auto n = static_cast(g.num_nodes()); + std::vector in(n, 0), out(n, 0), dropped_out(n, 0); + Amount total_drops = 0; + for (std::size_t i = 0; i < r.edges.size(); ++i) { + const auto [e, x] = r.edges[i]; + EXPECT_GT(x, 0) << where << " edge " << e; + if (i > 0) EXPECT_LT(r.edges[i - 1].first, e) << where; + out[static_cast(g.edge_src()[static_cast(e)])] += x; + in[static_cast(g.edge_dst()[static_cast(e)])] += x; + } + const bool lossy = r.admission == Admission::Lossy; + EXPECT_EQ(r.offered.has_value(), lossy) << where; + if (!lossy) EXPECT_TRUE(r.drops.empty()) << where; + for (std::size_t i = 0; i < r.drops.size(); ++i) { + const auto& [e, c] = r.drops[i]; + EXPECT_EQ(c.hi, 0u) << where; // the trials' drops stay below 2^64 + EXPECT_GT(c.lo, 0u) << where << " drop " << e; + if (i > 0) EXPECT_LT(r.drops[i - 1].first, e) << where; + const auto x = static_cast(c.lo); + dropped_out[static_cast(g.edge_src()[static_cast(e)])] += x; + total_drops += x; + } + auto in_set = [](const std::vector& set, std::size_t v) { + return std::find(set.begin(), set.end(), static_cast(v)) != set.end(); + }; + Amount into_destinations = 0, out_of_sources = 0; + for (std::size_t v = 0; v < n; ++v) { + if (in_set(r.destinations, v)) { + into_destinations += in[v] - out[v]; + } else if (in_set(r.sources, v)) { + out_of_sources += out[v] - in[v]; + } else { + EXPECT_EQ(in[v], out[v] + dropped_out[v]) << where << " node " << v; + } + } + EXPECT_EQ(into_destinations, r.delivered) << where; + if (lossy) { + if (r.offered.has_value() && r.offered->hi == 0) { + EXPECT_GE(static_cast(r.offered->lo), r.delivered + total_drops) << where; + } else { + ADD_FAILURE() << where << ": a Lossy record without offered, or offered beyond 2^64"; + } + } else { + EXPECT_EQ(out_of_sources, r.delivered) << where; + } + return r.delivered > 0 || total_drops > 0; +} + } // namespace netgraph::core::test diff --git a/tests/py/flow_oracle.py b/tests/py/flow_oracle.py index 333b406..b299271 100644 --- a/tests/py/flow_oracle.py +++ b/tests/py/flow_oracle.py @@ -1,10 +1,17 @@ -"""Independent checks of the flow-core payload (APPROACH 2.2) for the Python tests. +"""Independent checks of the flow-core payload (``_place_global_units``) for the Python tests. Nothing here calls the extension's solver: an exhaustive minimum cost of every integer -value on small graphs, and the certificate in its two ordered branches (feasibility, +value on small graphs, an independent successive-shortest-path solver (Bellman-Ford) for the +others, and the certificate in its two ordered branches (the payload shape, feasibility, conservation, attained value, the full residual closure of the real sources and its cut, -potentials with the NEVER_REACHED sentinel tested first, the virtual-terminal convention, -the curve shape, total and moment, and the complete curve against the exhaustive optimum). +potentials with the NEVER_REACHED sentinel tested first (never below it, never on a closure +vertex, and exactly on the vertices that no allowed source reaches over eligible edges of +positive view at zero flow), the virtual-terminal convention, +pi(t*) equal to the last curve cost, the curve shape, total and moment, and the complete +curve: against the exhaustive optimum of every value when the graph has at most 7 edges and +every view is at most 3, else against the independent solver at every breakpoint of either +curve; the C++ certificate checks the same clauses and makes the same two comparisons, its +branch chosen by each test). """ from __future__ import annotations @@ -43,6 +50,60 @@ def exhaustive(num_nodes, src, dst, cost, view, sources, dests, allowed=None): return best +def independent_curve(num_nodes, src, dst, cost, view, sources, dests, allowed=None): + """The minimum-cost curve as breakpoints [(value, cost)], from (0, 0): one shortest + augmenting path at a time, each found by Bellman-Ford over the residual arcs (a virtual + source feeds every source, every destination drains into a virtual sink).""" + big = sum(view) + 1 # more than any flow, so the virtual arcs never bind + arcs = [ + [src[e], dst[e], view[e], cost[e], 0] + for e in range(len(src)) + if allowed is None or allowed[e] + ] + s_v, t_v = num_nodes, num_nodes + 1 + arcs += [[s_v, s, big, 0, 0] for s in sources] + arcs += [[d, t_v, big, 0, 0] for d in dests] + points = [(0, 0)] + while True: + dist: list[int | None] = [None] * (num_nodes + 2) + parent: list[tuple[int, int] | None] = [None] * (num_nodes + 2) + dist[s_v] = 0 + for _ in range(num_nodes + 2): + changed = False + for i, (u, v, cap, w, flow) in enumerate(arcs): + du, dv = dist[u], dist[v] + if flow < cap and du is not None and (dv is None or du + w < dv): + dist[v], parent[v], changed = du + w, (i, 1), True + du, dv = dist[u], dist[v] + if flow > 0 and dv is not None and (du is None or dv - w < du): + dist[u], parent[u], changed = dv - w, (i, -1), True + if not changed: + break + if dist[t_v] is None: + return points + path, v = [], t_v + while v != s_v: + i, sign = parent[v] + path.append((i, sign)) + v = arcs[i][0] if sign == 1 else arcs[i][1] + amount = min( + arcs[i][2] - arcs[i][4] if sign == 1 else arcs[i][4] for i, sign in path + ) + for i, sign in path: + arcs[i][4] += sign * amount + value, total = points[-1] + points.append((value + amount, total + dist[t_v] * amount)) + + +def points_value(points, x): + """Cost of the piecewise-linear curve through the breakpoints at a value in its domain + (an empty or backward segment, which the curve-shape clause rejects, is skipped).""" + for (a, ca), (b, cb) in zip(points, points[1:], strict=False): + if a <= x <= b and a < b: + return ca + Fraction(cb - ca, b - a) * (x - a) + return Fraction(points[-1][1]) + + def curve_value(curve, x): """Cost of the convex function given by its marginal curve at the integer value x.""" total = Fraction(0) @@ -72,6 +133,9 @@ def certify( placed = payload["placed"] curve = [tuple(t) for t in payload["cost_distribution"]] fails: list[str] = [] + if len(f) != m or len(pi) != n: + fails.append("shape") + return fails if cap == 0: zero = { "placed": 0, @@ -106,7 +170,7 @@ def certify( fails.append(f"conservation at {v}") if sum(supply.values()) != placed or sum(inflow.values()) != placed: fails.append("value differs from supply or inflow") - if cap is not None and not 0 <= placed <= cap: + if placed < 0 or (cap is not None and placed > cap): fails.append("placed outside [0, cap]") s_star, t_star = n, n + 1 out: list[list[tuple[int, int]]] = [[] for _ in range(n + 2)] @@ -153,11 +217,26 @@ def certify( fails.append(f"min_cut {payload['min_cut']} != closure cut {expect}") if sum(view[e] for e in expect) != placed: fails.append("cut value differs from placed") + # The vertices some search settled: the first search runs on the zero flow and settles + # every vertex an allowed source reaches over eligible edges of positive view; a later + # search adds only the reverses of carrying edges, whose ends that one already settled. + settled = set(roots) + queue = deque(roots) + while queue: + u = queue.popleft() + for e in range(m): + if src[e] == u and edge_ok[e] and view[e] > 0 and dst[e] not in settled: + settled.add(dst[e]) + queue.append(dst[e]) for v in range(n): if pi[v] < NEVER_REACHED: fails.append("potential below the sentinel") if v in seen and pi[v] == NEVER_REACHED: fails.append(f"closure vertex {v} holds NEVER_REACHED") + if (pi[v] == NEVER_REACHED) == (v in settled): + fails.append( + f"NEVER_REACHED differs from reachability at zero flow at node {v}" + ) for s in roots: if pi[s] != 0: fails.append(f"root {s} potential {pi[s]}") @@ -173,6 +252,8 @@ def certify( for d in reached: if inflow[d] > 0 and pi[d] != pt: fails.append(f"inflow destination {d} potential differs from pi(t*)") + if curve and curve[-1][0] != pt: + fails.append("pi(t*) differs from the last curve cost") elif placed: fails.append("flow delivered but no destination reached") if any(u <= 0 for _c, u in curve): @@ -183,16 +264,28 @@ def certify( fails.append("curve total differs from placed") if sum(c * u for c, u in curve) != sum(cost[e] * f[e] for e in range(m)): fails.append("curve moment differs from the flow cost") + allowed_dests = [d for d in d_all if node_ok[d]] if m <= 7 and max(view, default=0) <= 3: - best = exhaustive( - n, src, dst, cost, view, roots, [d for d in d_all if node_ok[d]], edge_ok - ) + best = exhaustive(n, src, dst, cost, view, roots, allowed_dests, edge_ok) fmax = max(best) top = fmax if cap is None else min(cap, fmax) if placed != top: - fails.append(f"placed {placed} != min(cap, F_max) {top}") + fails.append(f"placed {placed} != min(cap, F_max) {top} (exhaustive)") for x in range(min(top, placed) + 1): if curve_value(curve, x) != best[x]: fails.append(f"curve differs from the exhaustive optimum at {x}") break + else: + ref = independent_curve(n, src, dst, cost, view, roots, allowed_dests, edge_ok) + fmax = ref[-1][0] + top = fmax if cap is None else min(cap, fmax) + if placed != top: + fails.append(f"placed {placed} != min(cap, F_max) {top} (independent)") + mine = [(0, 0)] + for c, u in curve: + mine.append((mine[-1][0] + u, mine[-1][1] + c * u)) + for x in sorted({x for x, _c in mine + ref}): + if x <= min(top, placed) and points_value(mine, x) != points_value(ref, x): + fails.append(f"curve differs from the independent solver at {x}") + break return fails diff --git a/tests/py/helpers.py b/tests/py/helpers.py new file mode 100644 index 0000000..cd950a0 --- /dev/null +++ b/tests/py/helpers.py @@ -0,0 +1,205 @@ +"""Builders and constants shared by the Python tests (not collected: no test_ prefix): graphs, +EdgeId mappings, DAGs, path selections, the two policy configurations most tests use, results +and ledgers as comparable data, the sweep of a call's Python object allocations, and the +pytest.raises pattern of an exact message.""" + +from __future__ import annotations + +import math +import re + +import pytest + +import netgraph_core as ngc + +INF = math.inf +L62 = 1 << 62 +DET = ngc.EdgeTieBreak.DETERMINISTIC +PREFER = ngc.EdgeTieBreak.PREFER_HIGHER_PRIORITY +CAP = ngc.EdgePriority.CAPACITY +RES = ngc.EdgePriority.RESIDUAL +# Every optional output of a placement or an evaluator. +ALL_OUT = ngc.OutputOptions(edge_flows=True, residual=True, bottleneck=True) +# A chain 0 -> 1 -> 2 -> 3 (cost 1 per edge) with the bypasses 0 -> 2 and 1 -> 3 (cost 3), every +# capacity 1: (u, v, capacity, cost) in input order. +CHAIN = [(0, 1, 1, 1), (1, 2, 1, 1), (2, 3, 1, 1), (0, 2, 1, 3), (1, 3, 1, 3)] + + +def exact(text): + """The pytest.raises pattern of exactly this message: the whole text, so a message with + anything more, a trailing newline included, does not match.""" + return rf"\A{re.escape(text)}\Z" + + +def build(n, edges, q=1.0): + """A Graph from (u, v, capacity, cost) rows in input order, at resolution q.""" + return ngc.Graph( + n, + [e[0] for e in edges], + [e[1] for e in edges], + [e[2] for e in edges], + [e[3] for e in edges], + q, + ) + + +def to_ids(g, values_in): + """A per-edge list in input order, permuted to EdgeId order.""" + return [values_in[int(i)] for i in g.input_index()] + + +def by_input(g, values): + """A per-edge list in EdgeId order, permuted to input order (the inverse of to_ids).""" + out = [0] * len(values) + for e, x in enumerate(values): + out[int(g.input_index()[e])] = x + return out + + +def stats_of(cache): + """The five statistics of a DagCache as one comparable tuple.""" + s = cache.stats() + return (s.hits, s.misses, s.entries, s.bytes, s.live_values) + + +def ids(g, positions): + """The EdgeIds of the edges at the given input positions, ascending.""" + wanted = {int(p) for p in positions} + return sorted(int(e) for e, p in enumerate(g.input_index()) if int(p) in wanted) + + +def dag_of(g, edges): + """The PredDAG of a set of EdgeIds, through the public PredDAG.from_csr.""" + per = [[] for _ in range(g.num_nodes())] + for e in sorted(edges): + per[int(g.edge_dst()[e])].append(e) + offsets, parents, via = [0], [], [] + for v in range(g.num_nodes()): + for e in per[v]: + parents.append(int(g.edge_src()[e])) + via.append(e) + offsets.append(len(via)) + return ngc.PredDAG.from_csr(g, offsets, parents, via) + + +def sel(tie=DET, prio=CAP, mp=True, me=True): + """A PathSelection: deterministic ties, capacity priority, multipath, multi-edge by default.""" + return ngc.PathSelection(tie, prio, mp, me) + + +def config( + routing, placement=ngc.FlowPlacement.EQUAL_BALANCED, admission=None, selection=None +): + """A FlowPolicyConfig: EqualBalanced, Lossless and sel() by default.""" + return ngc.FlowPolicyConfig( + routing=routing, + placement=placement, + admission=admission or ngc.Admission.LOSSLESS, + selection=selection or sel(), + ) + + +# The exact maximum flow (Global routing, MaxFlow placement) and CostOnly EqualBalanced, both +# Lossless with sel(). Configurations are immutable, so tests share these values. +GLOBAL = config(ngc.Global(), ngc.FlowPlacement.MAX_FLOW) +COSTONLY = config(ngc.CostOnly()) + + +# The result classes whose public attributes are all fields (no methods). +_RESULTS = ( + ngc.PlacementResult, + ngc.SensitivityResult, + ngc.SensitivityEntry, + ngc.SpfResult, +) + + +def plain(value): + """A result as comparable data, with no object identities: a result object by every public + field, a PredDAG by its three arrays, a Path by its endpoints, hops and cost, containers + element by element, arrays as lists.""" + if isinstance(value, _RESULTS): + fields = [f for f in dir(value) if not f.startswith("_")] + return {f: plain(getattr(value, f)) for f in fields} + if isinstance(value, ngc.PredDAG): + return [ + plain(a) + for a in (value.parent_offsets(), value.parents(), value.via_edges()) + ] + if isinstance(value, ngc.Path): + return [value.src(), value.dst(), plain(value.hops()), value.cost()] + if isinstance(value, dict): + return {k: plain(x) for k, x in value.items()} + if isinstance(value, (list, tuple)): + return [plain(x) for x in value] + return value.tolist() if hasattr(value, "tolist") else value + + +def ledger_state(ledger): + """A FlowLedger's public state as comparable data, with no object identity (its uid, which + is also the first half of every FlowId, and its graph's uid, which every bundle carries): + the residual, the allocations and every public field of every record, the bundle as its + three arrays.""" + return [ + ledger.residual().tolist(), + ledger.allocated().tolist(), + [ + ( + r.flow_id[1], + r.owner, + r.sources, + r.destinations, + r.placement, + r.admission, + [ + r.bundle.parent_offsets().tolist(), + r.bundle.parents().tolist(), + r.bundle.via_edges().tolist(), + ], + r.route_cost, + r.offered, + r.delivered, + r.edges, + r.drops, + ) + for r in ledger.records() + ], + ] + + +def full_ledger_state(ledger): + """The ledger's observable state, its next FlowId and bound and orphaned owners included + (``_state()``: private hooks needed).""" + return [ledger._state(), ledger_state(ledger)] + + +def sweep_python_allocation(make, limit=400): + """The all-or-nothing sweep of a call's Python object allocations (private hooks needed). + make() gives fresh (fn, args, snapshot); attempt k = 0, 1, ... makes the k-th Python object + allocation of fn(*args) fail, until a call completes. Every reached failure raises + MemoryError or RuntimeError with no value and leaves snapshot() as it was, and the same call + then returns the reference (the plain() value of a call on fresh state); the call that + completes returns the reference after each of its allocations failed exactly once. Returns + the number of failures, for the caller's check that the sweep is not vacuous.""" + import _netgraph_core as ext + + fn, args, _ = make() + want = plain(fn(*args)) + failures = 0 + for k in range(limit): + fn, args, snapshot = make() + before = snapshot() + hit, seen, value, error = ext._fail_python_allocation(fn, args, k) + where = (getattr(fn, "__name__", fn), k) + if not hit: + assert error is None, (where, repr(error)) + assert plain(value) == want, where + assert seen == failures, (where, seen) # every allocation failed once + return failures + failures += 1 + # A reached failure propagates: a call that absorbed it and returned a value fails here. + assert value is None, (where, value) + assert isinstance(error, (MemoryError, RuntimeError)), (where, repr(error)) + assert snapshot() == before, where + assert plain(fn(*args)) == want, where + pytest.fail(f"{getattr(fn, '__name__', fn)} never completed") diff --git a/tests/py/private_hooks.py b/tests/py/private_hooks.py index 797af25..cb8cca3 100644 --- a/tests/py/private_hooks.py +++ b/tests/py/private_hooks.py @@ -1,6 +1,14 @@ """Whether the extension carries the private hooks (CMake option NETGRAPH_CORE_PRIVATE_HOOKS: ON for the editable development build, OFF for an installed wheel). Tests that need a hook are -skipped without them, never failed.""" +skipped without them, never failed. + +A few scenario tests run in both builds and add checks of the private state observers +(``_state()``: next FlowId, bound and orphaned owners, the requested volume, a flow's members) +under ``if HOOKS:``; without the hooks those lines alone are left out. They are +test_flow_ledger.py's test_lifetime_bound_rows_and_epochs, +test_orphans_from_destruction_reset_and_copy, test_records_fields_and_counters_beyond_64_bits, +test_empty_split_graph_keeps_the_route_cost and test_rejected_requests_leave_the_policy_and_the_ledger, +and test_flow_policy.py's test_lifetimes_keep_alive_and_orphans.""" import _netgraph_core import pytest diff --git a/tests/py/test_apportionment.py b/tests/py/test_apportionment.py index 8321550..b6582a2 100644 --- a/tests/py/test_apportionment.py +++ b/tests/py/test_apportionment.py @@ -1,10 +1,11 @@ -"""The apportionment through the private hooks (``_round_robin``, ``_highest_averages``, -APPROACH 2.1).""" +"""The apportionment through the private hooks (``_round_robin``, ``_highest_averages``; +docs/semantics.md#1-units-the-one-numeric-domain).""" from __future__ import annotations import _netgraph_core as ext import pytest +from helpers import exact from private_hooks import needs_hooks @@ -16,8 +17,11 @@ def test_apportionment_entries(): assert ext._highest_averages(4, [1, 3, 3]) == [0, 2, 2] big = (1 << 62) - 2 a = ext._highest_averages(big, [(1 << 60) + 1, (1 << 60) - 1, 7]) + # By hand: the lower quotas floor(big * w / W), W = 2^61 + 7, are 2^61 - 6, 2^61 - 10 and + # 13; the one unit left goes to the largest w / (a + 1), the second member's. + assert a == [(1 << 61) - 6, (1 << 61) - 9, 13] assert sum(a) == big - with pytest.raises(ValueError, match="outside"): + with pytest.raises(ValueError, match=exact("weights[0] = 0 is outside [1, 2^62)")): ext._highest_averages(1, [0, 1]) - with pytest.raises(ValueError, match="2\\^62"): + with pytest.raises(ValueError, match=exact("the weights sum to 2^62 or more")): ext._highest_averages(1, [1 << 61, 1 << 61]) diff --git a/tests/py/test_dag_cache.py b/tests/py/test_dag_cache.py index 808b8e2..bffcb77 100644 --- a/tests/py/test_dag_cache.py +++ b/tests/py/test_dag_cache.py @@ -1,15 +1,18 @@ -"""The DagCache's modes through the binding (APPROACH 10, 11): the transition table, DagCacheMiss -as a dedicated exception (not a RuntimeError, so never a sensitivity status), the evaluators' -sharing rules (a frozen cache shared by every worker, a building one rejected with two or more), -the reuse of prepopulated Eligibility uids, frozen lookups from several Python threads, and a -frozen cache's statistics under failed calls (review-1d F1, NOTES R27).""" +"""The DagCache's modes through the binding (docs/semantics.md#9-the-dag-cache): the +transition table, DagCacheMiss as a dedicated exception propagated unchanged by the evaluators, +the evaluators' sharing rules (a frozen cache shared by every worker, a +building one rejected with two or more), the reuse of prepopulated Eligibility uids, frozen +lookups from several Python threads, and a frozen cache's statistics under failed calls.""" from __future__ import annotations +import itertools import threading +import traceback import _netgraph_core as ext import pytest +from helpers import ALL_OUT, COSTONLY, GLOBAL, build, stats_of from private_hooks import needs_hooks import netgraph_core as ngc @@ -20,47 +23,30 @@ def fabric(): cost 1).""" rows = [(leaf, s) for leaf in range(6) for s in range(6, 9)] rows += [(s, leaf) for leaf, s in rows] - return ngc.Graph( - 9, - [r[0] for r in rows], - [r[1] for r in rows], - [4] * len(rows), - [1] * len(rows), - 1.0, - ) - - -def costonly(): - return ngc.FlowPolicyConfig( - routing=ngc.CostOnly(), - placement=ngc.FlowPlacement.EQUAL_BALANCED, - admission=ngc.Admission.LOSSLESS, - selection=ngc.PathSelection( - ngc.EdgeTieBreak.DETERMINISTIC, ngc.EdgePriority.CAPACITY - ), - ) + return build(9, [(u, v, 4, 1) for u, v in rows]) -def prepopulate(g, keys, elig=None): +def prepopulate(g, keys): """A building cache holding the CostOnly support of each destination set (a capacity call looks up the destinations' reverse support; the source is spine 6), frozen afterwards.""" cache = ngc.DagCache(g) for dst in keys: - ngc.capacity(g, 6, dst, ngc.CapacityOptions(costonly(), elig=elig, cache=cache)) + ngc.capacity(g, 6, dst, ngc.CapacityOptions(COSTONLY, cache=cache)) cache.freeze() return cache def stats(cache): - s = cache.stats() - return (s.hits, s.misses, s.entries) + """Hits, misses and entries (the three statistics the transition table speaks of).""" + return stats_of(cache)[:3] def test_transition_table(): g = fabric() cache = ngc.DagCache(g) - opts = ngc.CapacityOptions(costonly(), cache=cache) - want = ngc.capacity(g, 0, 3, ngc.CapacityOptions(costonly())).placed + opts = ngc.CapacityOptions(COSTONLY, cache=cache) + want = ngc.capacity(g, 0, 3, ngc.CapacityOptions(COSTONLY)).placed + assert want == 12 # three spines of 4: the comparisons below are not of zeros assert ngc.capacity(g, 0, 3, opts).placed == want assert stats(cache) == (0, 1, 1) cache.freeze() @@ -95,31 +81,35 @@ def test_frozen_cache_shared_by_every_worker(): g = fabric() pairs = [(0, 3), (1, 4), (2, 5), ([0, 1], [3, 4])] want = [ - r.placed for r in ngc.batch_capacity(g, pairs, ngc.CapacityOptions(costonly())) + r.placed for r in ngc.batch_capacity(g, pairs, ngc.CapacityOptions(COSTONLY)) ] building = ngc.DagCache(g) with pytest.raises(ValueError, match="workers == 1"): ngc.batch_capacity( - g, pairs, ngc.CapacityOptions(costonly(), cache=building), workers=2 + g, pairs, ngc.CapacityOptions(COSTONLY, cache=building), workers=2 ) + assert all(w > 0 for w in want) # every pair places: the comparison is not of zeros cache = prepopulate(g, [[3], [4], [5], [3, 4]]) - before = stats(cache) for workers in (1, 2, 4): + before = stats(cache) got = ngc.batch_capacity( - g, pairs, ngc.CapacityOptions(costonly(), cache=cache), workers=workers + g, pairs, ngc.CapacityOptions(COSTONLY, cache=cache), workers=workers ) assert [r.placed for r in got] == want - assert stats(cache) == (before[0] + 12, before[1], 4) - # The lowest failing pair index's miss: pairs 0 and 2 miss, pair 0's key is reported. + assert stats(cache) == (before[0] + 4, before[1], 4), workers # a hit per pair + # The lowest failing pair index's miss: pairs 0 and 2 miss, pair 0's key is reported, and + # the call counts that one miss. partial = prepopulate(g, [[4]]) for workers in (1, 2, 4): + before = stats(partial) with pytest.raises(ngc.DagCacheMiss, match=r"roots \{3\}"): ngc.batch_capacity( g, pairs[:3], - ngc.CapacityOptions(costonly(), cache=partial), + ngc.CapacityOptions(COSTONLY, cache=partial), workers=workers, ) + assert stats(partial) == (before[0], before[1] + 1, 1), workers def test_prepopulated_uids_and_intersections(): @@ -129,15 +119,15 @@ def test_prepopulated_uids_and_intersections(): other = g.eligibility(excluded_edges=[1]) # the intersection with common is new pairs = [(1, 4), (2, 5)] cache = ngc.DagCache(g) - ngc.capacity(g, 1, 4, ngc.CapacityOptions(costonly(), elig=narrow, cache=cache)) - ngc.capacity(g, 2, 5, ngc.CapacityOptions(costonly(), elig=common, cache=cache)) + ngc.capacity(g, 1, 4, ngc.CapacityOptions(COSTONLY, elig=narrow, cache=cache)) + ngc.capacity(g, 2, 5, ngc.CapacityOptions(COSTONLY, elig=common, cache=cache)) cache.freeze() - opts = ngc.CapacityOptions(costonly(), elig=common, cache=cache) + opts = ngc.CapacityOptions(COSTONLY, elig=common, cache=cache) ngc.batch_capacity(g, pairs, opts, [narrow, None], workers=2) # returns the operand ngc.batch_capacity( g, pairs, - ngc.CapacityOptions(costonly(), cache=cache), + ngc.CapacityOptions(COSTONLY, cache=cache), [narrow, common], workers=2, ) @@ -149,24 +139,20 @@ def test_prepopulated_uids_and_intersections(): def test_sensitivity_propagates_the_miss_and_global_reads_no_cache(): g = fabric() cache = prepopulate(g, [[4]]) - opts = ngc.SensitivityOptions(costonly(), cache=cache) + opts = ngc.SensitivityOptions(COSTONLY, cache=cache) for workers in (1, 2, 4): + before = stats(cache) with pytest.raises(ngc.DagCacheMiss, match=r"roots \{4\}"): ngc.sensitivity(g, 1, 4, opts, workers) - glob = ngc.FlowPolicyConfig( - routing=ngc.Global(), - placement=ngc.FlowPlacement.MAX_FLOW, - admission=ngc.Admission.LOSSLESS, - selection=ngc.PathSelection( - ngc.EdgeTieBreak.DETERMINISTIC, ngc.EdgePriority.CAPACITY - ), - ) - want = ngc.sensitivity(g, [0, 1], [4, 5], ngc.SensitivityOptions(glob)) + assert stats(cache) == (before[0], before[1] + 1, 1), workers # the miss, once + want = ngc.sensitivity(g, [0, 1], [4, 5], ngc.SensitivityOptions(GLOBAL)) + assert want.baseline.placed > 0 + assert any(e.loss > 0 for e in want.entries) # the comparison below is not of zeros empty = ngc.DagCache(g) empty.freeze() for workers in (1, 2, 4): got = ngc.sensitivity( - g, [0, 1], [4, 5], ngc.SensitivityOptions(glob, cache=empty), workers + g, [0, 1], [4, 5], ngc.SensitivityOptions(GLOBAL, cache=empty), workers ) assert got.baseline.placed == want.baseline.placed assert [(e.edge, e.loss) for e in got.entries] == [ @@ -179,18 +165,19 @@ def test_frozen_lookups_from_python_threads(): g = fabric() cache = prepopulate(g, [[d] for d in range(6)]) want = { - d: ngc.capacity(g, 6, d, ngc.CapacityOptions(costonly())).placed - for d in range(6) + d: ngc.capacity(g, 6, d, ngc.CapacityOptions(COSTONLY)).placed for d in range(6) } + # The spine's one edge of 4 into each leaf: the comparisons below are not of zeros. + assert want == dict.fromkeys(range(6), 4) errors = [] def reader(t): try: for i in range(60): d = (i + t) % 6 - r = ngc.capacity(g, 6, d, ngc.CapacityOptions(costonly(), cache=cache)) + r = ngc.capacity(g, 6, d, ngc.CapacityOptions(COSTONLY, cache=cache)) assert r.placed == want[d] - except Exception as exc: # noqa: BLE001 - reported below + except BaseException as exc: # reported below; pytest's own failures included errors.append(exc) before = stats(cache) @@ -203,11 +190,6 @@ def reader(t): assert stats(cache) == (before[0] + 240, before[1], 6) -def all_stats(cache): - s = cache.stats() - return (s.hits, s.misses, s.entries, s.bytes, s.live_values) - - def plus(stats, hits=0, misses=0): return (stats[0] + hits, stats[1] + misses, *stats[2:]) @@ -218,23 +200,23 @@ def test_a_failed_call_changes_no_statistic_but_its_miss(): # worker and with several; CostOnly sensitivity drops its baseline's hit the same way. g = fabric() cache = prepopulate(g, [[3]]) - opts = ngc.CapacityOptions(costonly(), cache=cache) + opts = ngc.CapacityOptions(COSTONLY, cache=cache) for workers in (1, 2, 4): - before = all_stats(cache) + before = stats_of(cache) with pytest.raises(ngc.DagCacheMiss, match=r"roots \{4\}"): ngc.batch_capacity( g, [(0, 3), (1, 4), (2, 5), (0, 3)], opts, workers=workers ) - assert all_stats(cache) == plus(before, misses=1) - before = all_stats(cache) + assert stats_of(cache) == plus(before, misses=1) + before = stats_of(cache) with pytest.raises(ngc.DagCacheMiss, match=r"roots \{3\}"): ngc.sensitivity( - g, 0, 3, ngc.SensitivityOptions(costonly(), cache=cache), workers + g, 0, 3, ngc.SensitivityOptions(COSTONLY, cache=cache), workers ) - assert all_stats(cache) == plus(before, misses=1) - before = all_stats(cache) + assert stats_of(cache) == plus(before, misses=1) + before = stats_of(cache) assert ngc.batch_capacity(g, [(0, 3), (1, 3)], opts, workers=2) - assert all_stats(cache) == plus(before, hits=2) + assert stats_of(cache) == plus(before, hits=2) def test_concurrent_calls_keep_every_successful_hit(): @@ -242,7 +224,7 @@ def test_concurrent_calls_keep_every_successful_hit(): # successful capacity (one hit) and a failing batch (its hit dropped, one miss). g = fabric() cache = prepopulate(g, [[3], [4]]) - opts = ngc.CapacityOptions(costonly(), cache=cache) + opts = ngc.CapacityOptions(COSTONLY, cache=cache) errors = [] def caller(): @@ -251,35 +233,36 @@ def caller(): assert ngc.capacity(g, 0, 3, opts).placed > 0 with pytest.raises(ngc.DagCacheMiss): ngc.batch_capacity(g, [(1, 4), (2, 5)], opts, workers=2) - except Exception as exc: # noqa: BLE001 - reported below + except BaseException as exc: # reported below; pytest's own failures included errors.append(exc) - before = all_stats(cache) + before = stats_of(cache) threads = [threading.Thread(target=caller) for _ in range(4)] for t in threads: t.start() for t in threads: t.join() assert errors == [] - assert all_stats(cache) == plus(before, hits=160, misses=160) + assert stats_of(cache) == plus(before, hits=160, misses=160) def sweep_frozen(cache, fn, args, hits=0, miss=False): """fn(*args) with each Python object allocation failing in turn: a failed call leaves all - five statistics as they were, the result object's allocation included (the reviewer's - witness: capacity's allocation 8); the call that completes adds exactly `hits` hits, or, when - its outcome is the DagCacheMiss, one miss. One case keeps the miss: when the call has ended - in the miss and pybind11's translation of DagCacheMiss into its Python exception fails - (MemoryError instead), the miss is counted (NOTES R27, APPROACH 13 limit 1); every such - failure comes after every allocation of the call itself. Returns the number of failures.""" - failures = 0 + five statistics as they were, the result object's allocation included (for example + capacity's allocation 8); the call that completes adds exactly `hits` hits, or, when its + outcome is the DagCacheMiss, one miss. One case keeps the miss: when the call has ended in + the miss and pybind11's translation of DagCacheMiss into its Python exception fails + (MemoryError instead), the miss is counted (pybind11's own machinery is outside the + all-or-nothing guarantee); every such failure comes after every allocation of the call + itself. Returns the number of failures inside the call (the translation's excluded).""" + inside = 0 translated = ( None # the first allocation index past the call (translation of the miss) ) for k in range(1000): - before = all_stats(cache) + before = stats_of(cache) hit, _, _, error = ext._fail_python_allocation(fn, args, k) - after = all_stats(cache) + after = stats_of(cache) if not hit: if miss: assert isinstance(error, ngc.DagCacheMiss), repr(error) @@ -287,8 +270,7 @@ def sweep_frozen(cache, fn, args, hits=0, miss=False): else: assert error is None, repr(error) assert after == plus(before, hits=hits), (k, before, after) - return failures - failures += 1 + return inside assert isinstance(error, (MemoryError, RuntimeError)), (k, repr(error)) if miss and after == plus(before, misses=1): assert isinstance(error, MemoryError), (k, repr(error)) @@ -300,6 +282,7 @@ def sweep_frozen(cache, fn, args, hits=0, miss=False): "a failure inside the call after its translation", ) assert after == before, (k, before, after) + inside += 1 pytest.fail(f"{fn.__name__} never completed") @@ -308,13 +291,13 @@ def test_frozen_statistics_survive_python_allocation_failures(): g = fabric() cache = prepopulate(g, [[3], [4], [3, 4]]) opts = ngc.CapacityOptions( - costonly(), + COSTONLY, cache=cache, - outputs=ngc.OutputOptions(edge_flows=True, residual=True, bottleneck=True), + outputs=ALL_OUT, ) pairs = [(0, 3), (1, 4), ([0, 1], [3, 4])] assert sweep_frozen(cache, ngc.capacity, (g, 0, 3, opts), hits=1) >= 3 - assert sweep_frozen(cache, ngc.capacity, (g, 0, 5, opts), miss=True) >= 1 + assert sweep_frozen(cache, ngc.capacity, (g, 0, 5, opts), miss=True) >= 3 for workers in (1, 2): assert ( sweep_frozen( @@ -329,15 +312,15 @@ def test_frozen_statistics_survive_python_allocation_failures(): (g, [(0, 3), (1, 5)], opts, None, workers), miss=True, ) - >= 1 + >= 3 ) - sens = ngc.SensitivityOptions(costonly(), cache=cache) - assert sweep_frozen(cache, ngc.sensitivity, (g, 0, 3, sens, 1), miss=True) >= 1 - # CostOnly sensitivity from a source that reaches no destination: one hit (the baseline's), - # no placed deletion, and a result with an entry per edge. - island = ngc.Graph(3, [0, 1], [1, 0], [4, 4], [1, 1], 1.0) + sens = ngc.SensitivityOptions(COSTONLY, cache=cache) + assert sweep_frozen(cache, ngc.sensitivity, (g, 0, 3, sens, 1), miss=True) >= 3 + # CostOnly sensitivity from a source that reaches no destination: one hit (the baseline's) + # and no placed deletion. + island = build(3, [(0, 1, 4, 1), (1, 0, 4, 1)]) small = ngc.DagCache(island) - ngc.capacity(island, 0, 1, ngc.CapacityOptions(costonly(), cache=small)) + ngc.capacity(island, 0, 1, ngc.CapacityOptions(COSTONLY, cache=small)) small.freeze() for workers in (1, 2): assert ( @@ -348,10 +331,109 @@ def test_frozen_statistics_survive_python_allocation_failures(): island, 2, 1, - ngc.SensitivityOptions(costonly(), cache=small), + ngc.SensitivityOptions(COSTONLY, cache=small), workers, ), hits=1, ) >= 3 ) + + +@needs_hooks +def test_the_frozen_sweep_rejects_a_wrong_call(): + # sweep_frozen rejects a wrong call, each on its own clause: one that absorbs the injected + # failure; one that publishes a hit before the call the failure reaches; a hit where a miss + # is expected; a miss counted by another cache; a completing call that raises; a hit count + # other than expected; a failed translation of the miss reported as RuntimeError; a failure + # inside a call after a translation; and a call that never completes. + g = fabric() + cache = prepopulate(g, [[3]]) + other = prepopulate(g, [[3]]) + opts = ngc.CapacityOptions(COSTONLY, cache=cache, outputs=ALL_OUT) + elsewhere = ngc.CapacityOptions(COSTONLY, cache=other, outputs=ALL_OUT) + hit = (g, 0, 3, opts) + miss = (g, 0, 5, opts) + + def absorbs(*args): + try: + return ngc.capacity(*args) + except MemoryError: + return None + + def hits_first(*args): + ngc.capacity(*args) + return ngc.capacity(*args) + + def runtime_error(*args): + try: + return ngc.capacity(*args) + except MemoryError: + raise RuntimeError("in place of MemoryError") from None + + # The miss call until its allocations are used up (its translation fails last), then a + # longer call of hits that fails inside. + turn = ext._fail_python_allocation(ngc.capacity, miss, 10**6)[1] + longer = (g, [(0, 3), (1, 3)], opts, None, 1) + assert ext._fail_python_allocation(ngc.batch_capacity, longer, 10**6)[1] > turn + 1 + attempts = itertools.count() + + def inside_after_translation(*args): + if next(attempts) < turn: + return ngc.capacity(*args) + return ngc.batch_capacity(*longer) + + for fn, args, expect, clause in ( + ( + absorbs, + hit, + {"hits": 2}, + "assert isinstance(error, (MemoryError, RuntimeError)), (k, repr(error))", + ), + (hits_first, hit, {"hits": 2}, "assert after == before, (k, before, after)"), + ( + ngc.capacity, + hit, + {"miss": True}, + "assert isinstance(error, ngc.DagCacheMiss), repr(error)", + ), + ( + ngc.capacity, + (g, 0, 5, elsewhere), + {"miss": True}, + "assert after == plus(before, misses=1), (k, before, after)", + ), + (ngc.capacity, (g, 0, 99, opts), {}, "assert error is None, repr(error)"), + ( + ngc.capacity, + hit, + {"hits": 2}, + "assert after == plus(before, hits=hits), (k, before, after)", + ), + ( + runtime_error, + miss, + {"miss": True}, + "assert isinstance(error, MemoryError), (k, repr(error))", + ), + ( + inside_after_translation, + miss, + {"miss": True}, + "assert translated is None, (", + ), + ): + with pytest.raises(AssertionError) as info: + sweep_frozen(cache, fn, args, **expect) + assert traceback.extract_tb(info.tb)[-1].line == clause, clause + + # Made outside the sweep, as every bound object. + uncached = ngc.CapacityOptions(COSTONLY) + + def endless(*args): + while True: + ngc.capacity(g, 0, 3, uncached) + + with pytest.raises(pytest.fail.Exception) as info: + sweep_frozen(cache, endless, hit) + assert str(info.value) == "endless never completed" diff --git a/tests/py/test_docs_examples.py b/tests/py/test_docs_examples.py new file mode 100644 index 0000000..0c3a112 --- /dev/null +++ b/tests/py/test_docs_examples.py @@ -0,0 +1,201 @@ +"""The documented examples print what the documents show (CONTRIBUTING, "Behaviour +Documentation"): every Python example of docs/semantics.md, run in order in one namespace as the +document says, against the ``text`` block that follows it (an example without one prints +nothing), and README's quick start against the output written in the comment of each print.""" + +from __future__ import annotations + +import contextlib +import io +import re +import traceback +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +FENCE = re.compile(r"^```(\w*)\n(.*?)^```$", re.S | re.M) + + +def blocks(text: str) -> list[tuple[int, str, str, int, int]]: + """(line, language, body, start, end) of every fenced block, in document order.""" + found = list(FENCE.finditer(text)) + # A different Markdown closing fence may end a block before the parser's closing line. + assert not any( + re.search(r"^ {0,3}`{3,}[ \t]*$", m.group(2), re.M) for m in found + ), "unsupported closing fence inside a parsed block" + # A fence marker outside the parsed blocks uses an unsupported form or has no closing + # fence. Other markers inside a block are contents an example may print literally. + assert all( + any(m.start() <= fence.start() < m.end() for m in found) + for fence in re.finditer(r"^[ \t]*(?:`{3,}|~{3,}).*$", text, re.M) + ), "only unindented triple-backtick fences with a bare language are supported" + return [ + (text.count("\n", 0, m.start()) + 1, m.group(1), m.group(2), m.start(), m.end()) + for m in found + ] + + +def run(code: str, name: str, namespace: dict) -> str: + out = io.StringIO() + with contextlib.redirect_stdout(out): + exec(compile(code, name, "exec"), namespace) + return out.getvalue() + + +def check_examples(text: str, name: str) -> tuple[int, int]: + """Runs every Python example of a document in order in one namespace and compares what each + prints with the ``text`` block directly after it (an example without one prints nothing). + Returns (the examples, the documented outputs compared); a structural defect or a + mismatch fails an assertion.""" + found = blocks(text) + # Only Python examples and their outputs, each output directly after an example: a fence of + # another language (a typo such as ```py) would otherwise leave an example unchecked. + assert {lang for _, lang, *_ in found} <= {"python", "text"} + for i, (line, lang, _, start, _) in enumerate(found): + if lang == "text": + prev = found[i - 1] if i else None + assert prev is not None and prev[1] == "python", ( + f"line {line}: output of no example" + ) + assert text[prev[4] : start].strip() == "", ( + f"line {line}: text between example and output" + ) + examples = [b for b in found if b[1] == "python"] + assert examples + + namespace: dict = {} + mismatches = [] + outputs = 0 # documented outputs compared with what their example printed + for i, (line, lang, code, _, end) in enumerate(found): + if lang != "python": + continue + nxt = found[i + 1] if i + 1 < len(found) else None + expected = ( + nxt[2] + if nxt and nxt[1] == "text" and text[end : nxt[3]].strip() == "" + else "" + ) + got = run(code, f"{name}:{line}", namespace) + outputs += expected != "" + if got != expected: + mismatches.append( + f"line {line}:\n--- documented\n{expected}--- printed\n{got}" + ) + assert not mismatches, "\n".join(mismatches) + assert outputs == sum(lang == "text" for _, lang, *_ in found) > 0 + return len(examples), outputs + + +def check_quick_start(text: str) -> int: + """Runs README's quick start (the first fenced block after its heading, a Python block) and + compares every line it prints with the comment of its print. Returns the prints compared.""" + heading = text.index("\n## Quick Start\n") + line, lang, code, *_ = next(b for b in blocks(text) if b[3] > heading) + assert lang == "python", f"README.md:{line}: the quick start is not a Python block" + prints = [ln for ln in code.splitlines() if ln.startswith("print(")] + expected = [re.fullmatch(r"print\(.*\)\s+# (.*)", ln) for ln in prints] + assert prints and all(expected), ( + f"README.md:{line}: a print without its output comment" + ) + got = run(code, f"README.md:{line}", {}).splitlines() + assert got == [m.group(1) for m in expected if m] + return len(prints) + + +def test_semantics_examples_print_their_documented_output(): + text = (ROOT / "docs" / "semantics.md").read_text(encoding="utf-8") + # The counts are pinned, so an example deleted together with its output is noticed: a + # change to the document's examples updates them. + assert check_examples(text, "docs/semantics.md") == (13, 12) + + +def test_readme_quick_start_prints_its_commented_output(): + text = (ROOT / "README.md").read_text(encoding="utf-8") + assert check_quick_start(text) > 0 + + +GOOD = "```python\nx = 2\nprint(x)\n```\n\n```text\n2\n```\n\nProse.\n\n```python\ny = x\n```\n" + + +def test_the_checks_reject_a_broken_document(): + # The checkers pass a well-formed document and fail each defect they exist for, each at its + # own clause (the failing source line), so a deleted clause is noticed even when a later + # clause would also fail. + assert check_examples(GOOD, "good") == (2, 1) + literal = "```python\nprint('~~~python\\n ```python')\n```\n\n```text\n~~~python\n ```python\n```\n" + assert check_examples(literal, "literal markers") == (1, 1) + count = 'assert outputs == sum(lang == "text" for _, lang, *_ in found) > 0' + mismatch = 'assert not mismatches, "\\n".join(mismatches)' + broken = { + "a changed output": (GOOD.replace("```text\n2\n", "```text\n3\n"), mismatch), + "an unseen indented example": ( + GOOD + "\n ```python\n print(999)\n ```\n", + "assert all(", + ), + "an unseen tilde example": ( + GOOD + "\n~~~python\nprint(999)\n~~~\n", + "assert all(", + ), + "an unseen longer fence": ( + GOOD + "\n````python\nprint(999)\n````\n", + "assert all(", + ), + "an unseen fence with extra information": ( + GOOD + "\n```python title\nprint(999)\n```\n", + "assert all(", + ), + "an unseen unclosed fence": ( + GOOD + "\n```python\nprint(999)\n", + "assert all(", + ), + "another fence language": ( + GOOD.replace("```python\ny", "```py\ny"), + 'assert {lang for _, lang, *_ in found} <= {"python", "text"}', + ), + "an output of no example": ( + "```text\n2\n```\n\n" + GOOD, + 'assert prev is not None and prev[1] == "python", (', + ), + "prose between an example and its output": ( + GOOD + "\n```python\nz = 1\n```\nProse.\n```text\n1\n```\n", + 'assert text[prev[4] : start].strip() == "", (', + ), + "an example that prints without an output": ( + GOOD.replace("y = x", "print(x)"), + mismatch, + ), + "no example": ("Prose only.\n", "assert examples"), + "an empty output block": ("```python\nx = 1\n```\n\n```text\n```\n", count), + "no documented output": ("```python\nx = 1\n```\n", count), + } + for close in (" ```", "````", "``` "): + printed = "2\n" + close + "\n~~~python\nprint(999)\n~~~" + broken[f"an unseen example after {close!r}"] = ( + GOOD.replace("print(x)", f"print({printed!r})").replace( + "```text\n2\n```", "```text\n" + printed + "\n```" + ), + "assert not any(", + ) + for what, (text, clause) in broken.items(): + with pytest.raises(AssertionError) as info: + check_examples(text, what) + assert traceback.extract_tb(info.tb)[-1].line == clause, what + quick = "# Title\n\n## Quick Start\n\n```python\nprint(1 + 1) # 2\n```\n" + assert check_quick_start(quick) == 1 + commented = "assert prints and all(expected), (" + for text, clause in ( + ( + quick.replace("# 2", "# 3"), # a wrong output comment + "assert got == [m.group(1) for m in expected if m]", + ), + (quick.replace(" # 2", ""), commented), # a print without its comment + (quick.replace("print(1 + 1) # 2", "x = 1"), commented), # no print at all + ( + quick.replace("```python", "```bash"), # the quick start not a Python block + 'assert lang == "python", f"README.md:{line}: the quick start is not a Python block"', + ), + ): + with pytest.raises(AssertionError) as info: + check_quick_start(text) + assert traceback.extract_tb(info.tb)[-1].line == clause, text diff --git a/tests/py/test_edge_amounts.py b/tests/py/test_edge_amounts.py new file mode 100644 index 0000000..6f9500c --- /dev/null +++ b/tests/py/test_edge_amounts.py @@ -0,0 +1,193 @@ +"""The per-edge amount arrays at the binding: a ledger's baseline (at +construction and reset), a search's residual (the public search and its K shortest paths) and +the flow core's view (the private _place_global_units) share one validation of the unnarrowed Python integers: the length first, +then every entry in EdgeId order, the first one outside [0, capacity_units[e]] reported with its +exact value. Each user keeps its own names: ``residual`` / ``residual (units)`` and ``view`` / +``view (capacity units)``. +""" + +from __future__ import annotations + +import _netgraph_core as ext +import numpy as np +import pytest +from helpers import GLOBAL, build, exact, ledger_state, sel + +import netgraph_core as ngc + + +def graph(): + # EdgeIds 0: 0->1 (capacity 5), 1: 1->2 (capacity 3) + return build(3, [(0, 1, 5, 1), (1, 2, 3, 1)]) + + +# Each user: (name, label, call(graph, amounts, later_defect)). With later_defect the call also +# carries an argument checked after the amounts, which must not be the error reported. +def ledger(g, a, later): + return ngc.FlowLedger(g, a) + + +def reset(g, a, later): + fg = ngc.FlowLedger(g, [4, 2]) + fg.reset(a) + return fg + + +def search(g, a, later): + opts = ngc.SpfOptions( + ngc.Direction.FORWARD, sel(), residual=a, min_residual=0 if later else 1 + ) + return ngc.spf(g, 0, opts) + + +def paths(g, a, later): + elig = graph().eligibility() if later else None # a foreign Eligibility + return ngc.ksp(g, 0, 2, ngc.KspOptions(1, sel(), residual=a, elig=elig)) + + +def view(g, a, later): + return ext._place_global_units(g, a, [9] if later else [0], [2], None, None) + + +USERS = { + "ledger": ("residual", "residual (units)", ledger), + "reset": ("residual", "residual (units)", reset), + "search": ("residual", "residual (units)", search), + "paths": ("residual", "residual (units)", paths), + "view": ("view", "view (capacity units)", view), +} + +# (amounts, later defect, the message with {name} and {label}). +CASES = [ + # the length (the shape step) before any entry is read + ([1.5], False, "{name} has length 1, expected 2 (one entry per edge)"), + ([1, 1, 1], False, "{name} has length 3, expected 2 (one entry per edge)"), + ( + np.zeros((1, 2), dtype=np.int64), + False, + "{name} must be a 1-D array (got 2 dimensions)", + ), + ( + [5, [1]], + False, + "{name} must be a 1-D sequence of integers (entry 1 is a sequence)", + ), + ("ab", False, "{name} must be a 1-D sequence of integers (got str)"), + # the entries' types, all of them before any value + ([1, 2.5], False, "{label}[1] must be an integer (got float)"), + ([True, 0], False, "{label}[0] must be an integer (got bool)"), + ([6, 2.5], False, "{label}[1] must be an integer (got float)"), + ( + np.array([1.0, 2.0]), + False, + "{label} entries must be integers (got dtype float64)", + ), + # the values: the first entry outside, exact also beyond int64 + ([-1, 99], False, "{name}[0] = -1 is outside [0, capacity_units[0] = 5]"), + ([5, 4], False, "{name}[1] = 4 is outside [0, capacity_units[1] = 3]"), + ( + [5, 2**70], + False, + "{name}[1] = 1180591620717411303424 is outside [0, capacity_units[1] = 3]", + ), + ( + [-(2**70), 9], + False, + "{name}[0] = -1180591620717411303424 is outside [0, capacity_units[0] = 5]", + ), + ( + np.array([5, 2**64 - 1], dtype=np.uint64), + False, + "{name}[1] = 18446744073709551615 is outside [0, capacity_units[1] = 3]", + ), + # an amount above its capacity (one above; beyond 64 bits) wins over an argument checked + # after the amounts + ([6, 0], True, "{name}[0] = 6 is outside [0, capacity_units[0] = 5]"), + ( + [5, 2**70], + True, + "{name}[1] = 1180591620717411303424 is outside [0, capacity_units[1] = 3]", + ), +] + + +# No argument of a ledger is checked after its baseline: its users take no later defect. +PAIRS = [ + (user, case) + for user in sorted(USERS) + for case in range(len(CASES)) + if not (CASES[case][1] and user in ("ledger", "reset")) +] + + +@pytest.mark.parametrize(("user", "case"), PAIRS) +def test_every_user_reports_the_same_first_defect(user, case): + name, label, call = USERS[user] + amounts, later, text = CASES[case] + want = text.format(name=name, label=label) + with pytest.raises(ValueError, match=exact(want)): + call(graph(), amounts, later) + + +@pytest.mark.parametrize( + ("user", "text"), + [ + ("paths", "foreign Eligibility: built for another graph"), + ("search", "min_residual must be at least 1 unit (got 0)"), + ("view", "sources: node 9 out of node range [0, 3)"), + ], +) +def test_the_later_defect_alone_is_rejected(user, text): + # The later defect of the cases above is a defect of its own: with valid amounts the call + # still fails, so those cases test an order. + with pytest.raises(ValueError, match=exact(text)): + USERS[user][2](graph(), [5, 3], True) + + +# What each user's result shows of the amounts it took, for full amounts (the capacities) and +# for zero ones: the ledger's residual (its baseline), the search's costs (an edge with 0 units +# left is not used: its head stays unreached), the number of paths and the flow placed. +BOUND_OUTCOMES = { + "ledger": (lambda r: r.residual().tolist(), [5, 3], [0, 0]), + "reset": (lambda r: r.residual().tolist(), [5, 3], [0, 0]), + "search": ( + lambda r: r.costs.tolist(), + [0, 1, 2], + [0, ngc.UNREACHABLE, ngc.UNREACHABLE], + ), + "paths": (len, 1, 0), + "view": (lambda r: (r["placed"], r["edge_flows"]), (3, [3, 3]), (0, [0, 0])), +} + + +@pytest.mark.parametrize("user", sorted(USERS)) +def test_every_user_accepts_the_bounds(user): + _name, _label, call = USERS[user] + shown, full, zero = BOUND_OUTCOMES[user] + for amounts, want in ( + ([5, 3], full), + ([0, 0], zero), + (np.array([5, 3], dtype=np.uint64), full), + ): + assert shown(call(graph(), amounts, False)) == want, amounts + + +def test_a_failed_reset_leaves_the_ledger_unchanged(): + g = graph() + fg = ngc.FlowLedger(g, [4, 2]) + policy = ngc.FlowPolicy(fg, 0, 2, GLOBAL) + assert policy.place(2).placed == 2 + del policy # its records stay as orphans: reset() is allowed and would clear them + + uid, before = fg.uid(), ledger_state(fg) + assert before[1] == [2, 2] and len(before[2]) == 1 + for bad in ([1], [1, 2.5], [6, 0], [4, 2**70]): + with pytest.raises(ValueError): + fg.reset(bad) + assert (fg.uid(), ledger_state(fg)) == (uid, before) + fg.reset([1, 1]) + assert (fg.residual().tolist(), fg.allocated().tolist(), fg.records()) == ( + [1, 1], + [0, 0], + [], + ) diff --git a/tests/py/test_flow_core.py b/tests/py/test_flow_core.py index 81f9c0b..d68599d 100644 --- a/tests/py/test_flow_core.py +++ b/tests/py/test_flow_core.py @@ -1,5 +1,5 @@ -"""The private flow-core entry `_netgraph_core._place_global_units` (APPROACH 2.2): the payload -schema, the review witnesses, input validation and randomized certificates.""" +"""The private flow-core entry `_netgraph_core._place_global_units`: the payload +schema, the hand-derived witnesses, input validation and randomized certificates.""" from __future__ import annotations @@ -8,9 +8,14 @@ import _netgraph_core as core import numpy as np import pytest -from flow_oracle import NEVER_REACHED, certify - -import netgraph_core as ngc +from flow_oracle import ( + NEVER_REACHED, + certify, + exhaustive, + independent_curve, + points_value, +) +from helpers import CHAIN, build, by_input, exact, ids, to_ids COUNTERS = { "dijkstra_runs", @@ -20,29 +25,13 @@ "dinic_phases", "augmentations", } -CHAIN = [(0, 1, 1, 1), (1, 2, 1, 1), (2, 3, 1, 1), (0, 2, 1, 3), (1, 3, 1, 3)] - - -def build(n, edges, q=1.0): - return ngc.Graph( - n, - [e[0] for e in edges], - [e[1] for e in edges], - [e[2] for e in edges], - [e[3] for e in edges], - q, - ) - -def by_input(g, values): - out = [0] * len(values) - for e, x in enumerate(values): - out[int(g.input_index()[e])] = x - return out - -def to_ids(g, values_in): - return [values_in[int(i)] for i in g.input_index()] +def allowed(elig): + """The certificate's masks of an Eligibility (None: everything allowed).""" + if elig is None: + return None, None + return elig.node_allowed(), elig.edge_allowed() def solve(g, sources, dests, cap, view=None, elig=None): @@ -93,14 +82,24 @@ def test_chain_plus_bypasses(): ) for cap, p in ((1, one), (2, two), (None, inf)): assert certify(g, g.capacity_units().tolist(), [0], [3], cap, p) == [] - masked = solve( - g, - [0], - [3], - None, - elig=g.eligibility(excluded_edges=[to_ids(g, list(range(5))).index(1)]), - ) + mask = g.eligibility(excluded_edges=[ids(g, [1])[0]]) + masked = solve(g, [0], [3], None, elig=mask) assert masked["cost_distribution"] == [[4, 2]] + view = g.capacity_units().tolist() + assert certify(g, view, [0], [3], None, masked, *allowed(mask)) == [] + # Every cost times a factor: the curve's costs and the potentials scale, the volumes, flows, + # cut and operation counters do not. + for factor in (2, 7, 1000003): + scaled_g = build(4, [(u, v, c, w * factor) for u, v, c, w in CHAIN]) + scaled = solve(scaled_g, [0], [3], 2) + assert scaled["cost_distribution"] == [[3 * factor, 1], [5 * factor, 1]] + assert scaled["potentials"] == [0, 2 * factor, 3 * factor, 5 * factor] + for key in ("placed", "edge_flows", "min_cut", "counters"): + assert scaled[key] == two[key], (factor, key) + assert ( + certify(scaled_g, scaled_g.capacity_units().tolist(), [0], [3], 2, scaled) + == [] + ) def test_zero_cap_and_positive_cap_on_zero_view(): @@ -114,9 +113,12 @@ def test_zero_cap_and_positive_cap_on_zero_view(): "potentials": [NEVER_REACHED] * 3, "counters": dict.fromkeys(COUNTERS, 0), } - p = solve(g, [0], [2], 4, view=to_ids(g, [0, 5])) + assert certify(g, [5, 5], [0], [2], 0, z) == [] + zero_view = to_ids(g, [0, 5]) + p = solve(g, [0], [2], 4, view=zero_view) assert p["placed"] == 0 and p["min_cut"] == [] and p["potentials"] == [0, -1, -1] assert p["counters"]["dijkstra_runs"] == 1 + assert certify(g, zero_view, [0], [2], 4, p) == [] def test_legal_maximum_and_anycast(): @@ -128,42 +130,55 @@ def test_legal_maximum_and_anycast(): and p["cost_distribution"] == [[1, big]] and p["min_cut"] == [0] ) + assert certify(g, [big], [0], [1], None, p) == [] a = build(3, [(0, 1, 1, 1), (0, 2, 1, 2)]) q = solve(a, [0], [1, 2], None) assert q["placed"] == 2 and q["cost_distribution"] == [[1, 1], [2, 1]] assert q["potentials"] == [0, 2, 2] - assert solve(a, np.array([0, 0]), np.array([2, 1, 2]), np.int64(5)) == solve( - a, [0], [1, 2], 5 - ) + assert certify(a, [1, 1], [0], [1, 2], None, q) == [] + five = solve(a, np.array([0, 0]), np.array([2, 1, 2]), np.int64(5)) + assert five == solve(a, [0], [1, 2], 5) + assert certify(a, [1, 1], [0], [1, 2], 5, five) == [] -def test_release_witness_fresh_solve(): +def test_fresh_solve_uses_the_released_cheap_edge(): + # Two parallel edges 0 -> 1 (cost 1 with 1 unit, cost 3 with 2 units), then 1 -> 2. A view + # in which the cheap edge has its unit back (say, released by an earlier flow) is solved + # from scratch: the unit goes over the cheap edge, at cost 2. g = build(3, [(0, 1, 1, 1), (0, 1, 2, 3), (1, 2, 2, 1)]) - p = solve(g, [0], [2], 1, view=to_ids(g, [1, 1, 1])) + view = to_ids(g, [1, 1, 1]) + p = solve(g, [0], [2], 1, view=view) assert by_input(g, p["edge_flows"]) == [1, 0, 1] assert p["cost_distribution"] == [[2, 1]] + assert certify(g, view, [0], [2], 1, p) == [] @pytest.mark.parametrize( - "kwargs, pattern", + "kwargs, message", [ - ({"view": [1]}, "view has length 1, expected 2"), - ({"view": [1, 6]}, "capacity_units"), - ({"view": [1.0, 1.0]}, "view"), - ({"view": [-1, 0]}, "outside"), ({"sources": []}, "sources: the node set is empty"), ({"destinations": []}, "destinations: the node set is empty"), - ({"sources": [3]}, "out of node range"), - ({"sources": [0, 2]}, "both endpoint sets"), - ({"sources": [0.0]}, "sources"), - ({"cap": -1}, "cap"), - ({"cap": 1 << 63}, "cap"), - ({"cap": (1 << 63) - 1}, "cap"), - ({"cap": 1.0}, "cap"), - ({"cap": True}, "cap"), + ({"sources": [3]}, "sources: node 3 out of node range [0, 3)"), + ( + {"sources": [0, 2]}, + "node 2 is in both endpoint sets: sources and destinations must not overlap", + ), + ({"sources": [0.0]}, "sources node ids[0] must be an integer (got float)"), + ({"cap": -1}, "cap -1 is outside [0, 2^63 - 2]"), + ({"cap": 1 << 63}, "cap 9223372036854775808 is outside [0, 2^63 - 2]"), + ({"cap": (1 << 63) - 1}, "cap 9223372036854775807 is outside [0, 2^63 - 2]"), + ( + {"cap": 1.0}, + "cap must be an integer number of units in [0, 2^63 - 2] or None", + ), + ( + {"cap": True}, + "cap must be an integer number of units in [0, 2^63 - 2] or None", + ), + ({"graph": None}, "graph must be a Graph"), ], ) -def test_invalid_inputs(kwargs, pattern): +def test_invalid_inputs(kwargs, message): g = build(3, [(0, 1, 5, 1), (1, 2, 5, 1)]) call = { "graph": g, @@ -174,21 +189,57 @@ def test_invalid_inputs(kwargs, pattern): "cap": 3, } call.update(kwargs) - with pytest.raises(ValueError, match=pattern): + with pytest.raises(ValueError, match=exact(message)): core._place_global_units(**call) def test_foreign_eligibility_and_largest_cap(): g = build(3, [(0, 1, 5, 1), (1, 2, 5, 1)]) h = build(3, [(0, 1, 5, 1), (1, 2, 5, 1)]) - with pytest.raises(ValueError, match="foreign Eligibility"): + with pytest.raises( + ValueError, match=exact("foreign Eligibility: built for another graph") + ): solve(g, [0], [2], 1, elig=h.eligibility()) assert solve(g, [0], [2], (1 << 63) - 2)["placed"] == 5 +def kinds_of(sources, dests, view, g, cap, elig, excluded_nodes, p): + """The kinds of case a certified solve with a positive flow belongs to (none for no + flow): several sources or destinations, a view below the capacities, a cap that stopped + the solve (no cut) or a maximum flow proved by its cut, an Eligibility, an excluded node.""" + if p["placed"] == 0: + return [] + return [ + kind + for kind, present in ( + ("positive", True), + ("several sources", len(sources) > 1), + ("several destinations", len(dests) > 1), + ("reduced view", view != g.capacity_units().tolist()), + ("stopped by the cap", cap is not None and p["min_cut"] is None), + ("maximum with its cut", bool(p["min_cut"])), + ("masked", elig is not None), + ("node masked", bool(excluded_nodes)), + ) + if present + ] + + def test_random_graphs_certified(): rng = random.Random(2026) - positive = 0 + # The kinds of case the draws aim at, each to be certified at least this often (well below + # of today's counts: a change of the draws that loses a kind fails here). + floors = { + "positive": 51, + "several sources": 14, + "several destinations": 11, + "reduced view": 10, + "stopped by the cap": 6, + "maximum with its cut": 28, + "masked": 16, + "node masked": 1, + } + kinds = dict.fromkeys(floors, 0) for _ in range(400): n = rng.randint(2, 5) edges = [ @@ -206,32 +257,153 @@ def test_random_graphs_certified(): ] cap = rng.choice([None, 0, 1, rng.randint(0, 6), (1 << 63) - 2]) elig = None + excluded_nodes = [] if rng.random() < 0.4: + excluded_nodes = [rng.randrange(n)] if rng.random() < 0.3 else [] elig = g.eligibility( - excluded_nodes=[rng.randrange(n)] if rng.random() < 0.3 else [], + excluded_nodes=excluded_nodes, excluded_edges=[rng.randrange(len(edges))] if rng.random() < 0.6 else [], ) p = solve(g, sources, dests, cap, view=view, elig=elig) - fails = certify( - g, - view, - sources, - dests, - cap, - p, - None if elig is None else elig.node_allowed(), - None if elig is None else elig.edge_allowed(), + fails = certify(g, view, sources, dests, cap, p, *allowed(elig)) + assert fails == [], (edges, sources, dests, view, cap, fails) + for kind in kinds_of(sources, dests, view, g, cap, elig, excluded_nodes, p): + kinds[kind] += 1 + assert all(kinds[k] >= floors[k] for k in floors), kinds + + +def test_random_larger_graphs_certified_by_the_independent_solver(): + # Past the exhaustive bound (more than 7 edges here), the certificate compares the curve + # with the independent solver; that solver first matches the exhaustive optimum at every + # value of a small graph, so the second tier's reference is itself checked. + # The chain with bypasses at capacities 4: its second batch of 4 units cancels the first + # batch's flow on 1 -> 2 (the backward residual arc), so the optimum is 4 units at cost 3, + # then 4 at cost 5. + wide_chain = build(4, [(u, v, 4, w) for u, v, _c, w in CHAIN]) + args = ( + 4, + wide_chain.edge_src().tolist(), + wide_chain.edge_dst().tolist(), + wide_chain.cost().tolist(), + wide_chain.capacity_units().tolist(), + ) + assert independent_curve(*args, [0], [3]) == [(0, 0), (4, 12), (8, 32)] + p = solve(wide_chain, [0], [3], None) + assert p["cost_distribution"] == [[3, 4], [5, 4]] + assert certify(wide_chain, args[4], [0], [3], None, p) == [] + # Small graphs of 4 to 7 edges of capacity 1 or 2, with one or two sources and destinations + # and most with some edges excluded (the certificate passes both solvers its sets and its + # edge mask): the independent solver's curve equals the exhaustive optimum at every value, + # many of them after two augmentations or more. + oracle_rng = random.Random(2028) + # The kinds of case, each to be crossed at least this often (well below today's counts). + floors = { + "several augmentations": 45, + "several sources": 60, + "several destinations": 50, + "edges excluded": 50, + } + crossed = dict.fromkeys(floors, 0) + for _ in range(400): + n = oracle_rng.randint(3, 4) + small = [ + ( + oracle_rng.randrange(n), + oracle_rng.randrange(n), + oracle_rng.randint(1, 2), + oracle_rng.randint(1, 5), + ) + for _ in range(oracle_rng.randint(4, 7)) + ] + args = ( + n, + [u for u, _v, _c, _w in small], + [v for _u, v, _c, _w in small], + [w for _u, _v, _c, w in small], + [c for _u, _v, c, _w in small], ) + nodes = list(range(n)) + oracle_rng.shuffle(nodes) + ns = oracle_rng.randint(1, 2) + nd = oracle_rng.randint(1, min(2, n - ns)) + sources, dests = nodes[:ns], nodes[ns : ns + nd] + mask = None + if oracle_rng.random() < 0.7: + mask = [oracle_rng.random() < 0.8 for _ in small] + case = (small, sources, dests, mask) + best = exhaustive(*args, sources, dests, mask) + ref = independent_curve(*args, sources, dests, mask) + assert ref[-1][0] == max(best), case + assert all(points_value(ref, x) == c for x, c in best.items()), case + if max(best) > 0: + crossed["several augmentations"] += len(ref) > 2 + crossed["several sources"] += len(sources) > 1 + crossed["several destinations"] += len(dests) > 1 + crossed["edges excluded"] += mask is not None and not all(mask) + assert all(crossed[k] >= floors[k] for k in floors), crossed + rng = random.Random(2027) + # The kinds of case the draws aim at, each to be certified at least this often (well below + # today's counts). + floors = { + "positive": 40, + "several augmentations": 21, + "cost factor above 1": 8, + "several sources": 23, + "several destinations": 22, + "reduced view": 38, + "stopped by the cap": 11, + "maximum with its cut": 30, + "masked": 21, + "node masked": 3, + } + kinds = dict.fromkeys(floors, 0) + for _ in range(120): + n = rng.randint(3, 7) + factor = rng.choice([1, 1, 3, 1000003]) + edges = [ + ( + rng.randrange(n), + rng.randrange(n), + rng.randint(0, 9), + rng.randint(1, 9) * factor, + ) + for _ in range(rng.randint(8, 14)) + ] + g = build(n, edges) + nodes = list(range(n)) + rng.shuffle(nodes) + ns = rng.randint(1, 2) + sources, dests = nodes[:ns], nodes[ns : ns + rng.randint(1, min(2, n - ns))] + view = [ + c if rng.random() < 0.7 else rng.randint(0, c) + for c in g.capacity_units().tolist() + ] + cap = rng.choice([None, 1, rng.randint(0, 20), (1 << 63) - 2]) + elig = None + excluded_nodes = [] + if rng.random() < 0.4: + excluded_nodes = [rng.randrange(n)] if rng.random() < 0.3 else [] + elig = g.eligibility( + excluded_nodes=excluded_nodes, + excluded_edges=[rng.randrange(len(edges))], + ) + p = solve(g, sources, dests, cap, view=view, elig=elig) + fails = certify(g, view, sources, dests, cap, p, *allowed(elig)) assert fails == [], (edges, sources, dests, view, cap, fails) - positive += p["placed"] > 0 - assert positive > 50 + for kind in kinds_of(sources, dests, view, g, cap, elig, excluded_nodes, p): + kinds[kind] += 1 + several = len(p["cost_distribution"]) > 1 + kinds["several augmentations"] += several + kinds["cost factor above 1"] += several and factor > 1 + assert all(kinds[k] >= floors[k] for k in floors), kinds def test_entry_validates_in_the_core_order_before_narrowing(): - # review-1a D1 for the private entry: view length, view entries, sources, destinations, - # overlap, cap, each checked on the unnarrowed value. + # The private entry validates in a fixed order: view length, view entries, sources, + # destinations, overlap, cap, each checked on the unnarrowed value. The view's own checks, + # entries before a later argument included, are the shared ones of test_edge_amounts.py. g = build(3, [(0, 1, 5, 1), (1, 2, 5, 1)]) def call(**kw): @@ -245,43 +417,341 @@ def call(**kw): args.update(kw) return core._place_global_units(g, **args) - with pytest.raises(ValueError, match="view has length 1, expected 2"): - call(view=[2**70], sources=[2**40]) with pytest.raises( - ValueError, match=r"view\[0\] = 1180591620717411303424 is outside" + ValueError, match=exact("view has length 1, expected 2 (one entry per edge)") ): - call(view=[2**70, 5], sources=[2**40]) + call(view=[2**70], sources=[2**40]) with pytest.raises( - ValueError, match=r"sources: node 1099511627776 out of node range" + ValueError, match=exact("sources: node 1099511627776 out of node range [0, 3)") ): call(sources=[2**40], destinations=[-(2**70)]) - with pytest.raises(ValueError, match=r"destinations: node -1180591620717411303424"): + with pytest.raises( + ValueError, + match=exact( + "destinations: node -1180591620717411303424 out of node range [0, 3)" + ), + ): call(destinations=[-(2**70)], cap=-1) - with pytest.raises(ValueError, match="both endpoint sets"): + with pytest.raises( + ValueError, + match=exact("destinations: node 1099511627776 out of node range [0, 3)"), + ): + call(sources=[0, 2], destinations=[2, 2**40], cap=2**70) + with pytest.raises( + ValueError, + match=exact( + "node 2 is in both endpoint sets: sources and destinations must not overlap" + ), + ): call(sources=[0, 2], cap=2**70) - with pytest.raises(ValueError, match="cap 1180591620717411303424 is outside"): + with pytest.raises( + ValueError, match=exact("cap 1180591620717411303424 is outside [0, 2^63 - 2]") + ): call(cap=2**70) - with pytest.raises(ValueError, match="cap -1180591620717411303424 is outside"): - call(cap=-(2**70)) with pytest.raises( - ValueError, match=r"view\[1\] = 18446744073709551615 is outside" + ValueError, match=exact("cap -1180591620717411303424 is outside [0, 2^63 - 2]") ): - call(view=np.array([5, 2**64 - 1], dtype=np.uint64)) + call(cap=-(2**70)) def test_entry_view_rank_is_checked_with_its_length(): + # A 0-D view (the 2-D and nested views are test_edge_amounts.py's), and a 2-D sources array. g = build(3, [(0, 1, 5, 1), (1, 2, 5, 1)]) with pytest.raises( - ValueError, match=r"view must be a 1-D array \(got 2 dimensions\)" - ): - core._place_global_units(g, np.array([[5, 5]]), [2**40], [2], None, 3) - with pytest.raises( - ValueError, match=r"view must be a 1-D array \(got 0 dimensions\)" + ValueError, match=exact("view must be a 1-D array (got 0 dimensions)") ): core._place_global_units(g, np.array(5), [0], [2], None, 3) with pytest.raises( - ValueError, match=r"view must be a 1-D sequence of integers \(entry 1" + ValueError, + match=exact("sources node ids must be a 1-D array (got 2 dimensions)"), ): - core._place_global_units(g, [5, [5]], [0], [2], None, 3) - with pytest.raises(ValueError, match="sources node ids must be a 1-D array"): core._place_global_units(g, [5, 5], np.array([[0]]), [2], None, -1) + + +def test_certificate_rejects_mutated_payloads(): + # Each clean payload certifies first; changing one field must fail the clause it + # breaks. These controls include payload shape and the destination potential + # pi(t*) against the last curve cost, which the C++ certificate also checks. + def rejected(g, cap, payload, mutate, dests=(1,), allowed=(None, None)): + view = g.capacity_units().tolist() + assert certify(g, view, [0], list(dests), cap, payload, *allowed) == [] + mutate(payload) + return certify(g, view, [0], list(dests), cap, payload, *allowed) + + one = build(2, [(0, 1, 3, 7)]) + # Underfill: placed 1 is certified for cap 1 and an early stop for cap 2. + under = solve(one, [0], [1], 1) + assert certify(one, [3], [0], [1], 1, under) == [] + assert "attained value: early stop" in certify(one, [3], [0], [1], 2, under) + # The same underfill without a cap, in both branches (capacity 3 takes the exhaustive one, + # capacity 4 the independent solver's): an uncapped solve must be maximal, so the two clauses + # that prove it fail and nothing else does. + for capacity, branch in ((3, "exhaustive"), (4, "independent")): + single = build(2, [(0, 1, capacity, 7)]) + assert certify( + single, [capacity], [0], [1], None, solve(single, [0], [1], 1) + ) == [ + "uncapped solve left the destinations connected", + f"placed 1 != min(cap, F_max) {capacity} ({branch})", + ] + assert "curve moment differs from the flow cost" in rejected( + one, 2, solve(one, [0], [1], 2), lambda p: p.update(cost_distribution=[[6, 2]]) + ) + assert rejected( + one, None, solve(one, [0], [1], None), lambda p: p.update(min_cut=[]) + ) == ["min_cut [] != closure cut [0]"] + # The curve-shape clauses, each the only failure: an entry of volume 0 before the real one, + # and the one tier split in two at the same cost (total, moment and pi(t*) hold). Capacity 3 + # takes the exhaustive branch, capacity 4 the independent solver's. + for capacity in (3, 4): + single = build(2, [(0, 1, capacity, 7)]) + assert solve(single, [0], [1], None)["cost_distribution"] == [[7, capacity]] + assert rejected( + single, + None, + solve(single, [0], [1], None), + lambda p, c=capacity: p.update(cost_distribution=[[6, 0], [7, c]]), + ) == ["curve volume not positive"] + assert rejected( + single, + None, + solve(single, [0], [1], None), + lambda p, c=capacity: p.update(cost_distribution=[[7, 1], [7, c - 1]]), + ) == ["curve costs not strictly increasing"] + par = build(2, [(0, 1, 1, 1), (0, 1, 1, 3)]) + # Same moment as [[1, 1], [3, 1]], the breakpoint at 1 omitted. + assert "curve differs from the exhaustive optimum at 1" in rejected( + par, + None, + solve(par, [0], [1], None), + lambda p: p.update(cost_distribution=[[2, 2]]), + ) + anycast = build(3, [(0, 1, 1, 1), (0, 2, 1, 2)]) + # Destination 1 lowered to 1 becomes pi(t*), so destination 2, which also receives, differs. + assert "inflow destination 2 potential differs from pi(t*)" in rejected( + anycast, + None, + solve(anycast, [0], [1, 2], None), + lambda p: p["potentials"].__setitem__( + 1, 1 + ), # an inflow destination below pi(t*) + dests=(1, 2), + ) + assert "closure vertex 1 holds NEVER_REACHED" in rejected( + one, + 1, + solve(one, [0], [1], 1), + lambda p: p.update(potentials=[NEVER_REACHED, NEVER_REACHED]), + ) + assert rejected( + one, 0, solve(one, [0], [1], 0), lambda p: p["counters"].update(dijkstra_runs=1) + ) == ["zero cap: the prescribed zero payload"] + # The payload shape: one edge flow too many. + assert rejected( + one, None, solve(one, [0], [1], None), lambda p: p["edge_flows"].append(0) + ) == ["shape"] + # pi(t*) alone: the saturated edge leaves the destination outside the closure, so only the + # last curve cost (7) pins its potential. + assert rejected( + one, + None, + solve(one, [0], [1], None), + lambda p: p["potentials"].__setitem__(1, 8), + ) == ["pi(t*) differs from the last curve cost"] + # The flow clauses. Two parallel edges of capacity 1 and cost 1 carry one unit each; both + # units on EdgeId 0 keep the value, conservation and the cost but exceed its view of 1. + twin = build(2, [(0, 1, 1, 1), (0, 1, 1, 1)]) + over = solve(twin, [0], [1], None) + assert over["edge_flows"] == [1, 1] + assert "feasibility at 0" in rejected( + twin, None, over, lambda p: p.update(edge_flows=[2, 0]) + ) + # EdgeId 1 masked: the solve uses EdgeId 0; moving its unit to EdgeId 1 puts flow on the + # ineligible edge at the same cost. + mask = twin.eligibility(excluded_edges=[1]) + masked = solve(twin, [0], [1], None, elig=mask) + assert masked["edge_flows"] == [1, 0] + assert "flow on ineligible edge 1" in rejected( + twin, + None, + masked, + lambda p: p.update(edge_flows=[0, 1]), + allowed=(mask.node_allowed(), mask.edge_allowed()), + ) + # The chain 0 -> 1 -> 2 carries 2 units; one unit less out of node 1 leaves it unbalanced. + chain = build(3, [(0, 1, 2, 1), (1, 2, 2, 1)]) + out_of_1 = 0 if int(chain.edge_src()[0]) == 1 else 1 + assert "conservation at 1" in rejected( + chain, + None, + solve(chain, [0], [2], None), + lambda p: p["edge_flows"].__setitem__(out_of_1, 1), + dests=(2,), + ) + # The same chain with capacities 5, capped at 1: every vertex is in the closure with + # potentials (0, 1, 2). Raising pi(1) to 3 makes the reduced costs of 0 -> 1 (1 + 0 - 3) + # and of the backward arc 2 -> 1 (-1 + 2 - 3) negative; pi(t*) = 2 is still the last curve + # cost, so these two are the only failures. + wide = build(3, [(0, 1, 5, 1), (1, 2, 5, 1)]) + capped = solve(wide, [0], [2], 1) + assert capped["potentials"] == [0, 1, 2] + assert rejected( + wide, 1, capped, lambda p: p["potentials"].__setitem__(1, 3), dests=(2,) + ) == ["negative reduced cost 0->1", "negative reduced cost 2->1"] + # Past the exhaustive bound (capacities 4): the cut 0 -> 1 lets one unit through, and from + # node 1 the direct edge to 3 costs 5, the detour through 2 costs 1 + 1. One unit sent the + # direct way is feasible and conserved with the same cut and a matching pi(t*), but costs 6, + # not the optimum 3; only the independent solver's curve catches it. + detour = build(4, [(0, 1, 1, 1), (1, 3, 4, 5), (1, 2, 4, 1), (2, 3, 4, 1)]) + direct = [ + 1 if (int(u), int(v)) in ((0, 1), (1, 3)) else 0 + for u, v in zip(detour.edge_src(), detour.edge_dst(), strict=True) + ] + + def costlier(p): + p.update(edge_flows=direct, cost_distribution=[[6, 1]]) + p["potentials"][3] = 6 + + assert rejected( + detour, None, solve(detour, [0], [3], None), costlier, dests=(3,) + ) == ["curve differs from the independent solver at 1"] + # Two parallel edges of capacity 4 at costs 2 and 4: the curve [[3, 8]] keeps the moment 24, + # the value and pi(t*) = 3, and equals the optimum at its own breakpoints 0 and 8; only the + # independent solver's breakpoint at 4 (cost 8, not 12) shows it. + par4 = build(2, [(0, 1, 4, 2), (0, 1, 4, 4)]) + + def averaged(p): + p.update(cost_distribution=[[3, 8]]) + p["potentials"][1] = 3 + + assert rejected(par4, None, solve(par4, [0], [1], None), averaged) == [ + "curve differs from the independent solver at 4" + ] + + +def test_certificate_rejects_every_other_clause(): + # The clauses (and each condition of a clause with several) that the test above does not + # reach, one control each, as in the C++ twin. A control expects the exact list where the + # mutation can break one clause alone, else the clause it targets among those it also breaks. + def mutated(g, sources, dests, cap, mutate, elig=None): + view = g.capacity_units().tolist() + masks = allowed(elig) + payload = solve(g, sources, dests, cap, elig=elig) + assert certify(g, view, sources, dests, cap, payload, *masks) == [] + mutate(payload) + return certify(g, view, sources, dests, cap, payload, *masks) + + def flows(g, by_position): + """A mutation setting the edge flows of the given input positions.""" + + def mutate(p): + for pos, x in by_position.items(): + p["edge_flows"][ids(g, [pos])[0]] = x + + return mutate + + def potential(v, x): + return lambda p: p["potentials"].__setitem__(v, x) + + # One edge 0 -> 1 of capacity 3 and cost 7. Uncapped, the edge is saturated, so the closure + # is {0} and the destination's potential (7) is pinned only by the last curve cost; under + # cap 1 the destination stays in the closure. + one = build(2, [(0, 1, 3, 7)]) + assert mutated(one, [0], [1], None, lambda p: p["potentials"].append(0)) == [ + "shape" + ] + # The zero-cap payload, each of its fields alone (the counters are the test above's control). + for field, value in ( + ("placed", 1), + ("cost_distribution", [[7, 1]]), + ("min_cut", []), + ("edge_flows", [1]), + ("potentials", [0, NEVER_REACHED]), + ): + assert mutated( + one, [0], [1], 0, lambda p, f=field, x=value: p.update({f: x}) + ) == ["zero cap: the prescribed zero payload"], field + # A negative edge flow (the test above sends one above the view). + assert "feasibility at 0" in mutated(one, [0], [1], None, flows(one, {0: -1})) + # Sources {0, 1}, destination 2, edges 0 -> 1 and 1 -> 2: source 1 sends its unit directly. + # Moving it to 0 -> 1 makes source 1 absorb a unit. With destinations {1, 2} and source 0 + # the unit stops at destination 1; moving it to 1 -> 2 makes destination 1 emit a unit. + two = build(3, [(0, 1, 1, 1), (1, 2, 1, 1)]) + assert "source 1" in mutated(two, [0, 1], [2], None, flows(two, {0: 1, 1: 0})) + assert "destination 1" in mutated(two, [0], [1, 2], None, flows(two, {0: 0, 1: 1})) + # Excluded endpoints: 0 -> 2 and 1 -> 2 with sources {0, 1} and source 0 excluded (and with + # it the edge 0 -> 2); a unit on 0 -> 2 is supplied by the excluded source. Mirrored, 0 -> 1 + # and 0 -> 2 with destinations {1, 2} and destination 2 excluded. + into_2 = build(3, [(0, 2, 1, 1), (1, 2, 1, 1)]) + assert "source 0" in mutated( + into_2, + [0, 1], + [2], + None, + flows(into_2, {0: 1}), + into_2.eligibility(excluded_nodes=[0]), + ) + out_of_0 = build(3, [(0, 1, 1, 1), (0, 2, 1, 1)]) + assert "destination 2" in mutated( + out_of_0, + [0], + [1, 2], + None, + flows(out_of_0, {1: 1}), + out_of_0.eligibility(excluded_nodes=[2]), + ) + # The value against the supply alone and the inflow alone: on the chain 0 -> 1 -> 2 carrying + # 2, one unit less into node 1 lowers the supply only, one unit less out of it the inflow only. + chain = build(3, [(0, 1, 2, 1), (1, 2, 2, 1)]) + for pos in (0, 1): + assert "value differs from supply or inflow" in mutated( + chain, [0], [2], None, flows(chain, {pos: 1}) + ), pos + # placed outside [0, cap]: above the cap, and negative without one. + assert "placed outside [0, cap]" in mutated( + one, [0], [1], 1, lambda p: p.update(placed=2) + ) + assert "placed outside [0, cap]" in mutated( + one, [0], [1], None, lambda p: p.update(placed=-1) + ) + # The cut: present while cap 1 leaves the destination reachable, and its value (3) against + # a lowered placed (which also breaks the value and the curve total). + assert mutated(one, [0], [1], 1, lambda p: p.update(min_cut=[])) == [ + "min_cut on a connected solve" + ] + assert "cut value differs from placed" in mutated( + one, [0], [1], None, lambda p: p.update(placed=2) + ) + # The potentials: node 1's (outside the closure) below the sentinel (also below pi(t*)), + # the root's (the closure), and no destination potential at all although 3 units arrive + # (node 1 is reached at zero flow, so the sentinel also breaks that clause). + assert "potential below the sentinel" in mutated( + one, [0], [1], None, potential(1, NEVER_REACHED - 1) + ) + assert mutated(one, [0], [1], None, potential(0, 1)) == ["root 0 potential 1"] + assert mutated(one, [0], [1], None, potential(1, NEVER_REACHED)) == [ + "NEVER_REACHED differs from reachability at zero flow at node 1", + "flow delivered but no destination reached", + ] + # The sentinel against reachability at zero flow, each direction alone. On 0 -> 1 and + # 2 -> 1 nothing reaches node 2, which must hold NEVER_REACHED, not 0 or another value. On + # 0 -> 1 (capacity 3, cost 7) and 1 -> 2, uncapped, the first search settles node 2 through + # node 1 and the saturated edge then leaves both outside the closure: node 2 must not hold + # NEVER_REACHED. + unreached = build(3, [(0, 1, 1, 1), (2, 1, 1, 1)]) + for x in (0, 123456): + assert mutated(unreached, [0], [1], None, potential(2, x)) == [ + "NEVER_REACHED differs from reachability at zero flow at node 2" + ], x + beyond = build(3, [(0, 1, 3, 7), (1, 2, 1, 1)]) + assert mutated(beyond, [0], [1], None, potential(2, NEVER_REACHED)) == [ + "NEVER_REACHED differs from reachability at zero flow at node 2" + ] + # The curve total: a free tier of 1 before the real one keeps the moment (21) and pi(t*), + # and the curve then also differs from the optimum at 1. + assert mutated( + one, [0], [1], None, lambda p: p.update(cost_distribution=[[0, 1], [7, 3]]) + ) == [ + "curve total differs from placed", + "curve differs from the exhaustive optimum at 1", + ] diff --git a/tests/py/test_flow_ledger.py b/tests/py/test_flow_ledger.py index afd7d1a..f29de94 100644 --- a/tests/py/test_flow_ledger.py +++ b/tests/py/test_flow_ledger.py @@ -1,62 +1,46 @@ -"""The integer ledger FlowLedger through the bindings (APPROACH 8): baselines on unnarrowed -values, snapshots and records, FlowIds, bottleneck, and the lifetime table driven by manual -placements: FlowPolicy with Pinned routing and one bundle (the contract's manual flow), whose -PlacementResult the tests read.""" +"""The integer ledger FlowLedger through the bindings (docs/semantics.md#6-the-ledger): +baselines on unnarrowed values, snapshots and records, FlowIds, bottleneck, and the lifetime +table driven by manual placements: FlowPolicy with Pinned routing and one bundle (a manual +flow), whose PlacementResult the tests read.""" from __future__ import annotations import copy import gc -import math +import itertools import numpy as np import pytest -from private_hooks import HOOKS +from helpers import ( + COSTONLY, + GLOBAL, + INF, + L62, + RES, + build, + config, + dag_of, + exact, + ids, + ledger_state, + sel, +) +from private_hooks import HOOKS, needs_hooks import netgraph_core as ngc -L62 = 1 << 62 -INF = math.inf T = ngc.Termination -def build(n, edges, q=1.0): - return ngc.Graph( - n, - [e[0] for e in edges], - [e[1] for e in edges], - [e[2] for e in edges], - [e[3] for e in edges], - q, - ) - - -def dag_of(g, edges): - """The PredDAG of an edge set (ascending via per node).""" - per = [[] for _ in range(g.num_nodes())] - for e in sorted(edges): - per[int(g.edge_dst()[e])].append(e) - offsets, parents, via = [0], [], [] - for v in range(g.num_nodes()): - for e in per[v]: - parents.append(int(g.edge_src()[e])) - via.append(e) - offsets.append(len(via)) - return ngc.PredDAG.from_csr(g, offsets, parents, via) - - -def pinned(fg, src, dst, edges, placement, admission=None, priority=None, elig=None): +def pinned(fg, src, dst, edges, placement, admission=None, priority=None): """A manual flow: a FlowPolicy with Pinned routing and one bundle (the edges' DAG).""" - cfg = ngc.FlowPolicyConfig( - routing=ngc.Pinned([dag_of(fg.graph(), edges)]), - placement=placement, - admission=admission if admission is not None else ngc.Admission.LOSSLESS, - selection=ngc.PathSelection( - ngc.EdgeTieBreak.DETERMINISTIC, - priority if priority is not None else ngc.EdgePriority.RESIDUAL, - ), + cfg = config( + ngc.Pinned([dag_of(fg.graph(), edges)]), + placement, + admission, + sel(prio=RES if priority is None else priority), ) - return ngc.FlowPolicy(fg, src, dst, cfg, elig) + return ngc.FlowPolicy(fg, src, dst, cfg) def flow_id(policy): @@ -93,6 +77,35 @@ def test_baseline_validation_order_and_snapshots(): fg2 = ngc.FlowLedger(graph=g, residual=np.array([1, 2], dtype=np.uint64)) assert list(fg2.residual()) == [1, 2] assert fg2.uid() != fg.uid() + # residual() and allocated() are copies: writing one changes neither the ledger nor the next + # snapshot. capacity_units() is a read-only view of the graph's own capacities. + for snapshot in (fg.residual, fg.allocated): + a = snapshot() + assert a.dtype == np.int64 + before = a.tolist() + a[0] = 7 + assert snapshot().tolist() == before + assert list(fg.residual()) == [5, 3] and list(fg.allocated()) == [0, 0] + view = fg.capacity_units() + assert view.dtype == np.int64 and not view.flags.writeable + assert np.shares_memory(view, g.capacity_units()) + + +def test_the_ledger_has_no_public_placement_or_removal(): + # Only policies place or remove flows: the public members are the readers, reset and copy. + public = {n for n in dir(ngc.FlowLedger) if not n.startswith("_")} + assert public == { + "allocated", + "bottleneck", + "capacity_units", + "copy", + "graph", + "record", + "records", + "reset", + "residual", + "uid", + } def test_lifetime_bound_rows_and_epochs(): @@ -120,6 +133,8 @@ def test_lifetime_bound_rows_and_epochs(): fg.copy() with pytest.raises(ValueError, match="bound"): copy.copy(fg) + with pytest.raises(ValueError, match="bound"): + copy.deepcopy(fg) r = p.place(2) assert (r.placed, r.termination, r.routes_changed, flow_id(p)[1]) == ( 2, @@ -185,6 +200,8 @@ def test_orphans_from_destruction_reset_and_copy(): if HOOKS: assert fg._state() == {"next_flow_id": 2, "bound": [], "orphans": []} assert len(c.records()) == 2 # the copy is independent + fg.reset() # no baseline: back to the capacities + assert list(fg.residual()) == [5] def test_policy_keeps_the_ledger_alive(): @@ -211,17 +228,33 @@ def test_records_fields_and_counters_beyond_64_bits(): rec = fg.records()[0] assert rec.flow_id == (fg.uid(), 0) assert (rec.owner, rec.sources, rec.destinations) == (0, [0], [3]) + # The bundle is the ledger's own PredDAG, shared by every snapshot (not a copy). + assert rec.bundle is fg.records()[0].bundle is fg.record(rec.flow_id).bundle assert (rec.placement, rec.admission) == (EB, LOSSY) assert list(rec.bundle.via_edges()) == [0, 1, 2, 3] assert (rec.route_cost, rec.offered, rec.delivered) == (2, 10, 6) assert rec.edges == [(0, 5), (1, 5), (2, 1), (3, 5)] assert rec.drops == [(2, 4)] assert fg.record(rec.flow_id).delivered == 6 - # Lossless records: no offered; a route without a walk at creation: route_cost None. + # Records are snapshots: a later call changes the ledger's record, not one read before it. + held = fg.record(rec.flow_id) + p.place(1) + assert (rec.offered, held.offered, fg.record(rec.flow_id).offered) == (10, 10, 11) + # Lossless records: no offered. g2 = build(2, [(0, 1, 1, 1)]) fg2 = ngc.FlowLedger(g2) pinned(fg2, 0, 1, [0], MF).place(1) assert fg2.records()[0].offered is None + # An empty bundle: route_cost None. Global routing keeps a record per source; one unit from + # sources 0 and 1 takes the cheaper edge 0 -> 2, so source 1 has no share. + g3 = build(3, [(0, 2, 1, 1), (1, 2, 1, 5)]) + fg3 = ngc.FlowLedger(g3) + assert ngc.FlowPolicy(fg3, [0, 1], 2, GLOBAL).place(1).placed == 1 + rec_used, rec_idle = fg3.records() + assert (rec_used.sources, rec_used.route_cost, rec_used.delivered) == ([0], 1, 1) + assert (rec_idle.sources, rec_idle.route_cost, rec_idle.delivered) == ([1], None, 0) + assert rec_idle.edges == [] + assert list(rec_idle.bundle.via_edges()) == [] # Every unit dropped on a saturated link, nine times: the counters pass 2^64. sat = pinned(fg2, 0, 1, [0], EB, LOSSY) for _ in range(9): @@ -234,10 +267,23 @@ def test_records_fields_and_counters_beyond_64_bits(): assert lossy.offered > 1 << 64 +def test_record_endpoint_sets_are_ascending(): + # A destination set given out of order and with a repeat: every record lists it ascending, + # once; a record per source under Global, each with its one source. + g = build(4, [(0, 2, 5, 1), (0, 3, 5, 1), (1, 2, 5, 1), (1, 3, 5, 1)]) + fg = ngc.FlowLedger(g) + ngc.FlowPolicy(fg, 0, [3, 2, 3], COSTONLY).place(2) + ngc.FlowPolicy(fg, [1, 0, 1], [3, 2], GLOBAL).place(4) + records = fg.records() + assert [r.destinations for r in records] == [[2, 3]] * len(records) + assert sorted(r.sources for r in records[1:]) == [[0], [1]] + assert records[0].sources == [0] and len(records) == 3 + + def test_empty_split_graph_keeps_the_route_cost(): # Weighted Residual with every route edge at residual 0 under another owner's flow: the # record exists at zero delivery with an empty split graph; route_cost is the bundle's - # minimum walk cost (APPROACH 8), not a split-graph value. + # minimum walk cost, not a split-graph value. g = build(2, [(0, 1, 2, 3)]) fg = ngc.FlowLedger(g) pinned(fg, 0, 1, [0], MF).place(2) @@ -261,44 +307,114 @@ def test_flow_ids_of_other_ledgers_and_bad_shapes(): a, b = ngc.FlowLedger(g), ngc.FlowLedger(g) p = pinned(a, 0, 1, [0], MF) p.place(1) - with pytest.raises(ValueError, match="another ledger"): + with pytest.raises( + ValueError, + match=exact(f"FlowId {flow_id(p)} belongs to another ledger (uid {b.uid()})"), + ): b.record(flow_id(p)) - with pytest.raises(ValueError, match="tuple"): + with pytest.raises(ValueError, match=exact("a FlowId is a tuple (ledger uid, id)")): a.record(0) - with pytest.raises(ValueError, match="retired or unknown"): + with pytest.raises( + ValueError, match=exact(f"FlowId ({a.uid()}, {L62 * 8}) is retired or unknown") + ): a.record((a.uid(), L62 * 8)) -def test_requests_are_integer_units_or_inf(): +@needs_hooks +def test_a_flow_id_read_passes_an_allocation_failure_through(): + # A ledger uid outside [0, 2^64) names no ledger: the read raises "retired or unknown". When a + # Python allocation fails while that range error is raised, the call raises MemoryError, never + # the "retired or unknown" answer (which would hide the failure). Both readers of a FlowId. + import _netgraph_core as ext + g = build(2, [(0, 1, 5, 1)]) fg = ngc.FlowLedger(g) - p = pinned(fg, 0, 1, [0], EB) - for bad, msg in ( - (1.5, "integer units or"), - (-1, "out of range"), - (L62, "out of range"), - (True, "integer"), - (-INF, "integer units or"), - (float("nan"), "integer units or"), + policy = pinned(fg, 0, 1, [0], MF) + for uid, read in itertools.product( + (-1, 2**64, -(2**70)), (fg.record, policy.record) ): - with pytest.raises(ValueError, match=msg): - p.place(bad) + failures = 0 + for k in range(100): + hit, _, value, error = ext._fail_python_allocation(read, ((uid, 0),), k) + assert value is None, (k, value) + if not hit: + assert isinstance(error, ValueError), (k, repr(error)) + assert str(error) == f"FlowId ({uid}, 0) is retired or unknown" + break + failures += 1 + assert isinstance(error, (MemoryError, RuntimeError)), (k, repr(error)) + else: + pytest.fail("the read never completed") + assert failures > 0 + + +@pytest.mark.parametrize("routing", ["Pinned", "CostOnly", "Global"]) +def test_rejected_requests_leave_the_policy_and_the_ledger(routing): + """Every rejected request (a value that is not an integer unit or +inf, outside [0, 2^62), + or finite after +inf), on a fresh policy, after a finite call and after +inf, leaves the + ledger and the policy as they were: the records, allocations and placed demand always, and + with the private hooks the epoch state itself (the requested total, the +inf flag and the + flows). A NumPy integer is an integer unit.""" + g = build(4, [(0, 1, 5, 1), (1, 3, 5, 1), (0, 2, 3, 2), (2, 3, 3, 2)]) + fg = ngc.FlowLedger(g) + if routing == "Pinned": + p = pinned(fg, 0, 3, [0, 2], EB) # EdgeIds of 0 -> 1 -> 3 + else: + p = ngc.FlowPolicy(fg, 0, 3, GLOBAL if routing == "Global" else COSTONLY) + + def state(): + own = p._state() if HOOKS else None + return ledger_state(fg), p.placed_demand(), own + + def rejected(requests): + before = state() + for value, msg in requests: + with pytest.raises(ValueError, match=exact(msg)): + p.place(value) + assert state() == before + return before + + physical = ( + "requests are integer units or +inf (a physical request goes through " + "to_units first; got {})" + ) + out_of_range = "request (units) = {} is out of range [0, 4611686018427387903]" + bad = [ + (1.5, physical.format("1.5")), + (-1, out_of_range.format(-1)), + (L62, out_of_range.format(L62)), + ] + bad += [ + (True, "request (units) must be an integer (got bool)"), + (-INF, physical.format("-inf")), + (float("nan"), physical.format("nan")), + ] + rejected(bad) assert fg.records() == [] - assert p.place(np.int64(2)).placed == 2 + assert p.place(np.int64(4)).placed == 4 + rejected(bad) + p.place(INF) + after_inf = ( + "a finite request after +inf is rejected within an epoch (the cumulative " + "finite request is undefined)" + ) + before = rejected([(1, after_inf), (0, after_inf), *bad]) + if HOOKS: + assert (before[2]["requested"], before[2]["infinite"]) == (4, True) -def test_bottleneck_versus_min_cut(): +def test_ledger_bottleneck_with_eligibility_and_errors(): g = build(4, [(0, 1, 1, 1), (0, 2, 2, 3), (1, 2, 1, 1), (2, 3, 2, 1)]) fg = ngc.FlowLedger(g) p = pinned(fg, 0, 3, range(4), MF) r = p.place(INF) assert (r.placed, r.termination) == (2, T.EXHAUSTED) - inv = [0] * 4 - for e, pos in enumerate(g.input_index()): - inv[pos] = e - assert fg.bottleneck(0) == sorted([inv[0], inv[3]]) - assert fg.bottleneck([0, 0]) == sorted([inv[0], inv[3]]) + assert fg.bottleneck(0) == ids(g, [0, 3]) + assert fg.bottleneck([0, 0]) == ids(g, [0, 3]) assert fg.bottleneck([0], g.eligibility(excluded_nodes=[0])) == [] + # An excluded edge is neither crossed nor listed: without 0 -> 2 (the one edge with residual) + # the sources reach only {0}; of its leaving edges 0 -> 1 is listed and 0 -> 2 is not. + assert fg.bottleneck([0], g.eligibility(excluded_edges=ids(g, [1]))) == ids(g, [0]) with pytest.raises(ValueError, match="empty"): fg.bottleneck([]) with pytest.raises(ValueError, match="out of node range"): @@ -319,14 +435,7 @@ def test_pinned_construction_errors(): with pytest.raises(ValueError, match="both endpoint sets"): pinned(fg, 1, 1, [0], MF) other = build(3, [(0, 1, 5, 1), (2, 1, 5, 1)]) - foreign = ngc.FlowPolicyConfig( - routing=ngc.Pinned([dag_of(other, [0])]), - placement=MF, - admission=ngc.Admission.LOSSLESS, - selection=ngc.PathSelection( - ngc.EdgeTieBreak.DETERMINISTIC, ngc.EdgePriority.RESIDUAL - ), - ) + foreign = config(ngc.Pinned([dag_of(other, [0])]), MF, selection=sel(prio=RES)) with pytest.raises(ValueError, match="foreign PredDAG"): ngc.FlowPolicy(fg, 0, 1, foreign) fg.reset() # no policy is bound diff --git a/tests/py/test_flow_policy.py b/tests/py/test_flow_policy.py index c2f1d2c..54cce64 100644 --- a/tests/py/test_flow_policy.py +++ b/tests/py/test_flow_policy.py @@ -1,76 +1,42 @@ -"""The policy engine, the DagCache and the evaluators through the bindings (APPROACH 8 to 11): +"""The policy engine, the DagCache and the evaluators through the bindings (docs/semantics.md): configuration objects and their structural validation, PlacementResult fields and outputs, -lifetimes, the evaluators with workers, and the cache statistics. Prototype 2's fixtures -replay in ``test_flow_policy_fixtures.py``.""" +lifetimes, the evaluators with workers, and the cache statistics. The fixtures of the reference +(``tests/reference/policy/``) replay in ``test_flow_policy_fixtures.py``.""" from __future__ import annotations import gc import math +import sys +import weakref -import _netgraph_core as ext import numpy as np import pytest +from helpers import ( + ALL_OUT, + COSTONLY, + GLOBAL, + INF, + L62, + RES, + build, + config, + dag_of, + exact, + full_ledger_state, + ids, + plain, + sel, + sweep_python_allocation, + to_ids, +) from private_hooks import HOOKS, needs_hooks import netgraph_core as ngc -INF = math.inf -L62 = 1 << 62 T = ngc.Termination -def build(n, edges, q=1.0): - return ngc.Graph( - n, - [e[0] for e in edges], - [e[1] for e in edges], - [e[2] for e in edges], - [e[3] for e in edges], - q, - ) - - -def ids(g, positions): - inv = [0] * g.num_edges() - for e, pos in enumerate(g.input_index()): - inv[int(pos)] = e - return sorted(inv[p] for p in positions) - - -def dag(g, edges): - per = [[] for _ in range(g.num_nodes())] - for e in sorted(edges): - per[int(g.edge_dst()[e])].append(e) - offsets, parents, via = [0], [], [] - for v in range(g.num_nodes()): - for e in per[v]: - parents.append(int(g.edge_src()[e])) - via.append(e) - offsets.append(len(via)) - return ngc.PredDAG.from_csr(g, offsets, parents, via) - - -def sel( - tie=ngc.EdgeTieBreak.DETERMINISTIC, prio=ngc.EdgePriority.CAPACITY, mp=True, me=True -): - return ngc.PathSelection(tie, prio, mp, me) - - -def config( - routing, placement=ngc.FlowPlacement.EQUAL_BALANCED, admission=None, selection=None -): - return ngc.FlowPolicyConfig( - routing=routing, - placement=placement, - admission=admission or ngc.Admission.LOSSLESS, - selection=selection or sel(), - ) - - -GLOBAL = config(ngc.Global(), ngc.FlowPlacement.MAX_FLOW) - - def diamond(a=5, b=3, c=1): # input edges: 0: 0->1, 1: 1->3 (cap a, cost 1); 2: 0->2, 3: 2->3 (cap b, cost c) return build(4, [(0, 1, a, 1), (1, 3, a, 1), (0, 2, b, c), (2, 3, b, c)]) @@ -86,7 +52,7 @@ def test_configurations_are_structural_and_explicit(): routing=ngc.CostOnly(), placement=ngc.FlowPlacement.MAX_FLOW ) with pytest.raises(TypeError, match="incompatible"): - ngc.CostOnly(flow_count=2) # a field outside its routing variant (rule 13) + ngc.CostOnly(flow_count=2) # a field outside its routing variant: TypeError with pytest.raises(TypeError, match="incompatible"): ngc.Global(max_flow_count=4) with pytest.raises(TypeError, match="routing"): @@ -113,58 +79,356 @@ def test_configurations_are_structural_and_explicit(): ).max_path_cost == ngc.Absolute(7) with pytest.raises(TypeError): ngc.CapacityAware(2) # keyword-only - for bad in (2**40, True, 1.5): - with pytest.raises(ValueError, match="flow_count"): + for bad, message in ( + (2**40, "flow_count = 1099511627776 is out of range [-2147483648, 2147483647]"), + (True, "flow_count must be an integer (got bool)"), + (1.5, "flow_count must be an integer (got float)"), + ): + with pytest.raises(ValueError, match=exact(message)): ngc.CapacityAware(flow_count=bad) - with pytest.raises(ValueError, match="min_flow_bandwidth"): + with pytest.raises( + ValueError, + match=exact( + "min_flow_bandwidth (units) = 1208925819614629174706176 is out of range " + "[-9223372036854775808, 9223372036854775807]" + ), + ): ngc.CapacityAware(min_flow_bandwidth=2**80) with pytest.raises(TypeError, match="PredDAG"): ngc.Pinned([[0, 1]]) cfg = config(ca) assert isinstance(cfg.routing, ngc.CapacityAware) assert cfg.routing.flow_count == 2 + # Every field reads back as built, with the documented defaults. + d = ngc.CapacityAware() + assert ( + d.flow_count, + d.max_flow_count, + d.max_path_cost, + d.min_flow_bandwidth, + d.reoptimize, + d.diminishing, + ) == (1, None, None, 0, False, None) + s = sel(ngc.EdgeTieBreak.PREFER_HIGHER_PRIORITY, RES, False, True) + cfg = config(ngc.Global(), ngc.FlowPlacement.MAX_FLOW, ngc.Admission.LOSSY, s) + assert cfg.placement == ngc.FlowPlacement.MAX_FLOW + assert cfg.admission == ngc.Admission.LOSSY + assert cfg.selection == s + assert (ngc.OutputOptions().edge_flows, ngc.OutputOptions().residual) == ( + False, + False, + ) + assert ngc.OutputOptions().bottleneck is False + for flags in ((True, False, False), (False, True, False), (False, False, True)): + o = ngc.OutputOptions( + edge_flows=flags[0], residual=flags[1], bottleneck=flags[2] + ) + assert (o.edge_flows, o.residual, o.bottleneck) == flags + # Pinned keeps the bundles in supply order, copied from the given list: each read is a new + # list of the same PredDAG objects, and changing a list read changes nothing. + g = diamond() + a, b = dag_of(g, ids(g, [0, 1])), dag_of(g, ids(g, [2, 3])) + given = [b, a] + pinned = ngc.Pinned(given) + given.append(a) + first = pinned.bundles + assert first == [b, a] and first[0] is b and first[1] is a + first.append(b) + second = pinned.bundles + assert second == [b, a] and second is not first + # A policy's config is the configuration it was built with. + pol = ngc.FlowPolicy( + ngc.FlowLedger(g), 0, 3, config(pinned, ngc.FlowPlacement.MAX_FLOW) + ) + assert pol.config.placement == ngc.FlowPlacement.MAX_FLOW + assert pol.config.routing.bundles == [b, a] + # The constructors' own checks: an integer field outside its C++ type raises ValueError; a + # field of the wrong class, a reoptimize that is not a truth value and an unknown keyword + # raise TypeError. reoptimize takes a bool, a number or None by its truth value. + with pytest.raises( + ValueError, + match=exact( + "max_flow_count = 2147483648 is out of range [-2147483648, 2147483647]" + ), + ): + ngc.CapacityAware(max_flow_count=2**31) + with pytest.raises( + ValueError, + match=exact( + "diminishing window = 2147483648 is out of range [-2147483648, 2147483647]" + ), + ): + ngc.DiminishingReturns(2**31, 0.5) + assert ngc.Absolute(2**63 - 1).cost == 2**63 - 1 + assert ngc.Absolute(-(2**63)).cost == -(2**63) + for bad in (2**63, -(2**63) - 1): + with pytest.raises( + ValueError, + match=exact( + f"Absolute cost = {bad} is out of range [-9223372036854775808, 9223372036854775807]" + ), + ): + ngc.Absolute(bad) + with pytest.raises( + TypeError, match=exact("max_path_cost must be Absolute, Stretch or None") + ): + ngc.CapacityAware(max_path_cost=3) + with pytest.raises( + TypeError, match=exact("diminishing must be DiminishingReturns or None") + ): + ngc.CapacityAware(diminishing=0.5) + with pytest.raises(TypeError): + ngc.CapacityAware(reoptimize="yes") + assert [ngc.CapacityAware(reoptimize=x).reoptimize for x in (1, 0, 2.5, None)] == [ + True, + False, + True, + False, + ] + with pytest.raises(TypeError): + ngc.CapacityAware(flow_counts=2) + + +class _ConversionFailed(Exception): + pass + + +class _FailingFloat(np.float32): + # A NumPy floating scalar whose own conversion to a float fails. + def __float__(self): + raise _ConversionFailed("own float") + + +class _FailingIndex: + # An object whose own conversion to an index fails. + def __index__(self): + raise _ConversionFailed("own index") + + +class _BigInt(int): + pass + + +class _OwnFloat(int): + # An int subclass whose own __float__ gives 2.5, whatever its integer value. + def __float__(self): + return 2.5 + + +class _OverflowingFloat(np.float32): + # A NumPy floating scalar whose own __float__ raises OverflowError. + def __float__(self): + raise OverflowError("own conversion") + + +class _OverflowingIntFloat(int): + # An int subclass whose own __float__ raises OverflowError. + def __float__(self): + raise OverflowError("own conversion") + + +class _OverflowingIndex: + # An integer-like object whose own __index__ raises OverflowError. + def __index__(self): + raise OverflowError("own conversion") + + +def test_float_fields_hold_only_finite_floats(): + # Stretch's factor and DiminishingReturns' fraction are C++ doubles. An integer with no finite + # double (at either sign, as a Python int or through __index__) is outside the field's domain: + # ValueError naming the field. The last integer that rounds to the largest finite double is + # kept as that double; the next one rounds past it. + top = sys.float_info.max + last = 2**1024 - 2**970 - 1 + assert ngc.Stretch(last).factor == top + assert ngc.DiminishingReturns(1, -last).fraction == -top + + class Index: + def __index__(self): + return 10**400 + + too_large = "must be a number in the float range (got an integer beyond the largest finite float)" + for bad in (last + 1, -(last + 1), 10**400, Index()): + with pytest.raises(ValueError, match=exact("Stretch factor " + too_large)): + ngc.Stretch(bad) + with pytest.raises( + ValueError, match=exact("diminishing fraction " + too_large) + ): + ngc.DiminishingReturns(1, bad) + # So is an int subclass that keeps int's own conversion; one with its own __float__ is + # converted by that first, as float() does. + with pytest.raises(ValueError, match=exact("Stretch factor " + too_large)): + ngc.Stretch(_BigInt(10**400)) + assert ngc.Stretch(_OwnFloat(10**400)).factor == 2.5 + # Any other failure of the object's own conversion reaches the caller unchanged, an + # OverflowError raised by its own __float__ or __index__ too: no integer was returned. + with pytest.raises(_ConversionFailed): + ngc.Stretch(_FailingFloat(2.0)) + with pytest.raises(_ConversionFailed): + ngc.DiminishingReturns(1, _FailingFloat(0.5)) + for bad in (_OverflowingFloat(2.0), _OverflowingIndex(), _OverflowingIntFloat(2)): + with pytest.raises(OverflowError, match=exact("own conversion")): + ngc.Stretch(bad) + with pytest.raises(OverflowError, match=exact("own conversion")): + ngc.DiminishingReturns(1, bad) def test_validation_messages_before_binding(): g = diamond() fg = ngc.FlowLedger(g) - def rejects(cfg, msg, src=0, dst=3): - with pytest.raises(ValueError, match=msg): - ngc.FlowPolicy(fg, src, dst, cfg) + def rejects(cfg, msg, src=0): + with pytest.raises(ValueError, match=exact(msg)): + ngc.FlowPolicy(fg, src, 3, cfg) + stretch = "max_path_cost Stretch factor must be finite and in [1, inf) (got {})" + rejects( + config(ngc.CostOnly(), ngc.FlowPlacement.MAX_FLOW, ngc.Admission.LOSSY), + LOSSY_KERNEL, + ) + rejects(config(ngc.CapacityAware(), admission=ngc.Admission.LOSSY), LOSSY_ROUTING) + rejects(config(ngc.Global()), "Global routing requires placement = MaxFlow") + rejects( + config(ngc.CapacityAware(flow_count=0)), + "CapacityAware flow_count must be >= 1 (got 0)", + ) + rejects( + config(ngc.CapacityAware(flow_count=2, max_flow_count=1)), + "CapacityAware max_flow_count (1) must be >= flow_count (2)", + ) + rejects( + config(ngc.CapacityAware(max_path_cost=ngc.Stretch(0.5))), + stretch.format("0.500000"), + ) rejects( - config(ngc.CostOnly(), ngc.FlowPlacement.MAX_FLOW, ngc.Admission.LOSSY), "Lossy" + config(ngc.CapacityAware(max_path_cost=ngc.Stretch(math.nan))), + stretch.format("nan"), ) - rejects(config(ngc.CapacityAware(), admission=ngc.Admission.LOSSY), "Lossy") - rejects(config(ngc.Global()), "MaxFlow") - rejects(config(ngc.CapacityAware(flow_count=0)), "flow_count") - rejects(config(ngc.CapacityAware(flow_count=2, max_flow_count=1)), "max_flow_count") - rejects(config(ngc.CapacityAware(max_path_cost=ngc.Stretch(0.5))), "Stretch") - rejects(config(ngc.CapacityAware(max_path_cost=ngc.Stretch(math.nan))), "Stretch") rejects( config(ngc.CapacityAware(diminishing=ngc.DiminishingReturns(1, 1.0))), - "fraction", + "diminishing fraction must be in (0, 1) (got 1.000000)", ) - rejects(config(ngc.Pinned([])), "empty bundle list") - b = dag(g, ids(g, [0, 1])) - rejects(config(ngc.Pinned([b, b])), "duplicate bundle") - rejects(config(ngc.Pinned([dag(g, ids(g, [0, 3]))])), "malformed bundle") - rejects(config(ngc.Pinned([b])), "single endpoints", src=[0, 1]) - rejects(config(ngc.CostOnly()), "both endpoint sets", src=[0, 3]) - rejects(config(ngc.CostOnly()), "empty", src=[]) - rejects(config(ngc.CostOnly()), "out of node range", src=[L62]) - with pytest.raises(ValueError, match="foreign"): - ngc.FlowPolicy( - fg, 0, 3, config(ngc.CostOnly()), diamond().eligibility(excluded_edges=[0]) - ) - with pytest.raises(ValueError, match="foreign"): - ngc.FlowPolicy(fg, 0, 3, config(ngc.CostOnly()), None, ngc.DagCache(diamond())) + rejects(config(ngc.Pinned([])), "Pinned: an empty bundle list is rejected") + b = dag_of(g, ids(g, [0, 1])) + rejects( + config(ngc.Pinned([b, b])), + "Pinned: duplicate bundle 1 (equal to bundle 0 before pruning)", + ) + rejects( + config(ngc.Pinned([dag_of(g, ids(g, [0, 3]))])), + "Pinned: malformed bundle 0, no src -> dst walk (src 0, dst 3)", + ) + rejects( + config(ngc.Pinned([b])), + "Pinned routing requires single endpoints (bundles bind one source and one " + "destination)", + src=[0, 1], + ) + rejects( + COSTONLY, + "node 3 is in both endpoint sets: sources and destinations must not overlap", + src=[0, 3], + ) + rejects(COSTONLY, "sources: the node set is empty", src=[]) + rejects(COSTONLY, f"sources: node {L62} out of node range [0, 4)", src=[L62]) + with pytest.raises( + ValueError, match=exact("foreign Eligibility: built for another graph") + ): + ngc.FlowPolicy(fg, 0, 3, COSTONLY, diamond().eligibility(excluded_edges=[0])) + with pytest.raises( + ValueError, match=exact("foreign DagCache: bound to another graph") + ): + ngc.FlowPolicy(fg, 0, 3, COSTONLY, None, ngc.DagCache(diamond())) + # Arguments of the wrong kind: a ledger of None and non-integer node ids are ValueError; a + # ledger, config, elig or cache of the wrong class and an unknown keyword are TypeError. + with pytest.raises(ValueError, match=exact("ledger must be a FlowLedger")): + ngc.FlowPolicy(None, 0, 3, COSTONLY) + with pytest.raises( + ValueError, match=exact("sources[0] must be an integer (got float)") + ): + ngc.FlowPolicy(fg, [0.0], 3, COSTONLY) + with pytest.raises( + ValueError, match=exact("destinations must be a sequence of integers") + ): + ngc.FlowPolicy(fg, 0, 3.0, COSTONLY) + for wrong in ( + lambda: ngc.FlowPolicy(g, 0, 3, COSTONLY), + lambda: ngc.FlowPolicy(fg, 0, 3, ngc.CostOnly()), + lambda: ngc.FlowPolicy(fg, 0, 3, COSTONLY, ngc.DagCache(g)), + lambda: ngc.FlowPolicy(fg, 0, 3, COSTONLY, None, g.eligibility()), + lambda: ngc.FlowPolicy(fg, 0, 3, COSTONLY, cash=None), + ): + with pytest.raises(TypeError): + wrong() + fg.reset() # nothing bound + pol = ngc.FlowPolicy(fg, 0, 3, COSTONLY) + with pytest.raises( + TypeError, match=exact("outputs must be an OutputOptions (got CostOnly)") + ): + pol.place(1, ngc.CostOnly()) + assert pol.records() == [] + + +LOSSY_KERNEL = ( + "Lossy admission is rejected with the MaxFlow kernel: a controller never drops " + "(EqualBalanced or Weighted only)" +) +HEADROOM_KERNEL = ( + "the Weighted kernel is rejected with priority Headroom (weights are units)" +) +LOSSY_ROUTING = ( + "Lossy admission is rejected with Global and CapacityAware routing (hop-by-hop " + "forwarding without admission control neither grows nor reroutes)" +) +P = ngc.FlowPlacement +A = ngc.Admission + + +def zero_flow_count(): + return ngc.CapacityAware(flow_count=0) + + +@pytest.mark.parametrize( + ("routing", "placement", "admission", "priority", "msg"), + [ + (ngc.Global, P.MAX_FLOW, A.LOSSY, "CAPACITY", LOSSY_KERNEL), + (zero_flow_count, P.MAX_FLOW, A.LOSSY, "CAPACITY", LOSSY_KERNEL), + (ngc.Global, P.WEIGHTED, A.LOSSLESS, "HEADROOM", HEADROOM_KERNEL), + (zero_flow_count, P.WEIGHTED, A.LOSSY, "HEADROOM", HEADROOM_KERNEL), + (zero_flow_count, P.EQUAL_BALANCED, A.LOSSY, "CAPACITY", LOSSY_ROUTING), + (ngc.Global, P.WEIGHTED, A.LOSSY, "CAPACITY", LOSSY_ROUTING), + ], +) +def test_several_defects_report_the_first_in_the_contract_order( + routing, placement, admission, priority, msg +): + # Several defects at once: the kernel combinations (Lossy with MaxFlow, + # Weighted with Headroom) before the routing rules (Lossy with Global or CapacityAware, + # Global without MaxFlow, CapacityAware's counts); one exact ValueError text at + # construction and at the three evaluators (batch_capacity's precheck included); nothing is + # bound. + g = diamond() + fg = ngc.FlowLedger(g) + cfg = config( + routing(), placement, admission, sel(prio=getattr(ngc.EdgePriority, priority)) + ) + with pytest.raises(ValueError) as e: + ngc.FlowPolicy(fg, 0, 3, cfg) + assert str(e.value) == msg + with pytest.raises(ValueError) as e: + ngc.capacity(g, 0, 3, ngc.CapacityOptions(cfg)) + assert str(e.value) == msg + with pytest.raises(ValueError) as e: + ngc.batch_capacity(g, [(0, 3)], ngc.CapacityOptions(cfg)) + assert str(e.value) == msg + with pytest.raises(ValueError) as e: + ngc.sensitivity(g, 0, 3, ngc.SensitivityOptions(cfg), 1) + assert str(e.value) == msg fg.reset() # nothing bound def test_enum_values_outside_their_enumeration_are_rejected(): - # Rule 13: an enum built from an integer outside the enumeration is a domain error named + # An enum built from an integer outside the enumeration is a domain error named # by its field, at every constructor that takes it, so it never reaches a policy or record. with pytest.raises( ValueError, match="tie_break: 99 is not a value of EdgeTieBreak" @@ -181,6 +445,7 @@ def test_enum_values_outside_their_enumeration_are_rejected(): ValueError, match="admission: 99 is not a value of Admission" ): config(routing, ngc.FlowPlacement.MAX_FLOW, ngc.Admission(99)) + assert len(ngc.FlowPlacement.__members__) == 3 # the loop below is not empty for valid in ngc.FlowPlacement.__members__.values(): assert config(ngc.CostOnly(), valid).placement == valid @@ -188,7 +453,7 @@ def test_enum_values_outside_their_enumeration_are_rejected(): def test_placement_result_fields_and_outputs(): g = diamond() fg = ngc.FlowLedger(g) - pol = ngc.FlowPolicy(fg, [0], [3], config(ngc.CostOnly())) + pol = ngc.FlowPolicy(fg, [0], [3], COSTONLY) r = pol.place(4) assert (r.placed, r.remaining, r.termination) == (4, 0, T.COMPLETE) assert r.local_outcomes == [(0, T.COMPLETE)] @@ -200,9 +465,7 @@ def test_placement_result_fields_and_outputs(): and r.bottleneck is None and r.min_cut is None ) - r = pol.place( - INF, ngc.OutputOptions(edge_flows=True, residual=True, bottleneck=True) - ) + r = pol.place(INF, ALL_OUT) assert (r.placed, r.remaining, r.termination) == (3, INF, T.EXHAUSTED) assert r.edge_flows.dtype == np.int64 and r.residual.dtype == np.int64 assert list(r.residual) == list(fg.residual()) @@ -212,10 +475,27 @@ def test_placement_result_fields_and_outputs(): own[e] += x assert list(r.edge_flows) == own assert r.bottleneck == fg.bottleneck(0) + # By hand: the flow's 7 units split equally at node 0, the leftover unit to the lower + # EdgeId: 4 on 0 -> 1 -> 3 and 3 on 0 -> 2 -> 3, the most that keeps 0 -> 2 within its 3. + # Only 0 -> 2 is saturated where it leaves what the source still reaches. + assert r.edge_flows.tolist() == to_ids(g, [4, 4, 3, 3]) + assert r.residual.tolist() == to_ids(g, [1, 1, 0, 0]) + assert r.bottleneck == ids(g, [2]) assert r.min_cut is None # Global only assert pol.placed_demand() == 7 - with pytest.raises(ValueError, match="integer units or"): + with pytest.raises( + ValueError, + match=exact( + "requests are integer units or +inf (a physical request goes through " + "to_units first; got 1.5)" + ), + ): pol.place(1.5) + with pytest.raises(_ConversionFailed): # a float request whose own conversion fails + pol.place(_FailingFloat(1.0)) + for bad in (_OverflowingFloat(1.0), _OverflowingIndex()): # its OverflowError too + with pytest.raises(OverflowError, match=exact("own conversion")): + pol.place(bad) with pytest.raises(ValueError, match=r"after \+inf"): pol.place(np.int64(1)) pol.remove() @@ -230,19 +510,150 @@ def test_placement_result_fields_and_outputs(): assert [rec.delivered for rec in gl.records()] == [8] +def three_routes(a, b, c): + # Three disjoint routes 0 -> x -> 4 (input order): A through 1 of cost 1 + 1 = 2, B through 2 + # of cost 2 + 1 = 3, C through 3 of cost 2 + 2 = 4; capacities a, b, c on both edges. + return build( + 5, + [ + (0, 1, a, 1), + (1, 4, a, 1), + (0, 2, b, 2), + (2, 4, b, 1), + (0, 3, c, 2), + (3, 4, c, 2), + ], + ) + + +def routes_of(g, policy): + """Per record: (route cost, delivered, the bundle's input edges, units per input edge).""" + pos = g.input_index() + return [ + ( + r.route_cost, + r.delivered, + sorted(int(pos[e]) for e in r.bundle.via_edges()), + sorted((int(pos[e]), x) for e, x in r.edges), + ) + for r in policy.records() + ] + + +def gated(kernel, ceiling=None, floor=0): + return config( + ngc.CapacityAware(max_path_cost=ceiling, min_flow_bandwidth=floor), kernel + ) + + +# Each route of three_routes(2, 2, 2) carrying its 2 units, as routes_of reports it. +A2 = (2, 2, [0, 1], [(0, 2), (1, 2)]) +B2 = (3, 2, [2, 3], [(2, 2), (3, 2)]) +C2 = (4, 2, [4, 5], [(4, 2), (5, 2)]) +KERNELS = [ngc.FlowPlacement.MAX_FLOW, ngc.FlowPlacement.EQUAL_BALANCED] + + +@pytest.mark.parametrize("kernel", KERNELS) +@pytest.mark.parametrize( + "ceiling, placed, routes", + [ + (ngc.Absolute(3), 4, [A2, B2]), + (ngc.Stretch(1.5), 4, [A2, B2]), + (ngc.Absolute(4), 6, [A2, B2, C2]), + (ngc.Stretch(2.0), 6, [A2, B2, C2]), + (None, 6, [A2, B2, C2]), + (ngc.Absolute(1), 0, []), + ], +) +def test_path_cost_ceiling_excludes_a_costlier_route(kernel, ceiling, placed, routes): + # CapacityAwareGates.PathCostCeilingExcludesACostlierRoute through the binding, derived by + # hand on three_routes(2, 2, 2): A (cost 2) is created and filled to 2, + # growth adds B (cost 3; EqualBalanced: cohort [2, 2]), and C (cost 4) only when the + # inclusive ceiling admits it: Absolute(3) and Stretch(1.5) (1.5 * 2 = 3) exclude it + # (placed 4, exhausted), Absolute(4) and Stretch(2) admit it (placed 6). Absolute(1) admits + # no route: placed 0, no_route. + g = three_routes(2, 2, 2) + p = ngc.FlowPolicy(ngc.FlowLedger(g), 0, 4, gated(kernel, ceiling)) + r = p.place(10) + term = T.NO_ROUTE if placed == 0 else T.EXHAUSTED + assert (r.placed, r.remaining, r.termination) == (placed, 10 - placed, term) + assert r.local_outcomes == [(0, term)] + assert (r.routes_changed, r.weights_changed) == (bool(routes), False) + assert routes_of(g, p) == routes + + +@pytest.mark.parametrize("kernel", KERNELS) +@pytest.mark.parametrize("factor, placed, routes", [(1.5, 2, [B2]), (2.0, 4, [B2, C2])]) +def test_stretch_baseline_is_the_static_shortest_cost(kernel, factor, placed, routes): + # A third party holds both units of A: the static shortest cost stays 2 (the Stretch + # baseline ignores the residual), so Stretch(1.5) admits B (3) and not C (4): placed 2. A + # baseline on the residual (3) would admit C under 4.5. Stretch(2) admits C: placed 4. + g = three_routes(2, 2, 2) + ledger = ngc.FlowLedger(g) + third = ngc.FlowPolicy( + ledger, + 0, + 4, + config(ngc.Pinned([dag_of(g, ids(g, [0, 1]))]), ngc.FlowPlacement.MAX_FLOW), + ) + assert third.place(2).placed == 2 + p = ngc.FlowPolicy(ledger, 0, 4, gated(kernel, ngc.Stretch(factor))) + r = p.place(10) + assert (r.placed, r.termination) == (placed, T.EXHAUSTED) + assert routes_of(g, p) == routes + + +@pytest.mark.parametrize( + "kernel, floor, placed, routes", + [ + (ngc.FlowPlacement.MAX_FLOW, 3, 4, [(2, 4, [0, 1], [(0, 4), (1, 4)])]), + (ngc.FlowPlacement.MAX_FLOW, 2, 6, [(2, 4, [0, 1], [(0, 4), (1, 4)]), B2]), + (ngc.FlowPlacement.MAX_FLOW, 5, 0, []), + (ngc.FlowPlacement.EQUAL_BALANCED, 3, 4, [(2, 4, [0, 1], [(0, 4), (1, 4)])]), + ( + ngc.FlowPlacement.EQUAL_BALANCED, + 2, + 5, + [(2, 3, [0, 1], [(0, 3), (1, 3)]), B2], + ), + (ngc.FlowPlacement.EQUAL_BALANCED, 5, 0, []), + ], +) +def test_min_flow_bandwidth_stops_growth_below_the_floor(kernel, floor, placed, routes): + # CapacityAwareGates.MinFlowBandwidthStopsGrowthBelowTheFloor through the binding, derived + # by hand (every search gated at residual >= max(1, floor)) on three_routes(4, 2, 0): A of + # 4 units at cost 2, B of 2 at cost 3. Floor 3: A is filled to 4, B (residual 2) is below + # the floor, growth stops: placed 4. Floor 2: B is added: MaxFlow 4 + 2; EqualBalanced the + # cohort [3, 2] (round robin, the odd unit to the first flow; [3, 3] exceeds B), then A's + # residual 1 is below the floor: placed 5. Floor 5: no route at all, no_route. + g = three_routes(4, 2, 0) + p = ngc.FlowPolicy(ngc.FlowLedger(g), 0, 4, gated(kernel, floor=floor)) + r = p.place(10) + assert (r.placed, r.termination) == ( + placed, + T.NO_ROUTE if placed == 0 else T.EXHAUSTED, + ) + assert routes_of(g, p) == routes + + def test_lifetimes_keep_alive_and_orphans(): g = diamond() fg = ngc.FlowLedger(g) el = g.eligibility(excluded_edges=ids(g, [2])) cache = ngc.DagCache(g) - pol = ngc.FlowPolicy(fg, 0, 3, config(ngc.CostOnly()), el, cache) + pol = ngc.FlowPolicy(fg, 0, 3, COSTONLY, el, cache) + held = [weakref.ref(x) for x in (fg, el, cache)] del fg, el, cache gc.collect() + assert all(r() is not None for r in held) assert ( pol.place(10).placed == 5 ) # the ledger, the Eligibility and the cache are alive + del pol + gc.collect() + assert all(r() is None for r in held) # the policy alone held them fg2 = ngc.FlowLedger(g) - p = ngc.FlowPolicy(fg2, 0, 3, config(ngc.CostOnly())) + p = ngc.FlowPolicy(fg2, 0, 3, COSTONLY) p.place(2) del p gc.collect() @@ -250,7 +661,7 @@ def test_lifetimes_keep_alive_and_orphans(): assert fg2._state()["orphans"] == [0] assert fg2.records()[0].delivered == 2 with pytest.raises(ValueError, match="bound"): - q = ngc.FlowPolicy(fg2, 0, 3, config(ngc.CostOnly())) + q = ngc.FlowPolicy(fg2, 0, 3, COSTONLY) fg2.reset() del q gc.collect() @@ -258,10 +669,25 @@ def test_lifetimes_keep_alive_and_orphans(): assert fg2.records() == [] +@needs_hooks +def test_owner_hook_is_the_owner_of_the_records(): + # The private hook FlowPolicy._owner reads the ledger-local owner id: one per policy of a + # ledger, known before the policy has a record, and carried by its records as FlowRecord.owner. + g = diamond() + fg = ngc.FlowLedger(g) + policies = [ngc.FlowPolicy(fg, 0, 3, COSTONLY) for _ in range(3)] + owners = [p._owner() for p in policies] + assert len(set(owners)) == 3 + for p in reversed(policies): + assert p.place(1).placed == 1 + for p, owner in zip(policies, owners, strict=True): + assert [r.owner for r in p.records()] == [owner] + + def test_pinned_usable_bundles_and_records(): g = diamond() fg = ngc.FlowLedger(g) - r1, r2 = dag(g, ids(g, [0, 1])), dag(g, ids(g, [2, 3])) + r1, r2 = dag_of(g, ids(g, [0, 1])), dag_of(g, ids(g, [2, 3])) el = g.eligibility(excluded_edges=ids(g, [3])) pol = ngc.FlowPolicy(fg, 0, 3, config(ngc.Pinned([r1, r2])), el) assert pol.usable_bundles() == 1 @@ -269,16 +695,27 @@ def test_pinned_usable_bundles_and_records(): assert (r.placed, r.termination) == (5, T.EXHAUSTED) rec = pol.records()[0] assert rec.bundle == r1 and pol.record(rec.flow_id).delivered == 5 - # record() reads the policy's own records only (section 8: a policy's records). + # record() reads the policy's own records only, not another policy's on the same ledger. other = ngc.FlowPolicy(fg, 0, 3, config(ngc.Pinned([r1]))) other.place(1) - with pytest.raises(ValueError, match="not a record of this policy"): - pol.record(other.records()[0].flow_id) - assert fg.record(other.records()[0].flow_id).delivered == 0 - with pytest.raises(ValueError, match="another ledger"): - pol.record((ngc.FlowLedger(g).uid(), 0)) + theirs = other.records()[0].flow_id + with pytest.raises( + ValueError, match=exact(f"FlowId {theirs} is not a record of this policy") + ): + pol.record(theirs) + assert fg.record(theirs).delivered == 0 + elsewhere = ngc.FlowLedger(g).uid() + with pytest.raises( + ValueError, + match=exact( + f"FlowId ({elsewhere}, 0) belongs to another ledger (uid {fg.uid()})" + ), + ): + pol.record((elsewhere, 0)) pol.remove() - with pytest.raises(ValueError, match="retired or unknown"): + with pytest.raises( + ValueError, match=exact(f"FlowId {rec.flow_id} is retired or unknown") + ): pol.record(rec.flow_id) all_down = ngc.FlowPolicy( fg, 0, 3, config(ngc.Pinned([r2])), g.eligibility(excluded_edges=ids(g, [3])) @@ -292,7 +729,7 @@ def test_dag_cache_statistics_and_projection(): g = diamond() cache = ngc.DagCache(g) fg = ngc.FlowLedger(g) - a = ngc.FlowPolicy(fg, 0, 3, config(ngc.CostOnly()), None, cache) + a = ngc.FlowPolicy(fg, 0, 3, COSTONLY, None, cache) b = ngc.FlowPolicy( fg, 0, @@ -301,7 +738,7 @@ def test_dag_cache_statistics_and_projection(): ngc.CostOnly(), selection=sel( ngc.EdgeTieBreak.PREFER_HIGHER_PRIORITY, - ngc.EdgePriority.RESIDUAL, + RES, False, False, ), @@ -335,6 +772,9 @@ def test_capacity_batch_and_workers(monkeypatch): g, pairs, ngc.CapacityOptions(GLOBAL, elig=common), per, workers ) assert [x.placed for x in got] == want + # workers=None reads NGRAPH_CORE_BATCH_THREADS. The count changes only how many threads run, + # never a result, so this checks only that the default path with the variable set gives the + # same results (its parsing: the C++ test Evaluators.DefaultWorkersParseTheEnvironment). monkeypatch.setenv("NGRAPH_CORE_BATCH_THREADS", "4") got = ngc.batch_capacity(g, pairs, ngc.CapacityOptions(GLOBAL, elig=common), per) assert [x.placed for x in got] == want @@ -343,14 +783,18 @@ def test_capacity_batch_and_workers(monkeypatch): ngc.batch_capacity( g, [(0, 3), (0, 7), (0, 8)], ngc.CapacityOptions(GLOBAL), workers=4 ) - with pytest.raises(ValueError, match="per_pair_elig has 1 entries"): + with pytest.raises( + ValueError, match=exact("per_pair_elig has 1 entries for 4 pairs") + ): ngc.batch_capacity(g, pairs, ngc.CapacityOptions(GLOBAL), [None]) with pytest.raises(ValueError, match="Pinned"): ngc.batch_capacity( - g, pairs, ngc.CapacityOptions(config(ngc.Pinned([dag(g, ids(g, [0, 1]))]))) + g, + pairs, + ngc.CapacityOptions(config(ngc.Pinned([dag_of(g, ids(g, [0, 1]))]))), ) cache = ngc.DagCache(g) - eb = ngc.CapacityOptions(config(ngc.CostOnly()), cache=cache) + eb = ngc.CapacityOptions(COSTONLY, cache=cache) with pytest.raises(ValueError, match="workers == 1"): ngc.batch_capacity(g, pairs, eb, workers=2) got = ngc.batch_capacity( @@ -361,93 +805,168 @@ def test_capacity_batch_and_workers(monkeypatch): ngc.batch_capacity(g, pairs, ngc.CapacityOptions(GLOBAL), workers=0) -def test_sensitivity_candidates_losses_and_unevaluable(): - # Global: an unsaturated critical edge appears (rule 1). +def test_sensitivity_candidates_and_signed_losses(monkeypatch): + # Global: an unsaturated critical edge appears. g = build(3, [(0, 1, 9, 1), (1, 2, 4, 1), (0, 2, 2, 5)]) s = ngc.sensitivity(g, 0, 2, ngc.SensitivityOptions(GLOBAL)) assert s.baseline.placed == 6 pos = g.input_index() assert {int(pos[e.edge]): e.loss for e in s.entries} == {0: 4, 1: 4, 2: 2} - assert all( - e.status == ngc.SensitivityStatus.EVALUATED and e.reason is None - for e in s.entries - ) + # workers=None reads NGRAPH_CORE_SENSITIVITY_THREADS: the same results on 3 workers (only that + # the default path with the variable set works; its parsing is tested in C++). + monkeypatch.setenv("NGRAPH_CORE_SENSITIVITY_THREADS", "3") + assert plain(ngc.sensitivity(g, 0, 2, ngc.SensitivityOptions(GLOBAL))) == plain(s) # A policy model: signed losses; zero-capacity edges absent. p = build(2, [(0, 1, 1, 1), (0, 1, 10, 1), (0, 1, 0, 1)]) - opts = ngc.SensitivityOptions(config(ngc.CostOnly())) + opts = ngc.SensitivityOptions(COSTONLY) s = ngc.sensitivity(p, 0, 1, opts, workers=2) assert s.baseline.placed == 2 pos = p.input_index() assert {int(pos[e.edge]): e.loss for e in s.entries} == {0: -8, 1: 1} with pytest.raises(ValueError, match="both endpoint sets"): ngc.sensitivity(p, 0, 0, opts) + # The baseline is capacity() with the requested outputs. + for cfg in (GLOBAL, COSTONLY): + s = ngc.sensitivity(g, 0, 2, ngc.SensitivityOptions(cfg, outputs=ALL_OUT)) + want = ngc.capacity(g, 0, 2, ngc.CapacityOptions(cfg, outputs=ALL_OUT)) + assert want.edge_flows is not None and want.residual is not None + assert plain(s.baseline) == plain(want) -@needs_hooks -def test_sensitivity_reports_a_model_exception_as_unevaluable(): - # No valid configuration raises under a deletion; the private hook injects one (rule 1). - p = build(2, [(0, 1, 1, 1), (0, 1, 10, 1), (0, 1, 0, 1)]) - opts = ngc.SensitivityOptions(config(ngc.CostOnly())) - ext._sensitivity_fail_edge(ids(p, [1])[0]) - try: - s = ngc.sensitivity(p, 0, 1, opts) - finally: - ext._sensitivity_fail_edge(None) - bad = [e for e in s.entries if e.status == ngc.SensitivityStatus.UNEVALUABLE] - assert len(bad) == 1 and bad[0].loss is None and "injected" in bad[0].reason - - -def _state_of(policy, ledger): - return ( - policy.placed_demand(), - len(ledger.records()), - policy._state()["requested"], - [int(x) for x in ledger.allocated()], +def test_evaluator_options_and_results(): + # The options hold what they were given (None: everything allowed, no supplied cache, no + # output); the results have no public constructor. + g = diamond() + el = g.eligibility(excluded_edges=ids(g, [0])) + cache = ngc.DagCache(g) + for cls in (ngc.CapacityOptions, ngc.SensitivityOptions): + o = cls(GLOBAL, elig=el, cache=cache, outputs=ngc.OutputOptions(residual=True)) + assert ( + o.config.placement == GLOBAL.placement and o.elig is el and o.cache is cache + ) + out = o.outputs + assert (out.edge_flows, out.residual, out.bottleneck) == (False, True, False) + d = cls(COSTONLY) + assert d.elig is None and d.cache is None + assert (d.outputs.edge_flows, d.outputs.residual, d.outputs.bottleneck) == ( + False, + False, + False, + ) + for cls in (ngc.SensitivityEntry, ngc.SensitivityResult): + with pytest.raises(TypeError, match="No constructor defined"): + cls() + + +def test_explicit_workers_win_over_the_environment(monkeypatch): + # The environment supplies only the default of workers=None, which is 1 whenever a cache is + # supplied; an explicit count is always honoured (a building cache accepts only 1). + monkeypatch.setenv("NGRAPH_CORE_BATCH_THREADS", "4") + monkeypatch.setenv("NGRAPH_CORE_SENSITIVITY_THREADS", "4") + g = diamond(5, 3) + building = ngc.DagCache(g) + bopts = ngc.CapacityOptions(COSTONLY, cache=building) + sopts = ngc.SensitivityOptions(COSTONLY, cache=building) + want = [r.placed for r in ngc.batch_capacity(g, [(0, 3), (1, 3)], bopts, workers=1)] + assert want == [7, 5] + assert [r.placed for r in ngc.batch_capacity(g, [(0, 3), (1, 3)], bopts)] == want + base = plain(ngc.sensitivity(g, 0, 3, sopts, workers=1)) + assert plain(ngc.sensitivity(g, 0, 3, sopts)) == base + for call in ( + lambda: ngc.batch_capacity(g, [(0, 3)], bopts, workers=2), + lambda: ngc.sensitivity(g, 0, 3, sopts, workers=2), + ): + with pytest.raises(ValueError, match="unless workers == 1"): + call() + with pytest.raises(ValueError, match=exact("workers must be >= 1 (got 0)")): + ngc.sensitivity(g, 0, 3, ngc.SensitivityOptions(GLOBAL), workers=0) + + +def test_python_keeps_what_a_policy_and_an_option_need_alive(): + # Once the caller drops them, the evaluators' options still apply their Eligibility and cache, + # the ledger still reads its graph, and a policy still places on its ledger. Graphs of the + # same shape are built in between, so the memory of a released value would be reused. + g = diamond() + want = [g.capacity_units().tolist(), g.cost().tolist(), g.edge_dst().tolist()] + ledger = ngc.FlowLedger(g) + policy = ngc.FlowPolicy(ledger, 0, 3, COSTONLY) + opts = ngc.CapacityOptions( + COSTONLY, elig=g.eligibility(excluded_edges=ids(g, [2])), cache=ngc.DagCache(g) ) + gc.collect() + reused = [diamond(7, 9, 2) for _ in range(64)] + assert ngc.capacity(g, 0, 3, opts).placed == 5 # without the edge 0 -> 2 + assert opts.cache.stats().misses == 1 # the call's lookup + del g, opts + gc.collect() + reused += [diamond(7, 9, 2) for _ in range(64)] + kept = ledger.graph() + assert [ + kept.capacity_units().tolist(), + kept.cost().tolist(), + kept.edge_dst().tolist(), + ] == want + del kept, ledger + gc.collect() + reused += [diamond(7, 9, 2) for _ in range(64)] + assert policy.place(10).placed == 7 # EqualBalanced over routes of 5 and 3: [4, 3] + assert len(reused) == 192 + + +def test_exception_classes_of_conversions(): + # An integer beyond the float range where a float is expected: the resolution (a plain float + # parameter) cannot be converted, a TypeError; a request (integer units) is outside its + # domain, a ValueError. A request's own conversion failure reaches the caller unchanged. + for call in ( + lambda: ngc.Graph(2, [0], [1], [5], [1], 10**400), + lambda: ngc.to_units(1.0, 10**400), + lambda: ngc.as_float(1, 10**400), + ): + with pytest.raises(TypeError, match="incompatible"): + call() + g = build(2, [(0, 1, 5, 1)]) + p = ngc.FlowPolicy(ngc.FlowLedger(g), 0, 1, COSTONLY) + for big in (10**400, -(10**400)): + with pytest.raises( + ValueError, + match=exact(f"request (units) = {big} is out of range [0, {L62 - 1}]"), + ): + p.place(big) + # An index's own failure (a float's: test_placement_result_fields_and_outputs). + with pytest.raises(_ConversionFailed): + p.place(_FailingIndex()) + assert p.placed_demand() == 0 @needs_hooks @pytest.mark.parametrize("routing", ["costonly", "global"]) @pytest.mark.parametrize("traced", [False, True]) def test_python_publication_is_all_or_nothing(routing, traced): - # review-1c F2: every Python object of a placement exists before anything is published, so - # the k-th Python allocation failing raises with the ledger and the policy unchanged, never - # crashes, and a retry places once. - cfg = GLOBAL if routing == "global" else config(ngc.CostOnly()) + # Every Python object of a placement exists before anything is published, so the k-th + # Python allocation failing raises with the ledger and the policy unchanged (an earlier + # call's flow in the same epoch included), never crashes, and a retry places as a call + # without failure does. + cfg = GLOBAL if routing == "global" else COSTONLY g = build(2, [(0, 1, 10, 1)]) - ledger = ngc.FlowLedger(g) - want = ngc.FlowPolicy(ngc.FlowLedger(g), 0, 1, cfg).place(3).cost_distribution - reached = 0 - for k in range(400): + + def make(): ledger = ngc.FlowLedger(g) policy = ngc.FlowPolicy(ledger, 0, 1, cfg) - fn = policy._place_traced if traced else policy.place - before = _state_of(policy, ledger) - hit, _, value, error = ext._fail_python_allocation(fn, (3,), k) - if not hit: - assert error is None - r = value[0] if traced else value - assert (r.placed, r.cost_distribution) == (3, want) - break - reached += 1 - if ( - error is None - ): # a failure inside Python that the call absorbed (none expected) - continue - assert isinstance(error, (MemoryError, RuntimeError)), repr(error) - assert _state_of(policy, ledger) == before - r = fn(3) - r = r[0] if traced else r - assert r.placed == 3 and policy.placed_demand() == 3 - else: - pytest.fail("the call never completed") - assert reached >= 3 # not vacuous + assert policy.place(1).placed == 1 + + def snapshot(): + return [full_ledger_state(ledger), repr(policy._state())] + + return (policy._place_traced if traced else policy.place), (3,), snapshot + + assert sweep_python_allocation(make) >= 3 # not vacuous @needs_hooks def test_evaluator_results_survive_python_allocation_failures(): # The evaluators publish nothing; their result objects are made through the checked path - # too, so a failed Python allocation raises instead of crashing. + # too, so a failed Python allocation raises instead of crashing, and the call that + # completes returns what a clean call returns. g = diamond(5, 3) calls = [ (ngc.capacity, (g, 0, 3, ngc.CapacityOptions(GLOBAL))), @@ -455,42 +974,48 @@ def test_evaluator_results_survive_python_allocation_failures(): (ngc.sensitivity, (g, 0, 3, ngc.SensitivityOptions(GLOBAL))), ] for fn, args in calls: - for k in range(400): - hit, _, _, error = ext._fail_python_allocation(fn, args, k) - if not hit: - assert error is None - break - assert error is None or isinstance(error, (MemoryError, RuntimeError)) - else: - pytest.fail(f"{fn.__name__} never completed") + # An evaluator keeps no state between calls: nothing to snapshot. + failures = sweep_python_allocation(lambda fn=fn, args=args: (fn, args, list)) + assert failures >= 3, fn.__name__ # not vacuous: every result object allocates def test_batch_reports_the_lowest_failing_pair(): - # review-1c F3: the arguments first, then each pair completely in pair order, across the + # The arguments first, then each pair completely in pair order, across the # Python/C++ boundary. g = build(2, [(0, 1, 3, 7)]) opts = ngc.CapacityOptions(GLOBAL) - with pytest.raises(ValueError, match="node 0 is in both endpoint sets"): + overlap = ( + "node 0 is in both endpoint sets: sources and destinations must not overlap" + ) + out_of_range = "sources: node 2 out of node range [0, 2)" + with pytest.raises(ValueError, match=exact(overlap)): ngc.batch_capacity(g, [(0, 0), (2, 1)], opts, workers=2) - with pytest.raises(ValueError, match="node 2 out of node range"): + with pytest.raises(ValueError, match=exact(out_of_range)): ngc.batch_capacity(g, [(0, 1), (2, 1), (0, 0)], opts) other = build(2, [(0, 1, 3, 7)]).eligibility() - with pytest.raises(ValueError, match=r"per_pair_elig\[0\]"): + with pytest.raises( + ValueError, + match=exact("foreign Eligibility: per_pair_elig[0] built for another graph"), + ): ngc.batch_capacity(g, [(0, 1), (2, 1)], opts, per_pair_elig=[other, None]) - with pytest.raises(TypeError, match="per_pair_elig entries"): + with pytest.raises( + TypeError, match=exact("per_pair_elig entries must be Eligibility or None") + ): ngc.batch_capacity(g, [(0, 1), (2, 1)], opts, per_pair_elig=[3, None]) - with pytest.raises(ValueError, match="node 2 out of node range"): + with pytest.raises(ValueError, match=exact(out_of_range)): ngc.batch_capacity(g, [(0, 1), (2, 1)], opts, per_pair_elig=[None, 3]) # An argument error comes before every pair error. - with pytest.raises(ValueError, match="workers"): + with pytest.raises(ValueError, match=exact("workers must be >= 1 (got 0)")): ngc.batch_capacity(g, [(0, 0)], opts, workers=0) def test_batch_checks_an_explicit_empty_eligibility_list(): - # review-1c F4: a given per_pair_elig has one entry per pair, also when it is empty. + # A given per_pair_elig has one entry per pair, also when it is empty. g = build(2, [(0, 1, 3, 7)]) opts = ngc.CapacityOptions(GLOBAL) - with pytest.raises(ValueError, match="per_pair_elig has 0 entries for 1 pairs"): + with pytest.raises( + ValueError, match=exact("per_pair_elig has 0 entries for 1 pairs") + ): ngc.batch_capacity(g, [(0, 1)], opts, per_pair_elig=[]) assert ngc.batch_capacity(g, [], opts, per_pair_elig=[]) == [] assert [ @@ -499,7 +1024,7 @@ def test_batch_checks_an_explicit_empty_eligibility_list(): def test_global_zero_call_reports_the_cumulative_curve(): - # review-1c F5: 9.1's curve is cumulative (its amounts sum to placed_demand()); a zero call + # Global's curve is cumulative (its amounts sum to placed_demand()); a zero call # reports it ([] before the first placement and after remove()), changes nothing and claims # no cut. g = build(2, [(0, 1, 3, 7)]) @@ -507,7 +1032,7 @@ def test_global_zero_call_reports_the_cumulative_curve(): p = ngc.FlowPolicy(ledger, 0, 1, GLOBAL) assert p.place(0).cost_distribution == [] assert p.place(2).cost_distribution == [(7, 2)] - z = p.place(0, ngc.OutputOptions(edge_flows=True, residual=True, bottleneck=True)) + z = p.place(0, ALL_OUT) assert (z.placed, z.cost_distribution, z.min_cut) == (0, [(7, 2)], None) assert p.placed_demand() == 2 and [int(x) for x in ledger.allocated()] == [2] p.remove() diff --git a/tests/py/test_flow_policy_fixtures.py b/tests/py/test_flow_policy_fixtures.py index e971418..ac32c14 100644 --- a/tests/py/test_flow_policy_fixtures.py +++ b/tests/py/test_flow_policy_fixtures.py @@ -1,30 +1,36 @@ -"""Prototype 2's 59 fixtures replayed through the bindings (APPROACH 8, 9). +"""The 59 fixtures of the independent reference replayed through the bindings (the ledger and +the policies). -``tests/data/policy_fixtures.json`` was exported from the reference, prototype 2 of the design -tree (its ``source`` names it; the design tree is untracked by design; its maintained exporter -is ``equivalence/export_policy_fixtures.py`` there): every ledger, policy -and call of every fixture in order, with the prototype's values after each operation. The +``tests/data/policy_fixtures.json`` was exported from the independent reference +(its frozen copy, the exporter, the regeneration check and the meaning of the data's historical +``source`` string: ``tests/reference/policy/README.md``): every ledger, policy and call of +every fixture in order, with the reference's values after each operation. The replay asserts every one of them on the build: each call's result (placed, remaining, termination, local outcomes, flags, drops, the cost curve and the requested outputs), its -trace (prototype 2's keys: offers, created, per source the creation reservations, reroute +trace (the reference's keys: offers, created, per source the creation reservations, reroute decisions, amounts and growth steps; Global's view, cap, flow-core payload and shares), the ledger after it (allocations, residuals, every record field, next FlowId, owners) and the policy state (roster, requested, +inf, placed demand, usable bundles, per source the flows' -ids, routes, T, deliveries, allocations, members and weights). An operation the prototype -rejects must raise here too (the messages differ). Normalised on both sides: the T_max probe +ids, routes, T, deliveries, allocations, members and weights). An operation the reference +rejects must raise here too (the messages differ), and a rejected placement leaves the policy's +state as it was. Normalised on both sides: the T_max probe counts, the flow core's operation counters and ledger uids. The replay reads the trace and sets the creation cap through the private hooks, so it is skipped without them. """ from __future__ import annotations +import copy import gc import json import math +import sys +import traceback from pathlib import Path import _netgraph_core as ext import pytest +from helpers import build, dag_of from private_hooks import needs_hooks import netgraph_core as ngc @@ -72,31 +78,25 @@ def norm(x): return x -def build_graph(spec): - edges = spec["edges"] - g = ngc.Graph( - spec["n"], - [e[0] for e in edges], - [e[1] for e in edges], - [e[2] for e in edges], - [e[3] for e in edges], - spec["q"], - ) - assert list(g.input_index()) == spec["input_index"] - return g +def exactly(a, b): + """Whether a and b are equal with the same type at every level: a dict has the same keys + and exactly equal values, a list the same length and exactly equal items, and every other + value the same type and value (so True differs from 1, and 1 from 1.0).""" + if type(a) is not type(b): + return False + if isinstance(a, dict): + return a.keys() == b.keys() and all(exactly(a[k], b[k]) for k in a) + if isinstance(a, (list, tuple)): + return len(a) == len(b) and all( + exactly(x, y) for x, y in zip(a, b, strict=True) + ) + return a == b -def dag_of(g, edges): - per = [[] for _ in range(g.num_nodes())] - for e in sorted(edges): - per[int(g.edge_dst()[e])].append(e) - offsets, parents, via = [0], [], [] - for v in range(g.num_nodes()): - for e in per[v]: - parents.append(int(g.edge_src()[e])) - via.append(e) - offsets.append(len(via)) - return ngc.PredDAG.from_csr(g, offsets, parents, via) +def build_graph(spec): + g = build(spec["n"], spec["edges"], spec["q"]) + assert exactly(g.input_index().tolist(), spec["input_index"]) + return g def config_of(g, c): @@ -222,35 +222,35 @@ def policy_json(pol): } -def replay(fx): +def replay(fx, at=None): + """Replays fx, asserting every expectation; `at`, when given, collects the index of each + operation as it starts (so a failure can be attributed to its operation).""" graphs = [build_graph(spec) for spec in fx["graphs"]] ledgers: dict[int, object] = {} policies: dict[int, object] = {} alive = [] - curves: dict[ - int, list - ] = {} # per policy: the cumulative Global curve of the epoch (F5) + curves: dict[int, list] = {} # per policy: the cumulative Global curve of the epoch for i, op in enumerate(fx["ops"]): + if at is not None: + at.append(i) where = f"{fx['name']} op {i} ({op['op']})" kind = op["op"] expect = op.get("expect", {}) if kind == "ledger": - g = graphs[op["graph"]] - if op["error"]: - with pytest.raises((ValueError, TypeError)): - ngc.FlowLedger(g, op["baseline"]) - continue - ledgers[op["id"]] = ngc.FlowLedger(g, op["baseline"]) + # The data holds no ledger the reference rejects: a data file with one fails here, + # rather than in a branch no fixture would reach. + assert not op["error"], where + ledgers[op["id"]] = ngc.FlowLedger(graphs[op["graph"]], op["baseline"]) continue if kind == "copy": src = ledgers[op["ledger"]] if op["error"]: with pytest.raises((ValueError, TypeError)): src.copy() - assert ledger_json(src) == expect["ledger"], where + assert exactly(ledger_json(src), expect["ledger"]), where continue ledgers[op["id"]] = src.copy() - assert ledger_json(ledgers[op["id"]]) == expect["ledger"], where + assert exactly(ledger_json(ledgers[op["id"]]), expect["ledger"]), where continue if kind == "reset": fg = ledgers[op["ledger"]] @@ -259,7 +259,7 @@ def replay(fx): fg.reset(op["baseline"]) else: fg.reset(op["baseline"]) - assert ledger_json(fg) == expect["ledger"], where + assert exactly(ledger_json(fg), expect["ledger"]), where continue if kind == "policy": fg = ledgers[op["ledger"]] @@ -283,14 +283,16 @@ def construct(op=op, fg=fg, g=g): alive.append(pol) policies[op["id"]] = (pol, fg) curves[op["id"]] = [] - assert policy_json(pol) == expect["policy"], where + assert exactly(policy_json(pol), expect["policy"]), where continue pol, fg = policies[op["policy"]] + # Neither a removal nor a destruction can fail, so the reference must accept both. + assert kind not in ("remove", "destroy") or not op["error"], where if kind == "remove": pol.remove() curves[op["policy"]] = [] - assert ledger_json(fg) == expect["ledger"], where - assert policy_json(pol) == expect["policy"], where + assert exactly(ledger_json(fg), expect["ledger"]), where + assert exactly(policy_json(pol), expect["policy"]), where continue if kind == "destroy": # The destruction law: dropping the last reference orphans the records. @@ -298,7 +300,7 @@ def construct(op=op, fg=fg, g=g): policies[op["policy"]] = None del pol gc.collect() - assert ledger_json(fg) == expect["ledger"], where + assert exactly(ledger_json(fg), expect["ledger"]), where continue assert kind == "place", where volume = math.inf if op["volume"] == "inf" else op["volume"] @@ -309,26 +311,31 @@ def construct(op=op, fg=fg, g=g): old = ext._max_flows_per_call(op["cap"]) try: if op["error"]: + before = policy_json(pol) with pytest.raises((ValueError, TypeError)): pol.place(volume, outputs) - assert ledger_json(fg) == expect["ledger"], where + assert exactly(ledger_json(fg), expect["ledger"]), where + # The reference records the ledger only; the policy's own state (requested, + # +inf, the flows) must be as before the call too. + assert exactly(policy_json(pol), before), where continue r, trace = pol._place_traced(volume, outputs) finally: ext._max_flows_per_call(old) got = result_json(r) if isinstance(pol.config.routing, ngc.Global): - # Contract over prototype (1c NOTES P3, review-1c F5): a Global zero call reports - # the cumulative curve (9.1), which prototype 2 omits; it must equal the curve of the + # Where the reference differs from the library: a Global zero call reports + # the cumulative curve, which the reference omits; it must equal the curve of the # epoch's last positive call ([] before any), and only then is it set aside. if volume == 0 and "cost_distribution" not in expect["result"]: - assert got.pop("cost_distribution") == curves[op["policy"]], where + curve = curves[op["policy"]] + assert exactly(got.pop("cost_distribution"), curve), where if "cost_distribution" in expect["result"]: curves[op["policy"]] = expect["result"]["cost_distribution"] - assert got == expect["result"], where - assert norm(trace) == expect["trace"], where - assert ledger_json(fg) == expect["ledger"], where - assert policy_json(pol) == expect["policy"], where + assert exactly(got, expect["result"]), where + assert exactly(norm(trace), expect["trace"]), where + assert exactly(ledger_json(fg), expect["ledger"]), where + assert exactly(policy_json(pol), expect["policy"]), where def test_the_data_holds_every_fixture(): @@ -340,3 +347,296 @@ def test_the_data_holds_every_fixture(): @pytest.mark.parametrize("fx", FIXTURES, ids=[fx["name"] for fx in FIXTURES]) def test_fixture(fx): replay(fx) + + +def _first_leaf(x): + """(container, key) of the first leaf of x in document order; None when x has none.""" + items = ( + x.items() + if isinstance(x, dict) + else enumerate(x) + if isinstance(x, list) + else () + ) + for k, v in items: + if not isinstance(v, (dict, list)): + return x, k + found = _first_leaf(v) + if found is not None: + return found + return None + + +def _corrupt(container, key): + """Changes one leaf: None becomes 0, a bool flips, a number grows by one, a string gains a + character.""" + v = container[key] + if v is None: + container[key] = 0 + elif isinstance(v, bool): + container[key] = not v + elif isinstance(v, (int, float)): + container[key] = v + 1 + else: + container[key] = v + "#" + + +def _failure(fx, at=None): + """Where the replay of fx fails: (the index of the operation being replayed, None before + the first; the failing source line), or None when the replay passes. `at`, when given, is + the list the replay appends each operation's index to (so a control can read it).""" + at = [] if at is None else at + try: + replay(fx, at) + except (AssertionError, ValueError, TypeError, pytest.fail.Exception) as e: + return (at[-1] if at else None), traceback.extract_tb(e.__traceback__)[-1].line + return None + + +@needs_hooks +@pytest.mark.parametrize("key", ["result", "trace", "ledger", "policy"]) +def test_a_corrupted_expectation_fails(key): + # The replay is not vacuous (the twin of PolicyFixtures.ACorruptedExpectationFails): in + # every fixture, the first integer of the expectation `key` of the first successful + # placement that has one is corrupted; the replay must fail at that operation, on that + # expectation's assertion. + corrupted = 0 + for fx in FIXTURES: + bad = copy.deepcopy(fx) + n = next( + ( + n + for n, o in enumerate(bad["ops"]) + if o["op"] == "place" + and not o["error"] + and _corrupt_first_int(o["expect"][key]) + ), + None, + ) + if n is None: + continue + index, line = _failure(bad) + assert index == n, fx["name"] + assert f'expect["{key}"]' in line, fx["name"] + corrupted += 1 + assert corrupted > 50 + + +def test_exactly_compares_types_at_every_level(): + assert exactly( + {"a": [1, [True, None]], "b": "x"}, {"a": [1, [True, None]], "b": "x"} + ) + for a, b in ( + (True, 1), + (1, 1.0), + (0, False), + ([1], (1,)), + ({"a": [1]}, {"a": [1.0]}), + ({"a": [[0]]}, {"a": [[False]]}), + ({"a": 1}, {"a": 1, "b": 1}), + ([1, 2], [1]), + ): + assert not exactly(a, b), (a, b) + assert not exactly(b, a), (b, a) + + +@needs_hooks +@pytest.mark.parametrize("change", ["integer as float", "bool as integer"]) +def test_an_expected_value_of_another_type_fails(change): + # The replay compares types too: in every fixture, the first successful placement's + # expected `placed` becomes the same number as a float, or its `routes_changed` the same + # truth value as an integer; the replay must fail at that operation, on the result. + changed = 0 + for fx in FIXTURES: + bad = copy.deepcopy(fx) + n = next( + ( + n + for n, o in enumerate(bad["ops"]) + if o["op"] == "place" and not o["error"] + ), + None, + ) + if n is None: + continue + result = bad["ops"][n]["expect"]["result"] + if change == "integer as float": + result["placed"] = float(result["placed"]) + else: + result["routes_changed"] = int(result["routes_changed"]) + assert result == fx["ops"][n]["expect"]["result"] # equal under == + index, line = _failure(bad) + assert index == n, fx["name"] + assert 'expect["result"]' in line, fx["name"] + changed += 1 + assert changed > 50 + + +@needs_hooks +def test_every_other_replay_clause_reports_its_operation(monkeypatch): + # The replay's other assertions, each failing at the operation it concerns (the twin of the + # other-clause controls inside PolicyFixtures.ACorruptedExpectationFails): the first leaf of every section + # of every other operation's expectations, the error flag of the first operation of every + # kind and outcome in each fixture, the input order of the first graph, an unknown + # operation, a policy changed by a call the data rejects, and the curve a Global zero call + # reports against the remembered one. + sections = set() + flags = set() + for fx in FIXTURES: + flipped = set() + for n, op in enumerate(fx["ops"]): + combination = f"{op['op']} {'failed' if op['error'] else 'ok'}" + if "expect" in op and (op["op"] != "place" or op["error"]): + for key in op["expect"]: + bad = copy.deepcopy(fx) + leaf = _first_leaf(bad["ops"][n]["expect"][key]) + assert leaf is not None, (fx["name"], n, key) + _corrupt(*leaf) + index, line = _failure(bad) + assert index == n, (fx["name"], n, key) + assert f'expect["{key}"]' in line, (fx["name"], n, key) + sections.add(f"{combination} {key}") + if combination not in flipped: + flipped.add(combination) + bad = copy.deepcopy(fx) + bad["ops"][n]["error"] = not op["error"] + failure = _failure(bad) + assert failure is not None and failure[0] == n, (fx["name"], n) + flags.add(combination) + bad = copy.deepcopy(fx) + _corrupt(bad["graphs"][0]["input_index"], 0) + assert _failure(bad) == ( + None, + 'assert exactly(g.input_index().tolist(), spec["input_index"])', + ) + assert sections == { + "copy failed ledger", + "destroy ok ledger", + "place failed ledger", + "policy ok policy", + "remove ok ledger", + "remove ok policy", + "reset failed ledger", + "reset ok ledger", + } + assert flags == { + "copy failed", + "destroy ok", + "ledger ok", + "place failed", + "place ok", + "policy failed", + "policy ok", + "remove ok", + "reset failed", + "reset ok", + } + # An unknown operation in place of op 3 of global_epochs (a placement). + epochs = next(fx for fx in FIXTURES if fx["name"] == "global_epochs") + renamed = copy.deepcopy(epochs) + renamed["ops"][3]["op"] = "move" + assert _failure(renamed) == (3, 'assert kind == "place", where') + # A copy of global_epochs' ledger 0 before op 1 binds a policy to it succeeds (the data holds + # only a failing copy). Flagged as failing, with the source's true state expected, it fails + # at that operation only because it must raise; flagged as succeeding with a wrong expected + # ledger, its comparison fails. + assert epochs["ops"][0]["op"] == "ledger" and epochs["ops"][1]["op"] == "policy" + fresh = ledger_json(ngc.FlowLedger(build_graph(epochs["graphs"][0]), None)) + for error, expected in ((True, fresh), (False, {})): + copied = copy.deepcopy(epochs) + copied["ops"].insert( + 1, + { + "op": "copy", + "ledger": 0, + "id": 1, + "error": error, + "expect": {"ledger": expected}, + }, + ) + failure = _failure(copied) + assert failure is not None and failure[0] == 1, (error, failure) + if not error: + assert ( + failure[1] + == 'assert exactly(ledger_json(ledgers[op["id"]]), expect["ledger"]), where' + ) + # Every call the data rejects, with the policy's +inf flag read flipped after that call only + # (the binding gives no way to change a policy's state directly, so its state read is + # changed): the comparison with the policy before the call must fail there. The ledger is + # left as it is, so the ledger comparison before it still passes. + place, read_state = ngc.FlowPolicy.place, ngc.FlowPolicy._state + at, changed, target = [], set(), None + + def rejected_then_changed(self, *args): + try: + return place(self, *args) + except (ValueError, TypeError): + if at[-1] == target: + changed.add(id(self)) + raise + + def changed_state(self): + st = read_state(self) + if id(self) in changed: + st["infinite"] = not st["infinite"] + return st + + monkeypatch.setattr(ngc.FlowPolicy, "place", rejected_then_changed) + monkeypatch.setattr(ngc.FlowPolicy, "_state", changed_state) + rejected_calls = 0 + for fx in FIXTURES: + for n, op in enumerate(fx["ops"]): + if op["op"] != "place" or not op["error"]: + continue + rejected_calls += 1 + at.clear() + changed.clear() + target = n + assert _failure(fx, at) == ( + n, + "assert exactly(policy_json(pol), before), where", + ), (fx["name"], n) + assert rejected_calls == 4 + monkeypatch.undo() + # Op 2 of global_epochs is a Global zero call on a fresh policy, op 3 a positive call with + # the curve [[1, 1]]. With op 3 moved before op 2, the zero call reports [[1, 1]], the + # remembered curve; a result that reports another cumulative curve must fail there. + assert epochs["ops"][2]["volume"] == 0 + swapped = copy.deepcopy(epochs) + swapped["ops"][2], swapped["ops"][3] = swapped["ops"][3], swapped["ops"][2] + zero_call = 'assert exactly(got.pop("cost_distribution"), curve), where' + # Unchanged, the replay passes the zero call's curve and fails only after it, at the ledger + # line of the same operation: its expected ledger is the one from before the positive call. + assert _failure(swapped) == ( + 3, + 'assert exactly(ledger_json(fg), expect["ledger"]), where', + ) + unshifted = result_json + + def shifted(r): + got = unshifted(r) + if got["placed"] == 0 and got.get("cost_distribution"): + got["cost_distribution"] = [[c + 1, u] for c, u in got["cost_distribution"]] + return got + + monkeypatch.setattr(sys.modules[__name__], "result_json", shifted) + assert _failure(swapped) == (3, zero_call) + + +def _corrupt_first_int(x): + """Adds 1 to the first integer (not a bool) of x in document order; whether one was found.""" + items = ( + x.items() + if isinstance(x, dict) + else enumerate(x) + if isinstance(x, list) + else () + ) + for k, v in items: + if isinstance(v, int) and not isinstance(v, bool): + x[k] = v + 1 + return True + if _corrupt_first_int(v): + return True + return False diff --git a/tests/py/test_graph.py b/tests/py/test_graph.py index faf9864..19e56b7 100644 --- a/tests/py/test_graph.py +++ b/tests/py/test_graph.py @@ -1,4 +1,4 @@ -"""Graph on integer units (APPROACH 3): construction, validation order, B' ids, +"""Graph on integer units (docs/semantics.md#2-the-graph): construction, validation order, EdgeIds, read-only views, Eligibility.""" from __future__ import annotations @@ -8,11 +8,10 @@ import numpy as np import pytest +from helpers import L62, build, by_input, exact import netgraph_core as ngc -L62 = 1 << 62 - # (u, v, capacity_units, cost) in input order. EDGES = [ (2, 0, 5, 3), @@ -25,18 +24,7 @@ ] -def build(n, edges, q=1.0): - return ngc.Graph( - n, - [e[0] for e in edges], - [e[1] for e in edges], - [e[2] for e in edges], - [e[3] for e in edges], - q, - ) - - -def test_bprime_ids_and_input_index(): +def test_edge_id_order_and_input_index(): g = build(3, EDGES) order = sorted( range(len(EDGES)), key=lambda i: (EDGES[i][0], EDGES[i][3], EDGES[i][1], i) @@ -62,39 +50,6 @@ def test_bprime_ids_and_input_index(): assert g.resolution() == 1.0 -def test_views_are_read_only_and_keep_the_graph_alive(): - g = build(3, EDGES) - views = [ - g.capacity_units(), - g.cost(), - g.edge_src(), - g.edge_dst(), - g.input_index(), - g.row_offsets(), - g.in_row_offsets(), - g.in_adj_edge_index(), - ] - dtypes = [ - np.int64, - np.int64, - np.int32, - np.int32, - np.int32, - np.int32, - np.int32, - np.int32, - ] - for view, dtype in zip(views, dtypes, strict=True): - assert view.dtype == dtype - assert not view.flags.writeable - with pytest.raises(ValueError): - view[0] = 0 - expect = [v.tolist() for v in views] - del g - gc.collect() - assert [v.tolist() for v in views] == expect - - def test_numpy_integer_inputs_and_exact_large_units(): big = (1 << 53) + 1 g = ngc.Graph( @@ -122,53 +77,141 @@ def test_uids_are_distinct(): assert a.uid() != b.uid() and a.uid() > 0 +def test_resolution_has_no_default(): + with pytest.raises(TypeError): + ngc.Graph(2, [0], [1], [1], [1]) + + @pytest.mark.parametrize( - "args, pattern", + "args, message", [ # The resolution. - ((2, [0], [1], [1], [1], 3.0), "resolution"), - ((2, [0], [1], [1], [1], 2.0**-33), "resolution"), - ((2, [0], [1], [1], [1], 0.0), "resolution"), - ((2, [0], [1], [1], [1], float("nan")), "resolution"), + ( + (2, [0], [1], [1], [1], 3.0), + "resolution must be a power of two in [2^-32, 2^32]", + ), + ( + (2, [0], [1], [1], [1], 2.0**-33), + "resolution must be a power of two in [2^-32, 2^32]", + ), + ( + (2, [0], [1], [1], [1], 0.0), + "resolution must be a power of two in [2^-32, 2^32]", + ), + ( + (2, [0], [1], [1], [1], float("nan")), + "resolution must be a power of two in [2^-32, 2^32]", + ), # The unit values. - ((2, [0], [1], [-1], [1], 1), "capacity_units"), - ((2, [0], [1], [L62], [1], 1), "capacity_units"), - ((2, [0], [1], [1.0], [1], 1), "capacity_units"), - ((2, [0], [1], np.array([1.5]), [1], 1), "capacity_units"), - ((2, [0], [1], [True], [1], 1), "capacity_units"), + ( + (2, [0], [1], [-1], [1], 1), + "capacity_units[0] = -1 is outside the units domain [0, 2^62)", + ), + ( + (2, [0], [1], [L62], [1], 1), + "capacity_units[0] = 4611686018427387904 is outside the units domain [0, 2^62)", + ), + ( + (2, [0], [1], [1.0], [1], 1), + "capacity_units[0] must be an integer (got float)", + ), + ( + (2, [0], [1], np.array([1.5]), [1], 1), + "capacity_units entries must be integers (got dtype float64)", + ), + ( + (2, [0], [1], [True], [1], 1), + "capacity_units[0] must be an integer (got bool)", + ), # The shapes. - ((-1, [], [], [], [], 1), "num_nodes"), - ((2.0, [0], [1], [1], [1], 1), "num_nodes"), - ((2, [0, 1], [1], [1], [1], 1), "same length"), - ((2, [0], [1], [1, 1], [1], 1), "same length"), - # Zero and negative costs (rule 28). - ((2, [0], [1], [1], [0], 1), "cost"), - ((2, [0], [1], [1], [-3], 1), "cost"), - ((2, [0], [1], [1], [1.5], 1), "cost"), + ((-1, [], [], [], [], 1), "num_nodes = -1 is out of range [0, 2147483647]"), + ((2.0, [0], [1], [1], [1], 1), "num_nodes must be an integer (got float)"), + ( + (2, [0, 1], [1], [1], [1], 1), + "src, dst, capacity_units and cost must have the same length (got 2, 1, 1, 1)", + ), + ( + (2, [0], [1], [1, 1], [1], 1), + "src, dst, capacity_units and cost must have the same length (got 1, 1, 2, 1)", + ), + # Zero, negative and non-integer costs. + ((2, [0], [1], [1], [0], 1), "cost[0] = 0: costs must be positive (cost >= 1)"), + ( + (2, [0], [1], [1], [-3], 1), + "cost[0] = -3: costs must be positive (cost >= 1)", + ), + ((2, [0], [1], [1], [1.5], 1), "cost[0] must be an integer (got float)"), # The remaining bounds. - ((2, [0], [2], [1], [1], 1), "out of node range"), - ((2, [0, 0], [1, 1], [L62 // 2, L62 // 2], [1, 1], 1), "sum of all capacities"), - ((2, [0, 0], [1, 1], [1, 1], [L62 // 2, L62 // 2], 1), "sum of all edge costs"), + ( + (2, [0], [2], [1], [1], 1), + "edge 0: endpoint (0, 2) out of node range [0, 2)", + ), + ( + (2, [0, 0], [1, 1], [L62 // 2, L62 // 2], [1, 1], 1), + "the sum of all capacities reaches the bound of the units domain (2^62 units)", + ), + ( + (2, [0, 0], [1, 1], [1, 1], [L62 // 2, L62 // 2], 1), + "the sum of all edge costs reaches the bound 2^62: path costs would overflow int64", + ), ], ) -def test_validation_errors(args, pattern): - with pytest.raises(ValueError, match=pattern): +def test_validation_errors(args, message): + with pytest.raises(ValueError, match=exact(message)): ngc.Graph(*args) def test_validation_order(): - # The unit values before the shapes, the shapes before the costs, the costs before the - # remaining bounds (endpoints and sums). - with pytest.raises(ValueError, match="capacity_units"): + # The unit values before the shapes (the length message names capacity_units too), the + # shapes before the costs, the costs before the remaining bounds (endpoints and sums). + with pytest.raises( + ValueError, + match=exact("capacity_units[0] = -1 is outside the units domain [0, 2^62)"), + ): ngc.Graph(2, [0, 1], [1], [-1], [1], 1) - with pytest.raises(ValueError, match="same length"): + with pytest.raises( + ValueError, + match=exact( + "src, dst, capacity_units and cost must have the same length (got 2, 1, 1, 1)" + ), + ): ngc.Graph(2, [0, 1], [1], [1], [0], 1) - with pytest.raises(ValueError, match="cost"): + with pytest.raises( + ValueError, match=exact("cost[0] = 0: costs must be positive (cost >= 1)") + ): ngc.Graph(2, [0], [9], [1], [0], 1) - with pytest.raises(ValueError, match="cost"): + with pytest.raises( + ValueError, match=exact("cost[0] = 0: costs must be positive (cost >= 1)") + ): ngc.Graph(2, [0, 0], [1, 1], [L62 // 2, L62 // 2], [0, 1], 1) - with pytest.raises(ValueError, match="node range"): + with pytest.raises( + ValueError, match=exact("edge 1: endpoint (5, 1) out of node range [0, 2)") + ): ngc.Graph(2, [0, 5], [1, 1], [L62 // 2, L62 // 2], [1, 1], 1) + # Within the shapes: num_nodes before the lengths. + with pytest.raises( + ValueError, match=exact("num_nodes = -1 is out of range [0, 2147483647]") + ): + ngc.Graph(-1, [0, 1], [1], [1], [0], 1) + # The sums run edge by edge in input order: the first edge at which a running sum reaches + # 2^62 reports that sum (here the cost sum at edge 1, the capacity sum only at edge 2), and + # at the same edge the capacity sum comes first. + with pytest.raises( + ValueError, + match=exact( + "the sum of all edge costs reaches the bound 2^62: path costs would overflow int64" + ), + ): + ngc.Graph( + 2, [0, 0, 0], [1, 1, 1], [1, L62 // 2, L62 // 2], [L62 // 2] * 2 + [1], 1 + ) + with pytest.raises( + ValueError, + match=exact( + "the sum of all capacities reaches the bound of the units domain (2^62 units)" + ), + ): + ngc.Graph(2, [0, 0], [1, 1], [L62 // 2] * 2, [L62 // 2] * 2, 1) def test_bounds_just_below_are_accepted(): @@ -187,71 +230,103 @@ def test_eligibility(): el = g.eligibility(excluded_nodes=[1, 1]) assert el.uid() not in (0, every.uid()) assert el.node_allowed().tolist() == [True, False, True, True] - allowed_in = [False] * 5 - for e, ok in enumerate(el.edge_allowed().tolist()): - allowed_in[int(g.input_index()[e])] = ok - assert allowed_in == [False, False, True, False, True] - assert not el.edge_allowed().flags.writeable + assert by_input(g, el.edge_allowed().tolist()) == [False, False, True, False, True] + # Both flag arrays are read-only boolean views. + for view in (el.node_allowed(), el.edge_allowed()): + assert view.dtype == np.bool_ + assert not view.flags.writeable + with pytest.raises(ValueError): + view[0] = True + # Built only by Graph.eligibility and Eligibility.intersect. + with pytest.raises(TypeError): + ngc.Eligibility() xe = g.eligibility(excluded_edges=np.array([4, 0, 4])) assert xe.edge_allowed().tolist() == [False, True, True, True, False] assert g.eligibility([], [0]).uid() != g.eligibility([], [0]).uid() - with pytest.raises(ValueError, match="range"): + with pytest.raises( + ValueError, match=exact("excluded node 4 out of node range [0, 4)") + ): g.eligibility(excluded_nodes=[4]) - with pytest.raises(ValueError, match="range"): + with pytest.raises( + ValueError, match=exact("excluded edge -1 out of edge range [0, 5)") + ): g.eligibility(excluded_edges=[-1]) - with pytest.raises(ValueError): + with pytest.raises( + ValueError, match=exact("excluded_edges[0] must be an integer (got float)") + ): g.eligibility(excluded_edges=[0.0]) -# review-1a D1: the binding validates in the contract's order on unnarrowed integers, so the +# The binding validates in the documented order on unnarrowed integers, so the # reported defect does not depend on how the same defective input is represented. @pytest.mark.parametrize( - "args, pattern", + "args, message", [ # A zero or negative cost comes before any remaining bound, even one that does not fit # the C++ type (endpoints beyond int32, a cost beyond int64). - ((2, [2**31], [1], [1], [0], 1.0), r"cost\[0\] = 0: costs must be positive"), - ((2, [-1], [1], [1], [0], 1.0), r"cost\[0\] = 0: costs must be positive"), - ((2, [0], [2**31], [1], [-1], 1.0), r"cost\[0\] = -1: costs must be positive"), + ( + (2, [2**31], [1], [1], [0], 1.0), + "cost[0] = 0: costs must be positive (cost >= 1)", + ), + ( + (2, [-1], [1], [1], [0], 1.0), + "cost[0] = 0: costs must be positive (cost >= 1)", + ), + ( + (2, [0], [2**31], [1], [-1], 1.0), + "cost[0] = -1: costs must be positive (cost >= 1)", + ), ( (2, [0], [-(2**70)], [1], [-(2**70)], 1.0), - r"cost\[0\] = -1180591620717411303424", + "cost[0] = -1180591620717411303424: costs must be positive (cost >= 1)", ), ( (2, [0, 0], [1, 1], [1, 1], [2**63, 0], 1.0), - r"cost\[1\] = 0: costs must be positive", + "cost[1] = 0: costs must be positive (cost >= 1)", ), # The unit values come before the costs, the resolution before everything. ( (2, [0], [1], [2**62], [0], 1.0), - r"capacity_units\[0\] = 4611686018427387904", + "capacity_units[0] = 4611686018427387904 is outside the units domain [0, 2^62)", ), ( (2, [0], [1], [2**64], [0], 1.0), - r"capacity_units\[0\] = 18446744073709551616", + "capacity_units[0] = 18446744073709551616 is outside the units domain [0, 2^62)", ), ( (2, [0], [1], [-(2**70)], [0], 1.0), - r"capacity_units\[0\] = -1180591620717411303424", + "capacity_units[0] = -1180591620717411303424 is outside the units domain [0, 2^62)", + ), + ( + (2, [0], [1], [-1], [0], 3.0), + "resolution must be a power of two in [2^-32, 2^32]", ), - ((2, [0], [1], [-1], [0], 3.0), "resolution"), # The remaining bounds name the exact value: ids, then the sums. ( (2, [2**40], [1], [1], [1], 1.0), - r"edge 0: endpoint \(1099511627776, 1\) out of node range", + "edge 0: endpoint (1099511627776, 1) out of node range [0, 2)", ), ( (2, [0], [-(2**70)], [1], [1], 1.0), - r"endpoint \(0, -1180591620717411303424\)", + "edge 0: endpoint (0, -1180591620717411303424) out of node range [0, 2)", + ), + ( + (2, [0], [1], [1], [2**63], 1.0), + "the sum of all edge costs reaches the bound 2^62: path costs would overflow int64", + ), + ( + (2, [0, 5], [1, 1], [1, 1], [2**64, 1], 1.0), + "edge 1: endpoint (5, 1) out of node range [0, 2)", ), - ((2, [0], [1], [1], [2**63], 1.0), "sum of all edge costs"), - ((2, [0, 5], [1, 1], [1, 1], [2**64, 1], 1.0), "out of node range"), # The shapes. - ((2**40, [], [], [], [], 1.0), "num_nodes"), + ( + (2**40, [], [], [], [], 1.0), + "num_nodes = 1099511627776 is out of range [0, 2147483647]", + ), ], ) -def test_validation_order_before_narrowing(args, pattern): - with pytest.raises(ValueError, match=pattern): +def test_validation_order_before_narrowing(args, message): + with pytest.raises(ValueError, match=exact(message)): ngc.Graph(*args) @@ -274,21 +349,29 @@ def test_validation_independent_of_representation(): assert ( messages[0] == messages[1] == "cost[0] = 0: costs must be positive (cost >= 1)" ) - with pytest.raises(ValueError, match=r"endpoint \(9223372036854775808, 1\)"): + with pytest.raises( + ValueError, + match=exact( + "edge 0: endpoint (9223372036854775808, 1) out of node range [0, 2)" + ), + ): ngc.Graph(2, np.array([2**63], dtype=np.uint64), [1], [1], [1], 1.0) def test_eligibility_ids_checked_before_narrowing(): g = build(3, EDGES) with pytest.raises( - ValueError, match=r"excluded node 1099511627776 out of node range \[0, 3\)" + ValueError, match=exact("excluded node 1099511627776 out of node range [0, 3)") ): g.eligibility(excluded_nodes=[2**40]) with pytest.raises( - ValueError, match=r"excluded edge -1180591620717411303424 out of edge range" + ValueError, + match=exact("excluded edge -1180591620717411303424 out of edge range [0, 7)"), ): g.eligibility(excluded_edges=[-(2**70)]) - with pytest.raises(ValueError, match="excluded node"): # nodes before edges + with pytest.raises( + ValueError, match=exact("excluded node 5 out of node range [0, 3)") + ): # nodes before edges g.eligibility(excluded_nodes=[5], excluded_edges=[2**70]) @@ -300,13 +383,13 @@ def test_eligibility_ids_checked_before_narrowing(): @pytest.mark.parametrize("ndim", [0, 2]) @pytest.mark.parametrize("cost", [0, -1]) def test_array_rank_is_a_shape_defect_before_costs(name, ndim, cost): - # review-1a-fixes D1: the shape step checks rank 1 for every array argument before the + # The shape step checks rank 1 for every array argument before the # cost step, so a 2-D or 0-D array is reported before a zero or negative cost. args = [2, [0], [1], [1], [cost], 1.0] value = cost if name == "cost" else VALID[name] bad = np.array(value, dtype=np.int64) args[ARG_INDEX[name]] = bad.reshape(1, 1) if ndim == 2 else bad - pattern = rf"^{name} must be a 1-D array \(got {ndim} dimensions\)$" + pattern = exact(f"{name} must be a 1-D array (got {ndim} dimensions)") with pytest.raises(ValueError, match=pattern): ngc.Graph(*args) @@ -317,36 +400,57 @@ def test_nested_and_scalar_arguments_are_shape_defects(name): nested = [2, [0], [1], [1], [0], 1.0] nested[ARG_INDEX[name]] = [[value]] with pytest.raises( - ValueError, match=rf"^{name} must be a 1-D sequence of integers \(entry 0" + ValueError, + match=exact( + f"{name} must be a 1-D sequence of integers (entry 0 is a sequence)" + ), ): ngc.Graph(*nested) scalar = [2, [0], [1], [1], [0], 1.0] scalar[ARG_INDEX[name]] = value with pytest.raises( - ValueError, match=rf"^{name} must be a 1-D sequence of integers \(got int\)$" + ValueError, match=exact(f"{name} must be a 1-D sequence of integers (got int)") ): ngc.Graph(*scalar) def test_shape_step_keeps_its_place_in_the_order(): # The resolution and the unit values still come before the shapes, whatever the shape. - with pytest.raises(ValueError, match="resolution"): + with pytest.raises( + ValueError, match=exact("resolution must be a power of two in [2^-32, 2^32]") + ): ngc.Graph(2, np.array([[0]]), [1], [1], [0], 3.0) with pytest.raises( - ValueError, match=r"capacity_units\[0\] = 4611686018427387904 is outside" + ValueError, + match=exact( + "capacity_units[0] = 4611686018427387904 is outside the units domain [0, 2^62)" + ), ): ngc.Graph(2, [0], [1], np.array([[2**62]], dtype=np.int64), [0], 1.0) - with pytest.raises(ValueError, match=r"capacity_units\[0\] = -1 is outside"): + with pytest.raises( + ValueError, + match=exact("capacity_units[0] = -1 is outside the units domain [0, 2^62)"), + ): ngc.Graph(2, [0], [1], np.array(-1, dtype=np.int64), [0], 1.0) - with pytest.raises(ValueError, match=r"capacity_units\[1\] = -5 is outside"): + with pytest.raises( + ValueError, + match=exact("capacity_units[1] = -5 is outside the units domain [0, 2^62)"), + ): ngc.Graph(2, [0], [1], [[1], -5], [0], 1.0) - with pytest.raises(ValueError, match=r"capacity_units = -5 is outside"): + with pytest.raises( + ValueError, + match=exact("capacity_units = -5 is outside the units domain [0, 2^62)"), + ): ngc.Graph(2, [0], [1], -5, [0], 1.0) # With valid values the misshapen capacity is a shape defect, before the costs. - with pytest.raises(ValueError, match="capacity_units must be a 1-D array"): + with pytest.raises( + ValueError, match=exact("capacity_units must be a 1-D array (got 2 dimensions)") + ): ngc.Graph(2, [0], [1], np.array([[1]]), [0], 1.0) # Rank before lengths. - with pytest.raises(ValueError, match="dst must be a 1-D array"): + with pytest.raises( + ValueError, match=exact("dst must be a 1-D array (got 2 dimensions)") + ): ngc.Graph(2, [0, 1], np.array([[1, 0]]), [1, 1], [1, 1], 1.0) # A valid graph from 1-D arrays of every integer kind still builds. g = ngc.Graph( @@ -393,11 +497,13 @@ def test_repeated_views_share_storage_and_refuse_write_access(): ) def test_every_view_keeps_its_graph_alive_and_refuses_write_access(n, edges): # An empty view has no bytes to share, so the owner is checked by identity and a weak - # reference rather than by np.shares_memory. + # reference rather than by np.shares_memory. The values stay readable after the caller + # drops the graph. for name, dtype in VIEW_DTYPES.items(): g = build(n, edges) ref = weakref.ref(g) view = getattr(g, name)() + values = view.tolist() assert view.base is g, name assert view.dtype == dtype, name assert not view.flags.writeable, name @@ -408,13 +514,14 @@ def test_every_view_keeps_its_graph_alive_and_refuses_write_access(n, edges): del g gc.collect() assert ref() is not None, name + assert view.tolist() == values, name del view gc.collect() assert ref() is None, name -def _reference_layout(n, src, dst, cap, cost): - # B' ids: (src, cost, dst, input); the reverse slots: (dst, cost, src, input). +def _reference_layout(n, src, dst, cost): + # EdgeIds: (src, cost, dst, input); the reverse slots: (dst, cost, src, input). i = np.arange(len(src)) order = np.lexsort((i, dst, cost, src)) pos = np.empty_like(order) @@ -428,8 +535,8 @@ def _reference_layout(n, src, dst, cap, cost): ["hash", "combined_keys", "all_cost_sort", "reversed", "edgeless"], ) def test_construction_orders_match_a_reference(shape): - # One input per construction path of APPROACH 3: distinct-cost hashing, the combined-key - # passes (N * K < E), the all-cost sort (K > 65,536), a reversed B' order, no edges. + # One input per construction path of the graph: distinct-cost hashing, the combined-key + # passes (N * K < E), the all-cost sort (K > 65,536), input in reversed EdgeId order, no edges. rng = np.random.default_rng(7) n, e = 300, 1000 # N * K = 1500 > E: the separate passes if shape == "combined_keys": @@ -445,10 +552,10 @@ def test_construction_orders_match_a_reference(shape): cost = rng.permutation(np.arange(1, e + 1)) cap = rng.integers(0, 1 << 40, e) if shape == "reversed": - order, _, _ = _reference_layout(n, src, dst, cap, cost) + order, _, _ = _reference_layout(n, src, dst, cost) src, dst, cap, cost = (a[order[::-1]] for a in (src, dst, cap, cost)) g = ngc.Graph(n, src, dst, cap, cost, 1) - order, row, rev = _reference_layout(n, src, dst, cap, cost) + order, row, rev = _reference_layout(n, src, dst, cost) assert g.input_index().tolist() == order.tolist() assert g.row_offsets().tolist() == row.tolist() assert g.in_adj_edge_index().tolist() == rev.tolist() diff --git a/tests/py/test_ksp.py b/tests/py/test_ksp.py index a67b215..fbd8b30 100644 --- a/tests/py/test_ksp.py +++ b/tests/py/test_ksp.py @@ -1,39 +1,24 @@ -"""KSP and resolve_to_paths through the binding (APPROACH 5, 6, rule 10): the order, Expand and -Collapse, the ceilings, k = 1 against the single path of spf, the validation order on -unnarrowed values, and the all-or-nothing construction of the result lists.""" +"""KSP and resolve_to_paths through the binding (docs/semantics.md#k-shortest-paths): the +order, Expand and Collapse, the ceilings, k = 1 against the single path of spf, the validation +order on unnarrowed values, and the all-or-nothing construction of the result lists.""" from __future__ import annotations -import _netgraph_core as ext import pytest +from helpers import CHAIN, DET, PREFER, build, exact, sel, sweep_python_allocation from private_hooks import needs_hooks import netgraph_core as ngc -DET = ngc.EdgeTieBreak.DETERMINISTIC -PREFER = ngc.EdgeTieBreak.PREFER_HIGHER_PRIORITY -CAP = ngc.EdgePriority.CAPACITY - def bgraph(n, rows): - """Rows (u, v, capacity, cost) in B' order (src, cost, dst): ids are input positions.""" - rows = sorted(rows, key=lambda r: (r[0], r[3], r[1])) - return ngc.Graph( - n, - [r[0] for r in rows], - [r[1] for r in rows], - [r[2] for r in rows], - [r[3] for r in rows], - 1.0, - ) + """A graph from rows (u, v, capacity, cost), sorted by (src, cost, dst) first: that is the + EdgeId order, so each EdgeId is the row's position in the sorted list.""" + return build(n, sorted(rows, key=lambda r: (r[0], r[3], r[1]))) def ksp(g, s, t, k, *, multi_edge=True, tie=DET, **kw): - sel = ngc.PathSelection(tie, CAP, multi_edge=multi_edge) - return ngc.ksp(g, s, t, ngc.KspOptions(k, sel, **kw)) - - -CHAIN = [(0, 1, 1, 1), (1, 2, 1, 1), (2, 3, 1, 1), (0, 2, 1, 3), (1, 3, 1, 3)] + return ngc.ksp(g, s, t, ngc.KspOptions(k, sel(tie, me=multi_edge), **kw)) def test_order_and_ceilings(): @@ -77,7 +62,7 @@ def test_expand_collapse_and_the_single_path(): ) for tie in (DET, PREFER): first = ksp(three, 0, 5, 1, tie=tie)[0][1] - single = ngc.PathSelection(tie, CAP, multipath=False) + single = sel(tie, mp=False) for direction, roots, targets in ( (ngc.Direction.FORWARD, 0, 5), (ngc.Direction.REVERSE, 5, 0), @@ -86,48 +71,99 @@ def test_expand_collapse_and_the_single_path(): three, roots, ngc.SpfOptions(direction, single, targets=targets) ) assert r.dag == first.to_dag() + # multipath is ignored: the same selection with multipath = False lists the same paths. + for g, t in ((pr, 2), (three, 5)): + for tie, me in ((DET, True), (PREFER, True), (DET, False)): + want = ngc.ksp(g, 0, t, ngc.KspOptions(8, sel(tie, me=me))) + assert want + assert ( + ngc.ksp(g, 0, t, ngc.KspOptions(8, sel(tie, mp=False, me=me))) == want + ) def test_ksp_validation_order(): + # The residual's own checks are the shared ones of test_edge_amounts.py. g = bgraph(3, [(0, 1, 1, 1), (1, 2, 1, 1)]) other = bgraph(3, [(0, 1, 1, 1)]) - with pytest.raises(ValueError, match=r"^src: node 3 out of node range \[0, 3\)$"): + with pytest.raises(ValueError, match=exact("src: node 3 out of node range [0, 3)")): ksp(g, 3, 1, 1) - with pytest.raises(ValueError, match=r"^dst: node -1 out of node range"): + with pytest.raises( + ValueError, match=exact("dst: node -1 out of node range [0, 3)") + ): ksp(g, 0, -1, 1) - with pytest.raises(ValueError, match=r"^dst: node 1180591620717411303424 out of"): + with pytest.raises( + ValueError, + match=exact("dst: node 1180591620717411303424 out of node range [0, 3)"), + ): ksp(g, 0, 2**70, 1) with pytest.raises( - ValueError, match="^node 1 is in both endpoint sets: src and dst" + ValueError, + match=exact("node 1 is in both endpoint sets: src and dst must not overlap"), ): ksp(g, 1, 1, 1) - with pytest.raises(ValueError, match=r"^k must be >= 1 \(got 0\)$"): + with pytest.raises(ValueError, match=exact("k must be >= 1 (got 0)")): ksp(g, 0, 2, 0) - with pytest.raises(ValueError, match="k must be an integer"): + with pytest.raises(ValueError, match=exact("k must be >= 1 (got -1)")): + ksp(g, 0, 2, -1) + with pytest.raises( + ValueError, match=exact("k must be >= 1 (got -1180591620717411303424)") + ): + ksp(g, 0, 2, -(2**70)) + with pytest.raises(ValueError, match=exact("k must be an integer (got float)")): ksp(g, 0, 2, 1.5) - with pytest.raises(ValueError, match="^max_path_cost Absolute cost must be >= 0"): + absolute = "max_path_cost Absolute cost must be >= 0 (got -1)" + stretch = "max_path_cost Stretch factor must be finite and in [1, inf) (got {})" + with pytest.raises(ValueError, match=exact(absolute)): ksp(g, 0, 2, 1, max_path_cost=ngc.Absolute(-1)) - with pytest.raises(ValueError, match="^max_path_cost Stretch factor"): + with pytest.raises(ValueError, match=exact(stretch.format("0.500000"))): ksp(g, 0, 2, 1, max_path_cost=ngc.Stretch(0.5)) - with pytest.raises(ValueError, match="^max_path_cost Stretch factor"): + with pytest.raises(ValueError, match=exact(stretch.format("nan"))): ksp(g, 0, 2, 1, max_path_cost=ngc.Stretch(float("nan"))) - with pytest.raises(TypeError, match="max_path_cost must be Absolute, Stretch"): + with pytest.raises( + TypeError, + match=exact("max_path_cost must be Absolute, Stretch or None (got int)"), + ): ksp(g, 0, 2, 1, max_path_cost=3) - with pytest.raises(ValueError, match=r"^residual\[1\] = 2 is outside"): - ksp(g, 0, 2, 1, residual=[1, 2]) - with pytest.raises(ValueError, match="^foreign Eligibility"): + with pytest.raises( + ValueError, match=exact("foreign Eligibility: built for another graph") + ): ksp(g, 0, 2, 1, elig=other.eligibility([], [0])) - # The order: the endpoints, k, the ceiling, the residual. - with pytest.raises(ValueError, match="both endpoint sets"): + # The order: the endpoints (src, dst, src == dst), k, the ceiling, the residual. + with pytest.raises(ValueError, match=exact("src: node 3 out of node range [0, 3)")): + ksp(g, 3, -1, 0, max_path_cost=ngc.Absolute(-1), residual=[9]) + with pytest.raises( + ValueError, match=exact("dst: node -1 out of node range [0, 3)") + ): + ksp(g, 0, -1, 0, max_path_cost=ngc.Absolute(-1), residual=[9]) + with pytest.raises( + ValueError, + match=exact("node 2 is in both endpoint sets: src and dst must not overlap"), + ): ksp(g, 2, 2, 0, max_path_cost=ngc.Absolute(-1), residual=[9]) - with pytest.raises(ValueError, match="^k must be"): + with pytest.raises(ValueError, match=exact("k must be >= 1 (got 0)")): ksp(g, 0, 2, 0, max_path_cost=ngc.Absolute(-1), residual=[9]) - with pytest.raises(ValueError, match="^max_path_cost"): + with pytest.raises(ValueError, match=exact(absolute)): ksp(g, 0, 2, 1, max_path_cost=ngc.Absolute(-1), residual=[9]) # A huge k lists every path (no error). assert len(ksp(g, 0, 2, 2**70)) == 1 - with pytest.raises(TypeError, match="KspOptions expected"): - ngc.ksp(g, 0, 2, ngc.PathSelection(DET, CAP)) + with pytest.raises( + TypeError, match=exact("KspOptions expected (got PathSelection)") + ): + ngc.ksp(g, 0, 2, sel()) + with pytest.raises(TypeError): + ngc.KspOptions(1) # k and selection are explicit + # KspOptions keeps its arguments as given (the same objects) and validates none of them: + # ksp does, at the call, in the order above. + k, ceiling, residual = 2**70, ngc.Absolute(-1), [9] + o = ngc.KspOptions(k, sel(mp=False), max_path_cost=ceiling, residual=residual) + assert o.k is k and o.max_path_cost is ceiling and o.residual is residual + assert o.selection == sel(mp=False) and o.elig is None + with pytest.raises( + ValueError, match=exact("max_path_cost Absolute cost must be >= 0 (got -1)") + ): + ngc.ksp(g, 0, 2, o) + with pytest.raises(ValueError, match=exact("k must be >= 1 (got 0)")): + ngc.ksp(g, 0, 2, ngc.KspOptions(0, sel(), max_path_cost=ceiling)) def test_resolve_to_paths(): @@ -154,45 +190,57 @@ def test_resolve_to_paths(): assert len(ngc.resolve_to_paths(g, dag, 0, 3, True, 2)) == 2 assert ngc.resolve_to_paths(g, dag, 3, 0) == [] other = bgraph(4, [(0, 1, 1, 1)]) - with pytest.raises(ValueError, match="^foreign PredDAG"): + foreign = "foreign PredDAG: built for another graph" + overlap = "node 2 is in both endpoint sets: src and dst must not overlap" + with pytest.raises(ValueError, match=exact(foreign)): ngc.resolve_to_paths(other, dag, 0, 3) - with pytest.raises(ValueError, match=r"^src: node 4 out of node range \[0, 4\)$"): + with pytest.raises(ValueError, match=exact("src: node 4 out of node range [0, 4)")): ngc.resolve_to_paths(g, dag, 4, 3) - with pytest.raises( - ValueError, match="^node 2 is in both endpoint sets: src and dst" - ): + with pytest.raises(ValueError, match=exact(overlap)): ngc.resolve_to_paths(g, dag, 2, 2) - with pytest.raises(ValueError, match=r"^max_paths must be >= 1 \(got 0\)$"): + with pytest.raises(ValueError, match=exact("max_paths must be >= 1 (got 0)")): ngc.resolve_to_paths(g, dag, 0, 3, False, 0) - # A search's DAG resolves the same way from a reverse search. - sel = ngc.PathSelection(DET, CAP) - fw = ngc.spf(g, 0, ngc.SpfOptions(ngc.Direction.FORWARD, sel)).dag - rv = ngc.spf(g, 3, ngc.SpfOptions(ngc.Direction.REVERSE, sel)).dag - assert ngc.resolve_to_paths(g, fw, 0, 3) == ngc.resolve_to_paths(g, rv, 0, 3) + with pytest.raises( + ValueError, match=exact("max_paths must be >= 1 (got -1180591620717411303424)") + ): + ngc.resolve_to_paths(g, dag, 0, 3, False, -(2**70)) + # A count above INT64_MAX lists every walk (no error). + assert len(ngc.resolve_to_paths(g, dag, 0, 3, True, 2**70)) == 4 + # Several defects: the DAG's graph, src, dst, src == dst, then max_paths. + with pytest.raises(ValueError, match=exact(foreign)): + ngc.resolve_to_paths(other, dag, 9, -9, False, 0) + with pytest.raises(ValueError, match=exact("src: node 9 out of node range [0, 4)")): + ngc.resolve_to_paths(g, dag, 9, -9, False, 0) + with pytest.raises( + ValueError, match=exact("dst: node -9 out of node range [0, 4)") + ): + ngc.resolve_to_paths(g, dag, 0, -9, False, 0) + with pytest.raises(ValueError, match=exact(overlap)): + ngc.resolve_to_paths(g, dag, 2, 2, False, 0) + # A search's DAG resolves the same way from a reverse search: the two cost-2 paths (the + # cost-5 edge is on no shortest path). + fw = ngc.spf(g, 0, ngc.SpfOptions(ngc.Direction.FORWARD, sel())).dag + rv = ngc.spf(g, 3, ngc.SpfOptions(ngc.Direction.REVERSE, sel())).dag + forward = ngc.resolve_to_paths(g, fw, 0, 3) + assert [(p.cost(), p.hops()) for p in forward] == [ + (2, [[0, 1], [4]]), + (2, [[2], [5]]), + ] + assert ngc.resolve_to_paths(g, rv, 0, 3) == forward @needs_hooks def test_python_result_lists_are_all_or_nothing(): + # Every Python object allocation of the call fails once: the call raises (never crashes) + # and a clean retry gives the same list. g = bgraph(4, CHAIN) - opts = ngc.KspOptions(8, ngc.PathSelection(DET, CAP)) - want = ngc.ksp(g, 0, 3, opts) - dag = ngc.spf( - g, 0, ngc.SpfOptions(ngc.Direction.FORWARD, ngc.PathSelection(DET, CAP)) - ).dag - want_paths = ngc.resolve_to_paths(g, dag, 0, 2, True) - for fn, args, check in ( - (ngc.ksp, (g, 0, 3, opts), lambda r: r == want), - (ngc.resolve_to_paths, (g, dag, 0, 2, True), lambda r: r == want_paths), + opts = ngc.KspOptions(8, sel()) + dag = ngc.spf(g, 0, ngc.SpfOptions(ngc.Direction.FORWARD, sel())).dag + for fn, args in ( + (ngc.ksp, (g, 0, 3, opts)), + (ngc.resolve_to_paths, (g, dag, 0, 2, True)), ): - failures = 0 - for k in range(200): - hit, _seen, result, error = ext._fail_python_allocation(fn, args, k) - if not hit: - assert check(result) - break - failures += 1 - assert result is None and isinstance(error, (MemoryError, RuntimeError)), ( - error - ) - assert check(fn(*args)) - assert failures >= 3 + failures = sweep_python_allocation( + lambda fn=fn, args=args: (fn, args, list), 200 + ) + assert failures >= 3, fn.__name__ diff --git a/tests/py/test_native_allocation.py b/tests/py/test_native_allocation.py new file mode 100644 index 0000000..7b617da --- /dev/null +++ b/tests/py/test_native_allocation.py @@ -0,0 +1,486 @@ +"""Native allocation failures in the staged result constructions and in FlowLedger.reset: the +k-th C++ allocation of spf, ksp, resolve_to_paths, of the calls whose result objects are made +before anything is published (place, capacity, batch_capacity, sensitivity) and of reset fails, +for every k in turn (the private hook _fail_native_allocation). Every reached failure +raises MemoryError, never aborts, leaves nothing it allocated behind and the observable state as +it was; the call that completes returns the value of a call without failure. + +Each sweep runs in a child interpreter, so a crash (a double free) fails its test instead of +the whole session: python -B test_native_allocation.py SCENARIO EXTENSION loads the parent's +extension file and prints one JSON row per attempt.""" + +from __future__ import annotations + +import gc +import importlib.util +import itertools +import json +import os +import subprocess +import sys + +if ( + __name__ == "__main__" +): # the child: the parent's extension, whatever the import path finds + _spec = importlib.util.spec_from_file_location("_netgraph_core", sys.argv[2]) + sys.modules["_netgraph_core"] = importlib.util.module_from_spec(_spec) + _spec.loader.exec_module(sys.modules["_netgraph_core"]) + +import _netgraph_core as ext +from helpers import ( + ALL_OUT, + COSTONLY, + GLOBAL, + build, + full_ledger_state, + plain, + sel, + stats_of, +) + +import netgraph_core as ngc + +EDGES = [ # (u, v, capacity units, cost): unequal costs, parallel members, several routes + (0, 1, 5, 1), + (1, 3, 5, 1), + (0, 2, 3, 2), + (2, 3, 3, 2), + (1, 2, 2, 1), + (0, 1, 2, 1), + (3, 4, 9, 1), + (2, 4, 2, 4), +] + + +def graph(): + return build(5, EDGES) + + +def caps(g): + return [int(c) for c in g.capacity_units()] + + +def epoch_state(policy): + """The policy's epoch (the requested total, the +inf flag, its flows), with each FlowId's + local id only (the ledger uid differs between the scenario's fresh ledgers).""" + state = policy._state() + for flows in state["cohorts"].values(): + for flow in flows: + flow["flow_id"] = flow["flow_id"][1] + return state + + +# A scenario makes fresh state and returns (fn, args, snapshot): snapshot() is the observable +# state a failed call must leave as it was. +def stateless(call): + def make(): + g = graph() + fn, args = call(g) + return fn, args, lambda: [caps(g), g.cost().tolist()] + + return make + + +def placement(cfg, traced): + def make(): + g = graph() + ledger = ngc.FlowLedger(g) + policy = ngc.FlowPolicy(ledger, 0, 4, cfg) + policy.place( + 2 + ) # an earlier call's flows in the same epoch: a failed call keeps them + + def snap(): + records = [(r.flow_id[1], r.delivered) for r in policy.records()] + return [ + full_ledger_state(ledger), + policy.placed_demand(), + records, + epoch_state(policy), + ] + + return (policy._place_traced if traced else policy.place), (6, ALL_OUT), snap + + return make + + +def frozen_cache_capacity(): + g = graph() + cache = ngc.DagCache(g) + ngc.capacity(g, 0, 4, ngc.CapacityOptions(COSTONLY, cache=cache)) + cache.freeze() + opts = ngc.CapacityOptions(COSTONLY, cache=cache, outputs=ALL_OUT) + return ngc.capacity, (g, 0, 4, opts), lambda: stats_of(cache) + + +def with_orphan(call): + """A ledger holding an orphaned record (its policy destroyed without remove()); call(ledger) + gives (fn, args), and the snapshot is the ledger's state.""" + + def make(): + g = graph() + ledger = ngc.FlowLedger(g) + ngc.FlowPolicy(ledger, 0, 4, COSTONLY).place(2) + gc.collect() # the policy is gone: its record is an orphan + fn, args = call(ledger) + return fn, args, lambda: full_ledger_state(ledger) + + return make + + +def half(g): + return [c // 2 for c in caps(g)] + + +def spf_opts(direction=ngc.Direction.FORWARD, multipath=True, **kw): + return ngc.SpfOptions(direction, sel(mp=multipath), **kw) + + +SCENARIOS = { + "spf.full": stateless(lambda g: (ngc.spf, (g, 0, spf_opts()))), + "spf.targets_residual": stateless( + lambda g: ( + ngc.spf, + (g, [0, 1], spf_opts(ngc.Direction.REVERSE, targets=[4], residual=caps(g))), + ) + ), + "spf.single_path": stateless( + lambda g: (ngc.spf, (g, 0, spf_opts(multipath=False, targets=4))) + ), + "ksp": stateless( + lambda g: (ngc.ksp, (g, 0, 4, ngc.KspOptions(4, sel(), residual=caps(g)))) + ), + "FlowLedger.reset": with_orphan(lambda fg: (fg.reset, (half(fg.graph()),))), + "resolve_to_paths": stateless( + lambda g: (ngc.resolve_to_paths, (g, ngc.spf(g, 0, spf_opts()).dag, 0, 4, True)) + ), + "place.costonly": placement(COSTONLY, traced=False), + "place.global": placement(GLOBAL, traced=False), + "place_traced.global": placement(GLOBAL, traced=True), + "capacity.global": stateless( + lambda g: ( + ngc.capacity, + (g, 0, 4, ngc.CapacityOptions(GLOBAL, outputs=ALL_OUT)), + ) + ), + "capacity.frozen_cache": frozen_cache_capacity, + "batch_capacity.global": stateless( + lambda g: ( + ngc.batch_capacity, + (g, [(0, 4), (1, 3)], ngc.CapacityOptions(GLOBAL), None, 1), + ) + ), + "sensitivity.global": stateless( + lambda g: (ngc.sensitivity, (g, 0, 4, ngc.SensitivityOptions(GLOBAL), 1)) + ), +} + + +def absorbing(g): + """A wrong call for the row checks' controls: it absorbs the injected failure.""" + + def absorbs(o): + try: + return ngc.spf(g, 0, o) + except MemoryError: + return "absorbed" + + return absorbs, (spf_opts(),) + + +def changing(): + """A wrong call for the row checks' controls: it changes the observable state before the + failure reaches it.""" + g = graph() + log = [] + + def changes(o): + log.append(None) + return ngc.spf(g, 0, o) + + return changes, (spf_opts(),), lambda: len(log) + + +def leaking(): + """A wrong call for the row checks' controls: it keeps a result it made before the failure + reaches it (outside the snapshot), so that result's native allocations stay live.""" + g = graph() + kept = [] + + def leaks(o): + kept.append(ngc.spf(g, 0, o)) + return ngc.spf(g, 0, o) + + return leaks, (spf_opts(),), lambda: [caps(g), g.cost().tolist()] + + +def replacing(g): + """A wrong call for the row checks' controls: it raises another exception in place of the + injected failure.""" + + def replaces(o): + try: + return ngc.spf(g, 0, o) + except MemoryError: + raise LookupError("replaced") from None + + return replaces, (spf_opts(),) + + +def exiting(status, after): + """Wrong calls for the row checks' controls: the process ends with `status` when the failure + of an attempt after the first `after` reaches the call (a crash, or an exit with status 0).""" + + def make(): + m = next(MADE) # 0: the reference call + g = graph() + + def exits(o): + try: + return ngc.spf(g, 0, o) + except MemoryError: + if m > after: + os._exit(status) + raise + + return exits, (spf_opts(),), lambda: [caps(g), g.cost().tolist()] + + return make + + +def walks(): + """A call for the row checks' controls that needs more attempts than the sweep makes: the + walks of ten diamonds in a row (1,024 walks, far more than 5,000 native allocations).""" + edges = [] + for i in range(10): + a = 3 * i + edges += [ + (a, a + 1, 1, 1), + (a, a + 2, 1, 1), + (a + 1, a + 3, 1, 1), + (a + 2, a + 3, 1, 1), + ] + g = build(31, edges) + dag = ngc.spf(g, 0, spf_opts()).dag + return ( + ngc.resolve_to_paths, + (g, dag, 0, 30, True), + lambda: [caps(g), g.cost().tolist()], + ) + + +def by_turn(call): + """A wrong call for the row checks' controls whose completing call differs from the + reference: call(m, g) gives the call of the m-th make() (m = 0: the reference call).""" + + def make(): + g = graph() + return call(next(MADE), g), (spf_opts(),), lambda: [caps(g), g.cost().tolist()] + + return make + + +def raises_after(m, g): + """The completing call raises after its result (the reference call returns it).""" + + def raises(o): + result = ngc.spf(g, 0, o) + if m: + raise LookupError("after its result") + return result + + return raises + + +def appending(): + """A wrong call for the row checks' controls: the completing call adds two entries to the + observable log where the reference call added one.""" + m = next(MADE) + g = graph() + log = [] + + def appends(o): + result = ngc.spf(g, 0, o) + log.extend([None] * (2 if m else 1)) + return result + + return appends, (spf_opts(),), lambda: len(log) + + +def prebuilt(m, g): + """After ten failures the call returns a result built beforehand: it completes with no native + allocation, fewer than the failures before it.""" + built = ngc.spf(g, 0, spf_opts()) + + def call(o): + return built if m > 10 else ngc.spf(g, 0, o) + + return call + + +def discarding(g): + """A wrong call for the row checks' controls: it drops its result and returns a plain value, + so the completing call holds no native allocation.""" + + def discards(o): + ngc.spf(g, 0, o) + return 7 + + return discards, (spf_opts(),) + + +MADE = itertools.count() # make() calls of a control in the child (each child runs one) + +# Wrong calls that the row checks must reject (run by the controls, not by the sweep test), one +# per check. Their options are made before the call: a bound object constructed inside it would +# meet the failure in its constructor. +CONTROLS = { + "control.absorbs": stateless(absorbing), + "control.replaces": stateless(replacing), + "control.leaks": leaking, + "control.changes_state": changing, + "control.crashes": exiting(3, 0), + "control.never_completes": walks, + "control.exits": exiting(0, 1), + # reset under the floor of ten (its own scenario has a floor of its own, FEW) + "control.few": with_orphan(lambda fg: (fg.reset, (half(fg.graph()),))), + "control.raises_when_done": by_turn(raises_after), + "control.other_value": by_turn(lambda m, g: lambda o: ngc.spf(g, 1 if m else 0, o)), + "control.other_state": appending, + "control.fewer_allocations": by_turn(prebuilt), + "control.discards": stateless(discarding), +} + + +def sweep(name: str) -> None: + """Attempts k = 0, 1, ... of one scenario until a call completes without reaching k; one + ROW line per attempt, written before the next attempt starts.""" + make = {**SCENARIOS, **CONTROLS}[name] + fn, args, snap = make() + reference = plain(fn(*args)) # also the warm-up of lazily sized scratch + reference_after = snap() + gc.collect() + gc.disable() # no unrelated object is released inside an attempt + for k in range(5000): + fn, args, snap = make() + before = snap() + print(f"START {k}", flush=True) + hit, seen, value, error, live = ext._fail_native_allocation(fn, args, k) + row = {"k": k, "hit": hit, "seen": seen, "live": live} + row["error"] = None if error is None else f"{type(error).__name__}: {error}" + if error is None: + row["returns"] = value is not None + row["value_ok"] = plain(value) == reference + row["state_ok"] = snap() == reference_after + else: + row["state_ok"] = snap() == before + del value, error + print("ROW " + json.dumps(row), flush=True) + if not hit: + return + print("ROW " + json.dumps({"k": None}), flush=True) + + +def run_child(name): + proc = subprocess.run( + [sys.executable, "-B", __file__, name, ext.__file__], + capture_output=True, + text=True, + timeout=600, + ) + rows, started = [], None + for line in proc.stdout.splitlines(): + if line.startswith("START "): + started = int(line[6:]) + elif line.startswith("ROW "): + rows.append(json.loads(line[4:])) + return proc, rows, started + + +if __name__ != "__main__": + import traceback + + import pytest + from private_hooks import needs_hooks + + # The scenarios with fewer than ten native allocations, and at least how many they make: + # reset converts the residual from Python, then builds the new baseline. + FEW = {"FlowLedger.reset": 3} + + def check(name, proc, rows, started): + """The checks of one scenario's rows.""" + failed = [r for r in rows if r["k"] is not None and r["hit"]] + for row in failed: + # A reached failure propagates as MemoryError (a call that raised returns no value): + # nothing absorbs it, nothing it allocated stays allocated, and the state is as + # before the call. + assert row["error"] is not None, row + assert row["error"].startswith("MemoryError"), row + assert row["live"] == 0, row + assert row["state_ok"], row + assert proc.returncode == 0, ( + f"{name}: the child died (rc {proc.returncode}) in attempt {started}\n" + f"{proc.stderr[-2000:]}" + ) + assert rows and rows[-1]["k"] is not None, f"{name}: the call never completed" + # The child stops after the first call that completes: every earlier row is a reached + # failure (a child that ended early with status 0 leaves a failure last). + assert len(failed) == len(rows) - 1, f"{name}: {len(failed)} attempts" + assert len(failed) >= FEW.get(name, 10), f"{name}: {len(failed)} attempts" + done = rows[-1] + assert done["error"] is None, done + assert done["value_ok"], done + assert done["state_ok"], done + assert done["seen"] == len(failed), done + if done["returns"]: + # The probe counts what the result object holds. + assert done["live"] > 0, done + + @needs_hooks + @pytest.mark.parametrize("name", list(SCENARIOS)) + def test_native_allocation_failure_raises_and_changes_nothing(name): + check(name, *run_child(name)) + + @needs_hooks + def test_the_row_checks_reject_a_wrong_call(): + # Each row check rejects its wrong call: one that absorbs the injected failure, raises + # another exception in its place, keeps native allocations live past it or changes the + # observable state before it; one that crashes the child, needs more attempts than the + # sweep makes, or ends the child with status 0 after a failure; one with fewer + # allocations than the floor; and one whose completing call raises, returns another + # value, leaves another state, makes fewer allocations than failed before it, or returns + # a value that holds no native allocation. + attempts = ( + 'assert len(failed) == len(rows) - 1, f"{name}: {len(failed)} attempts"' + ) + for name, clause in ( + ("control.absorbs", 'assert row["error"] is not None, row'), + ("control.replaces", 'assert row["error"].startswith("MemoryError"), row'), + ("control.leaks", 'assert row["live"] == 0, row'), + ("control.changes_state", 'assert row["state_ok"], row'), + ("control.crashes", "assert proc.returncode == 0, ("), + ( + "control.never_completes", + 'assert rows and rows[-1]["k"] is not None, f"{name}: the call never completed"', + ), + ("control.exits", attempts), + ( + "control.few", + 'assert len(failed) >= FEW.get(name, 10), f"{name}: {len(failed)} attempts"', + ), + ("control.raises_when_done", 'assert done["error"] is None, done'), + ("control.other_value", 'assert done["value_ok"], done'), + ("control.other_state", 'assert done["state_ok"], done'), + ("control.fewer_allocations", 'assert done["seen"] == len(failed), done'), + ("control.discards", 'assert done["live"] > 0, done'), + ): + with pytest.raises(AssertionError) as info: + check(name, *run_child(name)) + assert traceback.extract_tb(info.tb)[-1].line == clause, name + +else: + if ngc.spf is not ext.spf: # the package binds to the loaded extension + sys.exit("netgraph_core does not use the extension the parent passed") + if not hasattr(ext, "_fail_native_allocation"): + sys.exit("the extension lacks the private hooks") + sweep(sys.argv[1]) diff --git a/tests/py/test_public_surface.py b/tests/py/test_public_surface.py index 45fb68a..f7eb706 100644 --- a/tests/py/test_public_surface.py +++ b/tests/py/test_public_surface.py @@ -1,11 +1,16 @@ """The public surface: the graph on units, Eligibility, the conversions, the route structures, the selection and placement axes, the ledger, the policy engine, the DagCache, the evaluators and the public search; the extension's private entry, which netgraph_core never re-exports; -the spellings the interface grooming retired and the retired search entry points (APPROACH 12), -which exist nowhere, without aliases.""" +the retired spellings and the retired search entry points, which exist nowhere, without +aliases; and the argument checks of the bindings, each with its exception class and whole +message.""" + +from types import SimpleNamespace import _netgraph_core +import numpy as np import pytest +from helpers import RES, config, exact, sel from private_hooks import HOOKS import netgraph_core as ngc @@ -42,7 +47,6 @@ "DagCacheStats", "CapacityOptions", "SensitivityOptions", - "SensitivityStatus", "SensitivityEntry", "SensitivityResult", "capacity", @@ -58,15 +62,15 @@ "resolve_to_paths", } -# The shipped private entry: the flow core's recorder entry (APPROACH 2.2). +# The shipped private entry: the flow core's _place_global_units. PRIVATE = {"_place_global_units"} # The private hooks (NETGRAPH_CORE_PRIVATE_HOOKS: the development build only): module functions # and class members. HOOK_FUNCTIONS = { "_max_flows_per_call", - "_sensitivity_fail_edge", "_fail_python_allocation", + "_fail_native_allocation", "_round_robin", "_highest_averages", } @@ -78,8 +82,14 @@ def test_public_surface(): assert set(ngc.__all__) == PUBLIC | {"__version__"} + # Every name the package exposes, not only the ones __all__ lists: the public names and the + # private metadata reader behind __version__ (dunder module attributes aside). + assert {n for n in dir(ngc) if not n.startswith("__")} == PUBLIC | {"_version"} for name in ngc.__all__: assert hasattr(ngc, name) + # Every public name is the compiled module's own object, re-exported. + for name in PUBLIC: + assert getattr(ngc, name) is getattr(_netgraph_core, name), name assert isinstance(ngc.__version__, str) assert ngc.MAX_FLOWS_PER_CALL == 10000 @@ -104,7 +114,26 @@ def test_extension_surface_is_exactly_the_public_and_private_sets(): assert private == want, cls -# The 1a to 1c spellings renamed before 1d (APPROACH 12) and the old Python keyword: no alias. +def test_signatures_name_registered_types_only(): + # pybind11 writes a signature's types when the binding is made: a type registered after a + # binding that takes, returns or defaults to it appears under its C++ name (the + # registration files run in an order where every such type comes first). + docs = [] + for name, obj in vars(_netgraph_core).items(): + docs.append((name, getattr(obj, "__doc__", None))) + if isinstance(obj, type): + for member, raw in vars(obj).items(): + target = raw.__func__ if isinstance(raw, staticmethod) else raw + docs.append((f"{name}.{member}", getattr(target, "__doc__", None))) + if isinstance(raw, property) and raw.fget is not None: + docs.append((f"{name}.{member}.fget", raw.fget.__doc__)) + signed = [(n, d) for n, d in docs if isinstance(d, str) and "(" in d] + assert len(signed) > 200 # not vacuous: every function, method and property + for name, doc in signed: + assert "::" not in doc, (name, doc.splitlines()[0]) + + +# Retired spellings of this API's names: no alias. RETIRED = { "StrictMultiDiGraph", "FlowGraph", @@ -113,7 +142,7 @@ def test_extension_surface_is_exactly_the_public_and_private_sets(): "kMaxFlowsPerCall", } -# The retired search entry points (APPROACH 12) and the 1b private search: never bound. +# The retired search entry points, their option types and the private name _spf: never bound. RETIRED_SEARCH = { "shortest_paths", "shortest_paths_to", @@ -135,7 +164,7 @@ def test_retired_spellings_are_absent(): assert ngc.FlowLedger.__name__ == "FlowLedger" assert ngc.OutputOptions.__name__ == "OutputOptions" assert ngc.DiminishingReturns.__name__ == "DiminishingReturns" - # PredDAG and Path carry no resolver or alternative constructors (APPROACH 12). + # PredDAG and Path carry no resolver or alternative constructors. for name in ("resolve_to_paths", "from_edges", "from_hops"): assert not hasattr(ngc.PredDAG, name), name for name in ("from_edges", "from_hops"): @@ -145,27 +174,190 @@ def test_retired_spellings_are_absent(): assert ngc.UNREACHABLE == 2**63 - 1 -def _config(): - return ngc.FlowPolicyConfig( - routing=ngc.CostOnly(), - placement=ngc.FlowPlacement.EQUAL_BALANCED, - admission=ngc.Admission.LOSSLESS, - selection=ngc.PathSelection( - ngc.EdgeTieBreak.DETERMINISTIC, ngc.EdgePriority.RESIDUAL - ), - ) - - -def test_policy_takes_its_ledger_by_the_ledger_keyword_only(): +def test_policy_takes_its_ledger_first_or_by_the_name_ledger(): + cfg = config(ngc.CostOnly(), selection=sel(prio=RES)) g = ngc.Graph(2, [0], [1], [3], [1], 1.0) ledger = ngc.FlowLedger(g) - p = ngc.FlowPolicy(ledger=ledger, sources=0, destinations=1, config=_config()) + p = ngc.FlowPolicy(ledger=ledger, sources=0, destinations=1, config=cfg) assert p.place(2).placed == 2 assert ledger.records()[0].delivered == 2 - # Positional order unchanged: the ledger first. - assert ngc.FlowPolicy(ledger, 0, 1, _config()).place(1).placed == 1 + # Positionally the ledger comes first. + assert ngc.FlowPolicy(ledger, 0, 1, cfg).place(1).placed == 1 with pytest.raises(TypeError): - ngc.FlowPolicy(flow_graph=ledger, sources=0, destinations=1, config=_config()) - with pytest.raises(ValueError, match="^ledger must be a FlowLedger$"): - ngc.FlowPolicy(None, 0, 1, _config()) + ngc.FlowPolicy(flow_graph=ledger, sources=0, destinations=1, config=cfg) + with pytest.raises(ValueError, match=exact("ledger must be a FlowLedger")): + ngc.FlowPolicy(None, 0, 1, cfg) assert "ledger: _netgraph_core.FlowLedger" in ngc.FlowPolicy.__init__.__doc__ + + +def _refusal_inputs(): + """What the refused calls below share: the chain 0 -> 1 -> 2 and valid options.""" + g = ngc.Graph(3, [0, 1], [1, 2], [5, 5], [1, 1], 1.0) + cfg = config(ngc.CostOnly()) + return SimpleNamespace( + g=g, + ledger=ngc.FlowLedger(g), + cfg=cfg, + capacity=ngc.CapacityOptions(cfg), + sensitivity=ngc.SensitivityOptions(cfg), + spf=ngc.SpfOptions(ngc.Direction.FORWARD, sel()), + ksp=ngc.KspOptions(1, sel()), + dag=ngc.Path(g, [[0], [1]]).to_dag(), + path=ngc.Path(g, [[0]]), + ) + + +GRAPH = "graph must be a Graph" +INTERSECT = "Eligibility.intersect takes two Eligibility values" +PAIR = "each pair must be (sources, destinations)" +ROUTING = "routing must be CostOnly, CapacityAware, Global or Pinned (got {})" +SEQUENCE = "Pinned bundles must be a sequence of PredDAG" +BUNDLE = "Pinned bundles must be PredDAG values (a Path converts with to_dag())" + +# Each wrong argument, the exception class and the whole message it is refused with. A None +# where a Graph, an Eligibility or a PredDAG is expected reaches the binding as a null value +# (pybind11 refuses every other wrong type first), so these checks also keep the call from +# reading it. +REFUSALS = { + "spf-graph-None": (lambda s: ngc.spf(None, 0, s.spf), ValueError, GRAPH), + "ksp-graph-None": (lambda s: ngc.ksp(None, 0, 2, s.ksp), ValueError, GRAPH), + "resolve-graph-None": ( + lambda s: ngc.resolve_to_paths(None, s.dag, 0, 2), + ValueError, + GRAPH, + ), + "resolve-dag-None": ( + lambda s: ngc.resolve_to_paths(s.g, None, 0, 2), + ValueError, + "dag must be a PredDAG", + ), + "DagCache-graph-None": (lambda s: ngc.DagCache(None), ValueError, GRAPH), + "FlowLedger-graph-None": (lambda s: ngc.FlowLedger(None), ValueError, GRAPH), + "FlowLedger-graph-None-with-residual": ( + lambda s: ngc.FlowLedger(None, [5, 5]), + ValueError, + GRAPH, + ), + "Path-graph-None": (lambda s: ngc.Path(None, [[0]]), ValueError, GRAPH), + "from_csr-graph-None": ( + lambda s: ngc.PredDAG.from_csr(None, [0, 0, 0, 0], [], []), + ValueError, + GRAPH, + ), + "capacity-graph-None": ( + lambda s: ngc.capacity(None, 0, 2, s.capacity), + ValueError, + GRAPH, + ), + "batch_capacity-graph-None": ( + lambda s: ngc.batch_capacity(None, [(0, 2)], s.capacity), + ValueError, + GRAPH, + ), + "sensitivity-graph-None": ( + lambda s: ngc.sensitivity(None, 0, 2, s.sensitivity), + ValueError, + GRAPH, + ), + "intersect-second-None": ( + lambda s: ngc.Eligibility.intersect(s.g.eligibility(), None), + ValueError, + INTERSECT, + ), + "intersect-first-None": ( + lambda s: ngc.Eligibility.intersect(None, s.g.eligibility()), + ValueError, + INTERSECT, + ), + # Shapes and types of sequence and field arguments. + "batch_capacity-pairs-not-a-sequence": ( + lambda s: ngc.batch_capacity(s.g, 5, s.capacity), + ValueError, + "pairs must be a sequence of (sources, destinations)", + ), + "batch_capacity-pair-of-one": ( + lambda s: ngc.batch_capacity(s.g, [(0,)], s.capacity), + ValueError, + PAIR, + ), + "batch_capacity-pair-not-a-sequence": ( + lambda s: ngc.batch_capacity(s.g, [5], s.capacity), + ValueError, + PAIR, + ), + "batch_capacity-per_pair_elig-not-a-sequence": ( + lambda s: ngc.batch_capacity(s.g, [(0, 2)], s.capacity, 5), + ValueError, + "per_pair_elig must be a sequence of Eligibility or None", + ), + "Path-hops-not-a-sequence": ( + lambda s: ngc.Path(s.g, 5), + ValueError, + "Path: hops must be a sequence of hops (sequences of EdgeIds)", + ), + "FlowPolicy-sources-2-D": ( + lambda s: ngc.FlowPolicy(s.ledger, np.array([[0]]), 2, s.cfg), + ValueError, + "sources must be a 1-D array (got 2 dimensions)", + ), + "FlowLedger-record-unknown-ledger": ( + lambda s: s.ledger.record((-1, 0)), + ValueError, + "FlowId (-1, 0) is retired or unknown", + ), + "config-routing-int": ( + lambda s: config(5), + TypeError, + ROUTING.format("int"), + ), + "config-routing-None": ( + lambda s: config(None), + TypeError, + ROUTING.format("NoneType"), + ), + "CapacityAware-flow_count-None": ( + lambda s: ngc.CapacityAware(flow_count=None), + ValueError, + "flow_count must be an integer (got None)", + ), + "Absolute-cost-None": ( + lambda s: ngc.Absolute(None), + ValueError, + "Absolute cost must be an integer (got None)", + ), + "DiminishingReturns-window-None": ( + lambda s: ngc.DiminishingReturns(None, 0.5), + ValueError, + "diminishing window must be an integer (got None)", + ), + "Stretch-factor-str": ( + lambda s: ngc.Stretch("1.5"), + ValueError, + "Stretch factor must be a number (got str)", + ), + "Stretch-factor-bool": ( + lambda s: ngc.Stretch(True), + ValueError, + "Stretch factor must be a number (got bool)", + ), + "DiminishingReturns-fraction-str": ( + lambda s: ngc.DiminishingReturns(1, "0.5"), + ValueError, + "diminishing fraction must be a number (got str)", + ), + "Pinned-bundles-int": (lambda s: ngc.Pinned(5), TypeError, SEQUENCE), + "Pinned-bundles-None": (lambda s: ngc.Pinned(None), TypeError, SEQUENCE), + "Pinned-bundle-int": (lambda s: ngc.Pinned([5]), TypeError, BUNDLE), + "Pinned-bundle-None": (lambda s: ngc.Pinned([None]), TypeError, BUNDLE), + "Pinned-bundle-Path": (lambda s: ngc.Pinned([s.path]), TypeError, BUNDLE), +} + + +@pytest.mark.parametrize( + ("call", "error", "message"), REFUSALS.values(), ids=REFUSALS.keys() +) +def test_a_wrong_argument_is_refused_with_its_message(call, error, message): + s = _refusal_inputs() + with pytest.raises(error, match=exact(message)) as info: + call(s) + assert type(info.value) is error diff --git a/tests/py/test_route_structures.py b/tests/py/test_route_structures.py index 5736486..3ecafb6 100644 --- a/tests/py/test_route_structures.py +++ b/tests/py/test_route_structures.py @@ -1,6 +1,6 @@ """PredDAG, Path, PathSelection, the enums and Eligibility.intersect through the bindings -(APPROACH 4, 5, 6): validation in the documented order on unnarrowed values, read-only views, -structural equality.""" +(docs/semantics.md#3-route-values-preddag-and-path): validation in the documented order on +unnarrowed values, read-only views, structural equality.""" from __future__ import annotations @@ -8,23 +8,13 @@ import numpy as np import pytest +from helpers import build, exact import netgraph_core as ngc L64 = 1 << 64 -def build(n, edges, q=1.0): - return ngc.Graph( - n, - [e[0] for e in edges], - [e[1] for e in edges], - [e[2] for e in edges], - [e[3] for e in edges], - q, - ) - - # ids by (src, cost, dst, input): 0: 0->1, 1: 0->1, 2: 0->2 (cost 3), 3: 1->2, 4: 2->0 G_EDGES = [(0, 1, 5, 1), (0, 1, 5, 1), (1, 2, 5, 1), (0, 2, 5, 3), (2, 0, 5, 1)] @@ -36,46 +26,149 @@ def test_from_csr_round_trip_and_views(): assert list(dag.parent_offsets()) == [0, 0, 2, 4] assert list(dag.parents()) == [0, 0, 0, 1] assert list(dag.via_edges()) == [0, 1, 2, 3] - view = dag.via_edges() - assert view.dtype == np.int32 + # parent_offsets() is a new read-only array on each call. + offsets = dag.parent_offsets() + assert offsets.dtype == np.int32 and not offsets.flags.writeable with pytest.raises(ValueError): - view[0] = 9 + offsets[0] = 9 + assert dag.parent_offsets() is not offsets + assert not np.shares_memory(dag.parent_offsets(), offsets) + # parents() and via_edges() are read-only views that keep the DAG alive. + parents, view = dag.parents(), dag.via_edges() + for array in (parents, view): + assert array.dtype == np.int32 and not array.flags.writeable + with pytest.raises(ValueError): + array[0] = 9 del dag gc.collect() - assert list(view) == [0, 1, 2, 3] # the view keeps the DAG alive + assert list(view) == [0, 1, 2, 3] # the views keep the DAG alive + assert list(parents) == [0, 0, 0, 1] same = ngc.PredDAG.from_csr( g, np.array([0, 0, 2, 4]), np.array([0, 0, 0, 1]), np.array([0, 1, 2, 3]) ) again = ngc.PredDAG.from_csr(g, [0, 0, 2, 4], [0, 0, 0, 1], [0, 1, 2, 3]) assert same == again and hash(same) == hash(again) assert same != ngc.PredDAG.from_csr(g, [0, 0, 0, 0], [], []) + # Equality includes the graph: the same CSR on a twin graph (the same arrays, another uid) is + # another value. + twin = build(3, G_EDGES) + there = ngc.PredDAG.from_csr(twin, [0, 0, 2, 4], [0, 0, 0, 1], [0, 1, 2, 3]) + assert there.graph_uid() == twin.uid() != g.uid() + assert list(there.via_edges()) == list(same.via_edges()) + assert there != same and not there == same @pytest.mark.parametrize( ("offsets", "parents", "via", "message"), [ - ([0, 0, 0], [], [], "offsets has length 3"), - ([1, 0, 2, 1], [], [], r"offsets\[0\] = 1"), - ([L64, 0, 0, 0], [], [], r"offsets\[0\] = 18446744073709551616"), - ([0, 2, 1, 2], [0, 0], [0, 1], "not monotone at 2"), - ([0, L64, 0, 0], [], [], "not monotone at 2"), - ([0, 0, 2, 2], [0], [0, 1], "parents has 1"), - ([0, 0, 2, 3], [9, 0], [0, 1], r"offsets\[N\] = 3"), - ([0, 0, 2, 2], [3, 0], [0, 1], r"parents\[0\] = 3"), - ([0, 0, 2, 2], [-L64, 0], [0, 1], r"parents\[0\] = -18446744073709551616"), - ([0, 0, 2, 2], [0, 0], [0, L64], r"via\[1\] = 18446744073709551616"), - ([0, 0, 2, 2], [1, 0], [0, 1], r"joins \(0, 1\), not \(1, 1\)"), - ([0, 0, 2, 2], [0, 0], [1, 1], "duplicate entry"), - ([0, 1, 2, 3], [2, 0, 1], [4, 0, 3], "cycle"), - ([0, 0, 2, 2], [0, 0], [1, 0], "not in ascending via EdgeId"), + ([0, 0, 0], [], [], "offsets has length 3, expected N + 1 = 4"), + ([1, 0, 2, 1], [], [], "offsets[0] = 1, expected 0"), + ([L64, 0, 0, 0], [], [], "offsets[0] = 18446744073709551616, expected 0"), + ([0, 2, 1, 2], [0, 0], [0, 1], "offsets are not monotone at 2 (2 > 1)"), + ( + [0, L64, 0, 0], + [], + [], + "offsets are not monotone at 2 (18446744073709551616 > 0)", + ), + ( + [0, 0, 2, 2], + [0], + [0, 1], + "offsets[N] = 2 but parents has 1 and via has 2 entries", + ), + ( + [0, 0, 2, 3], + [9, 0], + [0, 1], + "offsets[N] = 3 but parents has 2 and via has 2 entries", + ), + ([0, 0, 2, 2], [3, 0], [0, 1], "parents[0] = 3 out of node range [0, 3)"), + ( + [0, 0, 2, 2], + [-L64, 0], + [0, 1], + "parents[0] = -18446744073709551616 out of node range [0, 3)", + ), + ( + [0, 0, 2, 2], + [0, 0], + [0, L64], + "via[1] = 18446744073709551616 out of edge range [0, 5)", + ), + ( + [0, 0, 2, 2], + [1, 0], + [0, 1], + "entry 0 of node 1: via edge 0 joins (0, 1), not (1, 1)", + ), + ([0, 0, 2, 2], [0, 0], [1, 1], "duplicate entry (child 1, via 1)"), + # A cycle (0 -> 1 -> 2 -> 0) before the order check: node 1's entries descend. + ([0, 1, 3, 4], [2, 0, 0, 1], [4, 1, 0, 3], "the entries contain a cycle"), + ( + [0, 0, 2, 2], + [0, 0], + [1, 0], + "the entries of node 1 are not in ascending via EdgeId (1 then 0)", + ), ], ) def test_from_csr_validation_order(offsets, parents, via, message): g = build(3, G_EDGES) - with pytest.raises(ValueError, match=message): + with pytest.raises(ValueError, match=exact(f"PredDAG.from_csr: {message}")): ngc.PredDAG.from_csr(g, offsets, parents, via) +def test_from_csr_reports_the_first_of_several_defects(): + # Every defect at once, then each reported one removed: the shape (length, offsets[0], + # monotone, offsets[N]), then the ranges of every entry (unnarrowed, before any endpoint is + # read), the endpoints, a duplicate, a cycle, the order within a node. Node 1's entries hold + # the cycle 0 -> 1 -> 2 -> 0 with node 0's, a duplicate and a descending pair. + g = build(3, G_EDGES) + big = 2**70 + steps = [ + ([1, 0, 0], [big], [big], "offsets has length 3, expected N + 1 = 4"), + ([1, 0, 2, 5], [big], [big], "offsets[0] = 1, expected 0"), + ([0, 2, 1, 5], [big], [big], "offsets are not monotone at 2 (2 > 1)"), + ( + [0, 0, 2, 5], + [big], + [big], + "offsets[N] = 5 but parents has 1 and via has 1 entries", + ), + ( + [0, 1, 4, 6], + [2, 0, 0, 0, 0, 1], + [4, 1, 0, 0, 0, big], + f"via[5] = {big} out of edge range [0, 5)", + ), + ( + [0, 1, 4, 6], + [2, 0, 0, 0, 0, 1], + [4, 1, 0, 0, 0, 3], + "entry 4 of node 2: via edge 0 joins (0, 1), not (0, 2)", + ), + ( + [0, 1, 4, 6], + [2, 0, 0, 0, 0, 1], + [4, 1, 0, 0, 2, 3], + "duplicate entry (child 1, via 0)", + ), + ([0, 1, 3, 5], [2, 0, 0, 0, 1], [4, 1, 0, 2, 3], "the entries contain a cycle"), + ( + [0, 0, 2, 4], + [0, 0, 0, 1], + [1, 0, 2, 3], + "the entries of node 1 are not in ascending via EdgeId (1 then 0)", + ), + ] + for offsets, parents, via, message in steps: + with pytest.raises(ValueError) as info: + ngc.PredDAG.from_csr(g, offsets, parents, via) + assert str(info.value) == "PredDAG.from_csr: " + message + ngc.PredDAG.from_csr(g, [0, 0, 2, 4], [0, 0, 0, 1], [0, 1, 2, 3]) + + @pytest.mark.parametrize( "offsets", [ @@ -87,7 +180,7 @@ def test_from_csr_validation_order(offsets, parents, via, message): ], ) def test_from_csr_compares_offsets_beyond_int64_exactly(offsets): - # review-1b D2: two offsets above int64 compare by value, so a decreasing pair is the + # Two offsets above int64 compare by value, so a decreasing pair is the # monotonicity error at its index, before the offsets[N] count check. g = build(2, [(0, 1, 1, 1)]) with pytest.raises(ValueError, match="not monotone at 2"): @@ -99,13 +192,60 @@ def test_from_csr_compares_offsets_beyond_int64_exactly(offsets): ngc.PredDAG.from_csr(g, increasing, [], []) +class FailingIndex: + """An integer-like object whose __index__ returns 2**100, except on call `fail_on`, which + raises `error` (kept to check that the caller receives this very exception).""" + + def __init__(self, fail_on=0, error=None): + self.calls = 0 + self.fail_on = fail_on + self.error = error + + def __index__(self): + self.calls += 1 + if self.calls == self.fail_on: + raise self.error + return 2**100 + + +class ConversionFailed(Exception): + pass + + +@pytest.mark.parametrize("error_type", [ConversionFailed, MemoryError]) +@pytest.mark.parametrize("which", [1, 2]) +@pytest.mark.parametrize("fail_on", [1, 2]) +def test_from_csr_passes_a_failed_offset_conversion_through(fail_on, which, error_type): + # Offsets 1 and 2 both overflow int64: they are read once, then converted again to compare + # them exactly. A conversion that fails, on either read of either offset, raises its own + # exception to the caller, unchanged and with no other exception attached. + g = build(2, [(0, 1, 1, 1)]) + offsets = [0, FailingIndex(), FailingIndex()] + error = error_type("conversion failed") + offsets[which].fail_on, offsets[which].error = fail_on, error + with pytest.raises(error_type) as info: + ngc.PredDAG.from_csr(g, offsets, [], []) + assert info.value is error + assert info.value.__cause__ is None and info.value.__context__ is None + assert offsets[which].calls == fail_on + # Without a failure the same offsets reach the count check (2**100 is not 0 entries). + with pytest.raises(ValueError, match=r"^PredDAG.from_csr: offsets\[N\] = "): + ngc.PredDAG.from_csr(g, [0, FailingIndex(), FailingIndex()], [], []) + + def test_from_csr_type_and_shape_defects(): g = build(3, G_EDGES) - with pytest.raises(ValueError, match="1-D"): + with pytest.raises( + ValueError, match=exact("offsets must be a 1-D array (got 2 dimensions)") + ): ngc.PredDAG.from_csr(g, np.zeros((2, 2), dtype=np.int64), [], []) - with pytest.raises(ValueError, match="integer"): + with pytest.raises( + ValueError, match=exact("offsets[3] must be an integer (got float)") + ): ngc.PredDAG.from_csr(g, [0, 0, 0, 0.0], [], []) - with pytest.raises(ValueError, match="integer"): + with pytest.raises( + ValueError, match=exact("offsets[3] must be an integer (got bool)") + ): ngc.PredDAG.from_csr(g, [0, 0, 0, True], [], []) @@ -117,30 +257,65 @@ def test_path_cost_hops_and_to_dag(): dag = p.to_dag() assert list(dag.parent_offsets()) == [0, 0, 2, 3] assert list(dag.via_edges()) == [0, 1, 3] + assert p.graph_uid() == g.uid() assert ngc.Path(g, [[1, 0], [3]]) == p assert ngc.Path(g, [[0, 1], [3]]) != p assert hash(ngc.Path(g, [[1, 0], [3]])) == hash(p) + # Equality includes the graph: the same hops on a twin graph (the same arrays, another uid). + twin = build(3, G_EDGES) + there = ngc.Path(twin, [[1, 0], [3]]) + assert there.graph_uid() == twin.uid() != g.uid() + assert there.hops() == p.hops() and there.cost() == p.cost() + assert there != p and not there == p @pytest.mark.parametrize( ("hops", "message"), [ - ([[], [7]], "member 7 out of edge range"), - ([[L64]], "member 18446744073709551616 out of edge range"), + ([[], [7]], "hop 1 member 7 out of edge range [0, 5)"), + ([[L64]], "hop 0 member 18446744073709551616 out of edge range [0, 5)"), ([[0, 0], []], "duplicate member 0"), - ([], "hop list is empty"), + ([], "the hop list is empty"), ([[0], []], "hop 1 is empty"), - ([[0, 2]], r"do not share \(u, v\)"), - ([[0], [2]], "not contiguous"), + ( + [[0, 2]], + "the members of hop 0 do not share (u, v): edge 2 joins (0, 2), edge 0 " + "joins (0, 1)", + ), + ([[0], [2]], "hops 0 and 1 are not contiguous (1 then 0)"), ([[2], [4]], "node 0 is repeated"), ], ) def test_path_validation_order(hops, message): g = build(3, G_EDGES) - with pytest.raises(ValueError, match=message): + with pytest.raises(ValueError, match=exact(f"Path: {message}")): ngc.Path(g, hops) +def test_path_reports_the_first_of_several_defects(): + # Several defects at once, then each reported one removed; the later defect sits in an + # earlier hop where it can. Hop 2 of the fourth step mixes 0 -> 2 and 0 -> 1, hops 0 and 1 + # do not meet and node 0 repeats. + g = build(3, G_EDGES) + big = 2**70 + steps = [ + ([[0, 0, 2], [], [big]], f"hop 2 member {big} out of edge range [0, 5)"), + ([[0, 0, 2], [], [4]], "duplicate member 0"), + ([[0, 2], [], [4]], "hop 1 is empty"), + ( + [[1], [4], [2, 0]], + "the members of hop 2 do not share (u, v): edge 0 joins (0, 1), edge 2 " + "joins (0, 2)", + ), + ([[1], [4], [2]], "hops 0 and 1 are not contiguous (1 then 2)"), + ([[2], [4], [0]], "node 0 is repeated"), + ] + for hops, message in steps: + with pytest.raises(ValueError) as info: + ngc.Path(g, hops) + assert str(info.value) == "Path: " + message + + def test_path_selection_and_enums(): sel = ngc.PathSelection( ngc.EdgeTieBreak.PREFER_HIGHER_PRIORITY, ngc.EdgePriority.HEADROOM @@ -157,7 +332,7 @@ def test_path_selection_and_enums(): ) assert "PREFER_HIGHER_PRIORITY" in repr(sel) with pytest.raises(TypeError): - ngc.PathSelection() # type: ignore[call-arg] # tie_break and priority are explicit + ngc.PathSelection() # tie_break and priority are explicit assert {m.name for m in ngc.FlowPlacement.__members__.values()} == { "MAX_FLOW", "WEIGHTED", @@ -180,6 +355,11 @@ def test_eligibility_intersect_returns_the_operand_object(): assert ngc.Eligibility.intersect(everything, a) is a assert ngc.Eligibility.intersect(a, everything) is a assert ngc.Eligibility.intersect(a, a) is a + # Two equal values built separately (distinct uids): the first operand wins the tie. + a2 = g.eligibility(excluded_edges=[0]) + assert a2.uid() != a.uid() + assert ngc.Eligibility.intersect(a, a2) is a + assert ngc.Eligibility.intersect(a2, a) is a2 assert ngc.Eligibility.intersect(a, ab) is ab x = ngc.Eligibility.intersect(a, b) y = ngc.Eligibility.intersect(a, b) diff --git a/tests/py/test_search.py b/tests/py/test_search.py index 048bb90..13c31d6 100644 --- a/tests/py/test_search.py +++ b/tests/py/test_search.py @@ -1,45 +1,41 @@ -"""The public search through the binding (``spf``, APPROACH 6): results, the DAG's canonical -form, the validation order on unnarrowed values, the enumerations, and the all-or-nothing -construction of the result objects.""" +"""The public search through the binding (``spf``, docs/semantics.md#4-search): results, the +DAG's canonical form, the validation order on unnarrowed values, the enumerations, and the +all-or-nothing construction of the result objects.""" from __future__ import annotations -import _netgraph_core as ext +import itertools +import traceback + import numpy as np import pytest +from helpers import ( + DET, + L62, + PREFER, + RES, + build, + exact, + ids, + plain, + sel, + sweep_python_allocation, + to_ids, +) from private_hooks import needs_hooks import netgraph_core as ngc -DET = ngc.EdgeTieBreak.DETERMINISTIC -PREFER = ngc.EdgeTieBreak.PREFER_HIGHER_PRIORITY -CAP = ngc.EdgePriority.CAPACITY -RES = ngc.EdgePriority.RESIDUAL FWD = ngc.Direction.FORWARD REV = ngc.Direction.REVERSE -def build(n, edges, q=1.0): - return ngc.Graph( - n, - [e[0] for e in edges], - [e[1] for e in edges], - [e[2] for e in edges], - [e[3] for e in edges], - q, - ) - - -def ids(g, positions): - return sorted(int(e) for e, p in enumerate(g.input_index()) if p in positions) - - def via(dag): return sorted(int(e) for e in dag.via_edges()) def opts(direction=FWD, selection=None, **kw): - return ngc.SpfOptions(direction, selection or ngc.PathSelection(DET, CAP), **kw) + return ngc.SpfOptions(direction, selection or sel(), **kw) ANYCAST = [(0, 1, 1, 1), (1, 2, 1, 1), (0, 3, 1, 2), (3, 2, 1, 5), (1, 4, 1, 5)] @@ -84,12 +80,12 @@ def test_reverse_full_search_is_forward_oriented(): assert off[0] == 0 and off[-1] == len(r.dag.via_edges()) -def test_staged_single_path_review13_d3_both_directions(): +def test_staged_single_path_with_a_shared_bottleneck_takes_the_fewest_hops(): # s -> v (1, prio 1), v -> t (3, prio 1), v -> a -> b -> t (1 + 1 + 1, prio 2): the # bottleneck of every walk is 1, so the two-hop walk wins under both tie modes. g = build(5, [(0, 1, 1, 1), (1, 4, 1, 3), (1, 2, 2, 1), (2, 3, 2, 1), (3, 4, 2, 1)]) for tie in (DET, PREFER): - s = ngc.PathSelection(tie, CAP, multipath=False) + s = sel(tie, mp=False) f = ngc.spf(g, 0, opts(selection=s, targets=4)) assert via(f.dag) == ids(g, {0, 1}) assert f.target_cost == 4 @@ -100,9 +96,8 @@ def test_staged_single_path_review13_d3_both_directions(): def test_residual_gating_and_priority(): # Capacities 5; residuals by input [5, 4, 1, 3, 2]: 0->1->3 has bottleneck 4. g = build(4, [(0, 1, 5, 1), (1, 3, 5, 1), (0, 2, 5, 1), (2, 3, 5, 1), (0, 3, 5, 2)]) - by_input = [5, 4, 1, 3, 2] - residual = np.array([by_input[int(p)] for p in g.input_index()], dtype=np.int64) - s = ngc.PathSelection(PREFER, RES, multipath=False) + residual = np.array(to_ids(g, [5, 4, 1, 3, 2]), dtype=np.int64) + s = sel(PREFER, RES, mp=False) r = ngc.spf(g, 0, opts(selection=s, targets=3, residual=residual)) assert via(r.dag) == ids(g, {0, 1}) # Gated at 4 units: only 0->1 (5) and 1->3 (4) are routable on that walk; the direct edge @@ -115,16 +110,19 @@ def test_residual_gating_and_priority(): def test_validation_order_on_unnarrowed_values(): + # The residual's own checks are the shared ones of test_edge_amounts.py. g = build(3, [(0, 1, 2, 1), (1, 2, 2, 1)]) other = build(3, [(0, 1, 2, 1)]) - with pytest.raises(ValueError, match=r"^roots: the node set is empty$"): + with pytest.raises(ValueError, match=exact("roots: the node set is empty")): ngc.spf(g, [], opts()) - with pytest.raises(ValueError, match=r"^roots: node 3 out of node range \[0, 3\)$"): + with pytest.raises( + ValueError, match=exact("roots: node 3 out of node range [0, 3)") + ): ngc.spf(g, 3, opts()) big = 2**70 with pytest.raises(ValueError, match=rf"^roots: node {big} out of node range"): ngc.spf(g, [0, big], opts()) - with pytest.raises(ValueError, match=r"^targets: the node set is empty$"): + with pytest.raises(ValueError, match=exact("targets: the node set is empty")): ngc.spf(g, 0, opts(targets=[])) with pytest.raises(ValueError, match=r"^targets: node -1 out of node range"): ngc.spf(g, 0, opts(targets=[-1])) @@ -132,43 +130,55 @@ def test_validation_order_on_unnarrowed_values(): ValueError, match=r"^node 1 is in both endpoint sets: roots and targets" ): ngc.spf(g, [0, 1], opts(targets=[1])) - with pytest.raises(ValueError, match="^multipath = false requires targets"): - ngc.spf(g, 0, opts(selection=ngc.PathSelection(DET, CAP, multipath=False))) - with pytest.raises(ValueError, match="^residual has length 1, expected 2"): - ngc.spf(g, 0, opts(residual=[1])) - with pytest.raises(ValueError, match=r"^residual\[1\] = 3 is outside"): - ngc.spf(g, 0, opts(residual=[1, 3])) - with pytest.raises(ValueError, match=r"^residual\[0\] = -" + str(big)): - ngc.spf(g, 0, opts(residual=[-big, 1])) + single = ( + "multipath = false requires targets (the single path joins a root to a target)" + ) + with pytest.raises(ValueError, match=exact(single)): + ngc.spf(g, 0, opts(selection=sel(mp=False))) with pytest.raises( - ValueError, match=r"^min_residual must be at least 1 unit \(got 0\)$" + ValueError, match=exact("min_residual must be at least 1 unit (got 0)") ): ngc.spf(g, 0, opts(min_residual=0)) - with pytest.raises(ValueError, match=r"min_residual must be an integer"): + with pytest.raises( + ValueError, match=exact(f"min_residual must be at least 1 unit (got {-big})") + ): + ngc.spf(g, 0, opts(min_residual=-big)) + with pytest.raises( + ValueError, match=exact("min_residual must be an integer (got float)") + ): ngc.spf(g, 0, opts(min_residual=1.5)) with pytest.raises(ValueError, match="^foreign Eligibility"): ngc.spf(g, 0, opts(elig=other.eligibility([], [0]))) - # Several defects: the contract's order (roots, targets, overlap, single path, residual, - # min_residual, Eligibility). - with pytest.raises(ValueError, match="^roots: the node set is empty$"): + # Several defects: the first in the documented order (roots, targets, overlap, single + # path, residual, min_residual, Eligibility). + with pytest.raises(ValueError, match=exact("roots: the node set is empty")): ngc.spf(g, [], opts(targets=[], residual=[1], min_residual=0)) + with pytest.raises(ValueError, match=rf"^targets: node {big} out of node range"): + ngc.spf(g, 0, opts(targets=[0, big], residual=[])) with pytest.raises(ValueError, match="both endpoint sets"): ngc.spf(g, 0, opts(targets=0, residual=[])) - with pytest.raises(ValueError, match="requires targets"): + with pytest.raises(ValueError, match=exact(single)): ngc.spf( g, 0, - opts(selection=ngc.PathSelection(DET, CAP, multipath=False), residual=[]), + opts(selection=sel(mp=False), residual=[]), ) with pytest.raises(ValueError, match="^residual"): ngc.spf(g, 0, opts(residual=[1, 9], min_residual=0)) with pytest.raises(ValueError, match="^min_residual"): ngc.spf(g, 0, opts(min_residual=0, elig=other.eligibility([], [0]))) - # A huge min_residual gates every residual edge (no error). - assert list(ngc.spf(g, 0, opts(residual=[2, 2], min_residual=big)).costs)[1:] == [ - ngc.UNREACHABLE, - ngc.UNREACHABLE, - ] + # A min_residual above INT64_MAX is no error and acts as INT64_MAX does: it gates every + # residual edge, even one whose residual is the largest unit value 2^62 - 1, which + # min_residual = 2^62 - 1 itself admits. + top = build(2, [(0, 1, L62 - 1, 1)]) + for m in (L62 - 1, 2**63 - 1, big): + r = ngc.spf(top, 0, opts(residual=[L62 - 1], min_residual=m)) + assert list(r.costs) == [0, 1 if m == L62 - 1 else ngc.UNREACHABLE], m + huge = ngc.spf(g, 0, opts(residual=[2, 2], min_residual=big)) + assert list(huge.costs)[1:] == [ngc.UNREACHABLE, ngc.UNREACHABLE] + assert plain(huge) == plain( + ngc.spf(g, 0, opts(residual=[2, 2], min_residual=2**63 - 1)) + ) def test_options_types_and_enumerations(): @@ -176,49 +186,143 @@ def test_options_types_and_enumerations(): o = opts(targets=[1], min_residual=3) assert o.direction == FWD and o.targets == [1] and o.min_residual == 3 assert o.residual is None and o.elig is None - with pytest.raises(ValueError, match="^direction: 7 is not a value of Direction$"): - ngc.SpfOptions(ngc.Direction(7), ngc.PathSelection(DET, CAP)) + with pytest.raises( + ValueError, match=exact("direction: 7 is not a value of Direction") + ): + ngc.SpfOptions(ngc.Direction(7), sel()) with pytest.raises(TypeError): - ngc.SpfOptions(FWD, ngc.PathSelection(DET, CAP), fanout_edges=[0]) + ngc.SpfOptions(FWD, sel(), fanout_edges=[0]) with pytest.raises(TypeError, match=r"^spf\(\): incompatible function arguments"): ngc.spf(g, 0, opts(), fanout_edges=[0]) - with pytest.raises(TypeError, match="SpfOptions expected"): - ngc.spf(g, 0, ngc.PathSelection(DET, CAP)) + with pytest.raises( + TypeError, match=exact("SpfOptions expected (got PathSelection)") + ): + ngc.spf(g, 0, sel()) with pytest.raises(TypeError): ngc.SpfOptions(FWD) # Roots as a NumPy array, with duplicates. r = ngc.spf(g, np.array([0, 0], dtype=np.int32), opts()) assert list(r.costs) == [0, 1] + # SpfOptions keeps targets, residual and min_residual as given (the same objects) and + # validates only the direction: spf validates the rest at the call, in its order. + assert opts().min_residual == 1 + targets, residual, low = [1, 1], [9], -(2**70) + o = opts(targets=targets, residual=residual, min_residual=low) + assert o.targets is targets and o.residual is residual and o.min_residual is low + assert o.selection == sel() + with pytest.raises(ValueError, match=r"^residual\[0\] = 9 is outside"): + ngc.spf(g, 0, o) @needs_hooks def test_python_result_construction_is_all_or_nothing(): # Every Python object allocation of the call fails once: the call raises (never crashes) - # and a clean retry gives the same result. + # and a clean retry gives the same result, for a search with targets and a full search. g = build(5, ANYCAST) - o = opts(targets=[2, 4]) - want = ngc.spf(g, 0, o) - seen = None - k = 0 - failures = 0 - while True: - hit, seen, result, error = ext._fail_python_allocation(ngc.spf, (g, 0, o), k) - if not hit: - assert result is not None and result.dag == want.dag - break - failures += 1 - assert result is None and isinstance(error, (MemoryError, RuntimeError)), error - again = ngc.spf(g, 0, o) - assert again.dag == want.dag and again.target_cost == want.target_cost - k += 1 - assert failures >= 3 and seen > 0 - full = opts() - want_full = ngc.spf(g, 0, full) - for k in range(40): - hit, _seen, result, error = ext._fail_python_allocation( - ngc.spf, (g, 0, full), k - ) - if not hit: - assert list(result.costs) == list(want_full.costs) - break - assert result is None and error is not None + for o in (opts(targets=[2, 4]), opts()): + failures = sweep_python_allocation(lambda o=o: (ngc.spf, (g, 0, o), list), 200) + assert failures >= 3, o.targets + + +@needs_hooks +def test_the_python_allocation_sweep_rejects_a_wrong_call(): + # sweep_python_allocation rejects a wrong call, each on its own clause: one that absorbs the + # injected failure and returns a value, one that raises another exception in its place, one + # that changes the observable state before the failure reaches it; one whose completing call + # raises after its result, returns another value or makes fewer allocations than failed + # before it (a prebuilt result), one whose retry after a failure returns another value; and + # one that has not completed within the attempt limit. + g = build(5, ANYCAST) + + def absorbs(o): + try: + return ngc.spf(g, 0, o) + except MemoryError: + return "absorbed" + + def replaces(o): + try: + return ngc.spf(g, 0, o) + except MemoryError: + raise LookupError("replaced") from None + + def changing(): + log = [] + + def changes(o): + log.append(o) + return ngc.spf(g, 0, o) + + return changes, (opts(),), lambda: len(log) + + def root(r): + def search(o): + return ngc.spf(g, r, o) + + return search + + root0, root1 = root(0), root(1) + full = ngc.spf(g, 0, opts()) + + def prebuilt(o): + return full + + def raising(): + # The completing call raises after its result. It raises in the frame the sweep calls: + # CPython keeps an exception whose traceback entry for an outer frame failed to allocate. + made = itertools.count() + + def make(): + m = next(made) + calls = itertools.count() + + def raises(o): + first = not next(calls) + result = ngc.spf(g, 0, o) + if m and first: + raise LookupError("after its result") + return result + + return raises, (opts(),), list + + return make + + def by_turn(turns): + # The c-th call within the m-th make() runs turns(m, c); m = 0 is the sweep's reference. + made = itertools.count() + + def make(): + m = next(made) + calls = itertools.count() + return (lambda o: turns(m, next(calls))(o)), (opts(),), list + + return make + + for make, clause in ( + (lambda: (absorbs, (opts(),), list), "assert value is None, (where, value)"), + ( + lambda: (replaces, (opts(),), list), + "assert isinstance(error, (MemoryError, RuntimeError)), (where, repr(error))", + ), + (changing, "assert snapshot() == before, where"), + (raising(), "assert error is None, (where, repr(error))"), + ( + by_turn(lambda m, c: root1 if m and not c else root0), + "assert plain(value) == want, where", + ), + ( + by_turn(lambda m, c: prebuilt if m >= 3 and not c else root0), + "assert seen == failures, (where, seen) # every allocation failed once", + ), + ( + by_turn(lambda m, c: root1 if m and c == 1 else root0), + "assert plain(fn(*args)) == want, where", + ), + ): + with pytest.raises(AssertionError) as info: + sweep_python_allocation(make, 200) + assert traceback.extract_tb(info.tb)[-1].line == clause + # A call that needs more attempts than the limit: the sweep fails, never passes. + with pytest.raises(pytest.fail.Exception) as info: + sweep_python_allocation(lambda: (ngc.spf, (g, 0, opts()), list), 2) + assert str(info.value) == "spf never completed" diff --git a/tests/py/test_units.py b/tests/py/test_units.py index 2c62aea..38c1325 100644 --- a/tests/py/test_units.py +++ b/tests/py/test_units.py @@ -1,4 +1,4 @@ -"""to_units (round half up, the only entry of physical numbers) and as_float (APPROACH 2.1).""" +"""to_units (round half up, the only entry of physical numbers) and as_float.""" from __future__ import annotations @@ -7,6 +7,7 @@ import numpy as np import pytest +from helpers import exact import netgraph_core as ngc @@ -34,9 +35,16 @@ def test_arrays(): assert ngc.to_units(np.array([], dtype=float), 1).tolist() == [] assert ngc.as_float([1, 2, 3], 0.5).tolist() == [0.5, 1.0, 1.5] assert ngc.as_float(np.array([4096], dtype=np.int64), q).tolist() == [1.0] + # An int gives a float, an integer array a float64 array, also for whole results. + assert type(ngc.as_float(4096, q)) is float + assert type(ngc.as_float(2**70, 1.0)) is float + assert ngc.as_float([1, 2, 3], 1.0).dtype == np.float64 + assert ngc.as_float(np.array([4096], dtype=np.int64), q).dtype == np.float64 -def test_admission_is_not_additive(): +def test_rounding_is_per_value_not_additive(): + # Each value is converted to whole units on its own: two values of 0.6 units give 1 + 1, + # their sum 1.2 gives 1. q = 2.0**-12 assert ngc.to_units(0.6 * q, q) + ngc.to_units(0.6 * q, q) == 2 assert ngc.to_units(1.2 * q, q) == 1 @@ -48,39 +56,55 @@ def test_exactness_limits(): assert ngc.as_float(1 << 70, 2.0**-12) == 2.0**58 +NAN = "to_units: NaN has no unit value" +INFINITE = "to_units: an infinite amount is not finite and has no unit value" +BOUND = "to_units: the amount is at or above the bound of the units domain (2^62 units)" +NOT_PHYSICAL = "to_units takes physical numbers (a float or an array of floats)" +RESOLUTION = "resolution must be a power of two in [2^-32, 2^32]" + + @pytest.mark.parametrize( - "x, pattern", + "x, message", [ - (float("nan"), "NaN"), - (float("inf"), "finite"), - (-float("inf"), "finite"), - (-1e-300, "negative"), - (2.0**62, "bound"), - ([1.0, float("nan")], "NaN"), - ("1.0", "physical numbers"), - (True, "physical numbers"), - (["a"], "physical numbers"), + (float("nan"), NAN), + (float("inf"), INFINITE), + (-float("inf"), INFINITE), + (-1e-300, "to_units: a negative amount has no unit value"), + (2.0**62, BOUND), + ([1.0, float("nan")], NAN), + ("1.0", NOT_PHYSICAL), + (b"1.0", NOT_PHYSICAL), + (True, NOT_PHYSICAL), + (["a"], "to_units takes physical numbers (got dtype None: + self.graphs: list[dict] = [] + self.graph_ids: dict[int, int] = {} + self.ledgers: list[object] = [] + self.policies: list[object] = [] + self.ops: list[dict] = [] + + def graph_id(self, g) -> int: + got = self.graph_ids.get(g.uid) + if got is not None: + return got + pos = g.input_index() + edges = [None] * g.num_edges + for e in range(g.num_edges): + edges[pos[e]] = [g.src[e], g.dst[e], g.capacity[e], g.cost[e]] + self.graphs.append( + { + "n": g.num_nodes, + "q": float(g.q), + "edges": edges, + "input_index": list(pos), + } + ) + self.graph_ids[g.uid] = len(self.graphs) - 1 + return len(self.graphs) - 1 + + def ledger_id(self, fg) -> int: + for i, x in enumerate(self.ledgers): + if x is fg: + return i + raise AssertionError("a ledger the recorder did not see") + + def policy_id(self, pol) -> int: + for i, x in enumerate(self.policies): + if x is pol: + return i + raise AssertionError("a policy the recorder did not see") + + +REC: Recorder | None = None + + +# ------------------------------------------------------------------ normalisation + + +def norm(x): + """Drop process-dependent and probe-count fields; FlowIds to their id.""" + if isinstance(x, dict): + out = {} + for k, v in x.items(): + if k in ("evals", "ledger_uid", "counters", "evaluations"): + continue + if k in ("flow_id", "flow") and isinstance(v, list) and len(v) == 2: + out[k] = v[1] + continue + out[k] = norm(v) + return out + if isinstance(x, list): + return [norm(v) for v in x] + return x + + +def ledger_state(fg) -> dict: + return { + "allocated": list(fg.allocated()), + "residual": list(fg.residual()), + "records": [ + { + "flow_id": r.flow_id[1], + "owner": r.owner, + "sources": list(r.sources), + "destinations": list(r.destinations), + "placement": r.placement, + "admission": r.admission, + "bundle": sorted(r.bundle), + "route_cost": None + if r.route_cost == sys.modules["units"].UNREACHABLE + else r.route_cost, + "offered": r.offered, + "delivered": r.delivered, + "edges": [[e, x] for e, x in sorted(r.edges.items()) if x], + "drops": [[e, x] for e, x in sorted(r.drops.items()) if x], + } + for r in fg.records() + ], + "next_flow_id": fg.next_flow_id(), + "bound": sorted(fg._bound), + "orphans": sorted(fg._orphans), + } + + +def policy_state(pol) -> dict: + lossy = pol.admission == "Lossy" + cohorts = [] + for s in sorted(pol.cohorts): + flows = pol.cohorts[s] + if not flows: + continue + out = [] + for f in flows: + w = f.plan.weights + out.append( + { + "id": f.flow_id[1], + "route": list(f.route), + "T": None if lossy else f.T, + "delivered": f.delivered, + "alloc": [[e, x] for e, x in sorted(f.alloc.items()) if x], + "members": list(f.plan.members), + "weights": None + if w is None + else [[e, x] for e, x in sorted(w.items())], + } + ) + cohorts.append([s, out]) + return { + "roster": list(pol.roster), + "requested": pol.requested, + "infinite": pol.infinite, + "placed_demand": pol.placed_demand(), + "usable": pol.usable_bundles(), + "cohorts": cohorts, + } + + +def config_json(cfg) -> dict: + r = cfg.routing + kind = type(r).__name__ + routing: dict = {"kind": kind} + if kind == "Pinned": + routing["bundles"] = [sorted(b) for b in r.bundles] + elif kind == "CapacityAware": + routing.update( + flow_count=r.flow_count, + max_flow_count=r.max_flow_count, + max_path_cost=None + if r.max_path_cost is None + else [ + r.max_path_cost[0], + float(r.max_path_cost[1]) + if r.max_path_cost[0] == "Stretch" + else r.max_path_cost[1], + ], + min_flow_bandwidth=r.min_flow_bandwidth, + reoptimize=r.reoptimize, + diminishing=None + if r.diminishing is None + else [r.diminishing.window, float(r.diminishing.fraction)], + ) + sel = cfg.selection + return { + "routing": routing, + "placement": cfg.placement, + "admission": cfg.admission, + "selection": { + "multipath": sel.multipath, + "multi_edge": sel.multi_edge, + "tie_break": sel.tie_break, + "priority": sel.priority, + }, + } + + +def elig_json(elig): + if elig is None: + return None + return { + "nodes": [v for v, ok in enumerate(elig.node_allowed) if not ok], + "edges": [e for e, ok in enumerate(elig.edge_allowed) if not ok], + } + + +# ------------------------------------------------------------------ the shim + + +class RecFlowGraph(BaseFlowGraph): + def __init__(self, g, residual=None) -> None: + op = { + "op": "ledger", + "graph": REC.graph_id(g), + "baseline": None if residual is None else list(residual), + } + try: + super().__init__(g, residual) + except ContractError: + REC.ops.append({**op, "error": True}) + raise + REC.ledgers.append(self) + REC.ops.append({**op, "id": len(REC.ledgers) - 1, "error": False}) + + def copy(self): + op = {"op": "copy", "ledger": REC.ledger_id(self)} + try: + other = super().copy() + except ContractError: + REC.ops.append( + {**op, "error": True, "expect": {"ledger": ledger_state(self)}} + ) + raise + REC.ledgers.append(other) + REC.ops.append( + { + **op, + "id": len(REC.ledgers) - 1, + "error": False, + "expect": {"ledger": ledger_state(other)}, + } + ) + return other + + def reset(self, residual=None) -> None: + op = { + "op": "reset", + "ledger": REC.ledger_id(self), + "baseline": None if residual is None else list(residual), + } + try: + super().reset(residual) + except ContractError: + REC.ops.append( + {**op, "error": True, "expect": {"ledger": ledger_state(self)}} + ) + raise + REC.ops.append({**op, "error": False, "expect": {"ledger": ledger_state(self)}}) + + +def make_rec_policy(P): + Base = P.FlowPolicy + + class RecPolicy(Base): + def __init__( + self, fg, sources, destinations, cfg, elig=None, cache=None + ) -> None: + op = { + "op": "policy", + "ledger": REC.ledger_id(fg), + "sources": jsonable(sources), + "destinations": jsonable(destinations), + "config": config_json(cfg), + "elig": elig_json(elig), + } + try: + super().__init__(fg, sources, destinations, cfg, elig, cache) + except ContractError: + REC.ops.append({**op, "error": True}) + raise + REC.policies.append(self) + REC.ops.append( + { + **op, + "id": len(REC.policies) - 1, + "error": False, + "expect": {"policy": policy_state(self)}, + } + ) + + def place(self, volume, outputs=None): + op = { + "op": "place", + "policy": REC.policy_id(self), + "volume": "inf" + if isinstance(volume, float) and math.isinf(volume) and volume > 0 + else jsonable(volume), + "outputs": None + if outputs is None + else [outputs.edge_flows, outputs.residual, outputs.bottleneck], + "cap": P.MAX_FLOWS_PER_CALL, + } + try: + r = super().place(volume, outputs) + except ContractError: + REC.ops.append( + {**op, "error": True, "expect": {"ledger": ledger_state(self.fg)}} + ) + raise + REC.ops.append( + { + **op, + "error": False, + "expect": { + "result": jsonable(r.to_json()), + "trace": norm(jsonable(r.trace)), + "ledger": ledger_state(self.fg), + "policy": policy_state(self), + }, + } + ) + return r + + def remove(self) -> None: + super().remove() + REC.ops.append( + { + "op": "remove", + "policy": REC.policy_id(self), + "error": False, + "expect": { + "ledger": ledger_state(self.fg), + "policy": policy_state(self), + }, + } + ) + + def destroy(self) -> None: + super().destroy() + REC.ops.append( + { + "op": "destroy", + "policy": REC.policy_id(self), + "error": False, + "expect": {"ledger": ledger_state(self.fg)}, + } + ) + + return RecPolicy + + +def export() -> dict: + global REC + ledger.FlowGraph = RecFlowGraph + import policy as P + + P.FlowGraph = RecFlowGraph + P.FlowPolicy = make_rec_policy(P) + from fixtures import FIXTURES + from run_fixtures import run_one + + for directory, names in zip((P2, HERE / "p1_amount"), CLOSURE, strict=True): + for name in names: + origin = Path(sys.modules[name].__file__).resolve() + if origin != directory / f"{name}.py": + raise AssertionError(f"{name} came from {origin}, not the prototypes") + if any(m.split(".")[0] in ("netgraph_core", "_netgraph_core") for m in sys.modules): + raise AssertionError("the reference imported the build") + out = [] + for fx in FIXTURES: + REC = Recorder() + data = run_one(fx) + if not data["passed"]: + raise AssertionError(f"{fx.name}: the prototype fails its own fixture") + out.append({"name": fx.name, "graphs": REC.graphs, "ops": REC.ops}) + return { + "format": "p2-fixtures/1", + "source": "prototype 2 of the design tree dev/design/2026-10-streamline (untracked by " + "design): prototypes/p2_transaction in integer units (APPROACH 9), exported by " + "gates/1c/export_fixture_data.py", + "fixtures": out, + } + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--check", action="store_true") + ap.add_argument("--out", type=Path, default=OUT) + args = ap.parse_args(argv) + text = json.dumps(export(), separators=(",", ":"), sort_keys=False) + "\n" + if args.check: + same = args.out.exists() and args.out.read_text() == text + print("policy fixture data:", "identical" if same else "DIFFERS") + return 0 if same else 1 + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(text) + data = json.loads(text) + n_ops = sum(len(f["ops"]) for f in data["fixtures"]) + print( + f"wrote {args.out}: {len(data['fixtures'])} fixtures, {n_ops} operations, {len(text)} bytes" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/reference/policy/p1_amount/apportion.py b/tests/reference/policy/p1_amount/apportion.py new file mode 100644 index 0000000..744f02b --- /dev/null +++ b/tests/reference/policy/p1_amount/apportion.py @@ -0,0 +1,131 @@ +"""House-monotone integer apportionment (APPROACH revision 24, section 2.1). + +* ``round_robin(I, k)``: EqualBalanced and Lossy EqualBalanced. ``floor(I / k)`` + each, one more unit to the first ``I mod k`` members (members are listed in + ascending EdgeId). +* ``highest_averages(I, w)``: Weighted and Lossy Weighted, the Jefferson / D'Hondt + rule with EdgeId ties. Closed-form evaluator: every member starts at its lower + quota ``floor(I * w_m / W)`` (exactly the quotients ``w_m / j >= W / I``), then the + remaining ``I - sum`` units (fewer than ``k``) go one at a time by a heap of the + exact next quotients ``w_m / (a_m + 1)``, ties by ascending EdgeId. Quotients are + compared by cross-multiplication; every product is checked to stay below 2^127 + (the 128-bit claim of the contract). +* ``highest_averages_slow``: the sequential definition, for tests only. +* ``hamilton``: largest remainders, the rejected rule (review-13 D1), controls only. +""" + +from __future__ import annotations + +import heapq +from dataclasses import dataclass, field + +PRODUCT_LIMIT = 1 << 127 + + +@dataclass +class ApportionStats: + """Work counters of the closed-form evaluator (diagnostics).""" + + calls: int = 0 + heap_pops: int = 0 + max_product_bits: int = 0 + pops_per_call: list[int] = field(default_factory=list) + + +STATS = ApportionStats() + + +def round_robin(total: int, k: int) -> list[int]: + if k <= 0: + raise ValueError("round robin over no member") + if total < 0: + raise ValueError("negative amount") + base, extra = divmod(total, k) + return [base + (1 if i < extra else 0) for i in range(k)] + + +def _cross(a: int, b: int) -> int: + p = a * b + if p >= PRODUCT_LIMIT: + raise OverflowError("quotient comparison exceeds 128 bits") + bl = p.bit_length() + if bl > STATS.max_product_bits: + STATS.max_product_bits = bl + return p + + +class _Quotient: + """Heap entry for ``w / d`` with ascending-EdgeId ties (index = member rank).""" + + __slots__ = ("w", "d", "i") + + def __init__(self, w: int, d: int, i: int) -> None: + self.w, self.d, self.i = w, d, i + + def __lt__(self, other: _Quotient) -> bool: + # Larger quotient first; equal quotients by lower member rank (EdgeId). + left = _cross(self.w, other.d) + right = _cross(other.w, self.d) + if left != right: + return left > right + return self.i < other.i + + +def highest_averages(total: int, weights: list[int]) -> list[int]: + k = len(weights) + if k == 0: + raise ValueError("highest averages over no member") + if total < 0: + raise ValueError("negative amount") + if any(w <= 0 for w in weights): + raise ValueError("weights must be positive") + STATS.calls += 1 + wsum = sum(weights) + alloc = [_cross(total, w) // wsum for w in weights] + left = total - sum(alloc) + assert 0 <= left < k, (total, weights, alloc) + heap = [_Quotient(w, alloc[i] + 1, i) for i, w in enumerate(weights)] + heapq.heapify(heap) + pops = 0 + for _ in range(left): + q = heapq.heappop(heap) + pops += 1 + alloc[q.i] += 1 + heapq.heappush(heap, _Quotient(q.w, alloc[q.i] + 1, q.i)) + STATS.heap_pops += pops + STATS.pops_per_call.append(pops) + return alloc + + +def highest_averages_slow(total: int, weights: list[int]) -> list[int]: + """Sequential D'Hondt: each unit to the member maximising ``w / (a + 1)``.""" + alloc = [0] * len(weights) + for _ in range(total): + best = 0 + for i in range(1, len(weights)): + lhs = weights[i] * (alloc[best] + 1) + rhs = weights[best] * (alloc[i] + 1) + if lhs > rhs: + best = i + alloc[best] += 1 + return alloc + + +def hamilton(total: int, weights: list[int]) -> list[int]: + """Largest remainders (rejected; not house-monotone: [1, 3, 3] at 3 and 4).""" + wsum = sum(weights) + alloc = [total * w // wsum for w in weights] + order = sorted(range(len(weights)), key=lambda i: (-(total * weights[i] % wsum), i)) + for i in order[: total - sum(alloc)]: + alloc[i] += 1 + return alloc + + +def split(rule: str, total: int, weights: list[int] | None, k: int) -> list[int]: + """The node split of a kernel: ``"rr"`` or ``"ha"`` (weights in member order).""" + if rule == "rr": + return round_robin(total, k) + if rule == "ha": + assert weights is not None and len(weights) == k + return highest_averages(total, weights) + raise ValueError(f"unknown split rule {rule!r}") diff --git a/tests/reference/policy/p1_amount/certify.py b/tests/reference/policy/p1_amount/certify.py new file mode 100644 index 0000000..6d72abf --- /dev/null +++ b/tests/reference/policy/p1_amount/certify.py @@ -0,0 +1,273 @@ +"""Independent checks of the flow core (APPROACH revision 24, section 2.2). + +Nothing here imports ``flowcore``: the exhaustive minimum-cost function (tier a), +an independent complete solver (tier b: successive shortest paths by Bellman-Ford on +the residual, no potentials, no Dinic, its own arc order), and ``check_solve``, the +certificate: feasibility, conservation with supply and demand, the attained value, +the full residual closure, ``min_cut`` presence and value, dual feasibility inside +the closure with the ``NEVER_REACHED`` sentinel tested first, the virtual-terminal +convention, the curve shape and moment, and the complete curve on the independent +domain ``0..min(cap, F_max)`` at the union of both breakpoint sets. +""" + +from __future__ import annotations + +from collections import deque +from fractions import Fraction +from itertools import product + +NEVER_REACHED = -1 +VIRTUAL = (1 << 63) - 2 + + +def exhaustive(g, view, sources, dests, edges=None, want_flows=False): + """Minimum cost of every integer value; optionally every optimal flow.""" + edges = list(range(g.num_edges)) if edges is None else sorted(edges) + S, D = set(sources), set(dests) + best: dict[int, int] = {} + argmin: dict[int, list[tuple[int, ...]]] = {} + for flow in product(*(range(view[e] + 1) for e in edges)): + bal = [0] * g.num_nodes + for e, x in zip(edges, flow, strict=True): + bal[g.src[e]] -= x + bal[g.dst[e]] += x + ok = True + for v in range(g.num_nodes): + if v in S: + ok = bal[v] <= 0 + elif v in D: + ok = bal[v] >= 0 + else: + ok = bal[v] == 0 + if not ok: + break + if not ok: + continue + value = -sum(bal[s] for s in S) + cost = sum(g.cost[e] * x for e, x in zip(edges, flow, strict=True)) + full = [0] * g.num_edges + for e, x in zip(edges, flow, strict=True): + full[e] = x + if value not in best or cost < best[value]: + best[value] = cost + argmin[value] = [tuple(full)] + elif cost == best[value] and want_flows: + argmin[value].append(tuple(full)) + assert set(best) == set(range(max(best) + 1)) + return (best, argmin) if want_flows else best + + +def independent_curve(g, view, sources, dests, edges=None): + """Convex breakpoints ``[(value, cost)]`` by Bellman-Ford augmentation.""" + edges = list(range(g.num_edges)) if edges is None else sorted(edges) + n = g.num_nodes + s_star, t_star = n, n + 1 + arcs = [] # [u, v, cap, cost] + for e in edges: + arcs.append([g.src[e], g.dst[e], view[e], g.cost[e]]) + for s in sources: + arcs.append([s_star, s, VIRTUAL, 0]) + for d in dests: + arcs.append([d, t_star, VIRTUAL, 0]) + flow = [0] * len(arcs) + points = [(0, 0)] + while True: + dist: list[int | None] = [None] * (n + 2) + dist[s_star] = 0 + parent: dict[int, tuple[int, int]] = {} + for _ in range(n + 2): + changed = False + for i, (u, v, c, w) in enumerate(arcs): + if flow[i] < c and dist[u] is not None: + nd = dist[u] + w + if dist[v] is None or nd < dist[v]: + dist[v] = nd + parent[v] = (i, 1) + changed = True + if flow[i] > 0 and dist[v] is not None: + nd = dist[v] - w + if dist[u] is None or nd < dist[u]: + dist[u] = nd + parent[u] = (i, -1) + changed = True + if not changed: + break + if dist[t_star] is None: + return points + path = [] + v = t_star + while v != s_star: + i, sign = parent[v] + path.append((i, sign)) + v = arcs[i][0] if sign == 1 else arcs[i][1] + amount = min(arcs[i][2] - flow[i] if sign == 1 else flow[i] for i, sign in path) + for i, sign in path: + flow[i] += sign * amount + x, c = points[-1] + points.append((x + amount, c + amount * dist[t_star])) + + +def value_at(points, x): + for (a, c), (b, d) in zip(points, points[1:], strict=False): + if a <= x <= b: + return Fraction(c) + Fraction(d - c, b - a) * (x - a) + if points and x == points[-1][0]: + return Fraction(points[-1][1]) + raise ValueError(f"value {x} outside the curve domain") + + +def curve_points(curve): + pts = [(0, 0)] + for c, u in curve: + pts.append((pts[-1][0] + u, pts[-1][1] + c * u)) + return pts + + +def check_solve(g, view, sources, dests, cap, sol, *, edges=None, tier="b"): + """Return the list of failed certificate clauses (empty when certified).""" + fails: list[str] = [] + active = set(range(g.num_edges)) if edges is None else set(edges) + S, D = sorted(set(sources)), sorted(set(dests)) + f = sol["edge_flows"] + placed = sol["placed"] + pi = sol["potentials"] + curve = [tuple(t) for t in sol["cost_distribution"]] + for e in range(g.num_edges): + if e not in active and f[e]: + fails.append(f"flow on edge {e} outside the view") + if not 0 <= f[e] <= view[e]: + fails.append(f"edge {e}: flow {f[e]} outside [0, {view[e]}]") + bal = [0] * g.num_nodes + for e in range(g.num_edges): + bal[g.src[e]] -= f[e] + bal[g.dst[e]] += f[e] + supply = {s: -bal[s] for s in S} + inflow = {d: bal[d] for d in D} + for v in range(g.num_nodes): + if v in supply: + if supply[v] < 0: + fails.append(f"source {v} absorbs flow") + elif v in inflow: + if inflow[v] < 0: + fails.append(f"destination {v} emits flow") + elif bal[v] != 0: + fails.append(f"conservation at node {v}") + if sum(supply.values()) != placed or sum(inflow.values()) != placed: + fails.append("value differs from supply or inflow") + if cap is not None and not 0 <= placed <= cap: + fails.append("placed outside [0, cap]") + if cap == 0: + if pi != [NEVER_REACHED] * g.num_nodes: + fails.append("zero cap: potentials must all be NEVER_REACHED") + if curve or sol["min_cut"] is not None or any(sol["counters"].values()): + fails.append("zero cap: empty curve, min_cut None, counters 0") + return fails + # full residual closure of the super-source + n = g.num_nodes + s_star, t_star = n, n + 1 + out: list[list[tuple[int, int, int]]] = [[] for _ in range(n + 2)] + for e in active: + u, v = g.src[e], g.dst[e] + if f[e] < view[e]: + out[u].append((v, g.cost[e], e)) + if f[e] > 0: + out[v].append((u, -g.cost[e], e)) + for s in S: + out[s_star].append((s, 0, -1)) + if supply[s] > 0: + out[s].append((s_star, 0, -1)) + for d in D: + out[d].append((t_star, 0, -1)) + if inflow[d] > 0: + out[t_star].append((d, 0, -1)) + seen = set(S) # seeded at the real sources (revision 20) + q = deque(S) + while q: + u = q.popleft() + for v, _w, _e in out[u]: + if v not in seen: + seen.add(v) + q.append(v) + connected = t_star in seen + if cap is None and connected: + fails.append("uncapped solve left the destinations connected") + if cap is not None and placed != cap and connected: + fails.append("attained value: placed < cap with a residual path left") + mc = sol["min_cut"] + if connected: + if mc is not None: + fails.append("min_cut present on a connected (non-maximal) solve") + else: + expect = sorted( + e + for e in active + if g.src[e] in seen and g.dst[e] not in seen and view[e] > 0 + ) + if mc is None: + fails.append("min_cut missing on a maximal solve") + elif sorted(mc) != expect or list(mc) != sorted(mc): + fails.append(f"min_cut {mc} differs from the closure cut {expect}") + if sum(view[e] for e in expect) != placed: + fails.append("cut value differs from placed") + # dual feasibility inside the closure (sentinel tested before arithmetic) + for v in range(n): + if v in seen and pi[v] == NEVER_REACHED: + fails.append(f"closure vertex {v} holds NEVER_REACHED") + for s in S: + if s in seen and pi[s] != NEVER_REACHED and pi[s] != 0: + fails.append(f"reached root {s} has potential {pi[s]}") + for u in range(n): + if u not in seen or pi[u] == NEVER_REACHED: + continue + for v, w, _e in out[u]: + if v >= n or v not in seen or pi[v] == NEVER_REACHED: + continue + if w + pi[u] - pi[v] < 0: + fails.append(f"negative reduced cost on arc {u}->{v}") + reached = [d for d in D if pi[d] != NEVER_REACHED] + if reached: + pt = min(pi[d] for d in reached) + for d in reached: + if inflow[d] > 0 and pi[d] != pt: + fails.append(f"destination {d} with inflow: potential {pi[d]} != {pt}") + if pi[d] < pt: + fails.append("destination potential below pi(t*)") + elif placed: + fails.append("flow delivered but no destination reached") + # curve shape and moment + if any(u <= 0 for _c, u in curve): + fails.append("curve volume not positive") + if any(b[0] <= a[0] for a, b in zip(curve, curve[1:], strict=False)): + fails.append("curve costs not strictly increasing") + if sum(u for _c, u in curve) != placed: + fails.append("curve total differs from placed") + if sum(c * u for c, u in curve) != sum( + g.cost[e] * f[e] for e in range(g.num_edges) + ): + fails.append("curve moment differs from the flow cost") + # complete curve on the independent domain + pts = curve_points(curve) + if tier == "a": + best = exhaustive(g, view, S, D, edges=active) + fmax = max(best) + top = fmax if cap is None else min(cap, fmax) + if placed != top: + fails.append(f"placed {placed} != min(cap, F_max) = {top}") + for x in range(min(top, placed) + 1): + if value_at(pts, x) != best[x]: + fails.append(f"curve differs from the exhaustive optimum at {x}") + break + else: + ref = independent_curve(g, view, S, D, edges=active) + fmax = ref[-1][0] + top = fmax if cap is None else min(cap, fmax) + if placed != top: + fails.append(f"placed {placed} != min(cap, F_max) = {top}") + xs = {x for x, _ in pts} | {x for x, _ in ref} + for x in sorted(xs): + if x > min(top, placed): + continue + if value_at(pts, x) != value_at(ref, x): + fails.append(f"curve differs from the independent solver at {x}") + break + return fails diff --git a/tests/reference/policy/p1_amount/flowcore.py b/tests/reference/policy/p1_amount/flowcore.py new file mode 100644 index 0000000..ec3eada --- /dev/null +++ b/tests/reference/policy/p1_amount/flowcore.py @@ -0,0 +1,367 @@ +"""The one integer minimum-cost flow core (APPROACH revision 24, section 2.2). + +One objective on every view: minimum cost for the maximum value attainable under the +value cap. Successive shortest paths with potentials and blocking flows: each tier +runs Dijkstra on reduced costs to completion over the reachable residual (from the +super-source, through the super-sink and onward), adds the distances to the +potentials of the vertices it settles, then drains the zero-reduced-cost admissible +subgraph by Dinic phases (BFS levels, DFS with current-arc pointers) until no +admissible augmenting path remains or the cap is reached; then the next tier. + +Arc identity: every edge is its own arc. At a node the forward arcs of its +out-edges and the reverse arcs of its in-edges are visited in one sequence ordered +by ascending EdgeId (a self-loop's forward arc before its reverse arc); at a real +endpoint node its virtual arc comes first; the super-source visits its arcs by +ascending source NodeId and the super-sink by ascending destination NodeId. +Virtual arcs cost 0 and carry ``2^63 - 2`` units (never saturated). Every solve starts from zero flow +on the view, so potentials 0 are valid. + +``arc_order`` and ``arc_rank`` are mutant switches for the adjacency-order controls +only: ``"interleaved"`` is the contract and ``"forward_first"`` visits every forward +arc before any reverse arc; ``arc_rank[e]`` replaces the EdgeId as the sort key +(for example the old global ``(cost, src, dst)`` rank of layout B). +""" + +from __future__ import annotations + +import heapq +from collections import deque +from dataclasses import dataclass, field + +from graph import Eligibility, Graph, check_disjoint, check_node_set +from units import ( + INTERNAL_CAP_MAX, + NEVER_REACHED, + VIRTUAL_CAP, + ContractError, +) + +COUNTER_NAMES = ( + "dijkstra_runs", + "dijkstra_pops", + "settled", + "arc_scans", + "dinic_phases", + "augmentations", +) + + +@dataclass +class FlowSolve: + """The private payload of ``_place_global_units`` plus diagnostics.""" + + placed: int + cost_distribution: list[tuple[int, int]] + edge_flows: list[int] + min_cut: list[int] | None + potentials: list[int] + counters: dict[str, int] + # Diagnostics (not payload fields): per-source supply, per-destination inflow, + # the internal super-sink potential, the full residual closure, tier traces. + supply: dict[int, int] = field(default_factory=dict) + inflow: dict[int, int] = field(default_factory=dict) + pi_sink: int | None = None + closure: list[int] = field(default_factory=list) + sink_in_closure: bool = False + tiers: list[dict] = field(default_factory=list) + + def payload(self) -> dict: + return { + "placed": self.placed, + "cost_distribution": [list(t) for t in self.cost_distribution], + "edge_flows": list(self.edge_flows), + "min_cut": None if self.min_cut is None else list(self.min_cut), + "potentials": list(self.potentials), + "counters": dict(self.counters), + } + + +def place_global_units( + g: Graph, + view, + sources, + destinations, + elig: Eligibility | None, + cap: int | None, +) -> FlowSolve: + """The recorder's private entry point: validated, whole eligible graph.""" + return solve(g, view, sources, destinations, cap, elig=elig) + + +def _validate(g, view, sources, destinations, cap, elig): + if len(view) != g.num_edges: + raise ContractError("view length differs from the edge count") + for e, x in enumerate(view): + if not isinstance(x, int) or isinstance(x, bool): + raise ContractError("view entries must be integer units") + if not 0 <= x <= g.capacity[e]: + raise ContractError(f"view[{e}] = {x} outside [0, capacity]") + s = check_node_set(g, sources, "sources") + d = check_node_set(g, destinations, "destinations") + check_disjoint(s, d) + if cap is not None: + if not isinstance(cap, int) or isinstance(cap, bool): + raise ContractError("cap must be an int or None") + if not 0 <= cap <= INTERNAL_CAP_MAX: + raise ContractError("cap outside [0, 2^63 - 2]") + if elig is not None: + elig.check(g) + return s, d + + +def solve( + g: Graph, + view, + sources, + destinations, + cap: int | None, + *, + edges=None, + elig: Eligibility | None = None, + arc_order: str = "interleaved", + arc_rank=None, + trace: bool = False, +) -> FlowSolve: + """Minimum-cost flow of value ``min(cap, max)`` on ``view`` restricted to ``edges``.""" + srcs, dsts = _validate(g, view, sources, destinations, cap, elig) + n = g.num_nodes + S, T = n, n + 1 + allowed = None if elig is None else elig.edge_allowed + node_ok = None if elig is None else elig.node_allowed + if edges is None: + edges = range(g.num_edges) + active = sorted( + {e for e in edges if allowed is None or allowed[e]}, + ) + srcs = [s for s in srcs if node_ok is None or node_ok[s]] + dsts = [d for d in dsts if node_ok is None or node_ok[d]] + + counters = dict.fromkeys(COUNTER_NAMES, 0) + edge_flows = [0] * g.num_edges + if cap == 0: + # Zero cap: no Dijkstra, every potential NEVER_REACHED, an empty curve, + # min_cut None (the cap was attained), every counter 0. + return FlowSolve( + placed=0, + cost_distribution=[], + edge_flows=edge_flows, + min_cut=None, + potentials=[NEVER_REACHED] * n, + counters=counters, + supply=dict.fromkeys(srcs, 0), + inflow=dict.fromkeys(dsts, 0), + ) + + # ---- arcs: index j and j ^ 1 are a forward/reverse pair + head: list[int] = [] + res: list[int] = [] + acost: list[int] = [] + aedge: list[int] = [] # edge id, or -1 for virtual arcs + + def add(u: int, v: int, c: int, w: int, e: int) -> int: + j = len(head) + head.extend((v, u)) + res.extend((c, 0)) + acost.extend((w, -w)) + aedge.extend((e, e)) + return j + + tail: list[int] = [] + real_arcs: list[list[tuple]] = [[] for _ in range(n)] + edge_arc: dict[int, int] = {} + if arc_order not in ("interleaved", "forward_first"): + raise ValueError(f"unknown arc order {arc_order!r}") + for e in active: + u, v = g.src[e], g.dst[e] + j = add(u, v, view[e], g.cost[e], e) + tail.extend((u, v)) + edge_arc[e] = j + key = e if arc_rank is None else arc_rank[e] + if arc_order == "forward_first": + real_arcs[u].append((0, key, 0, j)) + real_arcs[v].append((1, key, 1, j + 1)) + else: + real_arcs[u].append((key, 0, j)) + real_arcs[v].append((key, 1, j + 1)) + src_arc: dict[int, int] = {} + for s in srcs: + j = add(S, s, VIRTUAL_CAP, 0, -1) + tail.extend((S, s)) + src_arc[s] = j + dst_arc: dict[int, int] = {} + for d in dsts: + j = add(d, T, VIRTUAL_CAP, 0, -1) + tail.extend((d, T)) + dst_arc[d] = j + adj: list[list[int]] = [[] for _ in range(n + 2)] + adj[S] = [src_arc[s] for s in srcs] + adj[T] = [dst_arc[d] + 1 for d in dsts] + for u in range(n): + first = [] + if u in src_arc: + first.append(src_arc[u] + 1) + if u in dst_arc: + first.append(dst_arc[u]) + adj[u] = first + [item[-1] for item in sorted(real_arcs[u])] + + # A positive cap always runs the solver; the clamp of the internal domain at the + # sources' view out-capacity only bounds the arithmetic (Python ints are exact), + # so the loop runs on the input cap (revision 20). + limit = cap + + pi = [0] * (n + 2) + ever = [False] * (n + 2) + total = 0 + curve: list[tuple[int, int]] = [] + tiers: list[dict] = [] + + def reduced(j: int) -> int: + return acost[j] + pi[tail[j]] - pi[head[j]] + + while limit is None or total < limit: + # ---- Dijkstra on reduced costs, to completion over the reachable residual + counters["dijkstra_runs"] += 1 + dist: list[int | None] = [None] * (n + 2) + dist[S] = 0 + heap = [(0, S)] + settled_now: list[int] = [] + while heap: + du, u = heapq.heappop(heap) + counters["dijkstra_pops"] += 1 + if du != dist[u]: + continue + settled_now.append(u) + counters["settled"] += 1 + for j in adj[u]: + counters["arc_scans"] += 1 + if res[j] <= 0: + continue + v = head[j] + rc = reduced(j) + assert rc >= 0, ("negative reduced cost", j, rc) + nd = du + rc + dv = dist[v] + if dv is None or nd < dv: + dist[v] = nd + heapq.heappush(heap, (nd, v)) + for u in settled_now: + pi[u] += dist[u] + ever[u] = True + if dist[T] is None: + break + tier_cost = pi[T] - pi[S] + before = total + # ---- drain the admissible subgraph by Dinic phases + while limit is None or total < limit: + level = [-1] * (n + 2) + level[S] = 0 + q = deque([S]) + while q: + u = q.popleft() + for j in adj[u]: + counters["arc_scans"] += 1 + v = head[j] + if res[j] > 0 and level[v] < 0 and reduced(j) == 0: + level[v] = level[u] + 1 + q.append(v) + # every BFS level build counts, the last unsuccessful one of a tier included + # (revision 23) + counters["dinic_phases"] += 1 + if level[T] < 0: + break + cur = [0] * (n + 2) + while limit is None or total < limit: + path = _find_path(S, T, adj, cur, res, head, level, reduced, counters) + if path is None: + break + amount = min(res[j] for j in path) + if limit is not None: + amount = min(amount, limit - total) + for j in path: + res[j] -= amount + res[j ^ 1] += amount + total += amount + counters["augmentations"] += 1 + drained = total - before + assert drained > 0, "a successful Dijkstra drains at least one unit" + curve.append((tier_cost, drained)) + if trace: + tiers.append( + { + "cost": tier_cost, + "units": drained, + "edge_flows": { + e: res[edge_arc[e] + 1] for e in active if res[edge_arc[e] + 1] + }, + "counters": dict(counters), + } + ) + + for e in active: + edge_flows[e] = res[edge_arc[e] + 1] + # ---- full residual closure seeded at the real sources (reverse and virtual + # arcs included; revision 20) + seen = [False] * (n + 2) + for s in srcs: + seen[s] = True + q = deque(srcs) + while q: + u = q.popleft() + for j in adj[u]: + v = head[j] + if res[j] > 0 and not seen[v]: + seen[v] = True + q.append(v) + if seen[T]: + min_cut = None + else: + min_cut = sorted( + e for e in active if seen[g.src[e]] and not seen[g.dst[e]] and view[e] > 0 + ) + return FlowSolve( + placed=total, + cost_distribution=curve, + edge_flows=edge_flows, + min_cut=min_cut, + potentials=[pi[v] if ever[v] else NEVER_REACHED for v in range(n)], + counters=counters, + supply={s: res[src_arc[s] + 1] for s in srcs}, + inflow={d: res[dst_arc[d] + 1] for d in dsts}, + pi_sink=pi[T] if ever[T] else None, + closure=[v for v in range(n) if seen[v]], + sink_in_closure=seen[T], + tiers=tiers, + ) + + +def _find_path(S, T, adj, cur, res, head, level, reduced, counters): + """One augmenting path in the level graph (current-arc pointers kept).""" + stack = [S] + path: list[int] = [] + while stack: + u = stack[-1] + if u == T: + return path + advanced = False + arcs = adj[u] + while cur[u] < len(arcs): + j = arcs[cur[u]] + counters["arc_scans"] += 1 + v = head[j] + if res[j] > 0 and level[v] == level[u] + 1 and reduced(j) == 0: + stack.append(v) + path.append(j) + advanced = True + break + cur[u] += 1 + if not advanced: + stack.pop() + if path: + j = path.pop() + # The arc into the dead end fails: advance the parent's pointer. + cur[stack[-1]] += 1 + return None + + +def flow_cost(g: Graph, flows) -> int: + return sum(g.cost[e] * x for e, x in enumerate(flows)) diff --git a/tests/reference/policy/p1_amount/graph.py b/tests/reference/policy/p1_amount/graph.py new file mode 100644 index 0000000..6ed4b06 --- /dev/null +++ b/tests/reference/policy/p1_amount/graph.py @@ -0,0 +1,243 @@ +"""The one graph type in layout B' (APPROACH revision 24, sections 3 and 4). + +Construction arrays are in input coordinates. The public ``EdgeId`` is the rank of +the edge in the stable order ``(src, cost, dst, input)``; ``input_index()[e]`` is +the input position of ``e``. Every other per-edge array and id is in id +coordinates. The inverse permutation is the caller's (``input_to_id``). +""" + +from __future__ import annotations + +import itertools +from dataclasses import dataclass +from fractions import Fraction + +from units import LIMIT, ContractError, check_resolution + +_UID = itertools.count(1) + + +class Graph: + """StrictMultiDiGraph(num_nodes, src, dst, capacity_units, cost, resolution).""" + + def __init__(self, num_nodes, src, dst, capacity_units, cost, resolution): + q = check_resolution(resolution) + if not isinstance(num_nodes, int) or isinstance(num_nodes, bool): + raise ContractError("num_nodes must be an int") + if num_nodes < 0: + raise ContractError("num_nodes must be non-negative") + e = len(src) + if not (len(dst) == len(capacity_units) == len(cost) == e): + raise ContractError("edge arrays differ in length") + for name, arr in (("src", src), ("dst", dst)): + for x in arr: + if not isinstance(x, int) or isinstance(x, bool): + raise ContractError(f"{name} entries must be ints") + if not 0 <= x < num_nodes: + raise ContractError(f"{name} node {x} out of range") + for c in capacity_units: + if not isinstance(c, int) or isinstance(c, bool): + raise ContractError("capacity_units must be integer units") + if not 0 <= c < LIMIT: + raise ContractError(f"capacity {c} outside [0, 2^62)") + if sum(capacity_units) >= LIMIT: + raise ContractError("sum of capacities must be below 2^62 units") + for w in cost: + if not isinstance(w, int) or isinstance(w, bool): + raise ContractError("costs must be integers") + if w < 1: + raise ContractError(f"cost {w} rejected: costs are positive (rule 28)") + if sum(cost) >= LIMIT: + raise ContractError("sum of costs must be below 2^62") + order = sorted(range(e), key=lambda i: (src[i], cost[i], dst[i], i)) + self.uid = next(_UID) + self.q: Fraction = q + self.num_nodes = num_nodes + self.num_edges = e + self._input_index = tuple(order) + self.src = tuple(src[i] for i in order) + self.dst = tuple(dst[i] for i in order) + self.capacity = tuple(capacity_units[i] for i in order) + self.cost = tuple(cost[i] for i in order) + offsets = [0] * (num_nodes + 1) + for u in self.src: + offsets[u + 1] += 1 + for u in range(num_nodes): + offsets[u + 1] += offsets[u] + self.row_offsets = tuple(offsets) + # Reverse CSR in today's (cost, src, input) order; slots map to ids. + in_lists: list[list[int]] = [[] for _ in range(num_nodes)] + for eid in sorted( + range(e), key=lambda i: (self.cost[i], self.src[i], self._input_index[i]) + ): + in_lists[self.dst[eid]].append(eid) + in_off = [0] + slots: list[int] = [] + for v in range(num_nodes): + slots.extend(in_lists[v]) + in_off.append(len(slots)) + self.in_row_offsets = tuple(in_off) + self.in_adj_edge_index = tuple(slots) + self.max_cost = max(self.cost, default=0) + + # ------------------------------------------------------------ accessors + + def input_index(self) -> tuple[int, ...]: + return self._input_index + + def resolution(self) -> Fraction: + return self.q + + def out_ids(self, u: int) -> range: + return range(self.row_offsets[u], self.row_offsets[u + 1]) + + def in_ids(self, v: int) -> tuple[int, ...]: + """In-edge ids of ``v`` in reverse-CSR order ``(cost, src, input)``.""" + return self.in_adj_edge_index[ + self.in_row_offsets[v] : self.in_row_offsets[v + 1] + ] + + def eligibility(self, excluded_nodes=(), excluded_edges=()) -> Eligibility: + return Eligibility.build(self, excluded_nodes, excluded_edges) + + def edge_tuple(self, e: int) -> tuple[int, int, int, int]: + return (self.src[e], self.dst[e], self.capacity[e], self.cost[e]) + + +def input_to_id(g: Graph) -> list[int]: + """The caller's inverse permutation of ``input_index()`` (one O(E) scatter).""" + inv = [0] * g.num_edges + for eid, pos in enumerate(g.input_index()): + inv[pos] = eid + return inv + + +def graph_from_input(num_nodes, edges, resolution=1) -> Graph: + """Convenience: ``edges`` as ``(u, v, capacity_units, cost)`` in input order.""" + return Graph( + num_nodes, + [e[0] for e in edges], + [e[1] for e in edges], + [e[2] for e in edges], + [e[3] for e in edges], + resolution, + ) + + +@dataclass(frozen=True) +class Eligibility: + """Immutable static eligibility: dense node and edge bits, graph-bound.""" + + graph_uid: int + uid: int + node_allowed: tuple[bool, ...] + edge_allowed: tuple[bool, ...] + + @staticmethod + def build(g: Graph, excluded_nodes=(), excluded_edges=()) -> Eligibility: + xn = set() + for v in excluded_nodes: + if not isinstance(v, int) or not 0 <= v < g.num_nodes: + raise ContractError(f"excluded node {v} out of range") + xn.add(v) + xe = set() + for e in excluded_edges: + if not isinstance(e, int) or not 0 <= e < g.num_edges: + raise ContractError(f"excluded edge {e} out of range") + xe.add(e) + node_allowed = tuple(v not in xn for v in range(g.num_nodes)) + edge_allowed = tuple( + e not in xe and node_allowed[g.src[e]] and node_allowed[g.dst[e]] + for e in range(g.num_edges) + ) + uid = 0 if not xn and not xe else next(_UID) + return Eligibility(g.uid, uid, node_allowed, edge_allowed) + + def check(self, g: Graph) -> None: + if self.graph_uid != g.uid: + raise ContractError("foreign Eligibility") + + +def all_allowed(g: Graph, elig: Eligibility | None) -> Eligibility: + if elig is None: + return Eligibility.build(g) + elig.check(g) + return elig + + +def check_node_set(g: Graph, nodes, name: str) -> tuple[int, ...]: + """Normalise a root or endpoint set: duplicates removed, ids validated.""" + if isinstance(nodes, int): + nodes = (nodes,) + out = set() + for v in nodes: + if not isinstance(v, int) or isinstance(v, bool) or not 0 <= v < g.num_nodes: + raise ContractError(f"{name}: node {v!r} out of range") + out.add(v) + if not out: + raise ContractError(f"{name}: empty set") + return tuple(sorted(out)) + + +def check_disjoint(sources, destinations) -> None: + if set(sources) & set(destinations): + raise ContractError("a vertex in both endpoint sets is rejected (rule 13)") + + +def check_dag_edges(g: Graph, edges) -> tuple[int, ...]: + """A DAG given by its edge ids: range checked, no duplicates, acyclic.""" + seen = set() + for e in edges: + if not isinstance(e, int) or not 0 <= e < g.num_edges: + raise ContractError(f"DAG edge {e!r} out of range") + if e in seen: + raise ContractError(f"duplicate DAG edge {e}") + seen.add(e) + out_adj: dict[int, list[int]] = {} + indeg: dict[int, int] = {} + for e in seen: + u, v = g.src[e], g.dst[e] + out_adj.setdefault(u, []).append(v) + indeg[v] = indeg.get(v, 0) + 1 + indeg.setdefault(u, 0) + queue = [v for v, d in indeg.items() if d == 0] + count = 0 + while queue: + u = queue.pop() + count += 1 + for v in out_adj.get(u, ()): + indeg[v] -= 1 + if indeg[v] == 0: + queue.append(v) + if count != len(indeg): + raise ContractError("route structure is cyclic") + return tuple(sorted(seen)) + + +def path_edges(g: Graph, hops) -> tuple[int, ...]: + """``Path(g, hops)`` validation; returns the member edge ids (a DAG).""" + if not hops: + raise ContractError("empty path") + members: list[int] = [] + nodes: list[int] = [] + for hop in hops: + if not hop: + raise ContractError("empty hop") + for e in hop: + if not isinstance(e, int) or not 0 <= e < g.num_edges: + raise ContractError(f"path member {e!r} out of range") + uv = {(g.src[e], g.dst[e]) for e in hop} + if len(uv) != 1: + raise ContractError("members of a hop must share (u, v)") + (u, v) = next(iter(uv)) + if nodes and nodes[-1] != u: + raise ContractError("hops are not contiguous") + if not nodes: + nodes.append(u) + nodes.append(v) + members.extend(hop) + if len(set(members)) != len(members): + raise ContractError("duplicate path member") + if len(set(nodes)) != len(nodes): + raise ContractError("path repeats a node") + return tuple(sorted(members)) diff --git a/tests/reference/policy/p1_amount/kernels.py b/tests/reference/policy/p1_amount/kernels.py new file mode 100644 index 0000000..39441c1 --- /dev/null +++ b/tests/reference/policy/p1_amount/kernels.py @@ -0,0 +1,305 @@ +"""Placement kernels in units (APPROACH revision 24, sections 2.1, 7 and 16). + +A plan is frozen at creation: the flow's route (a DAG of edge ids), its split graph +(members pruned to the walks from the source to the destination set) and, for +Weighted, the weights. Membership: MaxFlow takes the route (the view decides); +Weighted by its ``priority`` axis (``Residual``: residual >= 1 unit on the creation +view; ``Capacity``: static capacity >= 1 unit); EqualBalanced by static capacity >= 1 +unit; Lossy keeps its kernel's membership. + +* ``alloc(plan, T)``: node by node in topological order, the source's inflow being + ``T``; round robin (EqualBalanced) or highest averages over the frozen weights + (Weighted) at every node; member-monotone in ``T`` (house monotonicity). +* ``t_max(plan, view)``: the largest ``T`` whose allocation fits every member, + by binary search over units (exact because the allocation is monotone). +* ``maxflow(...)``: the flow core restricted to the route on the view (one + objective: minimum cost for the maximum value under the cap). +* ``lossy(plan, offer, W)``: the offer split at every node by the kernel's rule, + each edge carrying ``min(share, residual)``, the rest a transit drop on that edge; + ``+inf`` injects every source member to its residual. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import flowcore +import search +from apportion import split +from graph import Graph +from units import ContractError + +MAX_FLOW = "MaxFlow" +WEIGHTED = "Weighted" +EQUAL_BALANCED = "EqualBalanced" +KERNELS = (MAX_FLOW, WEIGHTED, EQUAL_BALANCED) +LOSSLESS = "Lossless" +LOSSY = "Lossy" + + +def check_kernel(kernel: str, admission: str, priority: str) -> None: + if kernel not in KERNELS: + raise ContractError(f"unknown placement {kernel!r}") + if admission not in (LOSSLESS, LOSSY): + raise ContractError(f"unknown admission {admission!r}") + if admission == LOSSY and kernel == MAX_FLOW: + raise ContractError("Lossy is rejected with MaxFlow (rule 13)") + if kernel == WEIGHTED and priority == "Headroom": + raise ContractError("Weighted with priority = Headroom is rejected") + + +@dataclass +class Plan: + kernel: str + admission: str + src: int + dsts: tuple[int, ...] + route: tuple[int, ...] + members: tuple[int, ...] + weights: dict[int, int] | None + out: dict[int, list[int]] = field(default_factory=dict) + topo: list[int] = field(default_factory=list) + + @property + def rule(self) -> str: + return "ha" if self.kernel == WEIGHTED else "rr" + + def to_json(self) -> dict: + return { + "kernel": self.kernel, + "admission": self.admission, + "route": list(self.route), + "members": list(self.members), + "weights": None + if self.weights is None + else [[e, w] for e, w in sorted(self.weights.items())], + } + + +def prune(g: Graph, edges, src: int, dsts) -> tuple[int, ...]: + """Edges on some walk from ``src`` to the destination set (destinations terminal).""" + dset = set(dsts) + edges = [e for e in edges if g.src[e] not in dset] + fwd = {src} + changed = True + while changed: + changed = False + for e in edges: + if g.src[e] in fwd and g.dst[e] not in fwd: + fwd.add(g.dst[e]) + changed = True + back = set(dset) + changed = True + while changed: + changed = False + for e in edges: + if g.dst[e] in back and g.src[e] not in back: + back.add(g.src[e]) + changed = True + return tuple(sorted(e for e in edges if g.src[e] in fwd and g.dst[e] in back)) + + +def make_plan( + g: Graph, + kernel: str, + admission: str, + src: int, + dsts, + route, + priority: str, + view, +) -> Plan: + """Capture membership and weights on the creation view (frozen until reroute).""" + check_kernel(kernel, admission, priority) + route = tuple(sorted(route)) + dsts = tuple(sorted(dsts)) + weights = None + if kernel == MAX_FLOW: + cand = route + elif kernel == WEIGHTED: + if priority == "Residual": + cand = tuple(e for e in route if view[e] >= 1) + else: + cand = tuple(e for e in route if g.capacity[e] >= 1) + else: + cand = tuple(e for e in route if g.capacity[e] >= 1) + members = prune(g, cand, src, dsts) + if kernel == WEIGHTED: + weights = { + e: (view[e] if priority == "Residual" else g.capacity[e]) for e in members + } + plan = Plan(kernel, admission, src, dsts, route, members, weights) + out: dict[int, list[int]] = {} + indeg: dict[int, int] = {src: 0} + for e in members: + out.setdefault(g.src[e], []).append(e) + indeg[g.dst[e]] = indeg.get(g.dst[e], 0) + 1 + indeg.setdefault(g.src[e], 0) + for lst in out.values(): + lst.sort() + topo = [] + ready = sorted(v for v, d in indeg.items() if d == 0) + while ready: + u = ready.pop(0) + topo.append(u) + for e in out.get(u, ()): + v = g.dst[e] + indeg[v] -= 1 + if indeg[v] == 0: + ready.append(v) + ready.sort() + assert len(topo) == len(indeg), "split graph is cyclic" + plan.out, plan.topo = out, topo + return plan + + +def alloc(g: Graph, plan: Plan, total: int) -> dict[int, int]: + """Cumulative allocation ``alloc_i(T)`` of a Weighted or EqualBalanced plan.""" + if plan.kernel == MAX_FLOW: + raise ValueError("MaxFlow allocations come from the flow core") + if total == 0: + return {} + if not plan.members: + raise ValueError("an empty split graph carries nothing") + inflow = {plan.src: total} + out: dict[int, int] = {} + dset = set(plan.dsts) + for u in plan.topo: + amount = inflow.get(u, 0) + if u in dset or amount == 0: + continue + outs = plan.out.get(u, []) + assert outs, "a non-destination node of the split graph has members" + weights = None if plan.weights is None else [plan.weights[e] for e in outs] + for e, x in zip( + outs, split(plan.rule, amount, weights, len(outs)), strict=True + ): + if x: + out[e] = x + inflow[g.dst[e]] = inflow.get(g.dst[e], 0) + x + assert sum(inflow.get(d, 0) for d in dset) == total + return out + + +def fits(allocation: dict[int, int], view) -> bool: + return all(x <= view[e] for e, x in allocation.items()) + + +@dataclass +class TMax: + value: int + evaluations: int + upper: int + + +def t_max(g: Graph, plan: Plan, view) -> TMax: + """Largest ``T`` with ``alloc(T)`` within ``view`` on every member (exact).""" + if not plan.members: + return TMax(0, 0, 0) + hi = sum(view[e] for e in plan.out.get(plan.src, [])) + upper = hi + lo = 0 + evals = 0 + while lo < hi: + mid = (lo + hi + 1) // 2 + evals += 1 + if fits(alloc(g, plan, mid), view): + lo = mid + else: + hi = mid - 1 + return TMax(lo, evals, upper) + + +def maxflow( + g: Graph, + plan: Plan, + view, + cap: int | None, + *, + arc_order="interleaved", + arc_rank=None, + trace=False, +): + """The MaxFlow kernel: a fresh flow-core solve on the view over the route.""" + return flowcore.solve( + g, + view, + [plan.src], + plan.dsts, + cap, + edges=plan.members, + arc_order=arc_order, + arc_rank=arc_rank, + trace=trace, + ) + + +@dataclass +class LossyCall: + offered: int + injected: int + delivered: int + carried: dict[int, int] + drops: dict[int, int] + shares: dict[int, int] + + @property + def transit_drops(self) -> int: + return sum(self.drops.values()) + + @property + def source_drops(self) -> int: + return self.offered - self.delivered - self.transit_drops + + +def lossy(g: Graph, plan: Plan, offer: int | None, W) -> LossyCall: + """One Lossy offer on the working residual ``W`` (``None`` = ``+inf``).""" + carried: dict[int, int] = {} + drops: dict[int, int] = {} + shares: dict[int, int] = {} + src_outs = plan.out.get(plan.src, []) + if not src_outs: + offered = 0 if offer is None else offer + return LossyCall(offered, 0, 0, carried, drops, shares) + dset = set(plan.dsts) + inflow: dict[int, int] = {} + injected = 0 + for u in plan.topo: + if u in dset: + continue + outs = plan.out.get(u, []) + if u == plan.src: + if offer is None: + part = [W[e] for e in outs] + else: + weights = ( + None if plan.weights is None else [plan.weights[e] for e in outs] + ) + part = split(plan.rule, offer, weights, len(outs)) + injected = sum(part) + else: + amount = inflow.get(u, 0) + if amount == 0: + continue + weights = None if plan.weights is None else [plan.weights[e] for e in outs] + part = split(plan.rule, amount, weights, len(outs)) + for e, x in zip(outs, part, strict=True): + if not x: + continue + c = min(x, W[e]) + shares[e] = x + if c: + carried[e] = c + inflow[g.dst[e]] = inflow.get(g.dst[e], 0) + c + if x - c: + drops[e] = x - c + delivered = sum(inflow.get(d, 0) for d in dset) + offered = injected if offer is None else offer + return LossyCall(offered, injected, delivered, carried, drops, shares) + + +def route_cost(g: Graph, edges, src: int, dsts) -> int | None: + """Minimum walk cost of a route from ``src`` to the destination set.""" + d = search.distances(g, edges, [src]) + reach = [d[t] for t in dsts if d[t] is not None] + return min(reach) if reach else None diff --git a/tests/reference/policy/p1_amount/ledger.py b/tests/reference/policy/p1_amount/ledger.py new file mode 100644 index 0000000..a6379ba --- /dev/null +++ b/tests/reference/policy/p1_amount/ledger.py @@ -0,0 +1,300 @@ +"""The integer ledger ``FlowGraph`` (APPROACH revision 24, section 8). + +The ledger is mutated only through policies: ``_bind`` registers a policy, +``_commit`` exchanges a policy's records atomically (feasibility checked before any +mutation, new FlowIds assigned in creation order), ``_detach`` reverts a policy's +allocations and retires its ids (``remove()``), ``_orphan`` marks a destroyed +policy's records as orphans (readable, never changed again). ``reset`` and +``copy`` are rejected while a policy is bound. Ids are never reused. +""" + +from __future__ import annotations + +import itertools +from dataclasses import dataclass, field, replace + +from graph import Eligibility, Graph, check_node_set +from units import COUNTER_LIMIT, UNREACHABLE, ContractError + +_UID = itertools.count(1) + + +@dataclass +class FlowRecord: + flow_id: tuple[int, int] + owner: int + sources: tuple[int, ...] + destinations: tuple[int, ...] + placement: str + admission: str + bundle: tuple[int, ...] + route_cost: int + offered: int | None + delivered: int + edges: dict[int, int] = field(default_factory=dict) + drops: dict[int, int] = field(default_factory=dict) + + def snapshot(self) -> FlowRecord: + return replace(self, edges=dict(self.edges), drops=dict(self.drops)) + + def to_json(self, g: Graph | None = None) -> dict: + pos = None if g is None else g.input_index() + + def pairs(d): + return [ + [e, x] if pos is None else [e, pos[e], x] for e, x in sorted(d.items()) + ] + + return { + "flow_id": list(self.flow_id), + "owner": self.owner, + "sources": list(self.sources), + "destinations": list(self.destinations), + "placement": self.placement, + "admission": self.admission, + "bundle": list(self.bundle), + "bundle_input": None + if pos is None + else sorted(pos[e] for e in self.bundle), + "route_cost": None if self.route_cost == UNREACHABLE else self.route_cost, + "offered": self.offered, + "delivered": self.delivered, + "edges": pairs(self.edges), + "drops": pairs(self.drops), + } + + +@dataclass +class Draft: + """A record proposed by a policy for the commit (``flow_id`` None: new).""" + + flow_id: tuple[int, int] | None + sources: tuple[int, ...] + destinations: tuple[int, ...] + placement: str + admission: str + bundle: tuple[int, ...] + route_cost: int + offered: int | None + delivered: int + edges: dict[int, int] + drops: dict[int, int] + + +class FlowGraph: + def __init__(self, g: Graph, residual=None) -> None: + self.graph = g + self.uid = next(_UID) + self._baseline = self._check_baseline(residual) + self._records: dict[int, FlowRecord] = {} + self._next_id = 0 + self._next_owner = 0 + self._bound: set[int] = set() + self._orphans: set[int] = set() + self._retired: set[int] = set() + + def _check_baseline(self, residual) -> list[int]: + g = self.graph + if residual is None: + return list(g.capacity) + if len(residual) != g.num_edges: + raise ContractError("baseline length differs from the edge count") + out = [] + for e, r in enumerate(residual): + if not isinstance(r, int) or isinstance(r, bool): + raise ContractError("baselines are integer units (to_units first)") + if not 0 <= r <= g.capacity[e]: + raise ContractError(f"baseline[{e}] = {r} outside [0, capacity]") + out.append(r) + return out + + # ------------------------------------------------------------ observers + + def capacity_units(self) -> list[int]: + return list(self.graph.capacity) + + def baseline(self) -> list[int]: + return list(self._baseline) + + def allocated(self) -> list[int]: + out = [0] * self.graph.num_edges + for r in self._records.values(): + for e, x in r.edges.items(): + out[e] += x + return out + + def residual(self) -> list[int]: + return [b - a for b, a in zip(self._baseline, self.allocated(), strict=True)] + + def records(self) -> list[FlowRecord]: + return [self._records[i].snapshot() for i in sorted(self._records)] + + def record(self, flow_id) -> FlowRecord: + uid, i = flow_id + if uid != self.uid: + raise ContractError("FlowId of another ledger") + if i not in self._records: + raise ContractError(f"retired or unknown FlowId {flow_id}") + return self._records[i].snapshot() + + def next_flow_id(self) -> int: + return self._next_id + + def owner_records(self, owner: int) -> list[FlowRecord]: + return [r for i, r in sorted(self._records.items()) if r.owner == owner] + + def bottleneck(self, sources, elig: Eligibility | None = None) -> list[int]: + """Saturation diagnostic: allowed edges of capacity >= 1 leaving the set + reachable from the sources over forward arcs with residual >= 1.""" + g = self.graph + srcs = check_node_set(g, sources, "sources") + if elig is not None: + elig.check(g) + ok = (lambda e: True) if elig is None else (lambda e: elig.edge_allowed[e]) + node_ok = (lambda v: True) if elig is None else (lambda v: elig.node_allowed[v]) + res = self.residual() + seen = {s for s in srcs if node_ok(s)} + stack = list(seen) + while stack: + u = stack.pop() + for e in g.out_ids(u): + v = g.dst[e] + if ok(e) and res[e] >= 1 and v not in seen: + seen.add(v) + stack.append(v) + return [ + e + for e in range(g.num_edges) + if g.src[e] in seen + and g.dst[e] not in seen + and ok(e) + and g.capacity[e] >= 1 + ] + + # ------------------------------------------------------------ lifetime + + def bound_policies(self) -> int: + return len(self._bound) + + def reset(self, residual=None) -> None: + if self._bound: + raise ContractError("reset is rejected while a policy is bound") + baseline = self._check_baseline(residual) + self._retired.update(self._records) + self._records.clear() + self._orphans.clear() + self._baseline = baseline + + def copy(self) -> FlowGraph: + if self._bound: + raise ContractError("a ledger with bound policies cannot be copied") + other = FlowGraph.__new__(FlowGraph) + other.graph = self.graph + other.uid = next(_UID) + other._baseline = list(self._baseline) + other._records = { + i: replace(r.snapshot(), flow_id=(other.uid, i)) + for i, r in self._records.items() + } + other._next_id = self._next_id + other._next_owner = self._next_owner + other._bound = set() + other._orphans = set(self._orphans) + other._retired = set(self._retired) + return other + + # ------------------------------------------------------------ policy-private + + def _bind(self) -> int: + owner = self._next_owner + self._next_owner += 1 + self._bound.add(owner) + return owner + + def _orphan(self, owner: int) -> None: + """The policy was destroyed without ``remove()``: its records stay.""" + self._bound.discard(owner) + self._orphans.add(owner) + + def _detach(self, owner: int) -> list[int]: + """``remove()``: revert the owner's allocations and retire its ids.""" + ids = [i for i, r in self._records.items() if r.owner == owner] + for i in ids: + del self._records[i] + self._retired.add(i) + return ids + + def _commit(self, owner: int, drafts: list[Draft]) -> list[tuple[int, int]]: + """One atomic exchange of the owner's records; returns the new FlowIds.""" + if owner not in self._bound: + raise ContractError("commit by an unbound policy") + g = self.graph + current = {i for i, r in self._records.items() if r.owner == owner} + kept = {d.flow_id[1] for d in drafts if d.flow_id is not None} + if kept != current: + raise ContractError("a commit must carry every live record of the policy") + alloc = [0] * g.num_edges + for r in self._records.values(): + if r.owner != owner: + for e, x in r.edges.items(): + alloc[e] += x + for d in drafts: + for e, x in d.edges.items(): + if x < 0: + raise AssertionError("negative allocation in a commit") + alloc[e] += x + for x in d.drops.values(): + if not 0 <= x < COUNTER_LIMIT: + raise ContractError("drop counter outside the 128-bit domain") + if d.offered is not None and not 0 <= d.offered < COUNTER_LIMIT: + raise ContractError("offered counter outside the 128-bit domain") + for e in range(g.num_edges): + if alloc[e] > self._baseline[e]: + raise AssertionError(f"infeasible commit on edge {e}") + new_ids: list[tuple[int, int]] = [] + for d in drafts: + if d.flow_id is None: + i = self._next_id + self._next_id += 1 + fid = (self.uid, i) + new_ids.append(fid) + else: + fid = d.flow_id + i = fid[1] + self._records[i] = FlowRecord( + flow_id=fid, + owner=owner, + sources=d.sources, + destinations=d.destinations, + placement=d.placement, + admission=d.admission, + bundle=d.bundle, + route_cost=d.route_cost, + offered=d.offered, + delivered=d.delivered, + edges={e: x for e, x in d.edges.items() if x}, + drops={e: x for e, x in d.drops.items() if x}, + ) + return new_ids + + # ------------------------------------------------------------ state digest + + def state(self) -> dict: + g = self.graph + pos = g.input_index() + alloc = self.allocated() + res = self.residual() + by_input_alloc = [0] * g.num_edges + by_input_res = [0] * g.num_edges + for e in range(g.num_edges): + by_input_alloc[pos[e]] = alloc[e] + by_input_res[pos[e]] = res[e] + return { + "ledger_uid": self.uid, + "allocated_input": by_input_alloc, + "residual_input": by_input_res, + "records": [r.to_json(g) for r in self.records()], + "bound": sorted(self._bound), + "orphan_owners": sorted(self._orphans), + "next_flow_id": self._next_id, + } diff --git a/tests/reference/policy/p1_amount/search.py b/tests/reference/policy/p1_amount/search.py new file mode 100644 index 0000000..65e4216 --- /dev/null +++ b/tests/reference/policy/p1_amount/search.py @@ -0,0 +1,422 @@ +"""Search semantics of APPROACH revision 24, section 6, as internal routines. + +* member graph: static eligibility, capacity >= 1 unit, optional residual gating at + ``min_residual``; ``multi_edge = False`` keeps one member per ``(u, v)`` by + ``select_adjacency``: minimum cost, then (``PreferHigherPriority``) the highest + priority value, then the lowest EdgeId; +* the unique shortest-path DAG (positive costs, 2.4): tight edges; with targets the + walks from the roots to the nearest targets (anycast), one edge set for both + directions; +* the staged canonical single path: (1) the tight DAG of the pair, (2) under + ``PreferHigherPriority`` the best attainable bottleneck ``B*`` (capped by a Yen + prefix's bottleneck) and the DAG restricted to priority ``>= B*``, (3) one reverse + hop count ``h`` and a greedy walk taking the smallest tight edge whose head has + ``h(v) = h(u) - 1``; +* ``CostCeiling``: ``Absolute(c)`` or ``Stretch(f)`` against the static shortest cost, + compared exactly (``stretch_le`` never forms a product above 128 bits). + +Priority values: ``Residual`` = residual units, ``Capacity`` = static capacity +units, ``Headroom`` = the exact rational ``residual / capacity``. A search without a +residual reads static capacities for every priority (section 10). +""" + +from __future__ import annotations + +import heapq +import math +from collections import deque +from dataclasses import dataclass +from fractions import Fraction + +from graph import Eligibility, Graph, check_disjoint, check_node_set +from units import ContractError + +DETERMINISTIC = "Deterministic" +PREFER = "PreferHigherPriority" +PRIORITIES = ("Residual", "Capacity", "Headroom") + + +@dataclass(frozen=True) +class Selection: + """PathSelection {multipath, multi_edge, tie_break, priority}.""" + + multipath: bool = True + multi_edge: bool = True + tie_break: str = DETERMINISTIC + priority: str = "Residual" + + def __post_init__(self) -> None: + if self.tie_break not in (DETERMINISTIC, PREFER): + raise ContractError(f"unknown tie_break {self.tie_break!r}") + if self.priority not in PRIORITIES: + raise ContractError(f"unknown priority {self.priority!r}") + + +def priority_fn(g: Graph, priority: str, residual): + """Per-edge priority value (exact: ints or Fractions).""" + if residual is None or priority == "Capacity": + return lambda e: g.capacity[e] + if priority == "Residual": + return lambda e: residual[e] + if priority == "Headroom": + return lambda e: Fraction(residual[e], g.capacity[e]) + raise ContractError(f"unknown priority {priority!r}") + + +def check_residual(g: Graph, residual) -> None: + if residual is None: + return + if len(residual) != g.num_edges: + raise ContractError("residual length differs from the edge count") + for e, r in enumerate(residual): + if not isinstance(r, int) or isinstance(r, bool): + raise ContractError("residual entries must be integer units") + if not 0 <= r <= g.capacity[e]: + raise ContractError(f"residual[{e}] = {r} outside [0, capacity]") + + +def member_graph( + g: Graph, + elig: Eligibility | None, + selection: Selection, + residual=None, + min_residual: int = 1, +) -> list[int]: + """Eligible routable edges, collapsed per ``(u, v)`` when ``multi_edge`` is off.""" + if elig is not None: + elig.check(g) + if min_residual < 1: + raise ContractError("min_residual is at least 1 unit") + ok = [] + for e in range(g.num_edges): + if elig is not None and not elig.edge_allowed[e]: + continue + if g.capacity[e] < 1: + continue + if residual is not None and residual[e] < min_residual: + continue + ok.append(e) + if selection.multi_edge: + return ok + return collapse(g, ok, selection, residual) + + +def collapse(g: Graph, edges, selection: Selection, residual) -> list[int]: + prio = priority_fn(g, selection.priority, residual) + best: dict[tuple[int, int], int] = {} + for e in edges: + key = (g.src[e], g.dst[e]) + cur = best.get(key) + if cur is None or _select_key(g, e, selection, prio) < _select_key( + g, cur, selection, prio + ): + best[key] = e + return sorted(best.values()) + + +def _select_key(g, e, selection, prio): + if selection.tie_break == PREFER: + return (g.cost[e], -prio(e), e) + return (g.cost[e], 0, e) + + +def distances( + g: Graph, edges, roots, reverse: bool = False, excluded_nodes=frozenset() +) -> list[int | None]: + """Dijkstra over ``edges`` from (or, reverse, toward) the root set.""" + adj: list[list[tuple[int, int]]] = [[] for _ in range(g.num_nodes)] + for e in edges: + u, v = g.src[e], g.dst[e] + if u in excluded_nodes or v in excluded_nodes: + continue + if reverse: + adj[v].append((u, g.cost[e])) + else: + adj[u].append((v, g.cost[e])) + dist: list[int | None] = [None] * g.num_nodes + heap = [] + for r in roots: + if r in excluded_nodes: + continue + dist[r] = 0 + heap.append((0, r)) + heapq.heapify(heap) + while heap: + d, u = heapq.heappop(heap) + if d != dist[u]: + continue + for v, c in adj[u]: + nd = d + c + dv = dist[v] + if dv is None or nd < dv: + dist[v] = nd + heapq.heappush(heap, (nd, v)) + return dist + + +def full_dag(g: Graph, edges, roots, reverse: bool = False) -> list[int]: + """All tight edges over the nodes reached from (or toward) the roots.""" + d = distances(g, edges, roots, reverse) + out = [] + for e in edges: + u, v = g.src[e], g.dst[e] + if reverse: + if d[v] is not None and d[u] is not None and d[v] + g.cost[e] == d[u]: + out.append(e) + elif d[u] is not None and d[v] is not None and d[u] + g.cost[e] == d[v]: + out.append(e) + return sorted(out) + + +def tight_dag( + g: Graph, edges, sources, destinations, excluded_nodes=frozenset() +) -> tuple[int | None, list[int]]: + """Edges on the minimum-cost walks from the source set to the destination set. + + The walks of cost ``d* = min dist(S, D)``; destinations and sources are terminal + (positive costs). Identical for a forward search with targets and a reverse + search with the roles swapped (2.4). + """ + ds = distances(g, edges, sources, False, excluded_nodes) + dd = distances(g, edges, destinations, True, excluded_nodes) + reach = [ds[t] for t in destinations if ds[t] is not None] + if not reach: + return None, [] + dstar = min(reach) + out = [] + for e in edges: + u, v = g.src[e], g.dst[e] + if u in excluded_nodes or v in excluded_nodes: + continue + du, dv = ds[u], dd[v] + if du is not None and dv is not None and du + g.cost[e] + dv == dstar: + out.append(e) + return dstar, sorted(out) + + +@dataclass +class SinglePath: + edges: list[int] + cost: int + bottleneck: object # min priority value along the walk (None: Deterministic) + b_star: object # the stage-2 threshold (None: Deterministic) + + +def canonical_path( + g: Graph, + edges, + sources, + destinations, + prio=None, + prefix_cap=None, + excluded_nodes=frozenset(), +) -> SinglePath | None: + """The staged canonical single path (section 6) on the member edges ``edges``. + + ``prio`` is the priority function under ``PreferHigherPriority`` (``None`` for + ``Deterministic``); ``prefix_cap`` caps ``B*`` by a Yen root prefix's bottleneck. + """ + dstar, dag = tight_dag(g, edges, sources, destinations, excluded_nodes) + if dstar is None: + return None + dests = set(destinations) + b_star = None + if prio is not None: + ds = distances(g, dag, sources, False, excluded_nodes) + best: dict[int, object] = {} + for s in sources: + if s not in excluded_nodes and ds[s] == 0: + best[s] = math.inf + order = sorted( + (v for v in range(g.num_nodes) if ds[v] is not None), key=ds.__getitem__ + ) + out_adj: dict[int, list[int]] = {} + for e in dag: + out_adj.setdefault(g.src[e], []).append(e) + for u in order: + if u not in best: + continue + for e in out_adj.get(u, ()): + cand = min(best[u], prio(e)) + v = g.dst[e] + if v not in best or cand > best[v]: + best[v] = cand + b_star = max(best[t] for t in destinations if t in best and ds[t] == dstar) + if prefix_cap is not None and prefix_cap < b_star: + b_star = prefix_cap + dag = [e for e in dag if prio(e) >= b_star] + # hop counts toward the destinations inside the (restricted) DAG + radj: dict[int, list[int]] = {} + for e in dag: + radj.setdefault(g.dst[e], []).append(e) + h: dict[int, int] = {t: 0 for t in destinations if t not in excluded_nodes} + q = deque(h) + while q: + v = q.popleft() + for e in radj.get(v, ()): + u = g.src[e] + if u not in h: + h[u] = h[v] + 1 + q.append(u) + starts = [s for s in sources if s in h] + if not starts: + return None + hmin = min(h[s] for s in starts) + out_adj2: dict[int, list[int]] = {} + for e in dag: + out_adj2.setdefault(g.src[e], []).append(e) + frontier = [s for s in starts if h[s] == hmin] + walk: list[int] = [] + u = None + while True: + cands = [] + for x in [u] if u is not None else frontier: + for e in out_adj2.get(x, ()): + v = g.dst[e] + if v in h and h[v] == h[x] - 1: + cands.append(e) + if not cands: + break + e = min(cands) + walk.append(e) + u = g.dst[e] + if u in dests: + break + if not walk: + return None + cost = sum(g.cost[e] for e in walk) + assert cost == dstar + bottleneck = None if prio is None else min(prio(e) for e in walk) + return SinglePath(walk, cost, bottleneck, b_star) + + +# ------------------------------------------------------------------ public-shaped spf + + +@dataclass +class SpfResult: + costs: list[int | None] | None + dag: list[int] + target_cost: int | None + path: SinglePath | None = None + + +def spf( + g: Graph, + roots, + *, + direction: str = "Forward", + targets=None, + selection: Selection | None = None, + residual=None, + min_residual: int = 1, + elig: Eligibility | None = None, +) -> SpfResult: + selection = Selection() if selection is None else selection + roots = check_node_set(g, roots, "roots") + if targets is not None: + targets = check_node_set(g, targets, "targets") + check_disjoint(roots, targets) + elif not selection.multipath: + raise ContractError("multipath = false requires targets (rule 13)") + if direction not in ("Forward", "Reverse"): + raise ContractError(f"unknown direction {direction!r}") + check_residual(g, residual) + members = member_graph(g, elig, selection, residual, min_residual) + node_ok = (lambda v: True) if elig is None else (lambda v: elig.node_allowed[v]) + live_roots = [r for r in roots if node_ok(r)] + if targets is None: + d = distances(g, members, live_roots, direction == "Reverse") + return SpfResult( + d, full_dag(g, members, live_roots, direction == "Reverse"), None + ) + live_targets = [t for t in targets if node_ok(t)] + src, dst = ( + (live_roots, live_targets) + if direction == "Forward" + else (live_targets, live_roots) + ) + if not src or not dst: + return SpfResult(None, [], None) + if selection.multipath: + dstar, dag = tight_dag(g, members, src, dst) + return SpfResult(None, dag, dstar) + prio = ( + priority_fn(g, selection.priority, residual) + if selection.tie_break == PREFER + else None + ) + path = canonical_path(g, members, src, dst, prio) + if path is None: + return SpfResult(None, [], None) + return SpfResult(None, sorted(path.edges), path.cost, path) + + +# ------------------------------------------------------------------ cost ceilings + + +def check_ceiling(ceiling) -> tuple[str, object] | None: + if ceiling is None: + return None + kind, value = ceiling + if kind == "Absolute": + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise ContractError("Absolute ceiling must be a non-negative integer cost") + return ceiling + if kind == "Stretch": + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ContractError("Stretch factor must be a number") + if not math.isfinite(value) or value < 1: + raise ContractError("Stretch factor outside [1, inf)") + return ceiling + raise ContractError(f"unknown ceiling {kind!r}") + + +def stretch_le(cost: int, base: int, f: float) -> bool: + """``cost <= f * base`` exactly; f is the dyadic rational it is (f >= 1). + + Exponent-aware: never forms a product above 128 bits (costs below 2^63, the + 53-bit mantissa, and bit lengths compared first). + """ + num, den = Fraction(f).as_integer_ratio() + k = den.bit_length() - 1 # den = 2^k + shift = 0 + while num % 2 == 0 and num > 1: + num //= 2 + shift += 1 + # f = num * 2^(shift - k), num odd and below 2^53 + exp = shift - k + if base == 0: + return cost <= 0 + rhs = base * num # below 2^116 + assert rhs.bit_length() <= 116 + if exp >= 0: + if rhs.bit_length() + exp > 64: + result = True # rhs * 2^exp >= 2^64 > cost + else: + result = cost <= rhs << exp + else: + lhs = cost << (-exp) # -exp < 53 because f >= 1 + assert lhs.bit_length() <= 128 + result = lhs <= rhs + assert result == (Fraction(cost) <= Fraction(f) * base) + return result + + +def within_ceiling(cost: int, ceiling, baseline: int | None) -> bool: + if ceiling is None: + return True + kind, value = ceiling + if kind == "Absolute": + return cost <= value + if baseline is None or baseline == 0: + return False + return stretch_le(cost, baseline, float(value)) + + +def static_shortest(g: Graph, elig, sources, destinations) -> int | None: + """The static shortest cost under the Eligibility (residual ignored).""" + members = member_graph(g, elig, Selection()) + d = distances(g, members, sources) + reach = [d[t] for t in destinations if d[t] is not None] + return min(reach) if reach else None diff --git a/tests/reference/policy/p1_amount/snap.py b/tests/reference/policy/p1_amount/snap.py new file mode 100644 index 0000000..a0b4470 --- /dev/null +++ b/tests/reference/policy/p1_amount/snap.py @@ -0,0 +1,174 @@ +"""Snapshot recording for prototype 1 fixtures. + +Every fixture records its graphs (input coordinates, the B' order and +``input_index``), every operation with its arguments, its result, its trace (views, +plans, ``T`` values, ``T_max`` searches, flow-core payloads with tiers) and the +ledger state after it (allocations and residuals in input coordinates, records with +ids and input positions), and every expectation with the expected and actual values. +""" + +from __future__ import annotations + +import math +from fractions import Fraction + +from graph import Graph, graph_from_input, input_to_id +from units import ContractError + + +def jsonable(x): + if isinstance(x, bool) or x is None or isinstance(x, (int, str)): + return x + if isinstance(x, float): + if math.isinf(x): + return "inf" if x > 0 else "-inf" + if math.isnan(x): + return "nan" + return x + if isinstance(x, Fraction): + return str(x) + if isinstance(x, dict): + return {str(k): jsonable(v) for k, v in x.items()} + if isinstance(x, (list, tuple, set, frozenset)): + seq = sorted(x) if isinstance(x, (set, frozenset)) else x + return [jsonable(v) for v in seq] + if hasattr(x, "to_json"): + return jsonable(x.to_json()) + return repr(x) + + +class Snap: + def __init__(self, name: str, family: str, description: str, rules) -> None: + self.data: dict = { + "fixture": name, + "family": family, + "description": description, + "rules": list(rules), + "graphs": {}, + "steps": [], + "checks": [], + } + self.failures: list[str] = [] + self.ledgers: dict[str, object] = {} + self.labels: dict[int, str] = {} + + # ------------------------------------------------------------ inputs + + def graph(self, label: str, n: int, edges, resolution=1) -> tuple[Graph, list[int]]: + g = graph_from_input(n, edges, resolution) + inv = input_to_id(g) + self.labels[g.uid] = label + self.data["graphs"][label] = { + "num_nodes": n, + "resolution": str(g.q), + "edges_input": [list(e) for e in edges], + "input_index": list(g.input_index()), + "edges_by_id": [list(g.edge_tuple(e)) for e in range(g.num_edges)], + } + return g, inv + + def note(self, text: str, **data) -> None: + self.data["steps"].append({"op": "note", "text": text, **jsonable(data)}) + + def step(self, op: str, **data) -> None: + self.data["steps"].append({"op": op, **jsonable(data)}) + + # ------------------------------------------------------------ operations + + def place(self, label: str, policy, volume, fg=None): + entry: dict = {"op": "place", "policy": label, "request": jsonable(volume)} + try: + r = policy.place(volume) + except ContractError as exc: + entry["error"] = f"{type(exc).__name__}: {exc}" + self.data["steps"].append(entry) + return exc + entry["result"] = jsonable(r.to_json()) + entry["trace"] = jsonable(getattr(r, "trace", {})) + fg = fg if fg is not None else getattr(policy, "fg", None) + if fg is not None: + entry["graph"] = self.labels.get(fg.graph.uid) + entry["state"] = jsonable(fg.state()) + self.data["steps"].append(entry) + return r + + def call(self, label: str, fn, *args, fg=None, **kw): + entry: dict = {"op": label} + try: + out = fn(*args, **kw) + except ContractError as exc: + entry["error"] = f"{type(exc).__name__}: {exc}" + self.data["steps"].append(entry) + return exc + entry["result"] = jsonable(out) + if fg is not None: + entry["state"] = jsonable(fg.state()) + self.data["steps"].append(entry) + return out + + # ------------------------------------------------------------ expectations + + def expect(self, name: str, actual, expected) -> bool: + ok = jsonable(actual) == jsonable(expected) + self.data["checks"].append( + { + "name": name, + "expected": jsonable(expected), + "actual": jsonable(actual), + "ok": ok, + } + ) + if not ok: + self.failures.append(f"{name}: expected {expected!r}, got {actual!r}") + return ok + + def check(self, name: str, ok: bool, detail=None) -> bool: + self.data["checks"].append( + {"name": name, "ok": bool(ok), "detail": jsonable(detail)} + ) + if not ok: + self.failures.append(f"{name}: {detail!r}") + return bool(ok) + + def raises(self, name: str, fn, *args, **kw) -> bool: + try: + fn(*args, **kw) + except ContractError as exc: + self.data["checks"].append( + {"name": name, "ok": True, "error": f"{type(exc).__name__}: {exc}"} + ) + return True + self.failures.append(f"{name}: no ContractError") + self.data["checks"].append({"name": name, "ok": False, "error": None}) + return False + + def finish(self) -> dict: + self.data["passed"] = not self.failures + self.data["failures"] = list(self.failures) + self.data["counts"] = { + "steps": len(self.data["steps"]), + "checks": len(self.data["checks"]), + } + return self.data + + +def to_input(g: Graph, per_id) -> list: + """A per-edge array in id coordinates, re-indexed by input position.""" + out = [0] * g.num_edges + pos = g.input_index() + for e, x in enumerate(per_id): + out[pos[e]] = x + return out + + +def dict_to_input(g: Graph, d: dict) -> list: + out = [0] * g.num_edges + pos = g.input_index() + for e, x in d.items(): + out[pos[e]] = x + return out + + +def ids(inv: list[int], positions) -> list[int]: + """Input positions to B' ids (through the caller's inverse of input_index).""" + return sorted(inv[p] for p in positions) diff --git a/tests/reference/policy/p1_amount/units.py b/tests/reference/policy/p1_amount/units.py new file mode 100644 index 0000000..d90378a --- /dev/null +++ b/tests/reference/policy/p1_amount/units.py @@ -0,0 +1,98 @@ +"""Integer units: the one numeric domain (APPROACH revision 24, sections 2.1 and 2.3). + +Every amount is a Python ``int`` number of units of the graph's resolution ``q``. +There is no finer grid; physical values enter only through ``to_units`` (round half +up) and leave only through ``as_float``. Costs are positive integers and are never +converted. +""" + +from __future__ import annotations + +import math +from fractions import Fraction + +LIMIT = 1 << 62 # exclusive bound: capacities, their sum, finite requests, cost sums +VIRTUAL_CAP = (1 << 63) - 2 # virtual arcs never saturate (2.2, revision 20) +INT64_MAX = (1 << 63) - 1 +UNREACHABLE = INT64_MAX # path-cost sentinel (None in Python accessors) +NEVER_REACHED = -1 # potential sentinel of the private flow-core payload (2.2) +INTERNAL_CAP_MAX = (1 << 63) - 2 # internal flow-core cap domain [0, 2^63 - 2] +COUNTER_LIMIT = 1 << 127 # signed 128-bit cumulative counters (offered, drops) +K_MAX_FLOWS_PER_CALL = 10000 # engine constant (9.2) +INF = math.inf # the only non-integer request: +inf + + +class ContractError(ValueError): + """Invalid input rejected before any mutation (rule 13).""" + + +def check_resolution(q) -> Fraction: + """A power of two in [2^-32, 2^32], given explicitly (no default in Core).""" + if isinstance(q, bool): + raise ContractError("resolution must be a number") + if isinstance(q, float) and not math.isfinite(q): + raise ContractError("resolution must be finite") + try: + f = Fraction(q) + except (TypeError, ValueError) as exc: + raise ContractError(f"resolution {q!r} is not a number") from exc + if f <= 0: + raise ContractError("resolution must be positive") + num, den = f.numerator, f.denominator + if num & (num - 1) or den & (den - 1): + raise ContractError(f"resolution {q!r} is not a power of two") + exponent = num.bit_length() - den.bit_length() + if not -32 <= exponent <= 32: + raise ContractError(f"resolution 2^{exponent} outside [2^-32, 2^32]") + return f + + +def to_units(x, q) -> int: + """Round half up of ``x / q``; NaN, infinity and negative values rejected. + + The scalar form of the conversion; arrays convert element by element. + """ + qf = check_resolution(q) + if isinstance(x, bool): + raise ContractError("physical amount must be a number") + if isinstance(x, float) and not math.isfinite(x): + raise ContractError("physical amount must be finite") + value = Fraction(x) + if value < 0: + raise ContractError("physical amount must be non-negative") + scaled = value / qf + Fraction(1, 2) + return scaled.numerator // scaled.denominator + + +def to_units_array(xs, q) -> list[int]: + return [to_units(x, q) for x in xs] + + +def as_float(units: int, q) -> float: + """Units to a physical double: exact below 2^53 units (q is a power of two).""" + qf = check_resolution(q) + if not isinstance(units, int) or isinstance(units, bool): + raise ContractError("as_float takes integer units") + return float(Fraction(units) * qf) + + +def check_units(name: str, x, *, upper: int = LIMIT) -> int: + """An integer amount in [0, upper).""" + if not isinstance(x, int) or isinstance(x, bool): + raise ContractError(f"{name} must be an integer number of units") + if x < 0 or x >= upper: + raise ContractError(f"{name} = {x} outside [0, {upper})") + return x + + +def check_request(volume) -> int | None: + """A request in units: an int in [0, 2^62) or +inf (returned as None).""" + if isinstance(volume, float): + if volume == math.inf: + return None + raise ContractError("requests are integer units or +inf") + return check_units("request", volume) + + +def bits(x: int) -> int: + return abs(x).bit_length() diff --git a/tests/reference/policy/p2_transaction/_p1.py b/tests/reference/policy/p2_transaction/_p1.py new file mode 100644 index 0000000..f31b1fb --- /dev/null +++ b/tests/reference/policy/p2_transaction/_p1.py @@ -0,0 +1,13 @@ +"""Make prototype 1's library importable (units, apportion, graph, flowcore, search, +kernels, ledger, pinned, certify). + +The directory is appended, not prepended, so this prototype's own ``fixtures``, +``run_fixtures`` and ``tests`` modules are never shadowed by prototype 1's. +""" + +import sys +from pathlib import Path + +P1_DIR = Path(__file__).resolve().parent.parent / "p1_amount" +if str(P1_DIR) not in sys.path: + sys.path.append(str(P1_DIR)) diff --git a/tests/reference/policy/p2_transaction/fixtures.py b/tests/reference/policy/p2_transaction/fixtures.py new file mode 100644 index 0000000..4177ab3 --- /dev/null +++ b/tests/reference/policy/p2_transaction/fixtures.py @@ -0,0 +1,2090 @@ +"""Prototype 2 fixtures in integer units (APPROACH revision 24, sections 8, 9 and 16). + +The 44 fixtures of the grid prototype regenerated in units (the old names mapped in +``FIXTURE_MAP``), plus the review witnesses of review 13 to review 17. Graphs are +stated in input coordinates; bundles and expected per-edge arrays are input +positions translated through the inverse of ``input_index()``. Expected values are +derived in the fixture text or bodies (round robin, highest averages, hand-traced +transactions), never read back from the engine. "Manual" owners are ``Pinned`` +policies with one bundle. + +Graph shapes (input positions): +* ``diamond(s0, t0, s1, t1)``: 0 -[0]-> 1 -[1]-> 3 and 0 -[2]-> 2 -[3]-> 3; route 0 + = {0, 1}, route 1 = {2, 3}; +* ``routes([(cap, cost), ...])``: route i is 0 -[2i]-> i+1 -[2i+1]-> d; +* ``parallel(caps)``: links 0 -> 1. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import _p1 # noqa: F401 +import certify +import policy as P +import search +from ledger import FlowGraph +from recorder import PSnap +from snap import dict_to_input +from units import INF, K_MAX_FLOWS_PER_CALL + +MF, W, EB = P.MF, P.W_, P.EB +LOSSLESS, LOSSY = P.LOSSLESS, P.LOSSY + +# Old fixture name -> regenerated fixture (the 44 grid fixtures). +FIXTURE_MAP = { + "admission_zero_src_dst_no_route": "admission_zero_and_no_route", + "cancellation_dag": "cancellation_dag", + "combine_custom_edges_explicit": "sources_augmentation_edges", + "combine_eb_pseudo_root": "sources_eb_per_source", + "combine_forward_vs_reverse_3_vs_5": "sources_lossy_per_source", + "combine_inf_every_kernel": "sources_inf_every_kernel", + "combine_limits_and_validation": "sources_limits_and_validation", + "combine_multi_source_attribution": "sources_attribution", + "combine_proportional_2_vs_3": "sources_maxflow_vs_global", + "combine_source_split_additivity": "sources_split_cumulative", + "creation_fewer_routes": "creation_fewer_routes", + "creation_retry_after_release": "creation_retry_after_release", + "eb_1_plus_2_vs_3": "eb_1_plus_2_vs_3", + "eb_three_members_g_1_1": "eb_correlated_remainders", + "eb_unequal_1_10": "eb_unequal_1_10", + "eight_versus_ten_capacity": "eight_versus_ten_capacity", + "eight_versus_ten_headroom": "eight_versus_ten_headroom", + "eight_versus_ten_residual": "eight_versus_ten_residual", + "flag_table": "flag_table", + "four_calls_EB": "four_calls_EB", + "four_calls_Lossy": "four_calls_Lossy", + "four_calls_P": "four_calls_MaxFlow", + "four_calls_W": "four_calls_W", + "growth_256_request_1278": "growth_256_request_1278", + "growth_request_6": "growth_request_6", + "harmful_growth_10_1": "harmful_growth_10_1", + "hidden_beneficial_route": "hidden_beneficial_route", + "inf_every_kernel": "inf_every_kernel", + "invalidation_and_detach": "lifetime_remove_orphan_reset", + "limits_diminishing": "limits_diminishing", + "limits_max_flows_created": "limits_kmaxflowspercall", + "lossy_diamond_10_then_1": "lossy_diamond_10_then_1", + "lossy_shared_ingress": "lossy_shared_ingress", + "pinned_bundle_rules": "pinned_bundle_rules", + "pinned_kernels_5_1": "pinned_kernels_5_1", + "reopt_double_spend": "reopt_double_spend", + "request_12_on_5_5": "request_12_on_5_5", + "retention_third_party_removal": "retention_third_party_removal", + "rounded_retention_3_plus_g": "retention_same_structure", + "shared_capacity_view": "shared_capacity_view", + "ten_versus_five": "ten_versus_five", + "third_party_occupancy": "third_party_occupancy", + "weighted_1_plus_5_vs_6": "weighted_1_plus_5_vs_6", + "weighted_frozen_third_party": "weighted_frozen_third_party", +} + + +@dataclass +class Fixture: + name: str + family: str + rules: tuple[str, ...] + description: str + run: object + + +FIXTURES: list[Fixture] = [] + + +def fixture(name, family, rules, description): + def wrap(fn): + FIXTURES.append(Fixture(name, family, tuple(rules), description, fn)) + return fn + + return wrap + + +# ------------------------------------------------------------------ helpers + + +def diamond(s0, t0, s1, t1, c0=1, c1=1): + return 4, [(0, 1, s0, c0), (1, 3, t0, c0), (0, 2, s1, c1), (2, 3, t1, c1)] + + +def routes(specs): + d = len(specs) + 1 + edges = [] + for i, (c, w) in enumerate(specs): + edges += [(0, i + 1, c, w), (i + 1, d, c, w)] + return d + 1, edges + + +def parallel(caps, costs=None): + costs = costs or [1] * len(caps) + return 2, [(0, 1, c, w) for c, w in zip(caps, costs, strict=True)] + + +def tunnel(priority="Capacity", tie=search.PREFER): + return search.Selection( + multipath=False, multi_edge=False, tie_break=tie, priority=priority + ) + + +def ca(kernel, priority="Capacity", fc=1, mx=None, reopt=False, sel=None, **kw): + return P.FlowPolicyConfig( + P.CapacityAware(flow_count=fc, max_flow_count=mx, reoptimize=reopt, **kw), + kernel, + LOSSLESS, + sel or tunnel(priority), + ) + + +def co(kernel, adm=LOSSLESS, sel=None): + return P.FlowPolicyConfig(P.CostOnly(), kernel, adm, sel or search.Selection()) + + +def glob(): + return P.FlowPolicyConfig(P.Global(), MF, LOSSLESS, search.Selection()) + + +class Ctx: + """One graph, its caller-side inverse map, and helpers in input coordinates.""" + + def __init__(self, s: PSnap, label, spec, resolution=1): + n, edges = spec + self.s, self.n, self.edges = s, n, edges + self.g, self.inv = s.graph(label, n, edges, resolution) + + def ids(self, positions): + return tuple(sorted(self.inv[p] for p in positions)) + + def ledger(self, residual_input=None): + if residual_input is None: + return FlowGraph(self.g) + return FlowGraph(self.g, [residual_input[p] for p in self.g.input_index()]) + + def pin(self, kernel, bundles, adm=LOSSLESS, sel=None): + return P.FlowPolicyConfig( + P.Pinned(tuple(self.ids(b) for b in bundles)), + kernel, + adm, + sel or search.Selection(), + ) + + def manual(self, fg, src, dst, positions, amount, label="manual owner"): + """A fixed-structure owner: a Pinned MaxFlow policy with one bundle.""" + pol = P.FlowPolicy(fg, src, dst, self.pin(MF, [positions])) + r = self.s.place(label, pol, amount, fg=fg) + return pol, r + + def alloc(self, fg): + return fg.state()["allocated_input"] + + def own(self, fg, pol): + out = [0] * self.g.num_edges + for r in pol.records(): + for e, x in r.edges.items(): + out[self.g.input_index()[e]] += x + return out + + def routes_of(self, pol, src=None): + st = pol.state()["cohorts"] + src = next(iter(st)) if src is None else src + return [f["route_input"] for f in st[src]] + + def shares(self, pol, src=None): + st = pol.state()["cohorts"] + src = next(iter(st)) if src is None else src + return [f["T"] for f in st[src]] + + +# ====================================================================== creation + + +@fixture( + "creation_fewer_routes", + "creation", + ["8b", "9.2"], + "One capacity-5 link, CapacityAware flow_count 2, max 2, request 20: the " + "first reservation exhausts the link, the second search finds nothing, so one " + "flow is created (created flows are the ones that received a route); placed 5, " + "exhausted (growth finds no candidate), routes_changed true.", +) +def creation_fewer_routes(s: PSnap): + c = Ctx(s, "one_link", parallel([5])) + for kernel in (EB, MF, W): + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 1, ca(kernel, fc=2, mx=2)) + r = s.place(f"{kernel}: request 20", pol, 20) + s.expect( + f"{kernel} result", + (r.placed, r.termination, r.routes_changed), + (5, "exhausted", True), + ) + s.expect(f"{kernel} cohort size", len(pol.flow_ids()), 1) + + +@fixture( + "creation_retry_after_release", + "creation", + ["8", "9.1", "9.4"], + "Another owner holds the only link: a CapacityAware call finds no route, ends " + "no_route, creates nothing and consumes no id (flags false). After the owner's " + "remove() the next call creates the cohort (creation is attempted on every call " + "while the cohort is empty), complete, routes_changed true.", +) +def creation_retry(s: PSnap): + c = Ctx(s, "one_link", parallel([5])) + fg = c.ledger() + other, _ = c.manual(fg, 0, 1, [0], 5) + pol = P.FlowPolicy(fg, 0, 1, ca(MF)) + nxt = fg.next_flow_id() + r = s.place("first call", pol, 1) + s.expect( + "no route", + (r.termination, r.routes_changed, r.weights_changed), + ("no_route", False, False), + ) + s.expect("no id consumed", fg.next_flow_id(), nxt) + other.remove() + r = s.place("second call", pol, 1) + s.expect( + "created", (r.placed, r.termination, r.routes_changed), (1, "complete", True) + ) + s.expect("created id", pol.flow_ids()[0][1], nxt) + + +# ====================================================================== reservations + + +@fixture( + "ten_versus_five", + "reservations", + ["8b", "9.2", "9.4"], + "Two disjoint capacity-5 routes, EB CapacityAware, request 10: flow_count 2 " + "places 10 (the second route is found after the first reservation, complete); " + "flow_count 1 with max 1 places 5 and ends exhausted (a fixed-count cohort full; " + "revision 20, the grid prototype said limit_flows).", +) +def ten_versus_five(s: PSnap): + c = Ctx(s, "diamond_5", diamond(5, 5, 5, 5)) + fg = c.ledger() + r = s.place("flow_count 2", P.FlowPolicy(fg, 0, 3, ca(EB, fc=2, mx=2)), 10, fg=fg) + s.expect("two flows", (r.placed, r.termination), (10, "complete")) + fg = c.ledger() + r = s.place( + "flow_count 1, max 1", P.FlowPolicy(fg, 0, 3, ca(EB, fc=1, mx=1)), 10, fg=fg + ) + s.expect("one flow", (r.placed, r.termination), (5, "exhausted")) + + +def _eight_versus_ten(s: PSnap, priority, at8, term8, routes8): + c = Ctx(s, "diamond_5", diamond(5, 5, 5, 5)) + for volume, expect, term in ((8, at8, term8), (10, 10, "complete")): + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 3, ca(EB, priority=priority, fc=2, mx=2)) + r = s.place(f"{priority}: request {volume}", pol, volume, fg=fg) + s.expect(f"{priority} {volume}", (r.placed, r.termination), (expect, term)) + s.expect( + f"{priority} reservations at {volume}", + [x["reserved"] for x in r.trace["sources"][0]["stage1"]["reservations"]], + [volume // 2] * 2 + if volume == 10 + else ([4, 1] if routes8 == "shared" else [4, 4]), + ) + if volume == 8: + want = [[0, 1], [0, 1]] if routes8 == "shared" else [[0, 1], [2, 3]] + s.expect(f"{priority} routes at 8", c.routes_of(pol), want) + + +@fixture( + "eight_versus_ten_capacity", + "reservations", + ["8b", "9.2"], + "EB reservation target (P + v) div k with Capacity priority: request 8 reserves " + "4 on route 0, whose residual 1 still ties on static capacity, so the second " + "search takes route 0 again (reservation 1): both flows share route 0 and place " + "5 (exhausted: fixed count); request 10 reserves 5, route 0 is gated, the second " + "flow takes route 1: 10. The non-monotone CapacityAware fixture (9.2).", +) +def eight_versus_ten_capacity(s: PSnap): + _eight_versus_ten(s, "Capacity", 5, "exhausted", "shared") + + +@fixture( + "eight_versus_ten_residual", + "reservations", + ["8b", "9.2"], + "Same with Residual priority: the second search prefers the untouched route " + "(bottleneck 5 against 1): 8 and 10.", +) +def eight_versus_ten_residual(s: PSnap): + _eight_versus_ten(s, "Residual", 8, "complete", "split") + + +@fixture( + "eight_versus_ten_headroom", + "reservations", + ["8b", "9.2"], + "Same with Headroom priority (exact rationals 1/5 against 5/5): 8 and 10.", +) +def eight_versus_ten_headroom(s: PSnap): + _eight_versus_ten(s, "Headroom", 8, "complete", "split") + + +@fixture( + "request_12_on_5_5", + "reservations", + ["8b", "9.2", "9.4"], + "Infeasible reservations: request 12, flow_count 2 = max 2, two capacity-5 " + "routes. EB targets 6 clip to 5 and 5; MaxFlow and Weighted sequential " + "reservations 5 and 5; placed 10, remaining 2, exhausted (fixed count).", +) +def request_12(s: PSnap): + c = Ctx(s, "diamond_5", diamond(5, 5, 5, 5)) + for kernel in (EB, MF, W): + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 3, ca(kernel, fc=2, mx=2)) + r = s.place(f"{kernel}: request 12", pol, 12, fg=fg) + s.expect( + f"{kernel} result", + (r.placed, r.remaining, r.termination), + (10, 2, "exhausted"), + ) + res = r.trace["sources"][0]["stage1"]["reservations"] + s.expect(f"{kernel} reservations", [x["reserved"] for x in res], [5, 5]) + + +# ====================================================================== growth + + +@fixture( + "growth_request_6", + "growth", + ["8b", "9.2"], + "EB CapacityAware flow_count 1, max 2, two capacity-5 routes, request 6: the " + "first flow takes 5, growth to two flows gives the cohort 6 split [3, 3] " + "(no over-admission); complete, routes_changed true.", +) +def growth_6(s: PSnap): + c = Ctx(s, "diamond_5", diamond(5, 5, 5, 5)) + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 3, ca(EB, fc=1, mx=2)) + r = s.place("request 6", pol, 6) + s.expect( + "result", + (r.placed, r.termination, r.routes_changed, r.weights_changed), + (6, "complete", True, False), + ) + s.expect("shares", c.shares(pol), [3, 3]) + + +@fixture( + "growth_256_request_1278", + "growth", + ["8b", "9.2"], + "256 parallel capacity-5 links, EB tunnels (Residual), flow_count 1, max 256, " + "request 1278: 255 accepted growth steps, k = 256, the cohort total 1278 split " + "by round robin: 254 flows at 5 and 2 at 4; complete.", +) +def growth_256(s: PSnap): + c = Ctx(s, "parallel_256", parallel([5] * 256)) + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 1, ca(EB, priority="Residual", fc=1, mx=256)) + r = pol.place(1278) + steps = r.trace["sources"][0]["stage4"] + for st in steps: + st.pop("W", None) + st.pop("shares", None) + s.step("request 1278", result=r.to_json(), growth=steps, created=r.trace["created"]) + s.expect("result", (r.placed, r.termination), (1278, "complete")) + shares = c.shares(pol) + s.expect( + "k and shares", (len(shares), shares.count(5), shares.count(4)), (256, 254, 2) + ) + s.expect( + "accepted growth steps", + sum(1 for x in steps if x.get("decision") == "accepted"), + 255, + ) + s.expect("created", r.trace["created"], 256) + + +@fixture( + "harmful_growth_10_1", + "growth", + ["8b", "9.2", "9.4"], + "Routes of capacity 10 (cost 2) and 1 (cost 4), EB, max 2. P = 10 on route 0; " + "place(1): the candidate on route 1 gives a cohort bound of 3 (round robin [2, 1] " + "over capacities [10, 1]) < 10 and is rejected: delta 0, exhausted, flags false, " + "no id consumed.", +) +def harmful_growth(s: PSnap): + c = Ctx(s, "harmful", diamond(10, 10, 1, 1, c0=1, c1=2)) + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 3, ca(EB, fc=1, mx=2)) + s.place("request 10", pol, 10) + nxt = fg.next_flow_id() + r = s.place("request 1", pol, 1) + s.expect( + "result", + (r.placed, r.termination, r.routes_changed, r.weights_changed), + (0, "exhausted", False, False), + ) + rej = [ + x + for x in r.trace["sources"][0]["stage4"] + if x.get("decision", "").startswith("rejected") + ] + s.expect( + "rejected candidate", + [(x["route"], x["T_candidate"]) for x in rej], + [(list(c.ids([2, 3])), 3)], + ) + s.expect("no id consumed", fg.next_flow_id(), nxt) + s.expect("total", pol.placed_demand(), 10) + + +@fixture( + "hidden_beneficial_route", + "growth", + ["8b", "9.4"], + "Routes (capacity, cost per edge) (10, 1), (1, 2), (10, 3); P = 10 on the " + "first; place(1) evaluates the cheapest candidate (route 1), rejects it (bound " + "3) and stops: exhausted under the greedy rule, although routes 0 and 2 jointly " + "would total 11 (round robin [6, 5] fits [10, 10]; documented, not searched).", +) +def hidden_beneficial(s: PSnap): + c = Ctx(s, "three_routes", routes([(10, 1), (1, 2), (10, 3)])) + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 4, ca(EB, fc=1, mx=2)) + s.place("request 10", pol, 10) + r = s.place("request 1", pol, 1) + s.expect("result", (r.placed, r.termination), (0, "exhausted")) + tried = [x["route"] for x in r.trace["sources"][0]["stage4"] if "route" in x] + s.expect("candidate tried", tried, [list(c.ids([2, 3]))]) + rr = P.round_robin(11, 2) + s.expect( + "alternative (not searched) fits", + (rr, rr[0] <= 10 and rr[1] <= 10), + ([6, 5], True), + ) + + +# ====================================================================== four calls + + +def _four_calls(s: PSnap, kernel, adm, expected): + for shape, caps in (("equal", (5, 5)), ("unequal", (1, 10))): + c = Ctx(s, f"diamond_{shape}", diamond(caps[0], caps[0], caps[1], caps[1])) + for mode in ("CostOnly", "Pinned"): + fg = c.ledger() + cfg = ( + co(kernel, adm) + if mode == "CostOnly" + else c.pin(kernel, [[0, 1], [2, 3]], adm) + ) + pol = P.FlowPolicy(fg, 0, 3, cfg) + got = [ + s.place(f"{shape} {mode} call {i}", pol, 1, fg=fg).placed + for i in range(4) + ] + s.expect(f"{shape} {mode}", got, expected[shape]) + + +@fixture( + "four_calls_MaxFlow", + "four_calls", + ["8a", "16.4"], + "MaxFlow, four calls of 1 on equal (5/5) and unequal (1/10) routes, one " + "CostOnly flow on the diamond and two Pinned flows: 1, 1, 1, 1.", +) +def four_calls_mf(s: PSnap): + _four_calls(s, MF, LOSSLESS, {"equal": [1] * 4, "unequal": [1] * 4}) + + +@fixture( + "four_calls_W", + "four_calls", + ["8a", "16.4"], + "Weighted (Residual weights at creation, 1:10 on the unequal diamond; highest " + "averages gives [0, T] up to T = 9): 1, 1, 1, 1 everywhere.", +) +def four_calls_w(s: PSnap): + _four_calls(s, W, LOSSLESS, {"equal": [1] * 4, "unequal": [1] * 4}) + + +@fixture( + "four_calls_EB", + "four_calls", + ["8a", "16.4"], + "EqualBalanced: equal 1, 1, 1, 1; unequal 1/10: 1, 1, 0, 0 (round robin [2, 1] " + "overfills the 1-route at T = 3).", +) +def four_calls_eb(s: PSnap): + _four_calls(s, EB, LOSSLESS, {"equal": [1] * 4, "unequal": [1, 1, 0, 0]}) + + +@fixture( + "four_calls_Lossy", + "four_calls", + ["19", "16.6"], + "Lossy EB (per-call offers, round robin from the lowest EdgeId every call): " + "equal 1, 1, 1, 1; unequal 1, 0, 0, 0 (every one-unit offer goes to the full " + "1-route and is dropped there; the grid prototype's halves do not exist in " + "units).", +) +def four_calls_lossy(s: PSnap): + _four_calls(s, EB, LOSSY, {"equal": [1] * 4, "unequal": [1, 0, 0, 0]}) + + +@fixture( + "eb_unequal_1_10", + "four_calls", + ["8a", "9.4"], + "EB on the unequal 1/10 diamond (one CostOnly flow): 1, 1, 0, 0; the third and " + "fourth calls are exhausted with nothing placed.", +) +def eb_unequal(s: PSnap): + c = Ctx(s, "diamond_1_10", diamond(1, 1, 10, 10)) + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 3, co(EB)) + rs = [s.place(f"call {i}", pol, 1) for i in range(4)] + s.expect("placed", [r.placed for r in rs], [1, 1, 0, 0]) + s.expect( + "terminations", + [r.termination for r in rs], + ["complete", "complete", "exhausted", "exhausted"], + ) + + +@fixture( + "cancellation_dag", + "four_calls", + ["20", "16.4"], + "CostOnly MaxFlow on the cancellation DAG (all costs 1): calls 1 then 1 give 1 " + "and 1 (edge 1->3 goes 1 -> 0), a single call of 2 gives the same allocation " + "[1,1,0,1,1,1,1].", +) +def cancellation(s: PSnap): + pairs = [(0, 1), (0, 2), (1, 3), (1, 4), (2, 3), (3, 5), (4, 5)] + c = Ctx(s, "cancellation", (6, [(u, v, 1, 1) for u, v in pairs])) + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 5, co(MF)) + a = s.place("call 1", pol, 1) + s.expect("after call 1", c.alloc(fg), [1, 0, 1, 0, 0, 1, 0]) + b = s.place("call 2", pol, 1) + s.expect("two calls", (a.placed, b.placed), (1, 1)) + s.expect("after call 2", c.alloc(fg), [1, 1, 0, 1, 1, 1, 1]) + fg2 = c.ledger() + s.place("single call 2", P.FlowPolicy(fg2, 0, 5, co(MF)), 2, fg=fg2) + s.expect("1 + 1 equals 2", c.alloc(fg2), c.alloc(fg)) + + +# ====================================================================== Lossy + + +@fixture( + "lossy_diamond_10_then_1", + "lossy", + ["19", "16.6", "15"], + "CostOnly Lossy EB on source links 10/10 over tails 1/10: request 10 delivers " + "6 (complete, 4 dropped on the 1-tail, remaining 4); request 1 is offered to the " + "full branch and dropped (complete, delivered 0); the record keeps offered 11, " + "delivered 6 and 5 drops on the tail; drops are never re-offered.", +) +def lossy_diamond(s: PSnap): + c = Ctx(s, "diamond_10_1_10_10", diamond(10, 1, 10, 10)) + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 3, co(EB, LOSSY)) + tail = c.ids([1])[0] + a = s.place("request 10", pol, 10) + s.expect( + "first", + (a.placed, a.termination, a.remaining, a.drops), + (6, "complete", 4, [(tail, 4)]), + ) + b = s.place("request 1", pol, 1) + s.expect( + "second", + (b.placed, b.termination, b.remaining, b.drops), + (0, "complete", 1, [(tail, 1)]), + ) + rec = pol.records()[0] + s.expect("record", (rec.offered, rec.delivered, rec.drops), (11, 6, {tail: 5})) + + +@fixture( + "lossy_shared_ingress", + "lossy", + ["19", "16.6"], + "Two Pinned Lossy EB flows share an ingress of capacity 2 (tails 1 and 10): " + "requests 2 then 2 deliver 2 then 0 (the second offers are dropped on the full " + "ingress, complete); one request 4 delivers 1 (the first flow spends the ingress " + "on its 1-tail, the second flow's offer of 2 is dropped on the ingress). Not " + "additive (rule 19).", +) +def lossy_shared(s: PSnap): + c = Ctx( + s, + "shared_ingress", + (4, [(0, 1, 2, 1), (1, 3, 1, 1), (1, 2, 10, 1), (2, 3, 10, 1)]), + ) + bundles = [[0, 1], [0, 2, 3]] + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 3, c.pin(EB, bundles, LOSSY)) + a = s.place("request 2", pol, 2) + b = s.place("request 2 again", pol, 2) + s.expect( + "2 then 2", + ((a.placed, a.termination), (b.placed, b.termination)), + ((2, "complete"), (0, "complete")), + ) + fg2 = c.ledger() + pol2 = P.FlowPolicy(fg2, 0, 3, c.pin(EB, bundles, LOSSY)) + r = s.place("request 4", pol2, 4, fg=fg2) + s.expect("single 4", (r.placed, r.termination), (1, "complete")) + recs = pol2.records() + s.expect( + "records (offered, delivered, drops)", + [(x.offered, x.delivered, dict_to_input(c.g, x.drops)) for x in recs], + [(2, 1, [0, 1, 0, 0]), (2, 0, [2, 0, 0, 0])], + ) + + +# ====================================================================== +inf + + +@fixture( + "inf_every_kernel", + "infinite", + ["15", "8a", "8b"], + "+inf for every kernel: CostOnly MaxFlow, Weighted and EB on 5/5 place 10 " + "(exhausted, remaining +inf); EB and MaxFlow CapacityAware grow to two flows " + "(10); Lossy injects every source member to its residual: 11 on the 1/10 " + "diamond; on 10/10 over 1/10 delivered 11 with 20 offered and 9 dropped.", +) +def inf_every_kernel(s: PSnap): + c = Ctx(s, "diamond_5", diamond(5, 5, 5, 5)) + for kernel in (MF, W, EB): + fg = c.ledger() + r = s.place( + f"CostOnly {kernel} +inf", P.FlowPolicy(fg, 0, 3, co(kernel)), INF, fg=fg + ) + s.expect( + f"CostOnly {kernel}", + (r.placed, r.remaining, r.termination), + (10, None, "exhausted"), + ) + for kernel in (EB, MF): + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 3, ca(kernel, fc=1)) + r = s.place(f"CapacityAware {kernel} +inf", pol, INF, fg=fg) + s.expect( + f"CapacityAware {kernel}", + (r.placed, r.termination, len(pol.flow_ids())), + (10, "exhausted", 2), + ) + c2 = Ctx(s, "diamond_1_10", diamond(1, 1, 10, 10)) + fg = c2.ledger() + r = s.place("Lossy +inf 1/10", P.FlowPolicy(fg, 0, 3, co(EB, LOSSY)), INF, fg=fg) + s.expect("Lossy 1/10", (r.placed, r.termination), (11, "exhausted")) + c3 = Ctx(s, "diamond_10_1_10_10", diamond(10, 1, 10, 10)) + fg = c3.ledger() + pol = P.FlowPolicy(fg, 0, 3, co(EB, LOSSY)) + r = s.place("Lossy +inf transit", pol, INF, fg=fg) + rec = pol.records()[0] + s.expect( + "Lossy transit", (r.placed, rec.offered, sum(rec.drops.values())), (11, 20, 9) + ) + + +# ====================================================================== reoptimisation + + +@fixture( + "retention_third_party_removal", + "reoptimisation", + ["8b", "9.2"], + "Two capacity-5 routes, another owner holds 2 on route 0. EB CapacityAware " + "count 2 (max 2), Capacity priority, reoptimisation. Request 8 reserves 3 and 4 " + "and places 6 ([3, 3]; exhausted). The owner leaves; request 1: flow 0 keeps " + "route 0, flow 1's candidate (route 0, view 2) cannot carry its 3 and is " + "rejected (retention); stage 3 raises the cohort to 7 ([4, 3]); flags false.", +) +def retention(s: PSnap): + c = Ctx(s, "diamond_5", diamond(5, 5, 5, 5)) + fg = c.ledger() + other, _ = c.manual(fg, 0, 3, [0, 1], 2) + pol = P.FlowPolicy(fg, 0, 3, ca(EB, fc=2, mx=2, reopt=True)) + r = s.place("request 8", pol, 8) + s.expect("first", (r.placed, r.termination), (6, "exhausted")) + res = r.trace["sources"][0]["stage1"]["reservations"] + s.expect("reservations", [x["reserved"] for x in res], [3, 4]) + other.remove() + r = s.place("request 1", pol, 1) + rows = r.trace["sources"][0]["stage2"] + s.expect( + "reopt decisions", + [x["decision"].split(":")[0] for x in rows], + ["kept", "rejected"], + ) + s.expect("second", (r.placed, pol.placed_demand(), c.shares(pol)), (1, 7, [4, 3])) + s.expect("flags", (r.routes_changed, r.weights_changed), (False, False)) + + +@fixture( + "shared_capacity_view", + "reoptimisation", + ["8b", "9.2"], + "Shared ingress e0 (10) and parallel egresses e1, e2 (10 each); a third party " + "holds 6 on e1. Two EB tunnels (Residual, Collapse picks the egress with the " + "larger residual) both take e2: [4, 4]. After the third party leaves, request " + "1: flow 0 sees e0 = 2 + 4 = 6 on its view W + own, moves to e1 at T_old = 4; " + "the cohort reaches 9 ([5, 4]); routes_changed true, weights_changed false.", +) +def shared_view(s: PSnap): + c = Ctx(s, "shared", (3, [(0, 1, 10, 1), (1, 2, 10, 1), (1, 2, 10, 1)])) + fg = c.ledger() + other, _ = c.manual(fg, 1, 2, [1], 6) + pol = P.FlowPolicy(fg, 0, 2, ca(EB, priority="Residual", fc=2, mx=2, reopt=True)) + r = s.place("request 8", pol, 8) + s.expect("first", (r.placed, c.routes_of(pol)), (8, [[0, 2], [0, 2]])) + other.remove() + r = s.place("request 1", pol, 1) + row = r.trace["sources"][0]["stage2"][0] + s.expect( + "flow 0 adopted e1", + (row["decision"], row["candidate"]), + ("adopted", list(c.ids([0, 1]))), + ) + s.expect("second", (r.placed, pol.placed_demand(), c.shares(pol)), (1, 9, [5, 4])) + s.expect("flags", (r.routes_changed, r.weights_changed), (True, False)) + + +@fixture( + "reopt_double_spend", + "reoptimisation", + ["8b", "9.2"], + "MaxFlow tunnel with reoptimisation on two parallel capacity-10 links: T = 4 on " + "e0; a third party fills e0. Request 1: the reroute to e1 happens at T_old = 4 " + "(no budget), stage 3 adds 1: placed 1, total 5. The single-link variant (no " + "third party) places 1 as well.", +) +def double_spend(s: PSnap): + c = Ctx(s, "parallel_10_10", parallel([10, 10])) + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 1, ca(MF, priority="Residual", fc=1, mx=1, reopt=True)) + s.place("request 4", pol, 4) + c.manual(fg, 0, 1, [0], 6, "third party fills e0") + r = s.place("request 1", pol, 1) + s.expect("result", (r.placed, pol.placed_demand(), c.routes_of(pol)), (1, 5, [[1]])) + s.expect("flags", (r.routes_changed, r.weights_changed), (True, False)) + c1 = Ctx(s, "one_link", parallel([10])) + fg1 = c1.ledger() + p1 = P.FlowPolicy(fg1, 0, 1, ca(MF, priority="Residual", fc=1, mx=1, reopt=True)) + s.place("single link request 4", p1, 4, fg=fg1) + r = s.place("single link request 1", p1, 1, fg=fg1) + s.expect("single link", (r.placed, p1.placed_demand()), (1, 5)) + + +@fixture( + "retention_same_structure", + "reoptimisation", + ["8b", "9.4"], + "Three parallel capacity-2 links, EB CapacityAware multipath with " + "reoptimisation: request 3 gives [1, 1, 1]; third parties fill the leftovers. " + "Request 1: the candidate DAG equals the flow's route (a structurally equal " + "candidate is not a reroute): kept, nothing shed, placed 0, exhausted, flags " + "false. (Replaces the grid fixture 3 + g.)", +) +def retention_same(s: PSnap): + c = Ctx(s, "parallel_2", parallel([2, 2, 2])) + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 1, ca(EB, fc=1, reopt=True, sel=search.Selection())) + s.place("request 3", pol, 3) + s.expect("allocation", c.own(fg, pol), [1, 1, 1]) + for p in range(3): + c.manual(fg, 0, 1, [p], 1, f"third party fills link {p}") + r = s.place("request 1", pol, 1) + s.expect( + "result", + (r.placed, r.termination, r.routes_changed, r.weights_changed), + (0, "exhausted", False, False), + ) + s.expect( + "reopt", [x["decision"] for x in r.trace["sources"][0]["stage2"]], ["kept"] + ) + s.expect("nothing shed", pol.placed_demand(), 3) + + +# ====================================================================== third parties + + +@fixture( + "weighted_frozen_third_party", + "third_party", + ["8a", "16.2", "9.4"], + "CostOnly Weighted on source links 10/10 over tails t/10: weights [10, 10] " + "captured at creation and frozen. (a) t = 1: 2 then 1 give 2 then 0 (T = 3 " + "needs 2 on the 1-tail). (b) t = 2: request 2 ([1, 1]); a third party takes 1 " + "on the tail; request 1 places 0; after the third party leaves, request 1 places " + "1. The plan never changes; weights_changed stays false.", +) +def weighted_frozen(s: PSnap): + c = Ctx(s, "diamond_10_1", diamond(10, 1, 10, 10)) + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 3, co(W)) + a = s.place("(a) request 2", pol, 2) + b = s.place("(a) request 1", pol, 1) + s.expect("(a)", (a.placed, b.placed), (2, 0)) + c2 = Ctx(s, "diamond_10_2", diamond(10, 2, 10, 10)) + fg = c2.ledger() + pol = P.FlowPolicy(fg, 0, 3, co(W)) + s.place("(b) request 2", pol, 2, fg=fg) + plan = pol.state()["cohorts"][0][0]["plan"] + other, _ = c2.manual(fg, 1, 3, [1], 1, "(b) third party on the tail") + r1 = s.place("(b) request 1 blocked", pol, 1, fg=fg) + other.remove() + r2 = s.place("(b) request 1 after removal", pol, 1, fg=fg) + s.expect("(b)", (r1.placed, r2.placed), (0, 1)) + s.expect("(b) plan frozen", pol.state()["cohorts"][0][0]["plan"], plan) + s.expect( + "(b) weights_changed", (r1.weights_changed, r2.weights_changed), (False, False) + ) + + +@fixture( + "third_party_occupancy", + "third_party", + ["8a", "16.2", "18"], + "Capacity 10, baseline 8, own 4, another owner 3: the residual is 1 and the " + "view 5; request 2 adds exactly 1 for MaxFlow, Weighted and EB (exhausted) and " + "never touches the other owner's record.", +) +def third_party(s: PSnap): + c = Ctx(s, "one_link", parallel([10])) + for kernel in (MF, W, EB): + fg = c.ledger([8]) + pol = P.FlowPolicy(fg, 0, 1, co(kernel)) + s.place(f"{kernel} own 4", pol, 4, fg=fg) + other, _ = c.manual(fg, 0, 1, [0], 3) + r = s.place(f"{kernel} request 2", pol, 2, fg=fg) + s.expect(f"{kernel}", (r.placed, r.termination), (1, "exhausted")) + s.expect( + f"{kernel} other untouched", [x.delivered for x in other.records()], [3] + ) + + +# ====================================================================== cohorts in units + + +@fixture( + "eb_correlated_remainders", + "units", + ["16.5", "9.2"], + "Round-robin remainders correlate across flows sharing a split: three parallel " + "1-unit links. A Pinned EB flow on the three links places 3. Two Pinned bundles " + "that become equal after pruning (the second carries a dead branch) keep two " + "flows on the same split: T = 2 gives each flow its unit on the lowest-id link " + "(2 > 1), so the cohort places 1 (exact and feasible; no start rotation, " + "decided).", +) +def eb_correlated(s: PSnap): + c = Ctx( + s, + "parallel_1_dead", + (3, [(0, 1, 1, 1), (0, 1, 1, 1), (0, 1, 1, 1), (0, 2, 1, 1)]), + ) + fg = c.ledger() + r = s.place("one flow", P.FlowPolicy(fg, 0, 1, c.pin(EB, [[0, 1, 2]])), 3, fg=fg) + s.expect("one flow", (r.placed, r.termination), (3, "complete")) + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 1, c.pin(EB, [[0, 1, 2], [0, 1, 2, 3]])) + r = s.place("two flows on the same split", pol, 3, fg=fg) + s.expect( + "cohort", (r.placed, r.termination, c.shares(pol)), (1, "exhausted", [1, 0]) + ) + s.expect("routes equal after pruning", c.routes_of(pol), [[0, 1, 2], [0, 1, 2]]) + + +@fixture( + "eb_1_plus_2_vs_3", + "units", + ["16.4", "16.5"], + "Three ample parallel links: EB place(1); place(2) equals place(3) exactly " + "([1, 1, 1]) for one CostOnly flow and for a Pinned cohort of three single-link " + "flows (round robin over the cohort in FlowId order).", +) +def eb_additivity(s: PSnap): + c = Ctx(s, "parallel_10", parallel([10, 10, 10])) + for mode in ("CostOnly", "Pinned"): + finals = [] + for calls in ([1, 2], [3]): + fg = c.ledger() + cfg = co(EB) if mode == "CostOnly" else c.pin(EB, [[0], [1], [2]]) + pol = P.FlowPolicy(fg, 0, 1, cfg) + got = [s.place(f"{mode} {v}", pol, v, fg=fg).placed for v in calls] + s.expect(f"{mode} {calls}", got, calls) + finals.append(c.alloc(fg)) + s.expect(f"{mode} 1 + 2 equals 3", finals[0], finals[1]) + s.expect(f"{mode} allocation", finals[1], [1, 1, 1]) + + +@fixture( + "weighted_1_plus_5_vs_6", + "units", + ["16.4"], + "Three equal parallel capacity-2 links, CostOnly Weighted: place(1); place(5) " + "equals place(6) ([2, 2, 2]).", +) +def weighted_additivity(s: PSnap): + c = Ctx(s, "parallel_2", parallel([2, 2, 2])) + finals = [] + for calls in ([1, 5], [6]): + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 1, co(W)) + got = [s.place(f"{calls} {v}", pol, v, fg=fg).placed for v in calls] + s.expect(f"{calls}", got, calls) + finals.append(c.alloc(fg)) + s.expect("1 + 5 equals 6", finals[0], finals[1]) + s.expect("allocation", finals[1], [2, 2, 2]) + + +# ====================================================================== admission + + +@fixture( + "admission_zero_and_no_route", + "admission", + ["13", "9.4", "8"], + "Zero request: complete, nothing created, flags false. Overlapping endpoint " + "sets (src == dst) are rejected at construction (rule 13). No route: CostOnly " + "over zero-capacity links has an empty static roster (no_route, nothing " + "created); CapacityAware on a zero baseline has a static roster but no residual " + "route (no_route, no id consumed); Pinned with every bundle excluded " + "(usable_bundles 0, no_route).", +) +def admission(s: PSnap): + c = Ctx(s, "diamond_5", diamond(5, 5, 5, 5)) + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 3, ca(MF)) + r = s.place("request 0", pol, 0) + s.expect( + "zero", + ( + r.termination, + r.placed, + r.routes_changed, + r.weights_changed, + fg.next_flow_id(), + ), + ("complete", 0, False, False, 0), + ) + s.raises("src == dst rejected", P.FlowPolicy, fg, 0, 0, ca(MF)) + s.raises("overlapping sets rejected", P.FlowPolicy, fg, [0, 1], [1, 3], co(MF)) + c0 = Ctx(s, "diamond_0", diamond(0, 0, 0, 0)) + fg0 = c0.ledger() + p0 = P.FlowPolicy(fg0, 0, 3, co(MF)) + r = s.place("CostOnly over zero-capacity links", p0, 1, fg=fg0) + s.expect( + "CostOnly no route", + (r.termination, p0.roster, fg0.records()), + ("no_route", [], []), + ) + fgz = c.ledger([0, 0, 0, 0]) + pz = P.FlowPolicy(fgz, 0, 3, ca(EB, fc=2)) + r = s.place("CapacityAware on a zero residual", pz, 1, fg=fgz) + s.expect( + "CapacityAware no route", (r.termination, fgz.next_flow_id()), ("no_route", 0) + ) + fgd = c.ledger() + elig = c.g.eligibility(excluded_edges=c.ids([0, 2])) + pd = P.FlowPolicy(fgd, 0, 3, c.pin(MF, [[0, 1], [2, 3]]), elig) + r = s.place("Pinned all down", pd, 1, fg=fgd) + s.expect("Pinned no route", (r.termination, pd.usable_bundles()), ("no_route", 0)) + + +# ====================================================================== limits + + +@fixture( + "limits_kmaxflowspercall", + "limits", + ["8b", "9.2", "9.4"], + "kMaxFlowsPerCall is an engine constant (10,000); the fixture lowers the " + "module's test hook to 3. Growth over five 1-unit links stops after 3 creations " + "(limit_flows, placed 3); a Pinned policy with 5 bundles still creates 5 flows " + "(the cap counts CapacityAware creations); two CapacityAware sources share one " + "created counter: source 0 creates 3, source 1's creation is blocked by the cap " + "(local limit_flows, nothing committed for it, no id consumed).", +) +def limits_created(s: PSnap): + s.expect("engine constant", K_MAX_FLOWS_PER_CALL, 10000) + saved = P.MAX_FLOWS_PER_CALL + P.MAX_FLOWS_PER_CALL = 3 + try: + c = Ctx(s, "parallel_1x5", parallel([1] * 5)) + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 1, ca(MF, priority="Residual")) + r = s.place("request 5", pol, 5) + s.expect( + "growth capped", + (r.placed, r.termination, len(pol.flow_ids())), + (3, "limit_flows", 3), + ) + fg = c.ledger() + pin = P.FlowPolicy(fg, 0, 1, c.pin(MF, [[i] for i in range(5)])) + r = s.place("Pinned 5 bundles", pin, 5, fg=fg) + s.expect("Pinned not capped", (r.placed, len(pin.flow_ids())), (5, 5)) + c2 = Ctx(s, "two_sources", (3, [(0, 2, 1, 1)] * 4 + [(1, 2, 1, 1)] * 4)) + fg = c2.ledger() + two = P.FlowPolicy(fg, [0, 1], 2, ca(MF, priority="Residual")) + r = s.place("two sources request 8", two, 8, fg=fg) + s.expect("offers", r.trace["offers"], {0: 4, 1: 4}) + s.expect( + "local outcomes", + r.trace["local_outcomes"], + {0: "limit_flows", 1: "limit_flows"}, + ) + s.expect( + "result", + (r.placed, r.termination, len(two.flow_ids())), + (3, "limit_flows", 3), + ) + finally: + P.MAX_FLOWS_PER_CALL = saved + + +@fixture( + "limits_diminishing", + "limits", + ["8b", "9.2", "9.4"], + "q = 2^-12: one 50-capacity link (204,800 units) and twenty 0.01 links (41 " + "units each). MaxFlow tunnels (Residual), Diminishing(window 8, fraction 1e-3), " + "request 100 (409,600 units): growth adds 41 units per step; after 8 steps the " + "window sum 328 < 409.6 stops growth (limit_diminishing). Without the guard all " + "20 links are used (exhausted); +inf never arms it.", +) +def limits_diminishing(s: PSnap): + q = 2.0**-12 + from units import to_units + + small = to_units(0.01, q) + big = to_units(50, q) + s.expect("units", (small, big), (41, 204800)) + c = Ctx(s, "fan", parallel([big] + [small] * 20), resolution=q) + dim = P.Diminishing(8, 1e-3) + for label, d, volume, steps, term in ( + ("window8", dim, to_units(100, q), 8, "limit_diminishing"), + ("no guard", None, to_units(100, q), 20, "exhausted"), + ("inf", dim, INF, 20, "exhausted"), + ): + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 1, ca(MF, priority="Residual", diminishing=d)) + r = s.place(f"{label}", pol, volume, fg=fg) + s.expect( + f"{label}", + (r.placed, r.termination, len(pol.flow_ids())), + (big + steps * small, term, steps + 1), + ) + + +# ====================================================================== lifetime + + +@fixture( + "lifetime_remove_orphan_reset", + "lifetime", + ["8", "18", "13"], + "Section 8 with policies (replaces the invalidation fixture: there is no " + "external removal): reset and copy are rejected while a policy is bound; " + "remove() reverts and retires the ids; a later place opens a new cohort with a " + "new id (routes_changed true); remove() on an empty cohort resets the epoch " + "state; a destroyed policy's records stay as orphans until reset; retired ids " + "throw; ids are never reused.", +) +def lifetime(s: PSnap): + c = Ctx(s, "diamond_5", diamond(5, 5, 5, 5)) + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 3, ca(MF)) + s.place("request 3", pol, 3) + fid = pol.flow_ids()[0] + s.raises("reset while bound", fg.reset) + s.raises("copy while bound", fg.copy) + pol.remove() + s.expect("reverted", (c.alloc(fg), pol.placed_demand()), ([0, 0, 0, 0], 0)) + s.raises("retired id", fg.record, fid) + r = s.place("restart", pol, 2) + s.expect( + "restart", + (r.routes_changed, r.weights_changed, pol.flow_ids()[0][1]), + (True, False, 1), + ) + pol.remove() + pol.remove() + s.expect( + "empty-cohort remove resets the epoch", + (pol.requested, pol.infinite), + (0, False), + ) + other = P.FlowPolicy(fg, 0, 3, co(EB)) + s.place("other 2", other, 2) + other.destroy() + s.expect("orphan readable", [x.delivered for x in fg.records()], [2]) + s.raises("reset while pol bound", fg.reset) + pol.destroy() + fg.reset() + s.expect("reset clears orphans", (fg.records(), fg.next_flow_id()), ([], 3)) + + +# ====================================================================== Pinned + + +@fixture( + "pinned_kernels_5_1", + "pinned", + ["8a", "19", "16.5"], + "Pinned routes of capacity 5 and 1, request 8: MaxFlow 6 and Weighted 6 " + "(sequential fill, exhausted, remaining 2); EqualBalanced 3 (round robin over " + "the cohort: [2, 1]); Lossy 5 (offers [4, 4]: the 1-route carries 1 and drops 3 " + "on its first link; complete).", +) +def pinned_kernels(s: PSnap): + c = Ctx(s, "diamond_5_1", diamond(5, 5, 1, 1)) + for kernel, adm, placed, term in ( + (MF, LOSSLESS, 6, "exhausted"), + (W, LOSSLESS, 6, "exhausted"), + (EB, LOSSLESS, 3, "exhausted"), + (EB, LOSSY, 5, "complete"), + ): + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 3, c.pin(kernel, [[0, 1], [2, 3]], adm)) + r = s.place(f"{kernel}/{adm} request 8", pol, 8, fg=fg) + s.expect(f"{kernel}/{adm}", (r.placed, r.termination), (placed, term)) + if adm == LOSSY: + s.expect("Lossy drops", r.drops, [(c.ids([2])[0], 3)]) + + +@fixture( + "pinned_bundle_rules", + "pinned", + ["13", "12", "9.1"], + "Pinned rules: an empty list, duplicate bundles (pre-prune), a bundle without a " + "src -> dst walk and a source set are rejected; bundles that become equal after " + "pruning keep independent flows; a multi-walk DAG bundle with mixed costs " + "reports its minimum-cost walk as route_cost (3, not 4).", +) +def pinned_rules(s: PSnap): + c = Ctx( + s, + "mixed", + (4, [(0, 1, 5, 1), (1, 3, 5, 2), (0, 2, 5, 1), (2, 3, 5, 3), (0, 3, 5, 9)]), + ) + fg = c.ledger() + s.raises( + "empty list", + P.FlowPolicy, + fg, + 0, + 3, + P.FlowPolicyConfig(P.Pinned(()), MF, LOSSLESS, search.Selection()), + ) + s.raises("duplicate", P.FlowPolicy, fg, 0, 3, c.pin(MF, [[0, 1], [0, 1]])) + s.raises("malformed", P.FlowPolicy, fg, 0, 3, c.pin(MF, [[0, 2]])) + s.raises("source set", P.FlowPolicy, fg, [0, 1], 3, c.pin(MF, [[0, 1]])) + elig = c.g.eligibility(excluded_edges=c.ids([4])) + pol = P.FlowPolicy(fg, 0, 3, c.pin(MF, [[0, 1], [0, 1, 4]]), elig) + s.expect("two usable bundles", pol.usable_bundles(), 2) + s.place("request 1", pol, 1) + s.expect( + "independent flows on equal pruned routes", c.routes_of(pol), [[0, 1], [0, 1]] + ) + fg2 = c.ledger() + multi = P.FlowPolicy(fg2, 0, 3, c.pin(MF, [[0, 1, 2, 3]])) + s.place("multi-walk bundle", multi, 7, fg=fg2) + s.expect("route_cost is the min-cost walk", multi.records()[0].route_cost, 3) + s.expect("cheapest walk fills first", c.alloc(fg2), [5, 5, 2, 2, 0]) + + +# ====================================================================== flags + + +@fixture( + "flag_table", + "flags", + ["14", "9.4"], + "The authoritative flag table of revision 20 (flags accumulate committed " + "events): creation (T, F), also at zero delivery; no-op (F, F); saturated call " + "(F, F); adopted Weighted reroute that admits zero (T, T; review-11 C4); " + "rejected growth only (F, F); accepted growth (T, F); EB routes [3, 2, 1] " + "holding 3, request 3: growth onto the second route accepted (gain 2), onto the " + "third rejected: routes_changed stays true; restart after remove() (T, F).", +) +def flag_table(s: PSnap): + c = Ctx(s, "diamond_5", diamond(5, 5, 5, 5)) + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 3, ca(MF, mx=1)) + rows = {} + rows["creation"] = s.place("creation", pol, 5) + rows["no-op"] = s.place("no-op", pol, 0) + rows["saturated without growth candidate change"] = s.place("saturated", pol, 1) + pol.remove() + rows["restart"] = s.place("restart", pol, 1) + cw = Ctx(s, "parallel_10_20", parallel([10, 20])) + fgw = cw.ledger() + blocking, _ = cw.manual(fgw, 0, 1, [1], 20, "blocking owner") + wsel = tunnel("Capacity") + wpol = P.FlowPolicy( + fgw, + 0, + 1, + P.FlowPolicyConfig( + P.CapacityAware(max_flow_count=1, reoptimize=True), W, LOSSLESS, wsel + ), + ) + s.place("Weighted place(4)", wpol, 4, fg=fgw) + cw.manual(fgw, 0, 1, [0], 6, "owner fills e0") + blocking.remove() + cw.manual(fgw, 0, 1, [1], 16, "owner takes 16 of e1") + rows["adopted Weighted reroute at zero"] = s.place( + "Weighted reroute", wpol, 1, fg=fgw + ) + s.expect( + "reroute admitted zero", rows["adopted Weighted reroute at zero"].placed, 0 + ) + ch = Ctx(s, "harmful", diamond(10, 10, 1, 1, c0=1, c1=2)) + fgh = ch.ledger() + ph = P.FlowPolicy(fgh, 0, 3, ca(EB, fc=1, mx=2)) + s.place("P = 10", ph, 10, fg=fgh) + rows["rejected growth only"] = s.place("rejected growth", ph, 1, fg=fgh) + cg = Ctx(s, "diamond_5b", diamond(5, 5, 5, 5)) + fgg = cg.ledger() + pg = P.FlowPolicy(fgg, 0, 3, ca(EB, fc=1, mx=2)) + s.place("first 5", pg, 5, fg=fgg) + rows["accepted growth"] = s.place("accepted growth", pg, 5, fg=fgg) + c3 = Ctx(s, "routes_3_2_1", routes([(3, 1), (2, 2), (1, 3)])) + fg3 = c3.ledger() + p3 = P.FlowPolicy(fg3, 0, 4, ca(EB, fc=1, mx=3)) + s.place("hold 3", p3, 3, fg=fg3) + r = s.place("request 3", p3, 3, fg=fg3) + decisions = [ + x.get("decision", x.get("stop")) for x in r.trace["sources"][0]["stage4"] + ] + s.expect( + "growth decisions", + [d.split(":")[0] for d in decisions], + ["accepted", "rejected"], + ) + s.expect( + "[3, 2, 1] witness", + (r.placed, r.termination, c3.shares(p3)), + (2, "exhausted", [3, 2]), + ) + rows["accepted then rejected growth"] = r + want = { + "creation": (True, False), + "no-op": (False, False), + "saturated without growth candidate change": (False, False), + "restart": (True, False), + "adopted Weighted reroute at zero": (True, True), + "rejected growth only": (False, False), + "accepted growth": (True, False), + "accepted then rejected growth": (True, False), + } + for name, (rc, wc) in want.items(): + res = rows[name] + s.expect(f"flags: {name}", (res.routes_changed, res.weights_changed), (rc, wc)) + + +@fixture( + "flag_zero_delivery_creation", + "flags", + ["9.4", "8"], + "Revision-20 witness: a first place(1) on a static route whose unit another " + "owner holds creates a zero-delivery record: routes_changed true, termination " + "exhausted, the record stays (CostOnly and Pinned); a second zero-delivery call " + "keeps the same ids and sets no flag (revision 21).", +) +def flag_zero_delivery(s: PSnap): + c = Ctx(s, "one_unit", parallel([1])) + for mode in ("CostOnly", "Pinned"): + fg = c.ledger() + c.manual(fg, 0, 1, [0], 1) + cfg = co(EB) if mode == "CostOnly" else c.pin(EB, [[0]]) + pol = P.FlowPolicy(fg, 0, 1, cfg) + r = s.place(f"{mode} place(1)", pol, 1, fg=fg) + s.expect( + f"{mode}", + ( + r.placed, + r.termination, + r.routes_changed, + [x.delivered for x in pol.records()], + ), + (0, "exhausted", True, [0]), + ) + ids = pol.flow_ids() + r = s.place(f"{mode} place(1) again", pol, 1, fg=fg) + s.expect( + f"{mode} records stay pinned (revision 21)", + (r.placed, r.routes_changed, pol.flow_ids()), + (0, False, ids), + ) + + +# ====================================================================== source sets + + +def two_direct(caps, costs=(1, 1)): + """Sources 0 and 1, each with one link to the destination 2.""" + return 3, [(0, 2, caps[0], costs[0]), (1, 2, caps[1], costs[1])] + + +@fixture( + "sources_lossy_per_source", + "sources", + ["24", "26", "19", "9.3"], + "Lossy per source (replaces the zero-cost forward-versus-reverse witness): " + "sources 0 and 1 reach 3 through X (0->X 4, 1->X 4, X->3 3) and source 1 also " + "through Y (1->Y 4, Y->3 4, cost 2 each). Request 8 offers [4, 4]; source 0 " + "places 3 and drops 1 on X->3; source 1's DAG is its own shortest walks (1->X->3 " + "only, cost 2 < 4) and the full X->3 drops its 4. Delivered 3, complete (both " + "offers injected).", +) +def sources_lossy(s: PSnap): + edges = [(0, 3, 4, 1), (1, 3, 4, 1), (3, 5, 3, 1), (1, 4, 4, 2), (4, 5, 4, 2)] + c = Ctx(s, "per_source", (6, edges)) + fg = c.ledger() + pol = P.FlowPolicy(fg, [0, 1], 5, co(EB, LOSSY)) + r = s.place("request 8", pol, 8) + s.expect("offers", r.trace["offers"], {0: 4, 1: 4}) + s.expect("result", (r.placed, r.termination), (3, "complete")) + recs = {x.sources[0]: x for x in pol.records()} + s.expect( + "source 0", + (recs[0].delivered, dict_to_input(c.g, recs[0].drops)), + (3, [0, 0, 1, 0, 0]), + ) + s.expect( + "source 1", + (recs[1].delivered, dict_to_input(c.g, recs[1].drops)), + (0, [0, 0, 4, 0, 0]), + ) + + +@fixture( + "sources_attribution", + "sources", + ["24", "26", "12", "9.3"], + "Sources A (A->T 1, cost 1) and B (B->M 10, M->T 2, cost 1 each). Lossy " + "request 20 (offers [10, 10]): A delivers 1 and drops 9 on A->T; B delivers 2 " + "and drops 8 on M->T; placed 3, complete. MaxFlow, Weighted and EB per source: " + "1 + 2 = 3, exhausted (the old pseudo-root EB gave 2). Route costs per record: " + "1 and 2.", +) +def sources_attribution(s: PSnap): + c = Ctx(s, "attribution", (4, [(0, 3, 1, 1), (1, 2, 10, 1), (2, 3, 2, 1)])) + fg = c.ledger() + pol = P.FlowPolicy(fg, [0, 1], 3, co(EB, LOSSY)) + r = s.place("Lossy 20", pol, 20) + recs = {x.sources[0]: x for x in pol.records()} + s.expect("Lossy", (r.placed, r.termination), (3, "complete")) + s.expect( + "A", + ( + recs[0].offered, + recs[0].delivered, + sum(recs[0].drops.values()), + recs[0].route_cost, + ), + (10, 1, 9, 1), + ) + s.expect( + "B", + ( + recs[1].offered, + recs[1].delivered, + sum(recs[1].drops.values()), + recs[1].route_cost, + ), + (10, 2, 8, 2), + ) + for kernel in (MF, W, EB): + fg = c.ledger() + pol = P.FlowPolicy(fg, [0, 1], 3, co(kernel)) + r = s.place(f"{kernel} 20", pol, 20, fg=fg) + s.expect( + f"{kernel}", + (r.placed, r.termination, [x.delivered for x in pol.records()]), + (3, "exhausted", [1, 2]), + ) + + +@fixture( + "sources_eb_per_source", + "sources", + ["26", "9.3"], + "EqualBalanced per source (independent routers, rule 26): A (A->T 4, cost 1) " + "and B (B->T 10, cost 2). Request 20 offers [10, 10]: A places 4, B places 10, " + "total 14 (the old pseudo-root flow with joint equalisation capped both at 4: " + "8). +inf also 14.", +) +def sources_eb(s: PSnap): + c = Ctx(s, "two_direct", two_direct((4, 10), (1, 2))) + for v, term in ((20, "exhausted"), (INF, "exhausted")): + fg = c.ledger() + pol = P.FlowPolicy(fg, [0, 1], 2, co(EB)) + r = s.place(f"EB {v}", pol, v, fg=fg) + s.expect( + f"EB {v}", + (r.placed, r.termination, [x.delivered for x in pol.records()]), + (14, term, [4, 10]), + ) + + +@fixture( + "sources_augmentation_edges", + "sources", + ["21", "9.3"], + "User augmentation links are ordinary positive-cost edges (no custom fanout): " + "source A with a real link A->T (1, cost 2) and an augmentation path A->X->T " + "(9 each, cost 1): the CostOnly DAG holds both cost-2 walks; MaxFlow places 10; " + "EB splits [ceil, floor] at A over A->X (lower id) and A->T: 3.", +) +def sources_augmentation(s: PSnap): + c = Ctx(s, "augmentation", (3, [(0, 2, 1, 2), (0, 1, 9, 1), (1, 2, 9, 1)])) + for kernel, placed in ((MF, 10), (EB, 3)): + fg = c.ledger() + r = s.place(f"{kernel} +inf", P.FlowPolicy(fg, 0, 2, co(kernel)), INF, fg=fg) + s.expect(f"{kernel}", r.placed, placed) + + +@fixture( + "sources_inf_every_kernel", + "sources", + ["24", "15", "9.3"], + "+inf per source on the attribution graph: MaxFlow 3, Weighted 3, EB 3 " + "(exhausted); Lossy injects every source member to its residual (A 1, B 10): " + "delivered 3, offered 11, drops 8, exhausted.", +) +def sources_inf(s: PSnap): + c = Ctx(s, "attribution", (4, [(0, 3, 1, 1), (1, 2, 10, 1), (2, 3, 2, 1)])) + for kernel in (MF, W, EB): + fg = c.ledger() + r = s.place( + f"{kernel} +inf", P.FlowPolicy(fg, [0, 1], 3, co(kernel)), INF, fg=fg + ) + s.expect(f"{kernel}", (r.placed, r.termination), (3, "exhausted")) + fg = c.ledger() + pol = P.FlowPolicy(fg, [0, 1], 3, co(EB, LOSSY)) + r = s.place("Lossy +inf", pol, INF, fg=fg) + s.expect( + "Lossy", + ( + r.placed, + r.termination, + sum(x.offered for x in pol.records()), + sum(sum(x.drops.values()) for x in pol.records()), + ), + (3, "exhausted", 11, 8), + ) + + +@fixture( + "sources_limits_and_validation", + "sources", + ["13", "9.3"], + "Endpoint-set validation: Pinned with a source set, an empty source set and " + "overlapping sets are rejected; an excluded source is skipped from the roster " + "(roster [1]); a source that does not reach a destination statically is not in " + "the roster; three sources of a CostOnly policy create three records.", +) +def sources_validation(s: PSnap): + c = Ctx( + s, + "three_sources", + (5, [(0, 3, 5, 1), (1, 3, 5, 1), (2, 3, 5, 1), (4, 0, 1, 1)]), + ) + fg = c.ledger() + s.raises("Pinned source set", P.FlowPolicy, fg, [0, 1], 3, c.pin(MF, [[0]])) + s.raises("empty source set", P.FlowPolicy, fg, [], 3, co(MF)) + s.raises("overlap", P.FlowPolicy, fg, [0, 3], 3, co(MF)) + elig = c.g.eligibility(excluded_nodes=[0]) + pe = P.FlowPolicy(fg, [0, 1], 3, co(MF), elig) + s.expect("excluded source skipped", pe.roster, [1]) + fg2 = c.ledger() + p3 = P.FlowPolicy(fg2, [0, 1, 2], 3, co(MF)) + r = s.place("three sources request 3", p3, 3, fg=fg2) + s.expect("three records", (r.placed, len(p3.records())), (3, 3)) + c2 = Ctx(s, "unreachable_source", (3, [(0, 2, 5, 1), (2, 1, 5, 1)])) + p = P.FlowPolicy(c2.ledger(), [0, 1], 2, co(MF)) + s.expect("roster excludes a source without a static route", p.roster, [0]) + + +@fixture( + "sources_maxflow_vs_global", + "sources", + ["24", "27", "9.3"], + "Independent sources versus the controller: 0->2 (cost 2), 1->2 (cost 1), " + "2->3 (cost 1) and 0->3 (cost 5), unit capacities. CostOnly MaxFlow per source: " + "source 0's DAG is its shortest walk 0->2->3 (cost 3) and takes the shared " + "edge; source 1 then finds 2->3 full: +inf places 1. Global's unique optimum " + "(cost 7 against 8) routes source 0 over 0->3 and source 1 over 1->2->3: 2 (the " + "per-source DAGs cannot cancel across sources).", +) +def sources_vs_global(s: PSnap): + c = Ctx( + s, "shared_edge", (4, [(0, 2, 1, 2), (1, 2, 1, 1), (2, 3, 1, 1), (0, 3, 1, 5)]) + ) + fg = c.ledger() + r = s.place( + "CostOnly MaxFlow +inf", P.FlowPolicy(fg, [0, 1], 3, co(MF)), INF, fg=fg + ) + s.expect("per source", r.placed, 1) + fg = c.ledger() + pg = P.FlowPolicy(fg, [0, 1], 3, glob()) + r = s.place("Global +inf", pg, INF, fg=fg) + s.expect("Global", (r.placed, c.alloc(fg)), (2, [0, 1, 1, 1])) + s.expect( + "Global per-source records", + [(x.sources[0], x.delivered) for x in pg.records()], + [(0, 1), (1, 1)], + ) + + +@fixture( + "sources_split_cumulative", + "sources", + ["24", "16.7", "9.3"], + "The source split is over the cumulative request (16.7): sources A, B, C with " + "direct links of 10. place(1); place(1) offers [1, 0, 0] then [0, 1, 0] (round " + "robin of 2 is [1, 1, 0]), identical to place(2); a per-call split would offer " + "[1, 0, 0] twice (control). Lossy follows the same cumulative split: two calls " + "of 1 over two sources offer [1, 1].", +) +def sources_split(s: PSnap): + c = Ctx(s, "three_direct", (4, [(0, 3, 10, 1), (1, 3, 10, 1), (2, 3, 10, 1)])) + fg = c.ledger() + pol = P.FlowPolicy(fg, [0, 1, 2], 3, co(MF)) + a = s.place("call 1", pol, 1) + b = s.place("call 2", pol, 1) + s.expect( + "offers", + (a.trace["offers"], b.trace["offers"]), + ({0: 1, 1: 0, 2: 0}, {0: 0, 1: 1, 2: 0}), + ) + fg2 = c.ledger() + s.place("one call of 2", P.FlowPolicy(fg2, [0, 1, 2], 3, co(MF)), 2, fg=fg2) + s.expect("1 + 1 equals 2", c.alloc(fg), c.alloc(fg2)) + s.expect("per-call split control differs", [1, 0, 0] + [1, 0, 0] != [1, 1, 0], True) + c2 = Ctx(s, "two_direct", two_direct((5, 5))) + fg3 = c2.ledger() + lp = P.FlowPolicy(fg3, [0, 1], 2, co(EB, LOSSY)) + x = s.place("Lossy 1", lp, 1, fg=fg3) + y = s.place("Lossy 1", lp, 1, fg=fg3) + s.expect( + "Lossy cumulative offers", + (x.trace["offers"], y.trace["offers"]), + ({0: 1, 1: 0}, {0: 0, 1: 1}), + ) + + +@fixture( + "sources_1_9_request_4", + "sources", + ["24", "9.3", "9.4"], + "Review-13 source budget witness: sources with capacities [1, 9], request 4 " + "offers [2, 2]; deliveries [1, 2]; unserved remainders are not redistributed: " + "placed 3, remaining 1; local outcomes exhausted and complete; call exhausted.", +) +def sources_1_9(s: PSnap): + c = Ctx(s, "two_direct_1_9", two_direct((1, 9))) + for kernel in (MF, W, EB): + fg = c.ledger() + pol = P.FlowPolicy(fg, [0, 1], 2, co(kernel)) + r = s.place(f"{kernel} request 4", pol, 4, fg=fg) + s.expect( + f"{kernel}", + ( + r.trace["offers"], + [x.delivered for x in pol.records()], + r.placed, + r.remaining, + r.trace["local_outcomes"], + r.termination, + ), + ({0: 2, 1: 2}, [1, 2], 3, 1, {0: "exhausted", 1: "complete"}, "exhausted"), + ) + + +@fixture( + "sources_partition_1_1_vs_2_0", + "sources", + ["24", "16.4", "9.2"], + "Source-set allocation is partition-dependent (review-14 R3; 16.4 excludes " + "competing sources): 0->2 and 1->2 (2 each) share 2->3 (2). place(2); place(2) " + "gives deliveries [1, 1] (the second call finds 2->3 full); place(4) gives " + "[2, 0] (source 0 is placed first in ascending NodeId). Same total, different " + "records: independent sources, no reassignment.", +) +def sources_partition(s: PSnap): + c = Ctx(s, "shared_2", (4, [(0, 2, 2, 1), (1, 2, 2, 1), (2, 3, 2, 1)])) + out = [] + for calls in ([2, 2], [4]): + fg = c.ledger() + pol = P.FlowPolicy(fg, [0, 1], 3, co(MF)) + for v in calls: + s.place(f"{calls} call {v}", pol, v, fg=fg) + out.append([x.delivered for x in pol.records()]) + s.expect("partition dependence", out, [[1, 1], [2, 0]]) + + +@fixture( + "eb_two_flows_per_source_reservation", + "sources", + ["9.2", "9.3", "8b"], + "Per-source binding of the EB reservation target (review-16 R4): two sources, " + "EB CapacityAware flow_count 2 each, request 4: offers [2, 2], each source's " + "target (P_s + v_s) div flow_count = 1 per flow (not the policy-wide 2).", +) +def eb_reservation_binding(s: PSnap): + c = Ctx( + s, + "two_sources_two_links", + (3, [(0, 2, 5, 1), (0, 2, 5, 1), (1, 2, 5, 1), (1, 2, 5, 1)]), + ) + fg = c.ledger() + pol = P.FlowPolicy(fg, [0, 1], 2, ca(EB, fc=2, mx=2, sel=tunnel("Residual"))) + r = s.place("request 4", pol, 4) + targets = {src: tr["stage1"]["eb_target"] for src, tr in r.trace["sources"].items()} + reserved = { + src: [x["reserved"] for x in tr["stage1"]["reservations"]] + for src, tr in r.trace["sources"].items() + } + s.expect("targets", targets, {0: 1, 1: 1}) + s.expect("reservations", reserved, {0: [1, 1], 1: [1, 1]}) + s.expect("result", (r.placed, r.termination), (4, "complete")) + + +# ====================================================================== termination + + +@fixture( + "termination_reduction", + "termination", + ["9.4", "14"], + "Local outcomes and the reduction limit_flows > limit_diminishing > exhausted > " + "no_route over unserved sources (review-16 R2): one limit_flows and one " + "exhausted give limit_flows; two sources without routes give no_route; one " + "exhausted and one no_route give exhausted; a fixed-count cohort full is " + "exhausted; a Lossy finite offer consumed with drops is complete; v = 0 is " + "complete; an empty static roster is no_route.", +) +def termination(s: PSnap): + # limit_flows + exhausted: source 0 grows over two 1-unit links with max 3 > + # flow_count 1 (limit_flows needs the guard to fire: max 2 reached with + # remaining offer); source 1 has one 1-unit link and no growth candidate. + c = Ctx(s, "mixed", (3, [(0, 2, 1, 1), (0, 2, 1, 1), (0, 2, 1, 1), (1, 2, 1, 1)])) + fg = c.ledger() + pol = P.FlowPolicy(fg, [0, 1], 2, ca(MF, priority="Residual", fc=1, mx=2)) + r = s.place("request 6 (offers [3, 3])", pol, 6) + s.expect( + "limit_flows + exhausted", + (r.trace["local_outcomes"], r.termination), + ({0: "limit_flows", 1: "exhausted"}, "limit_flows"), + ) + cz = Ctx(s, "no_routes", (3, [(0, 2, 1, 1), (1, 2, 1, 1)])) + fgz = cz.ledger([0, 0]) + pz = P.FlowPolicy(fgz, [0, 1], 2, ca(MF)) + r = s.place("two sources without routes", pz, 2, fg=fgz) + s.expect( + "no_route + no_route", + (r.trace["local_outcomes"], r.termination), + ({0: "no_route", 1: "no_route"}, "no_route"), + ) + fge = cz.ledger([1, 0]) + pe = P.FlowPolicy(fge, [0, 1], 2, ca(MF, mx=1)) + r = s.place("exhausted + no_route", pe, 4, fg=fge) + s.expect( + "exhausted + no_route", + (r.trace["local_outcomes"], r.termination), + ({0: "exhausted", 1: "no_route"}, "exhausted"), + ) + cf = Ctx(s, "fixed", parallel([1])) + fgf = cf.ledger() + r = s.place("fixed count", P.FlowPolicy(fgf, 0, 1, ca(MF, fc=1, mx=1)), 2, fg=fgf) + s.expect("fixed-count cohort full", (r.placed, r.termination), (1, "exhausted")) + cl = Ctx(s, "lossy", parallel([1, 10])) + fgl = cl.ledger() + r = s.place( + "Lossy finite with drops", P.FlowPolicy(fgl, 0, 1, co(EB, LOSSY)), 4, fg=fgl + ) + s.expect( + "Lossy complete with drops", + (r.placed, r.termination, r.remaining), + (3, "complete", 1), + ) + r = s.place("v = 0", P.FlowPolicy(cl.ledger(), 0, 1, co(EB)), 0) + s.expect("v = 0", r.termination, "complete") + c0 = Ctx(s, "no_roster", (2, [(0, 1, 0, 1)])) + p0 = P.FlowPolicy(c0.ledger(), 0, 1, co(EB)) + r = s.place("empty roster", p0, 1) + s.expect("empty roster", (r.termination, r.local_outcomes), ("no_route", [])) + s.place("empty roster +inf", p0, INF) + s.raises("finite after +inf with an empty roster", p0.place, 1) + + +@fixture( + "eb_resplit_3_3_to_3_2_2", + "growth", + ["16.3", "9.2"], + "Review-16 R7: CapacityAware EB, flow_count 2, max 3, three disjoint routes of " + "capacities [3, 3, 2] and increasing cost. Request 6 reserves 3 on each of the " + "first two ([3, 3]). Request 1: stage 3 cannot grow the pair; growth adds the " + "third route and the cohort total 7 is resplit [3, 2, 2]: the second flow loses " + "1 while the cohort and placed_demand() gain 1 (individual T_i and edge deltas " + "may decrease; the cohort total never does).", +) +def eb_resplit(s: PSnap): + c = Ctx(s, "routes_3_3_2", routes([(3, 1), (3, 2), (2, 3)])) + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 4, ca(EB, fc=2, mx=3)) + r = s.place("request 6", pol, 6) + s.expect("first", (r.placed, c.shares(pol)), (6, [3, 3])) + before = c.alloc(fg) + r = s.place("request 1", pol, 1) + s.expect( + "resplit", + (r.placed, r.termination, c.shares(pol), pol.placed_demand()), + (1, "complete", [3, 2, 2], 7), + ) + delta = [a - b for a, b in zip(c.alloc(fg), before, strict=True)] + s.expect("edge deltas (signed)", delta, [0, 0, -1, -1, 2, 2]) + + +# ====================================================================== Global + + +@fixture( + "global_chain_plus_bypasses", + "global", + ["27", "25", "16.4"], + "Global is a replacement kernel (review-13 D5): chain 0-1-2-3 (cost 1) with " + "bypasses 0->2 and 1->3 (cost 3). place(1) takes the chain (cost 3); the next " + "place(1) re-solves the cumulative value 2 on W + own: both bypasses, the middle " + "edge released, placed 1 (total 2, not 1), cost_distribution [(3, 1), (5, 1)], " + "routes_changed true (support changed). place(2) on a fresh ledger gives the " + "same allocation and curve (additive on a fixed view).", +) +def global_chain(s: PSnap): + c = Ctx( + s, + "chain", + (4, [(0, 1, 1, 1), (1, 2, 1, 1), (2, 3, 1, 1), (0, 2, 1, 3), (1, 3, 1, 3)]), + ) + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 3, glob()) + a = s.place("place(1)", pol, 1) + s.expect( + "first", + (a.placed, a.cost_distribution, c.alloc(fg), a.routes_changed), + (1, [(3, 1)], [1, 1, 1, 0, 0], True), + ) + b = s.place("place(1)", pol, 1) + s.expect( + "second", + (b.placed, b.cost_distribution, c.alloc(fg), b.routes_changed), + (1, [(3, 1), (5, 1)], [1, 0, 1, 1, 1], True), + ) + fg2 = c.ledger() + p2 = P.FlowPolicy(fg2, 0, 3, glob()) + r = s.place("place(2)", p2, 2, fg=fg2) + s.expect( + "additive on a fixed view", + (r.cost_distribution, c.alloc(fg2)), + (b.cost_distribution, c.alloc(fg)), + ) + sol = {k: v for k, v in b.trace["flow_core"].items() if k != "supply"} + s.expect( + "maximal at cap 2: min_cut present", + sorted(sol["min_cut"]), + sorted(c.ids([0, 3])), + ) + s.check( + "cumulative solve certified", + not certify.check_solve(c.g, list(c.g.capacity), [0], [3], 2, sol, tier="a"), + ) + + +@fixture( + "global_capped_underfill_control", + "global", + ["2.2", "27"], + "Attained value at the policy level: Global place(2) on one 3-unit edge places " + "2 (min_cut None: a residual path is left; requested with bottleneck outputs), " + "the cumulative solve passes the certificate; a mutated producer returning 1 " + "under cap 2 is rejected; place(+inf) then places the last unit and min_cut is " + "[0].", +) +def global_underfill(s: PSnap): + c = Ctx(s, "one_edge", (2, [(0, 1, 3, 7)])) + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 1, glob()) + r = s.place("place(2)", pol, 2, outputs=P.Outputs(bottleneck=True)) + s.expect("capped", (r.placed, r.termination, r.min_cut), (2, "complete", None)) + payload = {k: v for k, v in r.trace["flow_core"].items() if k != "supply"} + s.check( + "certified", not certify.check_solve(c.g, [3], [0], [1], 2, payload, tier="a") + ) + bad = dict(payload, placed=1, edge_flows=[1], cost_distribution=[[7, 1]]) + s.check( + "underfill rejected", + bool(certify.check_solve(c.g, [3], [0], [1], 2, bad, tier="a")), + ) + r = s.place("place(+inf)", pol, INF, outputs=P.Outputs(bottleneck=True)) + s.expect("maximal", (r.placed, r.termination, r.min_cut), (1, "exhausted", [0])) + + +@fixture( + "global_zero_growth_support_change", + "global", + ["9.4", "27", "20"], + "Review-16 R1: edges 0->1 (cost 1, cap 1), 0->1 (cost 3, cap 2), 1->2 (cap 2). " + "Owner X holds the cheap link and one unit of 1->2; Global place(1) takes the " + "costly link (cost 4). X leaves; owner Y takes the costly link and the rest of " + "1->2. Global place(1): the view admits no growth (placed 0, exhausted) but the " + "re-solve moves the unit onto the released cheap link (cost 2): routes_changed " + "true at placed 0; Y untouched; the next place(1) changes nothing (flags false).", +) +def global_support(s: PSnap): + c = Ctx(s, "release", (3, [(0, 1, 1, 1), (0, 1, 2, 3), (1, 2, 2, 1)])) + fg = c.ledger() + x, _ = c.manual(fg, 0, 2, [0, 2], 1, "X") + pol = P.FlowPolicy(fg, 0, 2, glob()) + r = s.place("Global place(1)", pol, 1) + s.expect("costly", (r.placed, c.own(fg, pol)), (1, [0, 1, 1])) + x.remove() + y, _ = c.manual(fg, 0, 2, [1, 2], 1, "Y") + r = s.place("Global place(1) at zero growth", pol, 1) + s.expect( + "moved", + (r.placed, r.termination, r.routes_changed, c.own(fg, pol)), + (0, "exhausted", True, [1, 0, 1]), + ) + s.expect("Y untouched", c.own(fg, y), [0, 1, 1]) + r = s.place("Global place(1) unchanged", pol, 1) + s.expect( + "unchanged", (r.placed, r.routes_changed, r.weights_changed), (0, False, False) + ) + + +@fixture( + "global_epochs", + "global", + ["8", "27", "9.1"], + "Epochs for Global (review-16, revision 20): sources 0 (cost 3) and 1 (cost 1), " + "each directly connected to the destination with capacity 2. place(0) creates " + "nothing; place(1) opens the cohort with records for both sources (ids [0, 1]) " + "and supply [0, 1] (no round robin: the controller picks the cheaper source); " + "remove(); place(1) again supplies [0, 1] under ids [2, 3]. place(+inf) gives 4; " + "a finite request and place(0) after +inf are rejected; remove(); place(1) is " + "accepted.", +) +def global_epochs(s: PSnap): + c = Ctx(s, "two_costs", two_direct((2, 2), (3, 1))) + fg = c.ledger() + pol = P.FlowPolicy(fg, [0, 1], 2, glob()) + r = s.place("place(0)", pol, 0) + s.expect("no creation", (r.termination, fg.next_flow_id()), ("complete", 0)) + r = s.place("place(1)", pol, 1) + s.expect( + "epoch 1", + ( + [x.flow_id[1] for x in pol.records()], + [x.delivered for x in pol.records()], + r.routes_changed, + ), + ([0, 1], [0, 1], True), + ) + pol.remove() + r = s.place("place(1) after remove", pol, 1) + s.expect( + "epoch 2", + ( + [x.flow_id[1] for x in pol.records()], + [x.delivered for x in pol.records()], + r.routes_changed, + ), + ([2, 3], [0, 1], True), + ) + r = s.place("place(+inf)", pol, INF) + s.expect( + "+inf", (r.placed, pol.placed_demand(), r.termination), (3, 4, "exhausted") + ) + s.raises("finite after +inf", pol.place, 1) + s.raises("place(0) after +inf", pol.place, 0) + pol.remove() + r = s.place("place(1) in a new epoch", pol, 1) + s.expect("accepted", r.placed, 1) + + +@fixture( + "epochs_place_remove_place", + "lifetime", + ["8", "9.3", "24"], + "Section 8's epoch law for CostOnly over two sources: place(1) offers [1, 0] " + "and creates both records (ids [0, 1], source 1 at zero delivery); remove(); " + "place(1) offers [1, 0] again (requested reset) with ids [2, 3]; " + "place(+inf); remove(); place(1) is accepted; remove() on an empty cohort " + "resets the epoch state.", +) +def epochs(s: PSnap): + c = Ctx(s, "two_direct", two_direct((5, 5))) + fg = c.ledger() + pol = P.FlowPolicy(fg, [0, 1], 2, co(MF)) + r = s.place("place(1)", pol, 1) + s.expect( + "epoch 1", + ( + r.trace["offers"], + [x.flow_id[1] for x in pol.records()], + [x.delivered for x in pol.records()], + ), + ({0: 1, 1: 0}, [0, 1], [1, 0]), + ) + pol.remove() + r = s.place("place(1) after remove", pol, 1) + s.expect( + "epoch 2", + (r.trace["offers"], [x.flow_id[1] for x in pol.records()]), + ({0: 1, 1: 0}, [2, 3]), + ) + s.place("place(+inf)", pol, INF) + pol.remove() + r = s.place("place(1) after +inf and remove", pol, 1) + s.expect("accepted", (r.placed, r.trace["offers"]), (1, {0: 1, 1: 0})) + pol.remove() + pol.remove() + s.expect( + "reset state", (pol.requested, pol.infinite, pol.records()), (0, False, []) + ) + + +@fixture( + "collapse_projection_every_priority", + "selection", + ["16", "9.1", "10"], + "Selection is a projection at creation on the full static support (review-15 " + "C4, review-16): equal-cost parallel links of capacities [3, 2], creation " + "residual [0, 2] (another owner holds link 0). A CostOnly EB policy with " + "multi_edge false keeps link 1 under PreferHigherPriority with Residual or " + "Headroom and link 0 otherwise, for both multipath settings (12 cases).", +) +def collapse_projection(s: PSnap): + c = Ctx(s, "parallel_3_2", parallel([3, 2])) + for mp in (False, True): + for tie in (search.DETERMINISTIC, search.PREFER): + for prio in search.PRIORITIES: + fg = c.ledger() + c.manual(fg, 0, 1, [0], 3) + sel = search.Selection( + multipath=mp, multi_edge=False, tie_break=tie, priority=prio + ) + pol = P.FlowPolicy(fg, 0, 1, co(EB, sel=sel)) + s.place(f"mp={mp} {tie} {prio}", pol, 1, fg=fg) + want = [1] if tie == search.PREFER and prio != "Capacity" else [0] + s.expect(f"mp={mp} {tie} {prio}", c.routes_of(pol), [want]) + + +@fixture( + "eb_growth_equal_route_stop", + "growth", + ["8b", "9.2"], + "Revision 24: with multipath routes growth never adds a flow on a DAG " + "structurally equal to an existing flow's route. Parallel links of capacity 1 and 5, EB " + "CapacityAware multipath, flow_count 1, no max: the first flow (both links) is " + "capped at 2 by round robin over the 1-unit link; growth adds a flow on the " + "5-unit link alone (cohort [2, 2], gain 2); the next candidate is that same " + "route and growth stops: placed 4, exhausted (before revision 24 each further " + "equal-route flow raised the bound by a rounding remainder).", +) +def eb_growth_equal_route(s: PSnap): + c = Ctx(s, "parallel_1_5", parallel([1, 5])) + fg = c.ledger() + pol = P.FlowPolicy(fg, 0, 1, ca(EB, fc=1, sel=search.Selection())) + r = s.place("request 100", pol, 100) + s.expect( + "result", (r.placed, r.termination, r.routes_changed), (4, "exhausted", True) + ) + s.expect( + "routes and shares", (c.routes_of(pol), c.shares(pol)), ([[0, 1], [1]], [2, 2]) + ) + last = r.trace["sources"][0]["stage4"][-1] + s.expect("growth stop reason", last["stop"], "candidate equals a cohort route") + + +@fixture( + "capacityaware_blocked_inf_remove_place", + "lifetime", + ["8", "9.4", "13"], + "A CapacityAware policy whose static roster is non-empty but whose residual " + "routes are blocked by another owner: place(+inf) creates nothing (no_route, no " + "id consumed) yet opens the infinite state, so place(1) is rejected; remove() on " + "the empty cohort resets the epoch state and place(1) is accepted (no_route while " + "blocked); after the owner leaves, place(1) creates the cohort with the next id.", +) +def capacityaware_blocked(s: PSnap): + c = Ctx(s, "one_link", parallel([3])) + fg = c.ledger() + other, _ = c.manual(fg, 0, 1, [0], 3, "owner blocks the only link") + pol = P.FlowPolicy(fg, 0, 1, ca(MF)) + s.expect("static roster", pol.roster, [0]) + nxt = fg.next_flow_id() + r = s.place("place(+inf)", pol, INF) + s.expect( + "+inf creates nothing", + (r.placed, r.termination, r.routes_changed, fg.next_flow_id()), + (0, "no_route", False, nxt), + ) + s.raises("place(1) after +inf in the same epoch", pol.place, 1) + pol.remove() + s.expect("epoch reset", (pol.requested, pol.infinite), (0, False)) + r = s.place("place(1) after remove", pol, 1) + s.expect( + "accepted, still blocked", + (r.placed, r.termination, fg.next_flow_id()), + (0, "no_route", nxt), + ) + other.remove() + r = s.place("place(1) after release", pol, 1) + s.expect( + "created", + (r.placed, r.termination, r.routes_changed, pol.flow_ids()[0][1]), + (1, "complete", True, nxt), + ) + + +@fixture( + "eb_growth_single_path_repeat", + "growth", + ["8b", "9.2"], + "Revision 24 (owner review): a repeated single path is a legitimate growth " + "candidate. Parallel links of capacity 1 and 2, EB CapacityAware single-path " + "tunnels (Deterministic, Collapse keeps the lowest id), flow_count 1, request 3: " + "the first tunnel takes the 1-link (1), growth adds a tunnel on the 2-link " + "(cohort 2, shares [1, 1]), then a third tunnel repeating the 2-link path (cohort " + "3, shares [1, 1, 1]): complete with three tunnels of 1. Stopping on the repeated " + "path would have ended at 2 (exhausted).", +) +def eb_growth_single_path(s: PSnap): + c = Ctx(s, "parallel_1_2", parallel([1, 2])) + fg = c.ledger() + pol = P.FlowPolicy( + fg, 0, 1, ca(EB, fc=1, sel=tunnel("Residual", search.DETERMINISTIC)) + ) + r = s.place("request 3", pol, 3) + s.expect( + "result", (r.placed, r.termination, r.routes_changed), (3, "complete", True) + ) + s.expect( + "three tunnels of 1", + (c.routes_of(pol), c.shares(pol)), + ([[0], [1], [1]], [1, 1, 1]), + ) diff --git a/tests/reference/policy/p2_transaction/policy.py b/tests/reference/policy/p2_transaction/policy.py new file mode 100644 index 0000000..e2d767a --- /dev/null +++ b/tests/reference/policy/p2_transaction/policy.py @@ -0,0 +1,1048 @@ +"""The policy engine ``FlowPolicy`` in integer units (APPROACH revision 24, 8 and 9). + +One placement engine for every routing mode: ``CostOnly``, ``CapacityAware``, +``Global`` and ``Pinned``, the kernels ``MaxFlow``, ``Weighted`` and +``EqualBalanced`` and the admissions ``Lossless`` and ``Lossy`` (prototype 1's +kernels). The transaction of 9.2 runs per source (9.3) for ``CostOnly`` and +``CapacityAware``: the roster is fixed at construction (eligible sources that +statically reach a destination, ascending NodeId), each source is offered the +increment of its round-robin share of the cumulative request, and the stages run on +the shared working residual ``W``; ``created`` is one counter per call; there is one +commit. ``Pinned`` and ``Global`` have one "source", the policy. ``Global`` is a +replacement kernel over the whole eligible graph with the canonical per-source +decomposition. Local outcomes, the termination reduction and the flag table follow +9.4 (revision 20: flags accumulate committed events). Epochs follow section 8. + +Readings where the contract is silent are listed in ``NOTES.md`` (P-numbers). +""" + +from __future__ import annotations + +import copy +from dataclasses import dataclass, field +from fractions import Fraction + +import _p1 # noqa: F401 +import flowcore +import kernels as K +import search +from apportion import round_robin +from graph import Eligibility, check_dag_edges, check_disjoint, check_node_set +from ledger import Draft, FlowGraph +from units import ( + INF, + K_MAX_FLOWS_PER_CALL, + UNREACHABLE, + ContractError, + check_request, +) + +MF, W_, EB = K.MAX_FLOW, K.WEIGHTED, K.EQUAL_BALANCED +LOSSLESS, LOSSY = K.LOSSLESS, K.LOSSY +OUTCOME_RANK = {"limit_flows": 4, "limit_diminishing": 3, "exhausted": 2, "no_route": 1} + +# Test hook only: the engine constant kMaxFlowsPerCall is 10,000 (9.2); fixtures +# that exercise the call-wide cap override this module attribute (NOTES P9). +MAX_FLOWS_PER_CALL = K_MAX_FLOWS_PER_CALL + + +# ====================================================================== configuration + + +@dataclass(frozen=True) +class CostOnly: + pass + + +@dataclass(frozen=True) +class Diminishing: + window: int + fraction: float + + +@dataclass(frozen=True) +class CapacityAware: + flow_count: int = 1 + max_flow_count: int | None = None + max_path_cost: tuple | None = None + min_flow_bandwidth: int = 0 + reoptimize: bool = False + diminishing: Diminishing | None = None + + +@dataclass(frozen=True) +class Global: + pass + + +@dataclass(frozen=True) +class Pinned: + bundles: tuple[tuple[int, ...], ...] + + +@dataclass(frozen=True) +class FlowPolicyConfig: + routing: object + placement: str + admission: str + selection: search.Selection + + +@dataclass +class Outputs: + edge_flows: bool = False + residual: bool = False + bottleneck: bool = False + + +@dataclass +class PlacementResult: + placed: int + remaining: int | None + termination: str + routes_changed: bool + weights_changed: bool + drops: list[tuple[int, int]] + cost_distribution: list[tuple[int, int]] | None = None + edge_flows: list[int] | None = None + residual: list[int] | None = None + bottleneck: list[int] | None = None + min_cut: list[int] | None = None + local_outcomes: list[tuple[int, str]] = field(default_factory=list) + trace: dict = field(default_factory=dict) + + def to_json(self) -> dict: + out = { + "placed": self.placed, + "remaining": "inf" if self.remaining is None else self.remaining, + "termination": self.termination, + "routes_changed": self.routes_changed, + "weights_changed": self.weights_changed, + "drops": [list(x) for x in self.drops], + "local_outcomes": [list(x) for x in self.local_outcomes], + } + if self.cost_distribution is not None: + out["cost_distribution"] = [list(x) for x in self.cost_distribution] + for name in ("edge_flows", "residual", "bottleneck", "min_cut"): + value = getattr(self, name) + if value is not None: + out[name] = list(value) + return out + + +# ====================================================================== working flows + + +@dataclass +class Flow: + src: int + route: tuple[int, ...] + plan: K.Plan + cost: int + flow_id: tuple[int, int] | None = None + T: int = 0 + alloc: dict[int, int] = field(default_factory=dict) + offered: int | None = None + delivered: int = 0 + drops: dict[int, int] = field(default_factory=dict) + new: bool = False + seq: int = 0 + bundle_index: int | None = None + + +def add_view(W: list[int], alloc: dict[int, int]) -> list[int]: + out = list(W) + for e, x in alloc.items(): + out[e] += x + return out + + +def sub_view(V: list[int], alloc: dict[int, int]) -> list[int]: + out = list(V) + for e, x in alloc.items(): + out[e] -= x + assert out[e] >= 0, ("overfilled view", e) + return out + + +def merge(allocs) -> dict[int, int]: + out: dict[int, int] = {} + for a in allocs: + for e, x in a.items(): + out[e] = out.get(e, 0) + x + return out + + +@dataclass +class SourceState: + """Per-source scope of one call (9.2 binding table).""" + + src: int + offer: int | None + P: int + B: int | None + T_entry: int + flows: list[Flow] + created_here: int = 0 + stage1_route: bool = True + capped: bool = False + limit: str | None = None + gains: list[int] = field(default_factory=list) + trace: dict = field(default_factory=dict) + + +class FlowPolicy: + def __init__( + self, + fg: FlowGraph, + sources, + destinations, + cfg: FlowPolicyConfig, + elig: Eligibility | None = None, + cache=None, + ) -> None: + g = fg.graph + self.fg, self.g, self.cfg = fg, g, cfg + if cache is not None: + raise ContractError("DagCache is not modelled by prototype 2") + if elig is not None: + elig.check(g) + self.elig = elig + self.sources = check_node_set(g, sources, "sources") + self.destinations = check_node_set(g, destinations, "destinations") + check_disjoint(self.sources, self.destinations) + self._validate_cfg() + self.mode = type(cfg.routing).__name__ + self.kernel, self.admission = cfg.placement, cfg.admission + self.selection = cfg.selection + static = search.member_graph(g, elig, search.Selection()) + self._static = static + node_ok = (lambda v: True) if elig is None else (lambda v: elig.node_allowed[v]) + self._dests_ok = tuple(d for d in self.destinations if node_ok(d)) + self.baseline: dict[int, int | None] = {} + if self.mode == "Pinned": + self.bundles = self._validate_bundles() + self.usable = [(i, route) for i, route in enumerate(self._pruned) if route] + self.roster = [self.sources[0]] if self.usable else [] + else: + roster = [] + for s in self.sources: + if not node_ok(s): + continue + d = search.distances(g, static, [s]) + if any(d[t] is not None for t in self._dests_ok): + roster.append(s) + self.baseline[s] = min( + d[t] for t in self._dests_ok if d[t] is not None + ) + self.roster = roster + self.owner = fg._bind() + self._reset_epoch() + + # ------------------------------------------------------------ validation + + def _validate_cfg(self) -> None: + cfg = self.cfg + r = cfg.routing + if not isinstance(r, (CostOnly, CapacityAware, Global, Pinned)): + raise ContractError("unknown routing variant") + if not isinstance(cfg.selection, search.Selection): + raise ContractError("selection is explicit (no default)") + K.check_kernel(cfg.placement, cfg.admission, cfg.selection.priority) + if cfg.admission == LOSSY and isinstance(r, (Global, CapacityAware)): + raise ContractError("Lossy is rejected with Global and CapacityAware") + if isinstance(r, Global) and cfg.placement != MF: + raise ContractError("Global requires placement = MaxFlow") + if isinstance(r, CapacityAware): + if not isinstance(r.flow_count, int) or r.flow_count < 1: + raise ContractError("flow_count >= 1") + if r.max_flow_count is not None and r.max_flow_count < r.flow_count: + raise ContractError("max_flow_count >= flow_count") + if not isinstance(r.min_flow_bandwidth, int) or r.min_flow_bandwidth < 0: + raise ContractError("min_flow_bandwidth is a non-negative integer") + search.check_ceiling(r.max_path_cost) + if r.diminishing is not None: + d = r.diminishing + if not isinstance(d.window, int) or d.window < 1: + raise ContractError("diminishing.window >= 1") + if not 0 < d.fraction < 1: + raise ContractError("diminishing.fraction in (0, 1)") + if isinstance(r, Pinned): + if len(self.sources) != 1 or len(self.destinations) != 1: + raise ContractError("Pinned requires single endpoints") + + def _validate_bundles(self): + g = self.g + bundles = self.cfg.routing.bundles + if not bundles: + raise ContractError("an empty bundle list is rejected") + src, dst = self.sources[0], self.destinations[0] + seen = set() + out = [] + self._pruned = [] + for b in bundles: + edges = check_dag_edges(g, b) + if edges in seen: + raise ContractError("duplicate bundle (pre-prune)") + seen.add(edges) + if not K.prune(g, edges, src, (dst,)): + raise ContractError("malformed bundle: no src -> dst walk") + out.append(edges) + ok = [ + e + for e in edges + if g.capacity[e] >= 1 + and (self.elig is None or self.elig.edge_allowed[e]) + ] + self._pruned.append(K.prune(g, ok, src, (dst,))) + return out + + # ------------------------------------------------------------ state + + def _reset_epoch(self) -> None: + self.cohorts: dict[int, list[Flow]] = {} + self.requested = 0 + self.infinite = False + self.epoch_started = False + + def placed_demand(self) -> int: + return sum(f.delivered for c in self.cohorts.values() for f in c) + + def records(self): + return [r for r in self.fg.records() if r.owner == self.owner] + + def record(self, flow_id): + return self.fg.record(flow_id) + + def usable_bundles(self) -> int: + return len(self.usable) if self.mode == "Pinned" else 0 + + def flow_ids(self) -> list[tuple[int, int]]: + return sorted(f.flow_id for c in self.cohorts.values() for f in c) + + def state(self) -> dict: + pos = self.g.input_index() + return { + "mode": self.mode, + "roster": list(self.roster), + "requested": self.requested, + "infinite": self.infinite, + "placed_demand": self.placed_demand(), + "cohorts": { + s: [ + { + "flow_id": None if f.flow_id is None else list(f.flow_id), + "route": list(f.route), + "route_input": sorted(pos[e] for e in f.route), + "T": f.T, + "delivered": f.delivered, + "alloc": sorted(f.alloc.items()), + "plan": f.plan.to_json(), + "bundle_index": f.bundle_index, + } + for f in c + ] + for s, c in sorted(self.cohorts.items()) + }, + } + + def remove(self) -> None: + """Detach every flow (allocations reverted, ids retired), new epoch.""" + self.fg._detach(self.owner) + self._reset_epoch() + + def destroy(self) -> None: + self.fg._orphan(self.owner) + + # ------------------------------------------------------------ routes + + def _route_cost(self, route, src) -> int: + c = K.route_cost(self.g, route, src, self._dests_ok) + return UNREACHABLE if c is None else c + + def _new_flow(self, src, route, view, ctx) -> Flow: + plan = K.make_plan( + self.g, + self.kernel, + self.admission, + src, + self._dests_ok, + route, + self.selection.priority, + view, + ) + ctx["seq"] += 1 + return Flow( + src=src, + route=plan.route, + plan=plan, + cost=self._route_cost(plan.route, src), + offered=0 if self.admission == LOSSY else None, + new=True, + seq=ctx["seq"], + ) + + def _cost_only_route(self, src, W) -> tuple[int, ...]: + """Static support restricted to the source, selection projected on W.""" + g, sel = self.g, self.selection + _, support = search.tight_dag(g, self._static, [src], self._dests_ok) + members = support + if not sel.multi_edge: + members = search.collapse(g, support, sel, W) + if sel.multipath: + _, dag = search.tight_dag(g, members, [src], self._dests_ok) + return tuple(dag) + prio = ( + search.priority_fn(g, sel.priority, W) + if sel.tie_break == search.PREFER + else None + ) + p = search.canonical_path(g, members, [src], self._dests_ok, prio) + return tuple() if p is None else tuple(sorted(p.edges)) + + def _search(self, src, view): + """CapacityAware route search on a view (residual gating, ceiling).""" + g, sel, r = self.g, self.selection, self.cfg.routing + threshold = max(1, r.min_flow_bandwidth) + members = search.member_graph(g, self.elig, sel, view, threshold) + if sel.multipath: + dstar, dag = search.tight_dag(g, members, [src], self._dests_ok) + if dstar is None: + return None + route, cost = tuple(dag), dstar + else: + prio = ( + search.priority_fn(g, sel.priority, view) + if sel.tie_break == search.PREFER + else None + ) + p = search.canonical_path(g, members, [src], self._dests_ok, prio) + if p is None: + return None + route, cost = tuple(sorted(p.edges)), p.cost + if not search.within_ceiling(cost, r.max_path_cost, self.baseline.get(src)): + return None + return route + + # ------------------------------------------------------------ amounts + + def _t_max(self, flow: Flow, view, evals) -> int: + if self.kernel == MF: + sol = K.maxflow(self.g, flow.plan, view, None) + return sol.placed + tm = K.t_max(self.g, flow.plan, view) + evals.append(tm.evaluations) + return tm.value + + def _alloc(self, flow: Flow, view, T) -> dict[int, int]: + if self.kernel == MF: + sol = K.maxflow(self.g, flow.plan, view, T) + assert sol.placed == T, "MaxFlow fill below its target" + return {e: x for e, x in enumerate(sol.edge_flows) if x} + return K.alloc(self.g, flow.plan, T) + + def _cohort_alloc(self, flows: list[Flow], T: int) -> list[tuple[int, dict]]: + parts = round_robin(T, len(flows)) + return [ + (t, K.alloc(self.g, f.plan, t)) for f, t in zip(flows, parts, strict=True) + ] + + def _cohort_t_max(self, flows: list[Flow], view, evals) -> int: + src_members = set() + for f in flows: + src_members.update(f.plan.out.get(f.src, [])) + hi = sum(view[e] for e in src_members) + lo, n = 0, 0 + while lo < hi: + mid = (lo + hi + 1) // 2 + n += 1 + total = merge(a for _, a in self._cohort_alloc(flows, mid)) + if K.fits(total, view): + lo = mid + else: + hi = mid - 1 + evals.append(n) + return lo + + def _set_cohort(self, flows: list[Flow], T: int) -> None: + for f, (t, a) in zip(flows, self._cohort_alloc(flows, T), strict=True): + if t != f.T or f.new: + f.alloc = a + f.T = t + f.delivered = t + + # ------------------------------------------------------------ place + + def place(self, volume, outputs: Outputs | None = None) -> PlacementResult: + req = check_request(volume) + if self.infinite and req is not None: + # validation precedes the zero shortcut (revision 20, section 8) + raise ContractError("a finite request after +inf is rejected (rule 13)") + if req is not None and self.requested + req >= 1 << 127: + raise ContractError("cumulative request counter overflow") + outputs = outputs or Outputs() + if req == 0: + served = [(src, "complete") for src in self.roster] + return self._finish( + PlacementResult( + 0, 0, "complete", False, False, [], local_outcomes=served + ), + outputs, + None, + ) + if not self.roster: + # the epoch's finite-or-infinite state and `requested` record every positive + # request, also one that finds no roster (section 8) + if req is None: + self.infinite = True + else: + self.requested += req + return self._finish( + PlacementResult( + 0, req, "no_route", False, False, [], trace={"roster": []} + ), + outputs, + None, + ) + if self.mode == "Global": + return self._place_global(req, outputs) + return self._place_flows(req, outputs) + + def _finish( + self, result: PlacementResult, outputs: Outputs, sol + ) -> PlacementResult: + if outputs.edge_flows: + alloc = [0] * self.g.num_edges + for r in self.records(): + for e, x in r.edges.items(): + alloc[e] += x + result.edge_flows = alloc + if outputs.residual: + result.residual = self.fg.residual() + if outputs.bottleneck: + result.bottleneck = self.fg.bottleneck(self.sources, self.elig) + if self.mode == "Global": + result.min_cut = None if sol is None else sol.min_cut + return result + + # ------------------------------------------------------------ Global + + def _place_global(self, req, outputs) -> PlacementResult: + g, fg = self.g, self.fg + W = fg.residual() + old = self.cohorts + created = not old + own = merge(f.alloc for c in old.values() for f in c) + V = add_view(W, own) + P = self.placed_demand() + cap = None if req is None else P + req + sol = flowcore.solve(g, V, self.roster, self._dests_ok, cap, elig=self.elig) + assert sol.placed >= P, "a Global call never reduces placed_demand()" + shares = self._decompose(sol) + changed = created + new_cohorts: dict[int, list[Flow]] = {} + for s in self.roster: + alloc = shares[s] + route = tuple(sorted(alloc)) + prev = old.get(s, [None])[0] + if prev is not None and prev.route != route: + changed = True + plan = K.Plan(MF, LOSSLESS, s, self._dests_ok, route, route, None) + f = Flow( + src=s, + route=route, + plan=plan, + cost=self._route_cost(route, s), + flow_id=None if prev is None else prev.flow_id, + T=sol.supply.get(s, 0), + alloc=alloc, + delivered=sol.supply.get(s, 0), + new=prev is None, + seq=self.roster.index(s), + ) + new_cohorts[s] = [f] + placed = sol.placed - P + drafts, order = self._drafts(new_cohorts) + new_ids = fg._commit(self.owner, drafts) + self._assign_ids(order, new_ids) + self.cohorts = new_cohorts + if req is None: + self.infinite = True + else: + self.requested += req + if req is not None and placed == req: + term = "complete" + elif sol.placed == 0: + term = "no_route" + else: + term = "exhausted" + trace = { + "V": V, + "cap": cap, + "flow_core": {**sol.payload(), "supply": sol.supply}, + "shares": {s: sorted(a.items()) for s, a in shares.items()}, + } + result = PlacementResult( + placed, + None if req is None else req - placed, + term, + changed, + False, + [], + cost_distribution=list(sol.cost_distribution), + local_outcomes=[(src, term) for src in self.roster], + trace=trace, + ) + return self._finish(result, outputs, sol) + + def _decompose(self, sol) -> dict[int, dict[int, int]]: + """Canonical per-source decomposition (9.1): sources ascending; the smallest + EdgeId walk with positive remaining flow to the first destination reached, + saturated by min(walk bottleneck, remaining virtual supply).""" + g = self.g + remaining = dict(enumerate(sol.edge_flows)) + dests = set(self._dests_ok) + out: dict[int, dict[int, int]] = {} + for s in self.roster: + mine: dict[int, int] = {} + left = sol.supply.get(s, 0) + while left > 0: + u, walk = s, [] + while not (walk and u in dests): + cands = [e for e in g.out_ids(u) if remaining.get(e, 0) > 0] + assert cands, "flow conservation guarantees a continuation" + e = min(cands) + walk.append(e) + u = g.dst[e] + assert len(walk) <= g.num_nodes, "acyclic support" + amount = min(min(remaining[e] for e in walk), left) + for e in walk: + remaining[e] -= amount + mine[e] = mine.get(e, 0) + amount + left -= amount + out[s] = mine + assert all(x == 0 for x in remaining.values()), "decomposition covers the flow" + return out + + # ------------------------------------------------------------ route-search modes + + def _offers(self, req) -> list[int | None]: + k = len(self.roster) + if self.mode == "Pinned": + return [req] + if req is None: + return [None] * k + old = round_robin(self.requested, k) + new = round_robin(self.requested + req, k) + return [b - a for a, b in zip(old, new, strict=True)] + + def _place_flows(self, req, outputs) -> PlacementResult: + fg = self.fg + ctx = { + "W": fg.residual(), + "created": 0, + "routes_changed": False, + "weights_changed": False, + "seq": 0, + "drops": {}, + } + cohorts = copy.deepcopy(self.cohorts) + entry = {s: sum(f.delivered for f in cohorts.get(s, [])) for s in self.roster} + offers = self._offers(req) + states = [] + for s, offer in zip(self.roster, offers, strict=True): + flows = cohorts.setdefault(s, []) + st = SourceState( + src=s, + offer=offer, + P=entry[s], + B=offer, + T_entry=sum(f.T for f in flows), + flows=flows, + ) + self._run_source(st, ctx) + states.append(st) + drafts, order = self._drafts(cohorts) + new_ids = fg._commit(self.owner, drafts) + self._assign_ids(order, new_ids) + self.cohorts = {s: c for s, c in cohorts.items() if c} + if req is None: + self.infinite = True + else: + self.requested += req + placed = sum( + sum(f.delivered for f in cohorts.get(s, [])) - entry[s] for s in self.roster + ) + assert placed >= 0 + if req is not None: + assert placed <= req + outcomes = {st.src: self._local_outcome(st) for st in states} + unserved = [o for o in outcomes.values() if o != "complete"] + term = max(unserved, key=OUTCOME_RANK.__getitem__) if unserved else "complete" + trace = { + "offers": { + st.src: "inf" if st.offer is None else st.offer for st in states + }, + "local_outcomes": outcomes, + "created": ctx["created"], + "sources": {st.src: st.trace for st in states}, + "W_final": ctx["W"], + } + result = PlacementResult( + placed, + None if req is None else req - placed, + term, + ctx["routes_changed"], + ctx["weights_changed"], + sorted(ctx["drops"].items()), + local_outcomes=[(st.src, outcomes[st.src]) for st in states], + trace=trace, + ) + return self._finish(result, outputs, None) + + def _local_outcome(self, st: SourceState) -> str: + delivered = sum(f.delivered for f in st.flows) - st.P + if st.offer == 0: + return "complete" + if self.admission == LOSSY: + if st.offer is not None and st.trace.get("injected") == st.offer: + return "complete" + elif st.offer is not None and delivered == st.offer: + return "complete" + if st.limit == "limit_flows" or st.capped: + return "limit_flows" + if st.limit == "limit_diminishing": + return "limit_diminishing" + if not st.flows: + return "no_route" + return "exhausted" + + def _run_source(self, st: SourceState, ctx) -> None: + if not st.flows: + self._stage1(st, ctx) + if not st.flows: + return + if self.mode == "CapacityAware" and self.cfg.routing.reoptimize: + self._stage2(st, ctx) + if self.admission == LOSSY: + self._stage3_lossy(st, ctx) + elif self.kernel == EB: + self._stage3_eb(st, ctx) + else: + self._stage3_seq(st, ctx) + if self.mode == "CapacityAware": + self._stage4(st, ctx) + + # ---- stage 1 + + def _stage1(self, st: SourceState, ctx) -> None: + W = ctx["W"] + tr: dict = {} + if self.mode == "CostOnly": + route = self._cost_only_route(st.src, W) + tr["route"] = list(route) + st.flows.append(self._new_flow(st.src, route, W, ctx)) + elif self.mode == "Pinned": + for i, route in self.usable: + f = self._new_flow(st.src, route, W, ctx) + f.bundle_index = i + st.flows.append(f) + tr["usable"] = [i for i, _ in self.usable] + else: + r = self.cfg.routing + S = list(W) + rem = st.offer + target = None + if self.kernel == EB and st.offer is not None: + target = (st.P + st.offer) // r.flow_count + tr.update(eb_target=target, reservations=[]) + budget = min(r.flow_count, MAX_FLOWS_PER_CALL - ctx["created"]) + stopped = False + for _ in range(budget): + route = self._search(st.src, S) + if route is None: + stopped = True + break + f = self._new_flow(st.src, route, S, ctx) + evals: list[int] = [] + bound = self._t_max(f, S, evals) + if self.kernel == EB: + t = bound if target is None else min(target, bound) + else: + t = bound if rem is None else min(rem, bound) + if rem is not None: + rem -= t + a = self._alloc(f, S, t) if t else {} + tr["reservations"].append( + { + "route": list(route), + "S": list(S), + "T_bound": bound, + "reserved": t, + } + ) + S = sub_view(S, a) + st.flows.append(f) + ctx["created"] += 1 + st.created_here += 1 + # the call-wide cap blocked (part of) the creation, not a missing route + st.capped = budget < r.flow_count and not stopped + tr["created_cap_blocked"] = st.capped + if st.flows: + ctx["routes_changed"] = True + st.trace["stage1"] = tr + + # ---- stage 2 + + def _stage2(self, st: SourceState, ctx) -> None: + rows = [] + for f in st.flows: + if f.new: + continue + W_i = add_view(ctx["W"], f.alloc) + cand = self._search(st.src, W_i) + row = { + "flow": list(f.flow_id), + "T": f.T, + "candidate": None if cand is None else list(cand), + } + if cand is None or cand == f.route: + row["decision"] = "kept" + rows.append(row) + continue + trial = self._new_flow(st.src, cand, W_i, {"seq": 0}) + evals: list[int] = [] + bound = self._t_max(trial, W_i, evals) + if bound < f.T: + row.update(decision="rejected: delivers less than T_i", bound=bound) + rows.append(row) + continue + a = self._alloc(trial, W_i, f.T) if f.T else {} + ctx["W"] = sub_view(W_i, a) + f.route, f.plan, f.cost, f.alloc = trial.route, trial.plan, trial.cost, a + ctx["routes_changed"] = True + if self.kernel == W_: + ctx["weights_changed"] = True + row.update(decision="adopted", alloc=sorted(a.items())) + rows.append(row) + st.trace["stage2"] = rows + + # ---- stage 3 + + def _stage3_seq(self, st: SourceState, ctx) -> None: + rows = [] + for f in st.flows: + V = add_view(ctx["W"], f.alloc) + evals: list[int] = [] + if self.kernel == MF: + cap = None if st.B is None else f.T + st.B + sol = K.maxflow(self.g, f.plan, V, cap) + t_new = sol.placed + assert t_new >= f.T + f.alloc = {e: x for e, x in enumerate(sol.edge_flows) if x} + curve = sol.cost_distribution + else: + bound = self._t_max(f, V, evals) + target = bound if st.B is None else min(f.T + st.B, bound) + t_new = max(f.T, target) + if t_new != f.T or f.new: + f.alloc = K.alloc(self.g, f.plan, t_new) + curve = None + ctx["W"] = sub_view(V, f.alloc) + gain = t_new - f.T + rows.append( + { + "T": [f.T, t_new], + "evals": evals, + "curve": curve, + "alloc": sorted(f.alloc.items()), + } + ) + f.T = t_new + f.delivered = t_new + if st.B is not None: + st.B -= gain + st.trace["stage3"] = rows + + def _stage3_eb(self, st: SourceState, ctx) -> None: + flows = st.flows + current = merge(f.alloc for f in flows) + V = add_view(ctx["W"], current) + T = sum(f.T for f in flows) + evals: list[int] = [] + bound = self._cohort_t_max(flows, V, evals) + target = bound if st.B is None else min(T + st.B, bound) + t_new = max(T, target) + self._set_cohort(flows, t_new) + ctx["W"] = sub_view(V, merge(f.alloc for f in flows)) + if st.B is not None: + st.B -= t_new - T + st.trace["stage3"] = { + "T": [T, t_new], + "bound": bound, + "evals": evals, + "shares": [f.T for f in flows], + } + + def _stage3_lossy(self, st: SourceState, ctx) -> None: + flows = st.flows + offers = ( + [None] * len(flows) + if st.offer is None + else round_robin(st.offer, len(flows)) + ) + injected = 0 + rows = [] + for f, offer in zip(flows, offers, strict=True): + call = K.lossy(self.g, f.plan, offer, ctx["W"]) + ctx["W"] = sub_view(ctx["W"], call.carried) + for e, x in call.carried.items(): + f.alloc[e] = f.alloc.get(e, 0) + x + for e, x in call.drops.items(): + f.drops[e] = f.drops.get(e, 0) + x + ctx["drops"][e] = ctx["drops"].get(e, 0) + x + f.offered = (f.offered or 0) + call.offered + f.delivered += call.delivered + injected += call.injected + rows.append( + { + "offer": "inf" if offer is None else offer, + "offered": call.offered, + "delivered": call.delivered, + "carried": sorted(call.carried.items()), + "drops": sorted(call.drops.items()), + } + ) + st.trace["stage3"] = rows + st.trace["injected"] = injected + + # ---- stage 4 + + def _stage4(self, st: SourceState, ctx) -> None: + r = self.cfg.routing + steps = [] + v = st.offer + threshold = None + if r.diminishing is not None and v is not None: + threshold = Fraction(r.diminishing.fraction) * v + accepted = 0 + while st.B is None or st.B > 0: + if ctx["created"] >= MAX_FLOWS_PER_CALL: + st.limit = "limit_flows" + steps.append({"stop": "kMaxFlowsPerCall"}) + break + if r.max_flow_count is not None and len(st.flows) >= r.max_flow_count: + if r.max_flow_count > r.flow_count: + st.limit = "limit_flows" + steps.append({"stop": "max_flow_count", "k": len(st.flows)}) + break + cand = self._search(st.src, ctx["W"]) + if cand is None: + steps.append({"stop": "no candidate"}) + break + if self.selection.multipath and any(f.route == cand for f in st.flows): + # revision 24 (owner review): with multipath routes a DAG already spans + # every equal-cost path, so a second flow on a structurally equal DAG + # gains only round-robin remainders: growth stops. A repeated single path + # is a legitimate tunnel and stays a growth candidate. + steps.append( + {"stop": "candidate equals a cohort route", "route": list(cand)} + ) + break + f = self._new_flow(st.src, cand, ctx["W"], ctx) + step: dict = {"route": list(cand), "W": list(ctx["W"])} + evals: list[int] = [] + if self.kernel == EB: + current = merge(x.alloc for x in st.flows) + V = add_view(ctx["W"], current) + T = sum(x.T for x in st.flows) + trial = st.flows + [f] + bound = self._cohort_t_max(trial, V, evals) + t_cand = bound if v is None else min(st.T_entry + v, bound) + step.update(T=T, T_candidate=t_cand, bound=bound) + if t_cand <= T: + step["decision"] = "rejected: cohort total not larger" + ctx["seq"] -= 1 + steps.append(step) + break + st.flows.append(f) + self._set_cohort(st.flows, t_cand) + ctx["W"] = sub_view(V, merge(x.alloc for x in st.flows)) + gain = t_cand - T + step["shares"] = [x.T for x in st.flows] + else: + bound = self._t_max(f, ctx["W"], evals) + fill = bound if st.B is None else min(st.B, bound) + step.update(bound=bound, fill=fill) + if fill == 0: + step["decision"] = "rejected: zero fill" + ctx["seq"] -= 1 + steps.append(step) + break + f.alloc = self._alloc(f, ctx["W"], fill) + f.T = f.delivered = fill + ctx["W"] = sub_view(ctx["W"], f.alloc) + st.flows.append(f) + gain = fill + ctx["created"] += 1 + ctx["routes_changed"] = True + accepted += 1 + st.gains.append(gain) + if st.B is not None: + st.B -= gain + step.update(decision="accepted", gain=gain, evals=evals) + steps.append(step) + if threshold is not None and accepted >= r.diminishing.window: + recent = sum(st.gains[-r.diminishing.window :]) + if recent < threshold and st.B > 0: + st.limit = "limit_diminishing" + steps.append( + { + "stop": "diminishing", + "recent": recent, + "threshold": str(threshold), + } + ) + break + st.trace["stage4"] = steps + + # ------------------------------------------------------------ commit + + def _drafts(self, cohorts) -> tuple[list[Draft], list[Flow]]: + drafts: list[Draft] = [] + order: list[Flow] = [] + flows = [f for s in sorted(cohorts) for f in cohorts[s]] + old = [f for f in flows if not f.new] + new = sorted( + (f for f in flows if f.new), + key=lambda f: (f.seq if self.mode != "Global" else f.src), + ) + for f in old + new: + drafts.append( + Draft( + flow_id=None if f.new else f.flow_id, + sources=(f.src,), + destinations=self._dests_ok, + placement=self.kernel, + admission=self.admission, + bundle=f.route, + route_cost=f.cost, + offered=f.offered if self.admission == LOSSY else None, + delivered=f.delivered, + edges=dict(f.alloc), + drops=dict(f.drops), + ) + ) + order.append(f) + return drafts, [f for f in order if f.new] + + def _assign_ids(self, new_flows: list[Flow], new_ids) -> None: + for f, fid in zip(new_flows, new_ids, strict=True): + f.flow_id = fid + f.new = False + + +def capacity(g, sources, destinations, cfg, elig=None, outputs=None) -> PlacementResult: + """``capacity`` = ``place(+inf)`` on a fresh ledger (section 11).""" + fg = FlowGraph(g) + return FlowPolicy(fg, sources, destinations, cfg, elig).place(INF, outputs) diff --git a/tests/reference/policy/p2_transaction/recorder.py b/tests/reference/policy/p2_transaction/recorder.py new file mode 100644 index 0000000..a9c20c8 --- /dev/null +++ b/tests/reference/policy/p2_transaction/recorder.py @@ -0,0 +1,31 @@ +"""Snapshot recording for prototype 2 (prototype 1's ``Snap`` plus policy state).""" + +from __future__ import annotations + +import _p1 # noqa: F401 +from snap import Snap, jsonable +from units import ContractError + + +class PSnap(Snap): + def place(self, label: str, policy, volume, fg=None, outputs=None): + entry: dict = {"op": "place", "policy": label, "request": jsonable(volume)} + try: + r = ( + policy.place(volume) + if outputs is None + else policy.place(volume, outputs) + ) + except ContractError as exc: + entry["error"] = f"{type(exc).__name__}: {exc}" + self.data["steps"].append(entry) + return exc + entry["result"] = jsonable(r.to_json()) + entry["trace"] = jsonable(r.trace) + fg = fg if fg is not None else policy.fg + entry["graph"] = self.labels.get(fg.graph.uid) + entry["state"] = jsonable(fg.state()) + if hasattr(policy, "state"): + entry["policy_state"] = jsonable(policy.state()) + self.data["steps"].append(entry) + return r diff --git a/tests/reference/policy/p2_transaction/run_fixtures.py b/tests/reference/policy/p2_transaction/run_fixtures.py new file mode 100644 index 0000000..33dcd87 --- /dev/null +++ b/tests/reference/policy/p2_transaction/run_fixtures.py @@ -0,0 +1,62 @@ +"""Run every prototype-2 fixture and write ``snapshots/.json``. + +Usage: ``venv/bin/python run_fixtures.py [fixture ...]``; exit status 1 on any +failed expectation. +""" + +from __future__ import annotations + +import json +import sys +import time +from pathlib import Path + +import _p1 # noqa: F401 +from fixtures import FIXTURE_MAP, FIXTURES +from recorder import PSnap + +HERE = Path(__file__).resolve().parent + + +def run_one(fx) -> dict: + s = PSnap(fx.name, fx.family, fx.description, fx.rules) + fx.run(s) + data = s.finish() + old = [k for k, v in FIXTURE_MAP.items() if v == fx.name] + data["regenerates"] = old + return data + + +def main(argv: list[str]) -> int: + only = set(argv[1:]) + out = HERE / "snapshots" + out.mkdir(exist_ok=True) + failed, steps, checks = [], 0, 0 + t0 = time.perf_counter() + names = {fx.name for fx in FIXTURES} + missing = sorted(v for v in FIXTURE_MAP.values() if v not in names) + assert not missing, missing + for fx in FIXTURES: + if only and fx.name not in only: + continue + data = run_one(fx) + (out / f"{fx.name}.json").write_text(json.dumps(data, indent=1) + "\n") + steps += data["counts"]["steps"] + checks += data["counts"]["checks"] + print( + f"{'ok ' if data['passed'] else 'FAIL'} {fx.name} ({data['counts']['checks']} checks)" + ) + for f in data["failures"]: + print(f" {f}") + if not data["passed"]: + failed.append(fx.name) + n = len(only) if only else len(FIXTURES) + print( + f"{n - len(failed)}/{n} fixtures pass ({len(FIXTURE_MAP)} regenerated from the " + f"grid set), {steps} operations, {checks} checks, {time.perf_counter() - t0:.1f} s" + ) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tests/typing/invalid_client.py b/tests/typing/invalid_client.py new file mode 100644 index 0000000..1251308 --- /dev/null +++ b/tests/typing/invalid_client.py @@ -0,0 +1,59 @@ +"""A deliberately invalid client: every marked line must get exactly its expected diagnostic. + +dev/stubs/run.sh type-checks it with Pyright against an installed wheel and an installation +built from the sdist. Each line ending in "# expect: RULE[, RULE...]" must produce one +diagnostic of each listed Pyright rule, and no other line may produce any. It is never run: +each marked line is a mistake the compiled module rejects at run time (or, for the Optional +and dtype lines, a mistake in the caller's own code) that a type checker must catch before. +""" + +import numpy as np +import numpy.typing as npt + +import netgraph_core as ng + +g = ng.Graph(2, [0], [1], [10], [1], 1.0) +sel = ng.PathSelection(ng.EdgeTieBreak.DETERMINISTIC, ng.EdgePriority.CAPACITY) +cfg = ng.FlowPolicyConfig( + routing=ng.Global(), + placement=ng.FlowPlacement.MAX_FLOW, + admission=ng.Admission.LOSSLESS, + selection=sel, +) + +# The eight result and factory classes have no constructor ("No constructor defined!"). +ng.Eligibility() # expect: reportCallIssue +ng.PredDAG() # expect: reportCallIssue +ng.FlowRecord() # expect: reportCallIssue +ng.PlacementResult() # expect: reportCallIssue +ng.DagCacheStats() # expect: reportCallIssue +ng.SensitivityEntry() # expect: reportCallIssue +ng.SensitivityResult() # expect: reportCallIssue +ng.SpfResult() # expect: reportCallIssue + +# dtype mistakes: the capacities are an int64 array of ints. (NumPy's own stubs type an element +# read arr[i] as Any, so "text: str = g.capacity_units()[0]" cannot be caught by any stub; an +# element read through .item() and the array itself carry the dtype.) +floats: npt.NDArray[np.float64] = g.capacity_units() # expect: reportAssignmentType +text: str = g.capacity_units().item(0) # expect: reportAssignmentType + +# Wrong argument types (the module raises ValueError or TypeError). +ng.Graph("wrong", [0], [1], [10], [1], 1.0) # expect: reportArgumentType +ng.Graph(2, [0], [1], [1.5], [1], 1.0) # expect: reportArgumentType +ng.FlowLedger(g, residual=np.zeros(1, dtype=np.float64)) # expect: reportArgumentType +ng.spf(g, 0, ng.KspOptions(1, sel)) # expect: reportArgumentType +ng.capacity(g, 0, 1, ng.SensitivityOptions(cfg)) # expect: reportArgumentType +guard = ng.DiminishingReturns(2, 0.5) +ng.CapacityAware(max_path_cost=guard) # expect: reportArgumentType +ng.to_units("1.5", 1.0) # expect: reportCallIssue, reportArgumentType +# Integer arrays serve as hops and pairs; float arrays do not. +ng.Path(g, np.zeros((1, 1), dtype=np.float64)) # expect: reportArgumentType +float_pairs = np.zeros((1, 2), dtype=np.float64) +ng.batch_capacity(g, float_pairs, ng.CapacityOptions(cfg)) # expect: reportArgumentType + +# Missing Optional checks: these results may be None. +result = ng.spf(g, 0, ng.SpfOptions(ng.Direction.FORWARD, sel, targets=1)) +cost: int = result.target_cost # expect: reportAssignmentType +first = result.costs[0] # expect: reportOptionalSubscript +flows = ng.capacity(g, 0, 1, ng.CapacityOptions(cfg)).edge_flows +total = flows.sum() # expect: reportOptionalMemberAccess diff --git a/tests/typing/valid_client.py b/tests/typing/valid_client.py new file mode 100644 index 0000000..1e7a6fd --- /dev/null +++ b/tests/typing/valid_client.py @@ -0,0 +1,118 @@ +"""A valid client of the installed package: no type checker diagnostic, and it runs. + +dev/stubs/run.sh type-checks it with Pyright against an installed wheel and an installation +built from the sdist (zero diagnostics expected), then runs it. assert_type pins the declared +types of the generated stub: a wrong dtype, a lost Optional or a widened result fails here. +""" + +from typing import assert_type + +import numpy as np +import numpy.typing as npt + +import netgraph_core as ng + +# The graph: integers as lists, tuples or NumPy arrays of any integer dtype. +g = ng.Graph( + 4, + np.array([0, 0, 1, 2], dtype=np.int32), + (1, 2, 3, 3), + [10, 5, ng.to_units(2.5, 1.0), 4], + np.array([1, 2, 1, 1], dtype=np.uint8), + 1.0, +) +assert_type(g.capacity_units(), npt.NDArray[np.int64]) +assert_type(g.cost(), npt.NDArray[np.int64]) +assert_type(g.edge_src(), npt.NDArray[np.int32]) +assert_type(g.row_offsets(), npt.NDArray[np.int32]) +assert_type(g.num_edges(), int) +first_capacity: int = int(g.capacity_units()[0]) + +elig = g.eligibility(excluded_nodes=[np.int64(2)]) +assert_type(elig.edge_allowed(), npt.NDArray[np.bool_]) +both = ng.Eligibility.intersect(elig, g.eligibility()) +assert_type(both, ng.Eligibility) + +# Units: a scalar gives an int, an array an int64 array; and back. +assert_type(ng.to_units(1.5, 0.5), int) +assert_type(ng.to_units([1.5, 2.0], 0.5), npt.NDArray[np.int64]) +assert_type(ng.as_float(3, 0.5), float) +assert_type(ng.as_float(np.array([3, 4]), 0.5), npt.NDArray[np.float64]) + +# The search: optional results are checked before use. +sel = ng.PathSelection(ng.EdgeTieBreak.DETERMINISTIC, ng.EdgePriority.CAPACITY) +full = ng.spf(g, 0, ng.SpfOptions(ng.Direction.FORWARD, sel)) +costs = full.costs +assert_type(costs, npt.NDArray[np.int64] | None) +if costs is not None: + reached = int(costs[3]) +near = ng.spf(g, [0], ng.SpfOptions(ng.Direction.FORWARD, sel, targets=3, elig=elig)) +cost = near.target_cost +if cost is not None: + assert_type(cost, int) +dag = near.dag +assert_type(dag.parents(), npt.NDArray[np.int32]) +paths = ng.resolve_to_paths(g, full.dag, 0, 3, max_paths=None) +assert_type(paths, list[ng.Path]) +for c, p in ng.ksp(g, 0, 3, ng.KspOptions(2, sel, max_path_cost=ng.Absolute(9))): + assert_type(c, int) + hops: list[list[int]] = p.hops() + pinned = ng.Pinned([p.to_dag()]) +same = ng.PredDAG.from_csr( + g, dag.parent_offsets(), dag.parents(), np.asarray(dag.via_edges()) +) +# An integer array serves as the sequence of hops (one row per hop): the walk 0 -> 1 -> 3. +walk = ng.Path(g, np.array([[0], [2]], dtype=np.int32)) +assert_type(walk.cost(), int) + +# The ledger and a policy. +ledger = ng.FlowLedger(g, residual=None) +policy = ng.FlowPolicy( + ledger, + 0, + [3], + ng.FlowPolicyConfig( + routing=ng.CapacityAware(flow_count=2, max_path_cost=ng.Stretch(1.5)), + placement=ng.FlowPlacement.EQUAL_BALANCED, + admission=ng.Admission.LOSSLESS, + selection=sel, + ), +) +result = policy.place(float("inf"), ng.OutputOptions(residual=True)) +assert_type(result, ng.PlacementResult) +assert_type(result.remaining, int | float) +residual = result.residual +if residual is not None: + assert_type(residual, npt.NDArray[np.int64]) +for record in policy.records(): + assert_type(record.edges, list[tuple[int, int]]) + assert_type(record.route_cost, int | None) + assert_type(ledger.record(record.flow_id), ng.FlowRecord) +policy.remove() + +# The evaluators, with optional arguments left out or None. +cfg = ng.FlowPolicyConfig( + routing=ng.Global(), + placement=ng.FlowPlacement.MAX_FLOW, + admission=ng.Admission.LOSSLESS, + selection=sel, +) +cache = ng.DagCache(g) +opts = ng.CapacityOptions(cfg, elig=None, cache=cache) +assert_type(opts.elig, ng.Eligibility | None) +placed: int = ng.capacity(g, 0, 3, opts).placed +batch = ng.batch_capacity(g, [(0, 3), ([0, 1], np.array([3]))], opts, workers=1) +assert_type(batch, list[ng.PlacementResult]) +# Integer arrays serve as the pairs too: one row (sources, destinations) per pair, or one pair. +from_rows = ng.batch_capacity(g, np.array([[0, 3], [1, 3]], dtype=np.int64), opts) +from_pair = ng.batch_capacity(g, [np.array([0, 3], dtype=np.int64)], opts) +assert_type(from_rows, list[ng.PlacementResult]) +assert_type(from_pair, list[ng.PlacementResult]) +sens = ng.sensitivity(g, 0, 3, ng.SensitivityOptions(cfg)) +for entry in sens.entries: + assert_type(entry.loss, int) +assert_type(cache.stats().hits, int) +routing = cfg.routing +if isinstance(routing, ng.Global): + print("Global routing:", routing) +print(placed, len(batch), len(paths), ng.UNREACHABLE, ng.__version__) diff --git a/third_party/pybind11/LICENSE b/third_party/pybind11/LICENSE new file mode 100644 index 0000000..e466b0d --- /dev/null +++ b/third_party/pybind11/LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2016 Wenzel Jakob , All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Please also refer to the file .github/CONTRIBUTING.md, which clarifies licensing of +external contributions to this project including patches, pull requests, etc. From cb4dd8e26e080b761d949cd61a529cae44c18c57 Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Sun, 11 Oct 2026 22:25:31 -0700 Subject: [PATCH 10/12] dev/perf: a minimal A/B/A timing tool One driver times the Core's rows as old build, new build, old build again on one machine, so drift shows, and checks that both builds give the same results. Its own tests run with make perf-tool-test and on one macOS CI job, so a library change that breaks the tool fails there. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yml | 8 + .gitignore | 3 + AGENTS.md | 4 +- Makefile | 6 +- README.md | 7 +- dev/perf/README.md | 380 +++ dev/perf/aba.py | 2215 +++++++++++++++++ dev/perf/aba_api.hpp | 589 +++++ dev/perf/aba_rows.hpp | 1128 +++++++++ dev/perf/build.sh | 150 ++ dev/perf/harness.cpp | 7 + dev/perf/queries/SHA256SUMS | 4 + dev/perf/queries/canonical16k.json | 48 + dev/perf/queries/far16k.json | 47 + dev/perf/queries/near65k.json | 271 ++ dev/perf/queries/storage/SHA256SUMS | 1 + dev/perf/queries/storage/inputs.json | 545 ++++ dev/perf/queries/targetset_clos.json | 65 + dev/perf/test_tool.py | 3418 ++++++++++++++++++++++++++ pyproject.toml | 2 + 20 files changed, 8895 insertions(+), 3 deletions(-) create mode 100644 dev/perf/README.md create mode 100644 dev/perf/aba.py create mode 100644 dev/perf/aba_api.hpp create mode 100644 dev/perf/aba_rows.hpp create mode 100755 dev/perf/build.sh create mode 100644 dev/perf/harness.cpp create mode 100644 dev/perf/queries/SHA256SUMS create mode 100644 dev/perf/queries/canonical16k.json create mode 100644 dev/perf/queries/far16k.json create mode 100644 dev/perf/queries/near65k.json create mode 100644 dev/perf/queries/storage/SHA256SUMS create mode 100644 dev/perf/queries/storage/inputs.json create mode 100644 dev/perf/queries/targetset_clos.json create mode 100644 dev/perf/test_tool.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 21b70c1..9af2762 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -46,6 +46,14 @@ jobs: - name: Run Python tests (Windows) if: runner.os == 'Windows' run: python -m pytest tests/py/ -v + # The timing tool of dev/perf compiles against the library (its adapter uses the public + # API and two private functions of the queue rule), and nothing else builds it: its own + # tests, which compile its driver with this checkout's library, run on one job, so a + # library change that breaks the tool fails here. macOS: the platform the tool is made + # and checked on (its build record and memory reading are macOS-first). + - name: Timing tool tests (macOS, py3.14) + if: runner.os == 'macOS' && matrix.python-version == '3.14' + run: make perf-tool-test test-centos-stream-9: name: CentOS Stream 9 (py${{ matrix.python-version }}) diff --git a/.gitignore b/.gitignore index 3b8c2d3..af80017 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ dist/ # Personal Superset lifecycle overrides .superset/config.local.json + +# The performance tool's default output directory +/dev/perf/results/ diff --git a/AGENTS.md b/AGENTS.md index 7b39e83..cdda238 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,4 +17,6 @@ cost ceilings (Absolute, Stretch). A binding signature or docstring change needs the regenerated type stub (`bash dev/stubs/run.sh generate`; `make stubs-check` checks drift, package contents and typed clients). Performance claims need correctness checks and -A/B/A on one quiet machine. Local checks do not replace CI. +A/B/A on one quiet machine (`dev/perf/README.md`; `make perf-tool-test` after +changing that tool; CI runs it on one macOS job, as a library change can break +it). Local checks do not replace CI. diff --git a/Makefile b/Makefile index 4488bdf..e48550e 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # NetGraph-Core Development Makefile -.PHONY: help venv clean-venv dev install install-native check check-ci lint format test build clean check-dist publish-test publish info hooks check-python cpp-test cpp-test-off cov sanitize-test tsan-test stubs-check rebuild +.PHONY: help venv clean-venv dev install install-native check check-ci lint format test build clean check-dist publish-test publish info hooks check-python cpp-test cpp-test-off cov sanitize-test tsan-test stubs-check perf-tool-test rebuild .DEFAULT_GOAL := help @@ -61,6 +61,7 @@ help: @echo " make tsan-test - Build and run C++ tests with ThreadSanitizer" @echo " make stubs-check - Type stub: drift, wheel/sdist contents, typed clients (own venvs)" @echo " (regenerate the stub: bash dev/stubs/run.sh generate)" + @echo " make perf-tool-test - Tests of the A/B/A timing tool in dev/perf (after changing it; CI: macOS)" @echo "" @echo "Build & Package:" @echo " make build - Build distribution packages" @@ -248,6 +249,9 @@ tsan-test: stubs-check: @PYTHON=$(PYTHON) bash dev/stubs/run.sh check +perf-tool-test: + @$(PYTHON) -B dev/perf/test_tool.py + # Clean + reinstall in dev mode (respects CMAKE_ARGS and MACOSX_DEPLOYMENT_TARGET) # Uses active PYTHON (venv or PATH) to avoid environment mismatches rebuild: clean diff --git a/README.md b/README.md index 2f6faf3..256eb0a 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,8 @@ bindings/python/ # pybind11 bindings python/netgraph_core/ # Python package tests/cpp/ # C++ tests (googletest) tests/py/ # Python tests (pytest) -dev/ # Developer scripts (not shipped): checks and coverage +dev/ # Developer scripts (not shipped): checks, coverage, +dev/perf/ # and the A/B/A timing tool ``` ## Development @@ -240,6 +241,10 @@ make format # Apply ruff formatting (make hooks runs every pre-commit fix `make check` and `make check-ci` only verify; they never edit a file. +Performance claims are timed with the A/B/A tool (old build, new build, then the old build again +on one machine, so drift shows), described in [dev/perf/README.md](dev/perf/README.md) +(`make perf-tool-test` tests the tool itself; CI runs it on one macOS job). + The editable development build (`make install`, `make dev`) carries the private test hooks (CMake option `NETGRAPH_CORE_PRIVATE_HOOKS`; a wheel build does not); the test suites skip the tests that need them when they are absent. diff --git a/dev/perf/README.md b/dev/perf/README.md new file mode 100644 index 0000000..32024df --- /dev/null +++ b/dev/perf/README.md @@ -0,0 +1,380 @@ +# Timing tool (A/B/A) + +Developer-only tool for timing two builds of NetGraph-Core against each other, as `AGENTS.md` +asks for every performance claim. Nothing here ships in the wheel or the sdist, and no timing +runs in CI; only the tool's own tests do (below), and the repository gates (`make check-ci`, +`make sanitize-test`, `make tsan-test`) stay the acceptance tests. Verify correctness first +(bit-identical results where the contract is exact, certificates for flows); time second. + +**A/B/A** means: the reference build A, the candidate build B, then A again, each block in a +fresh process, repeated seven times; the candidate's time is compared with the mean of the two +reference blocks around it, so slow drift of the machine cancels out. A bootstrap interval over +the seven triples says whether a difference is real. + +| File | Role | +| --- | --- | +| `build.sh` | builds one harness binary against the Core of a revision or of the working tree, with its build record | +| `harness.cpp`, `aba_rows.hpp`, `aba_api.hpp` | the harness: the rows (workloads, semantic work per iteration, digests) and the adapter that maps them onto the current public Core API (plus two private functions of the queue rule, below) | +| `aba.py` | runs one A/B/A comparison: blocks, statistics, verdicts, `report.json`, `summary.md`, `blocks.csv`, `meta.txt` | +| `queries/` | the frozen row inputs (`*.json`, `storage/inputs.json`) with their `SHA256SUMS` | +| `test_tool.py` | the tool's own tests (`make perf-tool-test`) | + +## Quick start + +Compare the working tree (candidate) with a revision (reference); both binaries carry the same +rows, each compiled against its own Core. Run on a quiet machine, nothing else running. + +```sh +bash dev/perf/build.sh build/perf/aba/ref # e.g. HEAD, or main +bash dev/perf/build.sh worktree build/perf/aba/cand +venv/bin/python dev/perf/aba.py \ + --old-bin build/perf/aba/ref/harness --new-bin build/perf/aba/cand/harness \ + --old-label ref --new-label cand --rows "kernel.*,maxflow.*" --out build/perf/aba/run +``` + +`aba.py` needs Python 3.10 or newer (the venv's; macOS's system `python3` 3.9 is refused with a +message). The run prints a plan (the expected duration of every row) before the first timed +block, then one line per row, and writes `report.json`, `summary.md` (the table to read), +`blocks.csv` (every block; in an aborted run, those of the finished warm-up pairs and triples) +and `meta.txt` (every hash) to `--out` (default `dev/perf/results//`, ignored by +git). The run's verdict is the worst row verdict, and a row whose results differ between the two +builds fails: the tool compares two builds of the current Core, so a change it times (an +optimisation) must leave every result identical. A change that is meant to change results is +checked for correctness by the test suites, not timed against the old results. The adapter +`aba_api.hpp` is written against the current public API, apart from the queue check of the +storage search rows, which reads the library's queue rule through the private +`detail::bucket_width` and `detail::uses_buckets`: a reference revision with another API needs +the adapter adapted first. + +**Exit statuses and acceptance.** `aba.py` exits 0 when the run completed, whatever its verdict +and conformance; 1 when it aborted after starting blocks; 2 when it refused an input or failed +before the first block; 130 for Ctrl-C before the first block. Every failure writes its reason +to `report.json` and `summary.md`, with verdict `aborted` and conformance false. A reason names +the side, mode and row when it comes from a block. A refusal before row selection writes a +minimal report with no rows. A failed harness, malformed response, changed or unreadable binary, +non-finite derived value, operating-system error, internal exception or Ctrl-C ends the run. +The first Ctrl-C stops the active harness; later interrupts are held while the outputs are +written. An interrupt during final writing records an abort and rewrites both outputs. + +Both files are created before the first block. Each final output is attempted even if another +fails; text that cannot be formed is replaced by a minimal abort record. Complete output text +replaces its file in one operation; if directory permissions no longer allow replacement, an +existing writable file is retried directly with the abort reason. An invalid, ambiguous, +occupied or unwritable `--out` cannot receive a new report; existing unrelated files are never +overwritten. A persistent write failure (for example a full disk), or a process killed by an +uncatchable signal, can prevent output. Write failures are also reported on stderr. `--help` +prints help and writes no run files. Both outputs list every selected row with the step it reached: `measured`, and in an +aborted run `not run`, `warm-up` (its warm-up step did not finish: a warm-up block failed, or a +count or the expected duration derived from them is not finite), `planned` (warmed up, not +timed) or `timing` (its timed step did not finish: a timed block failed, or a row statistic +derived from them is not finite). A run killed by another signal writes nothing more. Its exit +status is no acceptance: a run is accepted when it is conforming and its verdict is `pass`, the +first two items of `summary.md` (and `report.json`'s `conforming` and `verdict`). + +## Building the two sides + +`build.sh` requires `bash`, `git`, `tar`, `python3` and the compiler on `PATH` (on macOS, +`xcrun` for an explicitly selected compiler). `vtool` supplies optional build diagnostics. + +`bash dev/perf/build.sh REV OUT_DIR` compiles this checkout's `harness.cpp` (with `aba_rows.hpp` +and `aba_api.hpp`) against the Core sources of `REV`: `worktree` takes this checkout's `include/` +and `src/` with their uncommitted changes; a git revision is extracted with `git archive` into +`OUT_DIR/src`. `REV` is required (there is no default reference), and a revision git does not +know is refused. `OUT_DIR` must be absent or empty; it receives `harness` and the build record +`harness.json`. The compiler is `clang++` from `PATH` with the first `flags=(...)` line of +`build.sh` (`-std=c++20 -O3 -DNDEBUG -funroll-loops -fno-math-errno -fno-trapping-math -Wall +-Wextra`). `CXX` overrides the compiler; on macOS a set `CXX` also gets `-isysroot "$(xcrun +--show-sdk-path)"` (a toolchain compiler such as `$(xcrun --find clang++)` finds no system headers +without it), and a set `CXX` without an SDK is refused. A refusal exits 2. A failed compile exits +with the compiler's status, and a failure while the build record is written exits non-zero; +either leaves neither `harness` nor `harness.json` (a revision's extracted sources stay in +`OUT_DIR/src`, so a second attempt needs another or an emptied `OUT_DIR`). + +### Build records + +`harness.json` names everything that changes the binary's bytes: + +| Field | Meaning | +| --- | --- | +| `binary`, `binary_sha256` | the output path and the hash of the file written there; the file name is part of the bytes (below) | +| `rev`, `commit`, `dirty_core_paths` | the revision asked for, its commit (`HEAD` for `worktree`), and for `worktree` the number of paths `git status --short` lists under `include/` and `src/` (0 for a revision) | +| `core_sources_sha256` | sha256 of `" \n"` lines over every file under the `include/` and `src/` compiled against (the checkout's or the extracted ones), sorted by path; a path with a part that starts with a dot is left out | +| `driver_sha256` | the sha256 of `dev/perf/harness.cpp` and of every `dev/perf/*.hpp`, by path | +| `compiler`, `compiler_path`, `cxx_env` | the compiler's `--version` line, the program the name resolved to on `PATH`, and `CXX` as given (null when unset) | +| `flags`, `isysroot` | the flags passed (`-isysroot ` included when added), and that SDK path (null when none was passed) | +| `sdk.path`, `sdk.version`, `sdk.sdkroot_env`, `sdk.source` | the SDK `xcrun` names (the one the `clang++` shim and `-isysroot` use) and `SDKROOT`, which overrides the shim's choice; path and version null when `xcrun` names no SDK | +| `deployment_target.requested` | `MACOSX_DEPLOYMENT_TARGET` (null when unset: the compiler then targets the SDK's own OS version) | +| `deployment_target.effective_minos`, `binary_sdk`, `platform`, `raw`, `source` | the minimum OS and SDK version the binary carries, read back from its `LC_BUILD_VERSION` load command with `vtool -show-build` (the first value of each when the binary carries several; `raw` has every line) | +| `built_by` | `dev/perf/build.sh` | + +Besides the sources and the flags, the bytes depend on the **deployment target** (the same +sources built with `MACOSX_DEPLOYMENT_TARGET=15.0` and without it differ), the **output file +name** (the linker signs every arm64 binary ad hoc with the file name as the signature's +identifier, and the binary's UUID follows; copying a binary keeps its bytes, the name at link +time is what counts), the **SDK** and the **compiler**. A pinned binary hash is reproducible +only with all of them equal. `aba.py` refuses a binary whose hash differs from its record. Every field in the table is +required and no other key is accepted, including inside `sdk` and `deployment_target`. +All values are strings except `dirty_core_paths` (a finite nonnegative integer), the three +objects `sdk`, `deployment_target`, `driver_sha256`, and these nullable strings: `compiler_path`, `cxx_env`, `isysroot`, `sdk.path`, `sdk.version`, +`sdk.sdkroot_env`, and `deployment_target.requested`, `effective_minos`, `binary_sdk`, `platform`, +`raw`. A null means the value was unavailable or the environment variable was unset. `binary_sha256` and `core_sources_sha256` are 64 lowercase hexadecimal digits; +`driver_sha256` has exactly this checkout's driver paths and matching hashes. Descriptive +strings are recorded as text; the old build path, revision and compiler are not re-executed or +used to locate inputs. + +## Protocol + +**Blocks.** Each block is a fresh process of one side's harness running one row: setup +(untimed), one untimed iteration that fixes the reference digest, then a fixed iteration count +with every iteration timed; the harness reports the median iteration time and `block_s`, its own +wall-clock measurement of the timed loop. Every iteration must return the reference digest (else +the harness exits 3 and the run aborts), which the block line carries as 16 lowercase hexadecimal +digits. A block's whole output is its response, and the run +aborts unless it is exactly one block line for the requested row ended by a newline, with +nothing before or after it (a usable line is never picked out of a broken response). A block +line is malformed, and aborts the run, when it is not one JSON object, names a key twice or a +key outside the listed fields, or a required number is missing, of another type, not finite (an integer beyond the largest +float counts as not finite) or outside its domain: the iteration count is an integer in [1, 2^63 - 1]; the median iteration +time and the harness's own peak RSS are positive; `block_s`, `setup_s` and the children's peak +RSS may be 0 (the harness prints the two times rounded to microseconds, and starts no child +process); the statistics no rule reads (`mean_s`, `min_s`, `p10_s`, `p90_s`, `max_s`) are +nonnegative when present. A timed block must report the iteration count it was given. The +numeric options are checked the same way (`--tolerance`, `--rss-floor-mib` and +`--calibration-margin` may be 0, `--seed` is an integer of either sign within the finite float range, the others are positive), and the +values derived from them must be finite (`--target-seconds` times one plus the margin, the floor +in bytes). "Inputs" below lists every input's rule. + +**Derived values.** Every number of a block line is finite, and every one but the iteration +count is read as a float, but arithmetic on huge ones need not be finite: the mean of two +medians near 1e308 is infinite, and a ratio against it would read 0, a false pass. So a value +derived from the blocks that is not finite (a mean of A1 and A2, a ratio, the statistic, its +interval, the drift, a median, a peak sum, the memory allowance, a calibrated count, an expected +duration or a finish time beyond any date) aborts the run before the row's statistics or verdict +are recorded or printed. + +**Per row.** One warm-up block per side (discarded; it calibrates the iteration counts), then +seven triples A1, B, A2 in that order (fewer only with `--allow-short`, which labels the run +"not gate evidence"). The counts are **per side**: `ceil(target * (1 + margin) / median_side)` +from that side's own warm-up block (a calibrated count above 2^63 - 1 aborts with the side and row named) (`--target-seconds`, default 2, `--calibration-margin`, +default 0.10), so every block on either side lasts about the target even when the two sides' +speeds differ by orders of magnitude. A1 and A2 share side A's count. `--iters` pins the counts +instead (below). The block value is the median iteration time, so the ratio does not depend on +the counts. The warm-up blocks of every selected row run first; the expected duration of every +row and of the run is printed and stored (`report.json` `plan`, each row's `expected`) before +the first timed block. + +**Statistics.** `r_t = B_t / mean(A1_t, A2_t)`; the statistic is `median_t(r_t - 1)`; its 95 +percent percentile interval comes from `--resamples` (default 10,000) bootstrap resamples of +whole triples, seeded with `--seed`; drift is `median_t |A1_t / A2_t - 1|`. + +**Verdicts per row.** + +* timing: `inconclusive` when the drift exceeds the tolerance (`--tolerance`, default 0.03), + else `pass` when the interval's upper bound is within the tolerance, else `fail`; +* blocks: `fail` when any timed block's `block_s` is below `--target-seconds`; +* same-side clusters: per side, the timed blocks' per-iteration times sorted; a gap between two + consecutive blocks above the tolerance with at least two blocks on each side marks the side + `bimodal` and fails the row even when the median passes; +* memory (below); +* digests: every timed block of both sides must report one and the same digest. + +The row is `fail` if any check fails, else `inconclusive` if timing is, else `pass`. The run's +verdict is `aborted` when the run aborted, else the worst row verdict. A run is **conforming** +(gate evidence) only when it did not abort, `--allow-short` was not given (without it fewer than +seven triples or a target below 2 s are refused; a measured block shorter than the target fails +its row), no timed block was short and the +allocator environment is normal; the summary's first line says which. + +**Memory.** Each block's peak RSS (resident set size: the memory the process holds in RAM) of the +harness process tree is the larger of the harness's own `getrusage` (self + children) +`ru_maxrss` and `ru_maxrss` from the orchestrator's `os.wait4` (macOS reports bytes, Linux KiB, +converted). The harness's own record of the iteration times is the same in every block, whatever +its iteration count: allocated and written before the row's setup, it keeps a count per duration +below 65,536 clock ticks (65.5 µs with the nanosecond clocks of macOS and Linux) and a list of +the longer ones with 65,536 slots, which grows only past 4.3 s of such iterations in one block +(1 MiB of bookkeeping in all; its statistics are those of the sorted list of every iteration's +time). A faster side runs more iterations, and a record that grew with them would count in its +peak. Reference = the largest A1 block peak, candidate = the largest B block peak (over the +timed triples); the row fails when the candidate exceeds `max(1.10 * reference, reference + 8 +MiB)` bytes (`--budget`, `--rss-floor-mib`). A1, the first reference block of every triple, is +fixed in advance: a larger A2 peak or a larger warm-up peak never rescues a failure. Why the +maxima: macOS's allocator keeps freed large blocks in a cache whose pages stay resident, so on +several rows the block peaks of any build land on one of two levels about 9 to 10 MiB apart at +random; medians can fall on different levels by chance and fail identical binaries, while the +largest peaks of both sides reach the upper level. A row records `rss_a1_max_bytes`, +`rss_b_max_bytes`, `rss_allowance_bytes`, `rss_balanced_ratio`, `rss_a2_max_bytes` (a +diagnostic) and `rss_verdict`. The check compares the largest observed peaks; it is no absolute +memory bound. + +**Allocator environment.** `aba.py` neither sets nor clears an allocator variable +(`MallocLargeCache=0` would hide the cache levels and with them a candidate's genuine peak). It +records the harness's environment in `report.json` `allocator_env` and on the summary's +"allocator environment" line: whether `MallocLargeCache` is set, and every variable whose name +starts with `Malloc` or `MALLOC_` or is `DYLD_INSERT_LIBRARIES`, `LD_PRELOAD` or +`GLIBC_TUNABLES`. A run with any such variable is not conforming. + +**Provenance.** `meta.txt` lists the sha256 of both harness binaries (each checked against its +build record when it has one) and of the orchestrator and driver sources (`aba.py`, +`harness.cpp`, `aba_rows.hpp`, `aba_api.hpp`, hashed at the start). A side's binary is +re-hashed before and after every block and at the end of the run; a difference, or a binary +that can no longer be read, aborts the run. `--expect-meta meta.txt` refuses to start unless +every hash equals a previous run's (the refusal names the hashes that differ). Both sides' +harnesses run in the repository root with the environment `aba.py` was started in, unchanged: +`aba.py` sets and clears no variable. + +**Machine state.** Each block records the 1-minute load before and after; the summary has a +machine-state line (load averages, top CPU consumers) at the start and the end of the run. + +### The sides + +Every selected row runs as A1 = `--old-bin`, B = `--new-bin`, A2 = `--old-bin` with every rule +above: two builds of the current Core. Both must run this checkout's driver (`build.sh` builds +them so): the two harnesses must list the same rows, record for record, and a side's build +record, when it has one, must carry this checkout's driver as its `driver_sha256`: exactly the +driver files `meta.txt` names (`harness.cpp`, `aba_rows.hpp`, `aba_api.hpp`), each with the +sha256 it has at the start of the run; else the run is refused, so a harness built before the +rows or the adapter changed is not timed. A side without a build record is not checked this way +(`report.json` records its build record as empty). + +### Selecting rows + +`--rows` takes comma-separated globs over row names and groups (`all`, `construct.*`, +`kernel`); spaces around a glob are ignored, and no text means `all`. **A glob that matches no +row or group refuses the run** before any block, with the list of the groups that exist, even +beside globs that match. The report records, per glob, the rows it selected and the rows of its +groups it did not, and per group the rows the whole selection left out. + +Other options: `--iters row=N,...` (both sides) or `row=NA:NB,...` (per side) pins the timed +blocks' iteration counts of a row in place of the calibrated ones (every item names a selected +row, once; a count is at most 2^63 - 1, the largest the harness reads); `--triples` (default 7, +fewer only with `--allow-short`, "Per row" above); `--seed` (default 20261003) and +`--resamples` of the bootstrap; `--old-label` and `--new-label` (defaults `old` and `new`; two +different labels, below); `--out` (absent or empty). + +**Names.** A row's name and its group are 1 to 64 of the characters `a`-`z`, `0`-`9`, `.`, +`_` and `-`; a side's label is 1 to 32 of `a`-`z`, `0`-`9`, `_` and `-` (no dot). They make the +stderr logs' names, `blocks/.