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}