Skip to main content

hyperpaths_rs/
solver.rs

1//! Reusable arena solver: build an immutable [`Graph`] once, then assign many
2//! destinations through a [`Workspace`] with no per-destination allocation.
3//!
4//! Splitting the immutable graph from the mutable per-solve buffers is what
5//! makes the multi-destination / multi-request case cheap: the string interning
6//! and adjacency are built a single time, and each destination is then assigned
7//! by reusing a `Workspace`.
8//!
9//! Concurrency is enforced by the type system: a `&Graph` is shared and
10//! read-only (so concurrent assignments are safe), while a `Workspace` is
11//! `&mut` and therefore exclusive - the borrow checker will not let two threads
12//! mutate one. Build the graph once and give each thread its own workspace:
13//!
14//! ```
15//! use std::collections::{HashMap, HashSet};
16//! use hyperpaths_rs::{Graph, Link};
17//!
18//! let links = vec![Link::new("A", "B", "L", 10.0, 6.0)];
19//! let stops: HashSet<String> = ["A", "B"].iter().map(|s| s.to_string()).collect();
20//! let graph = Graph::new(&links, &stops);
21//!
22//! std::thread::scope(|s| {
23//!     for _ in 0..4 {
24//!         let g = &graph; // shared, immutable
25//!         s.spawn(move || {
26//!             let mut w = g.new_workspace(); // one per thread
27//!             let mut demand = vec![0.0; g.num_nodes()];
28//!             demand[g.node_index("A").unwrap()] = 1.0;
29//!             let res = w.assign(g.node_index("B").unwrap(), &demand);
30//!             let _ = res.labels;
31//!         });
32//!     }
33//! });
34//! ```
35
36use std::collections::{HashMap, HashSet};
37
38use crate::hyperpath::ALPHA;
39use crate::hyperpath_queue::PriorityQueue;
40use crate::transit_network::Link;
41
42fn intern(name: &str, n_id: &mut HashMap<String, usize>, n_name: &mut Vec<String>) -> usize {
43    if let Some(&id) = n_id.get(name) {
44        return id;
45    }
46    let id = n_name.len();
47    n_id.insert(name.to_string(), id);
48    n_name.push(name.to_string());
49    id
50}
51
52/// Graph is an immutable, interned transit network (integer arena). Build it
53/// once with [`Graph::new`] and share it across threads; it is read-only.
54pub struct Graph {
55    n_name: Vec<String>,
56    n_id: HashMap<String, usize>,
57    n: usize,
58    m: usize,
59    from: Vec<usize>,
60    to: Vec<usize>,
61    cost: Vec<f64>,
62    head: Vec<f64>,
63    adj_by_to: Vec<Vec<usize>>,
64}
65
66impl Graph {
67    /// Interns the network once. `all_stops` is interned first, so node indices
68    /// `[0, all_stops.len())` are the stop nodes; any link endpoint outside
69    /// `all_stops` (out of contract) is appended after.
70    ///
71    /// # Example
72    ///
73    /// ```
74    /// use std::collections::HashSet;
75    /// use hyperpaths_rs::{Graph, Link};
76    ///
77    /// // One line A -> B: 6-minute headway, 10-minute ride.
78    /// let links = vec![Link::new("A", "B", "L1", 10.0, 6.0)];
79    /// let stops: HashSet<String> = ["A", "B"].iter().map(|s| s.to_string()).collect();
80    ///
81    /// let graph = Graph::new(&links, &stops);      // once; immutable, shareable
82    /// let mut w = graph.new_workspace();            // reusable buffers
83    ///
84    /// let a = graph.node_index("A").unwrap();
85    /// let b = graph.node_index("B").unwrap();
86    /// let mut demand = vec![0.0; graph.num_nodes()];
87    /// demand[a] = 1.0; // one trip from A to B
88    ///
89    /// let res = w.assign(b, &demand);
90    /// // Expected time A -> B: 6 min wait + 10 min ride.
91    /// assert!((res.labels[a] - 16.0).abs() < 1e-9);
92    /// assert!((res.link_vol[0] - 1.0).abs() < 1e-9);
93    /// ```
94    pub fn new(all_links: &[Link], all_stops: &HashSet<String>) -> Graph {
95        let mut n_id: HashMap<String, usize> = HashMap::with_capacity(all_stops.len());
96        let mut n_name: Vec<String> = Vec::with_capacity(all_stops.len());
97        for stop in all_stops {
98            intern(stop.as_str(), &mut n_id, &mut n_name);
99        }
100        let m = all_links.len();
101        let mut from = vec![0usize; m];
102        let mut to = vec![0usize; m];
103        let mut cost = vec![0.0f64; m];
104        let mut head = vec![0.0f64; m];
105        for (k, link) in all_links.iter().enumerate() {
106            from[k] = intern(link.from_node.as_str(), &mut n_id, &mut n_name);
107            to[k] = intern(link.to_node.as_str(), &mut n_id, &mut n_name);
108            cost[k] = link.travel_cost;
109            head[k] = link.headway;
110        }
111        let n = n_name.len();
112        let mut adj_by_to: Vec<Vec<usize>> = vec![Vec::new(); n];
113        for k in 0..m {
114            adj_by_to[to[k]].push(k);
115        }
116        Graph {
117            n_name,
118            n_id,
119            n,
120            m,
121            from,
122            to,
123            cost,
124            head,
125            adj_by_to,
126        }
127    }
128
129    /// Number of nodes (size demand buffers with this).
130    pub fn num_nodes(&self) -> usize {
131        self.n
132    }
133
134    /// Number of links.
135    pub fn num_links(&self) -> usize {
136        self.m
137    }
138
139    /// Arena index of a node name, or `None` if unknown.
140    pub fn node_index(&self, name: &str) -> Option<usize> {
141        self.n_id.get(name).copied()
142    }
143
144    /// Name of an arena node index.
145    pub fn node_name(&self, id: usize) -> &str {
146        &self.n_name[id]
147    }
148
149    /// Allocates the working buffers for this graph once; reuse the workspace
150    /// across destinations and requests.
151    pub fn new_workspace(&self) -> Workspace<'_> {
152        Workspace {
153            g: self,
154            u: vec![0.0; self.n],
155            f: vec![0.0; self.n],
156            pq: PriorityQueue::with_capacity(self.m),
157            overline_a: Vec::with_capacity(self.m / 2),
158            a_set_idx: vec![Vec::new(); self.n],
159            a_set: Vec::with_capacity(self.m / 2),
160            link_vol: vec![0.0; self.m],
161            node_vol: vec![0.0; self.n],
162            cols: vec![Vec::new(); self.n],
163        }
164    }
165}
166
167/// DestResult is one destination's assignment in arena (integer) indexing. Its
168/// slices borrow the [`Workspace`] and are reused on the next assign, so copy
169/// out anything that must outlive it (the borrow checker enforces this).
170pub struct DestResult<'w> {
171    pub dest_id: usize,
172    /// node -> expected time to destination (u_i)
173    pub labels: &'w [f64],
174    /// node -> combined attractive frequency (f_i); +Inf = no-wait
175    pub freqs: &'w [f64],
176    /// accepted link indices, in acceptance order
177    pub a_set: &'w [usize],
178    /// link -> assigned volume
179    pub link_vol: &'w [f64],
180    /// node -> accumulated volume
181    pub node_vol: &'w [f64],
182}
183
184/// Workspace holds the reusable per-solve buffers for one [`Graph`]. Create one
185/// per thread (it borrows the graph immutably); it is not shareable while in
186/// use because its methods take `&mut self`.
187pub struct Workspace<'g> {
188    g: &'g Graph,
189    u: Vec<f64>,
190    f: Vec<f64>,
191    pq: PriorityQueue,
192    overline_a: Vec<Option<usize>>,
193    a_set_idx: Vec<Vec<usize>>,
194    a_set: Vec<usize>,
195    link_vol: Vec<f64>,
196    node_vol: Vec<f64>,
197    cols: Vec<Vec<(usize, f64)>>,
198}
199
200impl Workspace<'_> {
201    /// Spiess-Florian phase 1 for `dest_id` into u, f and a_set, reusing the
202    /// buffers. Identical algorithm to `find_optimal_strategy`, arena-indexed.
203    fn find_strategy(&mut self, dest_id: usize) {
204        let g = self.g;
205        for id in 0..g.n {
206            self.f[id] = 0.0;
207            self.u[id] = if id == dest_id { 0.0 } else { f64::INFINITY };
208            self.a_set_idx[id].clear();
209        }
210        self.overline_a.clear();
211
212        self.pq.clear();
213        for k in 0..g.m {
214            self.pq.push(k, self.u[g.to[k]] + g.cost[k]);
215        }
216        self.pq.init();
217
218        while self.pq.len() > 0 {
219            let entry_id = match self.pq.pop() {
220                Some(id) => id,
221                None => break,
222            };
223            let priority = self.pq.priority(entry_id);
224            if priority.is_infinite() && priority > 0.0 {
225                break;
226            }
227            let k = self.pq.link(entry_id);
228            let i = g.from[k];
229            let j = g.to[k];
230            let sum_uc = self.u[j] + g.cost[k];
231
232            if self.f[i].is_infinite() {
233                continue;
234            }
235            if self.u[i] <= sum_uc {
236                continue;
237            }
238            if g.head[k] <= 0.0 {
239                self.u[i] = sum_uc;
240                self.f[i] = f64::INFINITY;
241                for &idx in &self.a_set_idx[i] {
242                    self.overline_a[idx] = None;
243                }
244                self.a_set_idx[i].clear();
245                self.overline_a.push(Some(k));
246                self.a_set_idx[i].push(self.overline_a.len() - 1);
247            } else {
248                let freq = 1.0 / g.head[k];
249                let new_u = if self.f[i] == 0.0 {
250                    (ALPHA + freq * sum_uc) / freq
251                } else {
252                    (self.f[i] * self.u[i] + freq * sum_uc) / (self.f[i] + freq)
253                };
254                self.u[i] = new_u;
255                self.f[i] += freq;
256                self.overline_a.push(Some(k));
257                self.a_set_idx[i].push(self.overline_a.len() - 1);
258            }
259
260            for &kk in &g.adj_by_to[i] {
261                self.pq.update(kk, self.u[i] + g.cost[kk]);
262            }
263        }
264
265        self.a_set.clear();
266        for &opt in &self.overline_a {
267            if let Some(k) = opt {
268                self.a_set.push(k);
269            }
270        }
271    }
272
273    /// Phase 2: with node_vol seeded and a_set ready, load flow into link_vol
274    /// (reverse acceptance order, p. 97).
275    fn load(&mut self) {
276        let g = self.g;
277        for k in 0..g.m {
278            self.link_vol[k] = 0.0;
279        }
280        for idx in (0..self.a_set.len()).rev() {
281            let k = self.a_set[idx];
282            let i = g.from[k];
283            let f_i = self.f[i];
284            let va = if f_i.is_infinite() {
285                self.node_vol[i]
286            } else {
287                let freq = 1.0 / g.head[k];
288                (freq / f_i) * self.node_vol[i]
289            };
290            self.link_vol[k] = va;
291            self.node_vol[g.to[k]] += va;
292        }
293    }
294
295    /// Runs the full assignment (optimal strategy + demand loading) for one
296    /// destination index. `demand` is a per-node slice of trips heading to
297    /// `dest_id` (`demand[dest_id]` is ignored). The result borrows the
298    /// workspace and is valid until the next assign on it.
299    pub fn assign(&mut self, dest_id: usize, demand: &[f64]) -> DestResult<'_> {
300        self.find_strategy(dest_id);
301        let mut total = 0.0;
302        for (id, (nv, &d)) in self.node_vol.iter_mut().zip(demand.iter()).enumerate() {
303            if id != dest_id && d != 0.0 {
304                *nv = d;
305                total += d;
306            } else {
307                *nv = 0.0;
308            }
309        }
310        self.node_vol[dest_id] = -total;
311        self.load();
312        DestResult {
313            dest_id,
314            labels: &self.u,
315            freqs: &self.f,
316            a_set: &self.a_set,
317            link_vol: &self.link_vol,
318            node_vol: &self.node_vol,
319        }
320    }
321
322    /// Assigns every destination present in `od` (an
323    /// origin -> destination -> demand matrix) and calls `callback` with the
324    /// arena-indexed result for each. It transposes `od` into per-destination
325    /// columns once (reusing the workspace buffers), so there are no
326    /// per-destination allocations after warm-up. The result is reused between
327    /// calls, so copy out anything that must outlive the callback.
328    ///
329    /// # Example
330    ///
331    /// Build the graph once, reuse the workspace, and assign a full OD (here one
332    /// trip A -> B on the paper network):
333    ///
334    /// ```
335    /// use std::collections::{HashMap, HashSet};
336    /// use hyperpaths_rs::{DestResult, Graph, Link};
337    ///
338    /// let nodes: HashSet<String> = ["A", "X", "X2", "Y", "Y3", "B"]
339    ///     .iter()
340    ///     .map(|s| s.to_string())
341    ///     .collect();
342    /// let links = vec![
343    ///     Link::new("A", "B", "Line 1", 25.0, 6.0),
344    ///     Link::new("A", "X2", "Line 2", 7.0, 6.0),
345    ///     Link::new("X2", "X", "Line 2", 0.0, 0.0),
346    ///     Link::new("X", "X2", "Line 2", 0.0, 6.0),
347    ///     Link::new("X2", "Y", "Line 2", 6.0, 0.0),
348    ///     Link::new("Y3", "Y", "Line 3", 0.0, 15.0),
349    ///     Link::new("Y", "B", "Line 4", 10.0, 3.0),
350    ///     Link::new("X", "Y3", "Line 3", 4.0, 15.0),
351    ///     Link::new("Y", "Y3", "Line 3", 0.0, 15.0),
352    ///     Link::new("Y3", "B", "Line 3", 4.0, 0.0),
353    /// ];
354    ///
355    /// let graph = Graph::new(&links, &nodes);
356    /// let mut w = graph.new_workspace();
357    /// let a = graph.node_index("A").unwrap();
358    ///
359    /// let od = HashMap::from([("A".to_string(), HashMap::from([("B".to_string(), 1.0)]))]);
360    ///
361    /// let mut a_to_b = 0.0;
362    /// w.solve_each(&od, |res: &DestResult| {
363    ///     a_to_b = res.labels[a];
364    /// });
365    /// assert!((a_to_b - 27.75).abs() < 1e-9);
366    /// ```
367    pub fn solve_each<F: FnMut(&DestResult)>(
368        &mut self,
369        od: &HashMap<String, HashMap<String, f64>>,
370        mut callback: F,
371    ) {
372        for c in self.cols.iter_mut() {
373            c.clear();
374        }
375        for (origin, row) in od {
376            let oid = match self.g.node_index(origin) {
377                Some(id) => id,
378                None => continue,
379            };
380            for (dest, &d) in row {
381                if d == 0.0 {
382                    continue;
383                }
384                if let Some(did) = self.g.node_index(dest) {
385                    self.cols[did].push((oid, d));
386                }
387            }
388        }
389        let n = self.g.n;
390        for did in 0..n {
391            if self.cols[did].is_empty() {
392                continue;
393            }
394            self.find_strategy(did);
395            let mut total = 0.0;
396            for id in 0..n {
397                self.node_vol[id] = 0.0;
398            }
399            for idx in 0..self.cols[did].len() {
400                let (oid, d) = self.cols[did][idx];
401                if oid != did {
402                    self.node_vol[oid] = d;
403                    total += d;
404                }
405            }
406            self.node_vol[did] = -total;
407            self.load();
408            let res = DestResult {
409                dest_id: did,
410                labels: &self.u,
411                freqs: &self.f,
412                a_set: &self.a_set,
413                link_vol: &self.link_vol,
414                node_vol: &self.node_vol,
415            };
416            callback(&res);
417        }
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use crate::spiess_floarian::compute_sf;
425    use crate::testutil::{gen_grid_network, grid_stops};
426
427    #[test]
428    fn test_solver_parity() {
429        // The arena Graph/Workspace assignment must produce exactly the same
430        // labels and link volumes as the string-keyed compute_sf, on the 4x4
431        // grid to the corner destination.
432        let (links, nodes, dest, od) = gen_grid_network(4, 4, 6.0, 3.0);
433        let reference = compute_sf(&links, &nodes, &dest, &od);
434
435        let g = Graph::new(&links, &nodes);
436        let mut w = g.new_workspace();
437        let dest_id = g.node_index(&dest).unwrap();
438
439        let mut demand = vec![0.0; g.num_nodes()];
440        for (origin, row) in &od {
441            if let Some(&v) = row.get(&dest) {
442                demand[g.node_index(origin).unwrap()] = v;
443            }
444        }
445        let got = w.assign(dest_id, &demand);
446
447        const EPS: f64 = 1e-9;
448        for (name, &want) in &reference.strategy.labels {
449            let id = g.node_index(name).unwrap();
450            assert!((got.labels[id] - want).abs() < EPS, "label {}", name);
451            let wf = reference.strategy.freqs[name];
452            if wf.is_infinite() {
453                assert!(got.freqs[id].is_infinite());
454            } else {
455                assert!((got.freqs[id] - wf).abs() < EPS, "freq {}", name);
456            }
457        }
458        for (k, link) in links.iter().enumerate() {
459            let want = reference.volumes.links[&link.from_node][&link.to_node];
460            assert!(
461                (got.link_vol[k] - want).abs() < EPS,
462                "linkvol {}->{}",
463                link.from_node,
464                link.to_node
465            );
466        }
467    }
468
469    #[test]
470    fn test_solve_each_parity() {
471        // solve_each over a full OD matrix must accumulate exactly the same
472        // total link volume as calling compute_sf once per destination, on the
473        // 4x4 grid.
474        let (links, nodes, _, _) = gen_grid_network(4, 4, 6.0, 3.0);
475        let stops = grid_stops(4, 4);
476
477        // full OD: one trip from every stop to every other stop
478        let mut od: HashMap<String, HashMap<String, f64>> = HashMap::new();
479        for o in &stops {
480            let mut row = HashMap::new();
481            for d in &stops {
482                if d != o {
483                    row.insert(d.clone(), 1.0);
484                }
485            }
486            od.insert(o.clone(), row);
487        }
488
489        // reference: compute_sf per destination
490        let mut want_total = 0.0;
491        for dest in &stops {
492            let mut col: HashMap<String, HashMap<String, f64>> = HashMap::new();
493            for o in &stops {
494                if o != dest {
495                    col.insert(o.clone(), HashMap::from([(dest.clone(), 1.0)]));
496                }
497            }
498            let res = compute_sf(&links, &nodes, dest, &col);
499            for m in res.volumes.links.values() {
500                for v in m.values() {
501                    want_total += v;
502                }
503            }
504        }
505
506        let g = Graph::new(&links, &nodes);
507        let mut w = g.new_workspace();
508        let mut got_total = 0.0;
509        w.solve_each(&od, |res| {
510            for &v in res.link_vol {
511                got_total += v;
512            }
513        });
514        assert!((got_total - want_total).abs() < 1e-6, "{} {}", got_total, want_total);
515    }
516
517    #[test]
518    fn test_concurrent_shared_graph() {
519        // Many threads share one immutable Graph and each take their own
520        // Workspace. The type system already guarantees no data races; this
521        // checks correctness: every thread must reproduce the single-threaded
522        // reference total.
523        let (links, nodes, _, _) = gen_grid_network(6, 6, 6.0, 3.0);
524        let stops = grid_stops(6, 6);
525        let mut od: HashMap<String, HashMap<String, f64>> = HashMap::new();
526        for o in &stops {
527            let mut row = HashMap::new();
528            for d in &stops {
529                if d != o {
530                    row.insert(d.clone(), 1.0);
531                }
532            }
533            od.insert(o.clone(), row);
534        }
535        let graph = Graph::new(&links, &nodes);
536
537        let mut ref_total = 0.0;
538        {
539            let mut w = graph.new_workspace();
540            w.solve_each(&od, |res| {
541                for &v in res.link_vol {
542                    ref_total += v;
543                }
544            });
545        }
546
547        std::thread::scope(|s| {
548            let handles: Vec<_> = (0..8)
549                .map(|_| {
550                    let g = &graph;
551                    let od = &od;
552                    s.spawn(move || {
553                        let mut w = g.new_workspace();
554                        let mut total = 0.0;
555                        w.solve_each(od, |res| {
556                            for &v in res.link_vol {
557                                total += v;
558                            }
559                        });
560                        total
561                    })
562                })
563                .collect();
564            for h in handles {
565                let total = h.join().unwrap();
566                assert!((total - ref_total).abs() < 1e-6);
567            }
568        });
569    }
570}