diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f3ff3f4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,55 @@ +# Changelog + +All notable changes to this crate are documented here. + +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). + +## [0.2.0] - 2026-07-09 + +### Added + +- `Customizer` (via `Cch::customizer`) — a reusable customizer that derives the + elimination-tree level partition once and reuses output buffers across many + metrics through `Customizer::customize_into`, avoiding per-call allocation on + the hot re-customization path. + +### Changed + +- Customization now runs in parallel. Phase-1 reset parallelizes over + independent arcs; the phase-2 lower-triangle relaxation runs level by level + (barrier between levels, nodes parallel within a level). Output remains + **bit-identical** to the previous serial implementation and to the C++ + RoutingKit oracle. +- `rayon` is now a dependency. It is pure Rust, so the crate remains free of any + C++ or FFI. +- `Cch::customize` is unchanged in signature and output (byte-for-byte); it is + now a thin wrapper over `Customizer`. + +### Notes + +- The parallel relaxation's data-race-freedom is contracted to a well-formed + (chordal) CCH as produced by `Cch::build` or a faithful `load_struct` + round-trip; a one-time bounds validation additionally guards against + out-of-bounds access for any bounds-valid structure. + +## [0.1.1] - 2026-06-25 + +### Changed + +- Query paths reach parity with the C++ RoutingKit oracle by eliding per-arc + bounds checks in the hot relaxation loops. + +## [0.1.0] - 2026-06-25 + +### Added + +- Initial release: the complete CCH pipeline in pure, safe Rust — contraction + order (`degree_order`, `inertial_order`), structure build (`Cch::build`), + per-metric customization, memory-mappable bundle read/write, and + distance / distance-matrix / shortest-path queries — validated bit-for-bit + against RoutingKit. + +[0.2.0]: https://github.com/Rodeapps/cch/compare/v0.1.1...v0.2.0 +[0.1.1]: https://github.com/Rodeapps/cch/compare/v0.1.0...v0.1.1 +[0.1.0]: https://github.com/Rodeapps/cch/releases/tag/v0.1.0 diff --git a/Cargo.toml b/Cargo.toml index 4f7632a..dcbd3b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "cch" -version = "0.1.1" +version = "0.2.0" edition = "2024" license = "MIT" -description = "Pure-Rust Customizable Contraction Hierarchies (CCH): build, customize, and serve fast shortest-path distance, many-to-many matrix, and path queries on road networks" +description = "Pure-Rust Customizable Contraction Hierarchies (CCH): build, customize in parallel, and serve fast shortest-path distance, many-to-many matrix, and path queries on road networks" repository = "https://github.com/Rodeapps/cch" homepage = "https://github.com/Rodeapps/cch" documentation = "https://docs.rs/cch" @@ -19,6 +19,7 @@ exclude = ["oracle/", ".github/", ".superpowers/", ".plans/", ".claude/"] [dependencies] memmap2 = "0.9" +rayon = "1.10" [dev-dependencies] routingkit-cch = { path = "oracle/routingkit-cch" } diff --git a/README.md b/README.md index 984414e..f0d084b 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ It is a from-scratch Rust reimplementation of [RoutingKit](https://github.com/Ro ```toml [dependencies] -cch = "0.1" +cch = "0.2" ``` ## Quick start @@ -41,6 +41,10 @@ let cch = Cch::build(&graph, &order); // 3. Customize a metric (cheap — repeat per weight profile). let metric = cch.customize(&graph.weight); +// For repeated re-customization, reuse buffers: +// let cust = cch.customizer(); +// cust.customize_into(&graph.weight, &mut metric); + // 4. Query in memory, via zero-copy views. let dm = distance_matrix(&cch.view(), &metric.view(), &[0], &[3]); assert_eq!(dm[0], 3); // shortest distance 0 -> 3 @@ -61,8 +65,9 @@ The expensive build is amortized across many cheap customizations, which is exac ## Highlights -- **Pure Rust, no FFI.** The published library has zero C++ in its dependency tree — embed it in a Rust service with no C/C++ toolchain. (A C++ RoutingKit build is used *only* as a dev-time differential-test oracle; it is not part of the crate you depend on.) +- **Pure Rust, no FFI.** The published library has zero C++ in its dependency tree — embed it in a Rust service with no C/C++ toolchain. (Parallel customization uses [rayon](https://github.com/rayon-rs/rayon), also pure Rust. A C++ RoutingKit build is used *only* as a dev-time differential-test oracle; it is not part of the crate you depend on.) - **The complete pipeline** — contraction order, structure build, per-metric customization, bundle reader **and** writer, distance / distance-matrix / path queries, and shortcut unpacking. +- **Parallel, reusable customization.** `Cch::customizer` builds a [`Customizer`](https://docs.rs/cch/latest/cch/struct.Customizer.html) once per structure; `Customizer::customize_into` re-customizes for a new weight profile without reallocating output buffers, with both phases of customization running in parallel — bit-identical to the serial, single-shot `Cch::customize`. - **Two ordering strategies** — a lightweight `degree_order`, and **`inertial_order`**: a full geometric nested-dissection (inertial-flow max-flow / min-cut) that produces hierarchies of the *same quality as RoutingKit* (identical shortcut counts in testing). - **Zero-copy mmap bundles.** Build once, write `.cch-struct` / `.cch-metric` files, then serve them memory-mapped — the OS page cache backs the query slices directly, so many regions can be served within a bounded memory budget. The format is byte-compatible with RoutingKit-produced bundles. - **Proven correct.** Every stage is gated by a differential test against the C++ oracle (see [Correctness](#correctness)). @@ -113,6 +118,8 @@ Indicative numbers, Rust vs the C++ RoutingKit oracle, on a 24×24 bidirectional Every operation is at parity with (or faster than) RoutingKit. The query paths reach parity by eliding the per-arc bounds checks in the hot relaxation loops — the elision-able accesses via sliced iterators, and the data-dependent distance-array access via a `get_unchecked` guarded by a one-time structural validation. (Numbers are indicative; hardware is not standardized — run `cargo bench` for your own.) +A `customize_reuse` bench compares a fresh `Cch::customize` per call against a reused `Customizer::customize_into` on the same grid; the reused path avoids re-deriving the level partition and re-allocating output buffers, so it is never slower and is typically a few percent faster (`cargo bench --bench cch -- customize` to reproduce). The gain grows with structure size and call frequency; on this modest 24×24 fixture the parallel overhead largely offsets the savings. + ## Correctness The reference C++ RoutingKit is vendored as a **dev-only** differential-test oracle, and each stage of the pipeline is gated against it: @@ -127,9 +134,9 @@ The whole crate maintains **100% line coverage**, enforced by a CI gate (`cargo ## Status & roadmap -`0.1` ships the full pipeline — both orderings, build, customize, bundle read/write, and all query types — validated against RoutingKit. The API may still evolve before `1.0`. +`0.1` ships the full pipeline — both orderings, build, customize, bundle read/write, and all query types — validated against RoutingKit. Customization runs in parallel (via rayon) and supports buffer reuse across repeated calls through `Cch::customizer` / `Customizer::customize_into`, with no change to the bit-identical output. The API may still evolve before `1.0`. -Planned: narrowing the query-path performance gap, parallel customization, and broader benchmark coverage on real continental graphs. +Planned: narrowing the query-path performance gap and broader benchmark coverage on real continental graphs. ## Relationship to RoutingKit diff --git a/benches/cch.rs b/benches/cch.rs index 39129b0..0746e60 100644 --- a/benches/cch.rs +++ b/benches/cch.rs @@ -184,6 +184,39 @@ fn bench_customize(c: &mut Criterion) { g.finish(); } +// --------------------------------------------------------------------------- +// Bench: customize_reuse (fresh Cch::customize per call vs a reused +// Customizer::customize_into, both on the Rust side only) +// --------------------------------------------------------------------------- + +fn bench_customize_reuse(c: &mut Criterion) { + let (n, tail, head, weights) = make_grid(24, 24); + let graph = csr_from_arcs(n, &tail, &head); + let order = cch::degree_order(&graph); + let cch = cch::Cch::build(&graph, &order); + + let mut g: BenchmarkGroup = c.benchmark_group("customize_reuse/24x24"); + g.sample_size(50); + + g.bench_function(BenchmarkId::new("fresh_each_call", ""), |b| { + b.iter(|| { + let m = cch.customize(black_box(&weights)); + black_box(m); + }); + }); + + g.bench_function(BenchmarkId::new("reused_customizer", ""), |b| { + let cust = cch.customizer(); + let mut metric = cch.customize(&weights); + b.iter(|| { + cust.customize_into(black_box(&weights), &mut metric); + black_box(&metric); + }); + }); + + g.finish(); +} + // --------------------------------------------------------------------------- // Bench: distance_matrix (all 576 nodes as sources AND targets = 576×576) // --------------------------------------------------------------------------- @@ -336,6 +369,7 @@ criterion_group!( bench_degree_order, bench_build, bench_customize, + bench_customize_reuse, bench_distance_matrix, bench_node_path, ); diff --git a/src/customize.rs b/src/customize.rs index 4b3daab..ef2e31c 100644 --- a/src/customize.rs +++ b/src/customize.rs @@ -21,6 +21,16 @@ use crate::INF_WEIGHT; use crate::bundle::INVALID_ID; use crate::structure::Cch; +use rayon::prelude::*; +use std::cell::RefCell; + +thread_local! { + // Per-worker scratch for `relax_node`: maps node id -> the current node's + // up-arc id to that node. Reused across levels and across customizations; + // grown on demand. Never read stale (see `relax_node`'s chordal argument: + // each node overwrites every slot it later reads). + static ARC_ID_CACHE: RefCell> = const { RefCell::new(Vec::new()) }; +} /// A customized metric: the forward + backward shortcut weights of every CCH /// arc. Field semantics match the persisted `.cch-metric` sections and @@ -48,6 +58,58 @@ impl Metric { } } +/// Elimination-tree level partition of a [`Cch`], grouping nodes by height so +/// that same-level nodes have no ancestor/descendant relationship. Derived once +/// (metric-independent) and reused across customizations. `nodes` lists every +/// node id level-major (level 0 first); `first[l]..first[l+1]` slices `nodes` +/// for level `l`. +pub(crate) struct Levels { + pub nodes: Vec, + pub first: Vec, +} + +/// Compute the elimination-tree height of every node and bucket nodes by height. +/// +/// `height[x] = 0` for a node with no elim-tree children, else +/// `1 + max(height[child])`. Because `elimination_tree_parent[x]` always has a +/// strictly higher rank than `x`, iterating `x` in increasing rank finalizes +/// `height[x]` before it is read as a child (all children have lower rank). +pub(crate) fn compute_levels(cch: &Cch) -> Levels { + let n = cch.node_count(); + let mut height = vec![0u32; n]; + let mut num_levels = 1u32; // at least level 0 (or 0 nodes -> unused) + for (x, &p) in cch.elimination_tree_parent.iter().enumerate() { + if p != INVALID_ID { + let cand = height[x] + 1; + if cand > height[p as usize] { + height[p as usize] = cand; + } + } + if height[x] + 1 > num_levels { + num_levels = height[x] + 1; + } + } + + // CSR bucket by height. + let mut first = vec![0u32; num_levels as usize + 1]; + for &h in &height { + first[h as usize + 1] += 1; + } + for l in 0..num_levels as usize { + first[l + 1] += first[l]; + } + let mut cursor: Vec = first[..num_levels as usize].to_vec(); + let mut nodes = vec![0u32; n]; + #[allow(clippy::cast_possible_truncation)] // node ids fit u32 (CCH limit) + for (x, &h) in height.iter().enumerate() { + let l = h as usize; + nodes[cursor[l] as usize] = x as u32; + cursor[l] += 1; + } + + Levels { nodes, first } +} + /// Saturating addition against [`INF_WEIGHT`]. /// /// Matches the C++ exactly: it adds raw `unsigned`s, but since @@ -74,94 +136,319 @@ fn min_to(x: &mut u32, y: u32) { } } +/// Raw, `Send + Sync` pointers into the forward/backward arc-weight arrays for +/// the parallel relaxation. Sharing these across rayon tasks is data-race-free +/// ONLY for a well-formed (chordal) CCH — as produced by `Cch::build` or a +/// faithful `load_struct` round-trip — under the level-synchronized schedule in +/// `customize_into`: +/// +/// * Each task processes one node `x`; every write targets an **up-arc of `x`** +/// (`top`), and up-arc id sets of distinct nodes are disjoint → no two tasks +/// in a level write the same slot. +/// * Reads (`forward[mid]`, `backward[bottom]`) are arcs of `x`'s down-neighbors, +/// which sit in strictly-lower elimination-tree levels — finalized before this +/// level runs and never written during it. A node in level `l` is never a +/// down-neighbor of another level-`l` node, so no same-level task reads `x`'s +/// arcs either. +/// +/// This disjointness argument depends on chordality; a bounds-valid but +/// non-chordal `Cch` is out of scope (see `Cch::customizer`). Every index used +/// is bounds-validated once in `Cch::customizer`, so `len` is an upper bound on +/// all `top`/`mid`/`bottom` accesses regardless. +struct DisjointArcs { + forward: *mut u32, + backward: *mut u32, + len: usize, +} +// SAFETY: see the disjointness/finalization argument above; the sole caller +// (`customize_into`'s level loop) upholds it. +unsafe impl Send for DisjointArcs {} +unsafe impl Sync for DisjointArcs {} + +/// Relax every lower triangle apexed at node `x`, writing the resulting +/// candidates into `x`'s up-arc weights. `cache` is per-worker scratch reused +/// across the nodes a worker handles; `x`'s first inner loop overwrites every +/// slot it later reads (`z` is always an up-neighbor of `x` in a chordal CCH), +/// so stale entries from a previously handled node are never read. +/// +/// # Safety +/// `cch` must be a well-formed (chordal) `Cch` — as produced by `Cch::build` or +/// a faithful `load_struct` round-trip — that has also passed +/// `Cch::customizer`'s bounds validation, so that `arcs` (derived from that +/// same `cch`) satisfies the `DisjointArcs` contract. `x` must be +/// `< node_count`. +unsafe fn relax_node(x: usize, cache: &mut [u32], arcs: &DisjointArcs, cch: &Cch) { + for xz_up in cch.up_first_out[x]..cch.up_first_out[x + 1] { + cache[cch.up_head[xz_up as usize] as usize] = xz_up; + } + for xy_down in cch.down_first_out[x]..cch.down_first_out[x + 1] { + let bottom = cch.down_to_up[xy_down as usize] as usize; + let y = cch.down_head[xy_down as usize] as usize; + let y_up_begin = cch.up_first_out[y]; + let mut cursor = cch.up_first_out[y + 1]; + while cursor > y_up_begin { + cursor -= 1; + let mid = cursor as usize; + let z = cch.up_head[mid] as usize; + if z <= x { + break; + } + let top = cache[z] as usize; + debug_assert!(top < arcs.len && mid < arcs.len && bottom < arcs.len); + // SAFETY: bottom/mid are arcs of the lower node `y` (finalized, + // read-only this level); top is an up-arc of `x` (written only by + // this task). All three indices are `< arcs.len` (validated). + let bwd_bottom = unsafe { *arcs.backward.add(bottom) }; + let fwd_mid = unsafe { *arcs.forward.add(mid) }; + let fwd_bottom = unsafe { *arcs.forward.add(bottom) }; + let bwd_mid = unsafe { *arcs.backward.add(mid) }; + let fwd_candidate = add(bwd_bottom, fwd_mid); + let bwd_candidate = add(fwd_bottom, bwd_mid); + // SAFETY: `top` is exclusive to this task (see `DisjointArcs`). + let fwd_top = unsafe { &mut *arcs.forward.add(top) }; + min_to(fwd_top, fwd_candidate); + let bwd_top = unsafe { &mut *arcs.backward.add(top) }; + min_to(bwd_top, bwd_candidate); + } + } +} + impl Cch { + /// Build a reusable [`Customizer`] for this structure. Derives the + /// elimination-tree level partition once; reuse the returned `Customizer` + /// across many metrics to avoid recomputing it and to reuse output buffers + /// via [`Customizer::customize_into`]. + /// + /// # Panics + /// Panics if `self` is structurally malformed: a `up_head`/`down_head` + /// entry names a node id `>= node_count`, a `down_to_up` entry names an + /// arc id `>= cch_arc_count`, or `up_first_out[node_count] != + /// cch_arc_count`. These asserts guard the parallel relaxation's + /// raw-pointer accesses against out-of-bounds indexing for any + /// bounds-valid `Cch`; they do NOT establish chordality. The parallel + /// relaxation's correctness and data-race-freedom additionally require + /// `self` to be well-formed (chordal), which every `Cch` from + /// [`Cch::build`] or a faithful `load_struct` round-trip is. A + /// deliberately corrupted, bounds-valid but non-chordal `Cch` is outside + /// this safety contract. + #[must_use] + pub fn customizer(&self) -> Customizer<'_> { + // Bounds precondition for the parallel relaxation's raw-pointer + // `.add()` accesses (see `relax_node`): every head is a valid node id, + // every arc reference is a valid arc id, and the up-adjacency + // terminates at `cch_arc_count`. This rules out out-of-bounds access + // for any bounds-valid `Cch`; it does not establish chordality, which + // `relax_node`'s data-race-freedom additionally requires (see + // `DisjointArcs`) and which every `Cch` from `build`/`load_struct` + // satisfies. + let n = self.node_count(); + let arc_count = self.cch_arc_count(); + assert!( + self.up_head.iter().all(|&h| (h as usize) < n) + && self.down_head.iter().all(|&h| (h as usize) < n), + "malformed Cch: head contains a node id >= node_count" + ); + assert!( + self.down_to_up.iter().all(|&a| (a as usize) < arc_count), + "malformed Cch: down_to_up contains an arc id >= cch_arc_count" + ); + assert_eq!( + self.up_first_out.get(n).copied(), + u32::try_from(arc_count).ok(), + "malformed Cch: up_first_out[node_count] != cch_arc_count" + ); + Customizer { + cch: self, + levels: compute_levels(self), + } + } + /// Customizes this CCH with per-INPUT-arc `weights`, producing the forward /// and backward shortcut weights of every CCH arc. /// - /// Bit-identical to `RoutingKit`'s single-threaded - /// `CustomizableContractionHierarchyMetric::customize()`. + /// Bit-identical to `RoutingKit`'s `customize()`. Delegates to + /// [`Cch::customizer`], which re-validates structure and recomputes the + /// elimination-tree level partition on every call. For repeated + /// customization of the same structure, hold a [`Customizer`] (via + /// [`Cch::customizer`]) and reuse it with [`Customizer::customize`] / + /// [`Customizer::customize_into`] instead, to avoid re-validating, + /// re-partitioning, and re-allocating output buffers each call. + /// + /// # Panics + /// Panics if `weights.len()` != the number of input arcs + /// (`self.input_arc_to_cch_arc.len()`), or if `self` is structurally + /// malformed (see [`Cch::customizer`]'s panics). + #[must_use] + pub fn customize(&self, weights: &[u32]) -> Metric { + self.customizer().customize(weights) + } +} + +/// Reusable customizer for one [`Cch`]. Owns the metric-independent +/// elimination-tree level partition so repeated customizations do not recompute +/// it, and lets callers reuse output buffers via [`Self::customize_into`]. +pub struct Customizer<'a> { + cch: &'a Cch, + levels: Levels, +} + +impl Customizer<'_> { + /// Customize `weights` into a freshly allocated [`Metric`]. /// /// # Panics - /// Panics if `weights.len()` does not equal the number of input arcs (i.e. - /// `self.input_arc_to_cch_arc.len()`). + /// Panics if `weights.len()` != the number of input arcs. #[must_use] pub fn customize(&self, weights: &[u32]) -> Metric { + let arc_count = self.cch.cch_arc_count(); + let mut out = Metric { + forward: vec![INF_WEIGHT; arc_count], + backward: vec![INF_WEIGHT; arc_count], + }; + self.customize_into(weights, &mut out); + out + } + + /// Customize `weights`, reusing `out`'s `forward`/`backward` allocations. + /// On return, `out` holds the customized metric for `weights`. No allocation + /// occurs when `out`'s buffers already have `cch_arc_count` capacity. + /// + /// # Panics + /// Panics if `weights.len()` != the number of input arcs. + pub fn customize_into(&self, weights: &[u32], out: &mut Metric) { + let cch = self.cch; assert_eq!( weights.len(), - self.input_arc_to_cch_arc.len(), + cch.input_arc_to_cch_arc.len(), "weights length must equal input arc count" ); + let arc_count = cch.cch_arc_count(); - let arc_count = self.cch_arc_count(); + // Reuse buffers: resize to arc_count and reset every slot to INF_WEIGHT. + out.forward.clear(); + out.forward.resize(arc_count, INF_WEIGHT); + out.backward.clear(); + out.backward.resize(arc_count, INF_WEIGHT); + let forward = &mut out.forward; + let backward = &mut out.backward; + + // Phase 1: reset (extract_initial_metric) — arcs are independent. + forward + .par_iter_mut() + .zip(backward.par_iter_mut()) + .enumerate() + .for_each(|(cch_arc, (fwd, bwd))| { + let fwd_in = cch.forward_input_arc_of_cch[cch_arc]; + if fwd_in != INVALID_ID { + *fwd = weights[fwd_in as usize]; + } + let bwd_in = cch.backward_input_arc_of_cch[cch_arc]; + if bwd_in != INVALID_ID { + *bwd = weights[bwd_in as usize]; + } + let ef = &cch.first_extra_forward_input_arc_of_cch; + for j in ef[cch_arc]..ef[cch_arc + 1] { + let ia = cch.extra_forward_input_arc_of_cch[j as usize] as usize; + min_to(fwd, weights[ia]); + } + let eb = &cch.first_extra_backward_input_arc_of_cch; + for j in eb[cch_arc]..eb[cch_arc + 1] { + let ia = cch.extra_backward_input_arc_of_cch[j as usize] as usize; + min_to(bwd, weights[ia]); + } + }); + + // Phase 2: lower-triangle relaxation, level-synchronized parallelism. + // Levels run sequentially (barrier between); within a level, nodes run + // in parallel with provably-disjoint writes (see `DisjointArcs`). + let node_count = cch.node_count(); + let arcs = DisjointArcs { + forward: forward.as_mut_ptr(), + backward: backward.as_mut_ptr(), + len: arc_count, + }; + let levels = &self.levels; + for l in 0..levels.first.len() - 1 { + let level = &levels.nodes[levels.first[l] as usize..levels.first[l + 1] as usize]; + level.par_iter().for_each(|&x| { + ARC_ID_CACHE.with(|c| { + let mut cache = c.borrow_mut(); + if cache.len() < node_count { + cache.resize(node_count, 0); + } + // SAFETY: `arcs` upholds the `DisjointArcs` contract under this + // level-synchronized schedule; `cch` passed `customizer` + // validation; `x < node_count` (it is a node id from `levels`). + unsafe { relax_node(x as usize, &mut cache, &arcs, cch) }; + }); + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::graph::Graph; + + // Independent serial reference for the relaxation, retained ONLY for tests so + // the parallel path is checked against a non-parallel, non-oracle baseline. + // This is a faithful copy of the pre-parallel Phase-2 loop. + fn relax_serial(cch: &Cch, weights: &[u32]) -> Metric { + let arc_count = cch.cch_arc_count(); let mut forward = vec![INF_WEIGHT; arc_count]; let mut backward = vec![INF_WEIGHT; arc_count]; - - // Phase 1: reset (extract_initial_metric, C++ 659–690). for cch_arc in 0..arc_count { - let fwd_in = self.forward_input_arc_of_cch[cch_arc]; + let fwd_in = cch.forward_input_arc_of_cch[cch_arc]; if fwd_in != INVALID_ID { forward[cch_arc] = weights[fwd_in as usize]; } - let bwd_in = self.backward_input_arc_of_cch[cch_arc]; + let bwd_in = cch.backward_input_arc_of_cch[cch_arc]; if bwd_in != INVALID_ID { backward[cch_arc] = weights[bwd_in as usize]; } - // Parallel-arc minimum over the extra (overflow) lists. - let ef = &self.first_extra_forward_input_arc_of_cch; + let ef = &cch.first_extra_forward_input_arc_of_cch; for j in ef[cch_arc]..ef[cch_arc + 1] { - let ia = self.extra_forward_input_arc_of_cch[j as usize] as usize; - min_to(&mut forward[cch_arc], weights[ia]); + min_to( + &mut forward[cch_arc], + weights[cch.extra_forward_input_arc_of_cch[j as usize] as usize], + ); } - let eb = &self.first_extra_backward_input_arc_of_cch; + let eb = &cch.first_extra_backward_input_arc_of_cch; for j in eb[cch_arc]..eb[cch_arc + 1] { - let ia = self.extra_backward_input_arc_of_cch[j as usize] as usize; - min_to(&mut backward[cch_arc], weights[ia]); + min_to( + &mut backward[cch_arc], + weights[cch.extra_backward_input_arc_of_cch[j as usize] as usize], + ); } } - - // Phase 2: lower-triangle relaxation (C++ 778–798). - let node_count = self.node_count(); - let mut arc_id_cache = vec![0u32; node_count]; + let node_count = cch.node_count(); + let mut cache = vec![0u32; node_count]; for x in 0..node_count { - let xz_up_end = self.up_first_out[x + 1]; - for xz_up in self.up_first_out[x]..xz_up_end { - arc_id_cache[self.up_head[xz_up as usize] as usize] = xz_up; + for xz_up in cch.up_first_out[x]..cch.up_first_out[x + 1] { + cache[cch.up_head[xz_up as usize] as usize] = xz_up; } - - let xy_down_end = self.down_first_out[x + 1]; - for xy_down in self.down_first_out[x]..xy_down_end { - // `bottom` = the y→x up-arc; `y` = the lower triangle apex node. - let bottom = self.down_to_up[xy_down as usize] as usize; - let y = self.down_head[xy_down as usize] as usize; - let y_up_begin = self.up_first_out[y]; - let mut cursor = self.up_first_out[y + 1]; + for xy_down in cch.down_first_out[x]..cch.down_first_out[x + 1] { + let bottom = cch.down_to_up[xy_down as usize] as usize; + let y = cch.down_head[xy_down as usize] as usize; + let y_up_begin = cch.up_first_out[y]; + let mut cursor = cch.up_first_out[y + 1]; while cursor > y_up_begin { cursor -= 1; - // `mid` = the y→z up-arc; `z` = the triangle top node. let mid = cursor as usize; - let z = self.up_head[mid] as usize; + let z = cch.up_head[mid] as usize; if z <= x { break; } - let top = arc_id_cache[z] as usize; - // min_to(forward[top], backward[bottom] + forward[mid]) - let fwd_candidate = add(backward[bottom], forward[mid]); - // min_to(backward[top], forward[bottom] + backward[mid]) - let bwd_candidate = add(forward[bottom], backward[mid]); - min_to(&mut forward[top], fwd_candidate); - min_to(&mut backward[top], bwd_candidate); + let top = cache[z] as usize; + let fc = add(backward[bottom], forward[mid]); + let bc = add(forward[bottom], backward[mid]); + min_to(&mut forward[top], fc); + min_to(&mut backward[top], bc); } } } - Metric { forward, backward } } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::graph::Graph; #[test] fn metric_view_borrows_fields() { @@ -275,6 +562,18 @@ mod tests { assert!(s > INF_WEIGHT); } + // Reset must min-combine parallel/extra input arcs identically under rayon. + // Two 0->1 arcs (w=50,9) and two 1->0 arcs (w=40,8): min wins per direction. + #[test] + fn parallel_reset_min_combines() { + let g = csr(2, &[0, 0, 1, 1], &[1, 1, 0, 0]); + let order = vec![0u32, 1]; + let c = Cch::build(&g, &order); + let m = c.customizer().customize(&[50, 9, 40, 8]); + assert_eq!(m.forward, vec![9]); + assert_eq!(m.backward, vec![8]); + } + // customize panics on wrong weight length. #[test] #[should_panic(expected = "weights length must equal input arc count")] @@ -284,4 +583,230 @@ mod tests { let c = Cch::build(&g, &order); let _ = c.customize(&[1, 2, 3]); } + + // customizer() validates structural soundness before handing out raw + // pointers to the parallel relaxation. Cover each assertion branch with a + // hand-corrupted Cch (as would result from loading arbitrary/corrupt bytes). + + #[test] + #[should_panic(expected = "malformed Cch: head contains a node id >= node_count")] + #[allow(clippy::cast_possible_truncation)] // tiny fixture: node_count fits u32 + fn customizer_rejects_out_of_range_up_head() { + let g = csr(3, &[0, 0, 1, 2], &[1, 2, 0, 0]); + let order = vec![0u32, 1, 2]; + let mut c = Cch::build(&g, &order); + let n = c.node_count(); + c.up_head[0] = n as u32; // one past the last valid node id + let _ = c.customizer(); + } + + #[test] + #[should_panic(expected = "malformed Cch: head contains a node id >= node_count")] + #[allow(clippy::cast_possible_truncation)] // tiny fixture: node_count fits u32 + fn customizer_rejects_out_of_range_down_head() { + let g = csr(3, &[0, 0, 1, 2], &[1, 2, 0, 0]); + let order = vec![0u32, 1, 2]; + let mut c = Cch::build(&g, &order); + let n = c.node_count(); + c.down_head[0] = n as u32; + let _ = c.customizer(); + } + + #[test] + #[should_panic(expected = "malformed Cch: down_to_up contains an arc id >= cch_arc_count")] + #[allow(clippy::cast_possible_truncation)] // tiny fixture: arc_count fits u32 + fn customizer_rejects_out_of_range_down_to_up() { + let g = csr(3, &[0, 0, 1, 2], &[1, 2, 0, 0]); + let order = vec![0u32, 1, 2]; + let mut c = Cch::build(&g, &order); + let arc_count = c.cch_arc_count(); + c.down_to_up[0] = arc_count as u32; + let _ = c.customizer(); + } + + #[test] + #[should_panic(expected = "malformed Cch: up_first_out[node_count] != cch_arc_count")] + fn customizer_rejects_bad_up_first_out_tail() { + let g = csr(3, &[0, 0, 1, 2], &[1, 2, 0, 0]); + let order = vec![0u32, 1, 2]; + let mut c = Cch::build(&g, &order); + let last = c.up_first_out.len() - 1; + c.up_first_out[last] += 1; + let _ = c.customizer(); + } + + // customize_into into an empty Metric matches a fresh customize. + #[test] + fn customize_into_empty_matches_fresh() { + let g = csr(3, &[0, 0, 1, 2], &[1, 2, 0, 0]); + let order = vec![0u32, 1, 2]; + let c = Cch::build(&g, &order); + let w = [3u32, 5, 4, 6]; + + let fresh = c.customize(&w); + let cust = c.customizer(); + let mut out = Metric { + forward: Vec::new(), + backward: Vec::new(), + }; + cust.customize_into(&w, &mut out); + assert_eq!(out, fresh); + } + + // customize_into into an over-sized Metric overwrites cleanly (no stale tail). + #[test] + fn customize_into_oversized_matches_fresh() { + let g = csr(3, &[0, 0, 1, 2], &[1, 2, 0, 0]); + let order = vec![0u32, 1, 2]; + let c = Cch::build(&g, &order); + let w = [3u32, 5, 4, 6]; + + let fresh = c.customize(&w); + let cust = c.customizer(); + let mut out = Metric { + forward: vec![999; fresh.forward.len() + 5], + backward: vec![999; fresh.backward.len() + 5], + }; + cust.customize_into(&w, &mut out); + assert_eq!(out, fresh); + } + + // One Customizer reused across two different weight vectors gives the same + // results as two independent customize calls (no scratch bleed). + #[test] + fn customizer_reuse_no_bleed() { + let g = csr(3, &[0, 0, 1, 2], &[1, 2, 0, 0]); + let order = vec![0u32, 1, 2]; + let c = Cch::build(&g, &order); + let w1 = [3u32, 5, 4, 6]; + let w2 = [10u32, 1, 2, 20]; + + let cust = c.customizer(); + let mut out = c.customize(&w1); // seed with anything + cust.customize_into(&w1, &mut out); + assert_eq!(out, c.customize(&w1)); + cust.customize_into(&w2, &mut out); + assert_eq!(out, c.customize(&w2)); + } + + // A node's level is 1 + its elimination-tree parent-chain depth from a leaf: + // level(x) = 0 if x has no elim-tree descendant, else max(level(child))+1. + // Every node appears exactly once; a node's level must exceed every + // down-neighbor's level (down-neighbors are strictly-lower elim-tree nodes). + #[test] + fn levels_partition_is_valid() { + // path 1-0-2 (undirected), order [0,1,2]: elim tree 0->1->2 (root 2). + let g = csr(3, &[0, 0, 1, 2], &[1, 2, 0, 0]); + let order = vec![0u32, 1, 2]; + let c = Cch::build(&g, &order); + let levels = compute_levels(&c); + + // every node present exactly once + let mut seen = levels.nodes.clone(); + seen.sort_unstable(); + assert_eq!(seen, vec![0, 1, 2]); + + // first[] is a valid CSR offset array over nodes + assert_eq!(*levels.first.first().unwrap(), 0); + assert_eq!(*levels.first.last().unwrap(), 3); + + // per-node level lookup + let mut level_of = vec![0u32; c.node_count()]; + #[allow(clippy::cast_possible_truncation)] // levels count is tiny (3) in this test + for l in 0..levels.first.len() - 1 { + for &x in &levels.nodes[levels.first[l] as usize..levels.first[l + 1] as usize] { + level_of[x as usize] = l as u32; + } + } + // each down-neighbor is strictly lower level than its node + for x in 0..c.node_count() { + for d in c.down_first_out[x]..c.down_first_out[x + 1] { + let y = c.down_head[d as usize] as usize; + assert!( + level_of[y] < level_of[x], + "down-neighbor must be lower level" + ); + } + } + } + + // Build a 5x5 bidirectional grid so the elimination tree has several levels, + // then assert the parallel relaxation equals an independent serial reference + // arc-for-arc, on multiple random-ish weight vectors. + fn grid(side: u32) -> Graph { + let n = side * side; + let mut tail = Vec::new(); + let mut head = Vec::new(); + let idx = |r: u32, col: u32| r * side + col; + for r in 0..side { + for col in 0..side { + if col + 1 < side { + tail.push(idx(r, col)); + head.push(idx(r, col + 1)); + tail.push(idx(r, col + 1)); + head.push(idx(r, col)); + } + if r + 1 < side { + tail.push(idx(r, col)); + head.push(idx(r + 1, col)); + tail.push(idx(r + 1, col)); + head.push(idx(r, col)); + } + } + } + csr(n as usize, &tail, &head) + } + + #[test] + #[allow(clippy::cast_possible_truncation)] // 5x5 grid: arc/node counts are tiny + fn parallel_relax_equals_serial_reference() { + let g = grid(5); + let order: Vec = (0..(g.first_out.len() as u32 - 1)).collect(); + let c = Cch::build(&g, &order); + let input_arcs = c.input_arc_to_cch_arc.len(); + + for seed in [1u32, 7, 13, 99] { + let weights: Vec = (0..input_arcs as u32) + .map(|i| 1 + (i.wrapping_mul(2_654_435_761).wrapping_add(seed) % 97)) + .collect(); + let parallel = c.customize(&weights); + let reference = relax_serial(&c, &weights); + assert_eq!(parallel.forward, reference.forward, "seed {seed} forward"); + assert_eq!( + parallel.backward, reference.backward, + "seed {seed} backward" + ); + } + } + + // Determinism: the parallel path yields byte-identical output across runs. + #[test] + #[allow(clippy::cast_possible_truncation)] // 5x5 grid: arc/node counts are tiny + fn parallel_relax_is_deterministic() { + let g = grid(5); + let order: Vec = (0..(g.first_out.len() as u32 - 1)).collect(); + let c = Cch::build(&g, &order); + let weights: Vec = (0..c.input_arc_to_cch_arc.len() as u32) + .map(|i| 1 + i % 50) + .collect(); + let a = c.customize(&weights); + let b = c.customize(&weights); + assert_eq!(a, b); + } + + // relax_serial's extra-forward/extra-backward min-combine branches (the + // reset half of the reference) only trigger when an up-arc has parallel + // input arcs; exercise that here so the serial reference itself is fully + // covered, matching the parallel path on the same fixture. + #[test] + fn relax_serial_matches_parallel_with_parallel_arcs() { + let g = csr(2, &[0, 0, 1, 1], &[1, 1, 0, 0]); + let order = vec![0u32, 1]; + let c = Cch::build(&g, &order); + let weights = [50u32, 9, 40, 8]; + let parallel = c.customize(&weights); + let reference = relax_serial(&c, &weights); + assert_eq!(parallel.forward, reference.forward); + assert_eq!(parallel.backward, reference.backward); + } } diff --git a/src/lib.rs b/src/lib.rs index b095231..5421d7a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,16 @@ //! construction and bundle format are bit-identical to `RoutingKit`, so bundles //! interoperate with existing artifacts. //! +//! Customization runs in parallel internally (via [rayon](https://docs.rs/rayon)). +//! For a one-off metric, [`Cch::customize`] is a thin wrapper that is all you +//! need. For repeated re-customization of the same structure — e.g. re-solving +//! for many weight profiles, or refreshing live traffic weights — build a +//! [`Customizer`] once with [`Cch::customizer`] and call +//! [`Customizer::customize_into`] on each new weight vector: it reuses the +//! output buffers (no allocation once they're sized) and the precomputed +//! elimination-tree level partition, producing output bit-identical to +//! [`Cch::customize`]. +//! //! Two contraction-order heuristics are provided: a lightweight degree order //! ([`degree_order`]) and inertial-flow (geometric) nested dissection //! ([`inertial_order`]), which yields far higher-quality hierarchies on road @@ -74,7 +84,7 @@ pub mod structure; mod writer; pub use bundle::{CchBundle, CchView, MetricBundle, MetricView}; -pub use customize::Metric; +pub use customize::{Customizer, Metric}; pub use order::{degree_order, inertial_order}; pub use path::node_path; pub use query::{ElimTreeQuery, distance_matrix}; diff --git a/src/query.rs b/src/query.rs index 0b6ab5b..5373637 100644 --- a/src/query.rs +++ b/src/query.rs @@ -58,7 +58,7 @@ impl<'a> ElimTreeQuery<'a> { /// # Panics /// /// Panics if `cch` is malformed — specifically if any `up_head` value is not - /// a valid node id (`>= node_count`). A `CchView` from [`Cch::build`] or a + /// a valid node id (`>= node_count`). A `CchView` from [`Cch::build`](crate::Cch::build) or a /// [`CchBundle`](crate::CchBundle) always satisfies this. #[must_use] pub fn new(cch: &'a CchView<'a>) -> Self {