praxis_runtime/graph.rs
1//! The graph walks behind §6.5's prelude helpers (ADR-060).
2//!
3//! §6.5 asks for "closure-based algorithms that do not require materializing a
4//! graph object": the caller supplies a start state and a function from a state
5//! to its neighbours, and the helper walks whatever that function describes.
6//! There is no graph value, no adjacency table and no node type — the graph is
7//! the closure.
8//!
9//! # Why the walks do not call the closures themselves
10//!
11//! Calling a Praxis closure means transmuting a JIT'd function pointer and
12//! passing it a live `RuntimeContext`, which no unit test can supply. So the
13//! walks below never touch a closure: they ask a [`GraphOracle`], and
14//! `praxis_runtime::abi` supplies the one implementation that calls closures.
15//! A test supplies one backed by an adjacency table, which is what makes
16//! "`dijkstra` relaxes an edge it has already settled" a question that can be
17//! asked without a compiler in the room.
18//!
19//! # States are values, and the walks hold them
20//!
21//! A state is a `GcRef` and the walks keep every state they have seen — in a
22//! visited set, in a queue, in a cost table, in a parent table. Those are Rust
23//! structures the collector cannot see, so **the caller must root every state
24//! it hands in and every state an oracle hands back** before the next call that
25//! may allocate.
26//! [`GraphOracle::retain`] is where that happens: the walks call it once per
27//! newly discovered state, immediately, and the ABI implementation roots it in
28//! its [`NativeScope`](crate::roots::NativeScope).
29//!
30//! # Identity
31//!
32//! Two states are the same state when [`DynamicKey`] says so — the same
33//! descriptor and a structural `equals` — which is exactly the rule a `Set`
34//! element and a `Map` key follow. That is why inference requires
35//! `CapKind::HashStable` of the state type at every call site: a state that can
36//! change after the walk has stored it cannot be found again, and the walk
37//! would revisit it forever.
38
39use std::cmp::Reverse;
40use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque};
41
42use crate::GcRef;
43use crate::context::FaultKind;
44use crate::dynamic_key::DynamicKey;
45
46/// A walk stopped before it had an answer, because a fault is pending.
47///
48/// Never constructed by a walk directly: it comes back from an oracle call that
49/// faulted, or from [`GraphOracle::abort`], so "a walk returned `Err` and left
50/// no fault behind" is not a state a walk can produce.
51#[derive(Clone, Copy, PartialEq, Eq, Debug)]
52pub struct Aborted;
53
54/// What a walk asks about the graph it is walking.
55///
56/// Every method may fault — the closures are arbitrary Praxis code — so every
57/// answer is a `Result`. An `Err(Aborted)` means a fault is already pending on
58/// the context and the walk must stop; it never means "no answer".
59pub trait GraphOracle {
60 /// The states reachable in one step from `state`.
61 fn neighbours(&mut self, state: GcRef) -> Result<Vec<GcRef>, Aborted>;
62
63 /// The cost of the edge from `from` to `to`. Only called for a pair the
64 /// oracle itself reported adjacent.
65 fn weight(&mut self, from: GcRef, to: GcRef) -> Result<i64, Aborted>;
66
67 /// The estimated remaining cost from `state` to a goal.
68 fn heuristic(&mut self, state: GcRef) -> Result<i64, Aborted>;
69
70 /// Whether `state` is a goal.
71 fn is_goal(&mut self, state: GcRef) -> Result<bool, Aborted>;
72
73 /// Keep `state` alive for the rest of the walk. Called once per state, the
74 /// moment the walk decides to remember it.
75 fn retain(&mut self, state: GcRef);
76
77 /// Raise `kind` and stop the walk. The `Aborted` it returns is the only way
78 /// a walk reports a fault of its own.
79 fn abort(&mut self, kind: FaultKind) -> Aborted;
80}
81
82/// Remembered states, in the order they were first seen.
83///
84/// The visited set and the visit order are one structure because every walk
85/// needs both and keeping them apart is how a state ends up in one and not the
86/// other. `insert` answers whether the state was new, which is the only
87/// question the walks ask.
88struct Seen {
89 keys: HashSet<DynamicKey>,
90 order: Vec<GcRef>,
91}
92
93impl Seen {
94 fn new() -> Seen {
95 Seen {
96 keys: HashSet::new(),
97 order: Vec::new(),
98 }
99 }
100
101 /// Record `state` if it is new. Returns whether it was.
102 fn insert(&mut self, state: GcRef) -> bool {
103 if self.keys.insert(DynamicKey::new(state)) {
104 self.order.push(state);
105 true
106 } else {
107 false
108 }
109 }
110}
111
112/// What a goal-directed search found: the route from the start to the goal it
113/// stopped at, and what that route cost.
114///
115/// One answer for both halves of a goal-directed family. `X_distance` projects
116/// `cost` and `X_path` projects `states`, so the number and the route are
117/// always the *same* route's — two searches, one run apart, could disagree
118/// about which goal they stopped at, and this makes that unrepresentable.
119///
120/// `states` runs from the start to the goal, both included, so a start that is
121/// itself a goal is a one-element route at cost 0. `cost` is the route's own
122/// price in the units the search counts: edges for the unweighted walks, the
123/// sum of the weights for the weighted ones.
124#[derive(Clone, PartialEq, Eq, Debug)]
125pub struct Route {
126 /// What the route cost, in the search's own units.
127 pub cost: i64,
128 /// The states from the start to the goal, in order, inclusive of both.
129 pub states: Vec<GcRef>,
130}
131
132/// Where each state was first reached from, for the states a route can pass
133/// through. The start is absent: it was reached from nowhere, which is what
134/// terminates [`route_to`].
135type Parents = HashMap<DynamicKey, GcRef>;
136
137/// The route to `goal`, walked back through `parents` and reversed.
138///
139/// Every state on the way is a key whose value is one step closer to the start,
140/// and the start has no entry — so the walk back is finite and ends exactly
141/// there.
142fn route_to(parents: &Parents, goal: GcRef) -> Vec<GcRef> {
143 let mut states = vec![goal];
144 let mut at = goal;
145 while let Some(parent) = parents.get(&DynamicKey::new(at)) {
146 states.push(*parent);
147 at = *parent;
148 }
149 states.reverse();
150 states
151}
152
153/// `bfs(start, neighbours)` — every reachable state, in breadth-first order.
154///
155/// The start is the first element: a walk always reaches where it began, which
156/// is why this result needs no `Option`.
157pub fn bfs_order(oracle: &mut dyn GraphOracle, start: GcRef) -> Result<Vec<GcRef>, Aborted> {
158 let mut seen = Seen::new();
159 oracle.retain(start);
160 seen.insert(start);
161 let mut queue = VecDeque::new();
162 queue.push_back(start);
163 while let Some(state) = queue.pop_front() {
164 for next in oracle.neighbours(state)? {
165 oracle.retain(next);
166 if seen.insert(next) {
167 queue.push_back(next);
168 }
169 }
170 }
171 Ok(seen.order)
172}
173
174/// `dfs(start, neighbours)` — every reachable state, in depth-first pre-order.
175///
176/// The neighbours are pushed in reverse so the *first* neighbour a state
177/// reports is the first one descended into. Without that the order is the
178/// mirror image of the one the program wrote, which is the kind of difference
179/// only an end-to-end test sees.
180pub fn dfs_order(oracle: &mut dyn GraphOracle, start: GcRef) -> Result<Vec<GcRef>, Aborted> {
181 let mut seen = Seen::new();
182 oracle.retain(start);
183 let mut stack = vec![start];
184 while let Some(state) = stack.pop() {
185 if !seen.insert(state) {
186 continue;
187 }
188 let next = oracle.neighbours(state)?;
189 for n in next.into_iter().rev() {
190 oracle.retain(n);
191 stack.push(n);
192 }
193 }
194 Ok(seen.order)
195}
196
197/// `flood_fill(start, neighbours)` — every reachable state.
198///
199/// The same walk as [`bfs_order`]; only the result type differs, and the ABI
200/// wrapper is what turns the states into a `Set` rather than a `Vec`. Sharing
201/// the walk is deliberate: "which states are reachable" has one answer, and two
202/// implementations of it would eventually disagree.
203pub fn reachable(oracle: &mut dyn GraphOracle, start: GcRef) -> Result<Vec<GcRef>, Aborted> {
204 bfs_order(oracle, start)
205}
206
207/// `bfs_distance`/`bfs_path`'s one walk: the shortest route from `start` to a
208/// state satisfying `is_goal`, or `None` when no such state is reachable.
209///
210/// Every edge counts one, so the first time the walk *dequeues* a goal it has
211/// reached it by a shortest route, and the [`Route::cost`] is that route's edge
212/// count. `is_goal` is asked on dequeue rather than on discovery, which is what
213/// makes a start that is already a goal zero steps rather than one.
214///
215/// A state's parent is recorded the moment [`Seen::insert`] first accepts it,
216/// and breadth-first order means that first sighting is along a shortest route
217/// — so the parent chain is final as soon as it is written.
218pub fn bfs_route(oracle: &mut dyn GraphOracle, start: GcRef) -> Result<Option<Route>, Aborted> {
219 let mut seen = Seen::new();
220 let mut parents = Parents::new();
221 oracle.retain(start);
222 seen.insert(start);
223 let mut queue = VecDeque::new();
224 queue.push_back((start, 0_i64));
225 while let Some((state, steps)) = queue.pop_front() {
226 if oracle.is_goal(state)? {
227 return Ok(Some(Route {
228 cost: steps,
229 states: route_to(&parents, state),
230 }));
231 }
232 // A step count cannot overflow before the visited set exhausts memory,
233 // but the addition is still checked: `saturating_add` would report a
234 // distance nobody walked.
235 let Some(next_steps) = steps.checked_add(1) else {
236 return Err(oracle.abort(FaultKind::IntOverflow));
237 };
238 for next in oracle.neighbours(state)? {
239 oracle.retain(next);
240 if seen.insert(next) {
241 parents.insert(DynamicKey::new(next), state);
242 queue.push_back((next, next_steps));
243 }
244 }
245 }
246 Ok(None)
247}
248
249/// `dfs_distance`/`dfs_path`'s one walk: the route depth-first search found to
250/// a state satisfying `is_goal`, or `None` when no such state is reachable.
251///
252/// **The route is the one the descent happened to reach first, which need not
253/// be a shortest one** — that is the whole difference from [`bfs_route`], and
254/// the reason both families exist rather than one.
255///
256/// The stack carries each entry's parent, because the parent that counts is the
257/// one on the entry that was *popped and accepted*: a state can be pushed from
258/// several predecessors before it is first visited, and only the push the walk
259/// actually descended from is on its route.
260pub fn dfs_route(oracle: &mut dyn GraphOracle, start: GcRef) -> Result<Option<Route>, Aborted> {
261 let mut seen = Seen::new();
262 let mut parents = Parents::new();
263 oracle.retain(start);
264 let mut stack: Vec<(GcRef, Option<GcRef>)> = vec![(start, None)];
265 while let Some((state, parent)) = stack.pop() {
266 if !seen.insert(state) {
267 continue;
268 }
269 if let Some(parent) = parent {
270 parents.insert(DynamicKey::new(state), parent);
271 }
272 if oracle.is_goal(state)? {
273 let states = route_to(&parents, state);
274 // A route through `n` states crosses `n - 1` edges, and a route
275 // always holds at least its own goal.
276 let cost = (states.len() - 1) as i64;
277 return Ok(Some(Route { cost, states }));
278 }
279 let next = oracle.neighbours(state)?;
280 for n in next.into_iter().rev() {
281 oracle.retain(n);
282 stack.push((n, Some(state)));
283 }
284 }
285 Ok(None)
286}
287
288/// How a search turns the cost of reaching a state into the priority its
289/// frontier entry is filed under: the cost itself for Dijkstra
290/// ([`cost_itself`]), `g + h` for A\* ([`estimate`]).
291///
292/// It takes the oracle because A\*'s half of the answer comes from the
293/// program's heuristic closure, and returns a `Result` because that closure can
294/// fault like any other.
295type PriorityOf = fn(&mut dyn GraphOracle, GcRef, i64) -> Result<i64, Aborted>;
296
297/// The priority queue Dijkstra and A\* share, with the three tables that make a
298/// pop mean something: the least cost known per state, the states already
299/// settled, and the insertion counter that breaks ties.
300///
301/// The two searches differ in one expression — the priority an entry is filed
302/// under — and in what they do with a state once it settles. Everything else is
303/// here, both refusals included, because written twice they would eventually
304/// disagree and two searches that fault differently on the same graph is the
305/// bug this shape prevents. It is the argument [`Seen`] was factored out for,
306/// and the one [`reachable`] delegates to [`bfs_order`] for.
307struct Frontier {
308 /// Ordered by `(priority, sequence)`, carrying the state alongside: a state
309 /// is not orderable — nothing requires it to be — so the tie-break is
310 /// insertion order, which also makes the walk deterministic.
311 heap: BinaryHeap<Reverse<(i64, usize, StateEntry)>>,
312 /// The least cost known to reach each state so far.
313 best: HashMap<DynamicKey, i64>,
314 /// The states already settled; nothing relaxes into one again.
315 done: HashSet<DynamicKey>,
316 /// The predecessor on the cheapest route known to each state.
317 ///
318 /// **A settled state's parent is final.** [`Frontier::push`] writes here
319 /// only for an entry that improved on what was known, and
320 /// [`Frontier::relax`] skips a state already in `done` — so nothing can
321 /// push a settled state again, and nothing overwrites its parent. What
322 /// settles a state is its cheapest entry (a lower `g` is a lower priority
323 /// under both [`cost_itself`] and [`estimate`], because `h` is a property
324 /// of the state alone), and that entry's push is the one that wrote the
325 /// parent. So the chain from a settled goal is the chain of the route the
326 /// cost belongs to.
327 parents: Parents,
328 /// Pushes so far — the heap's tie-break.
329 seq: usize,
330}
331
332impl Frontier {
333 fn new() -> Frontier {
334 Frontier {
335 heap: BinaryHeap::new(),
336 best: HashMap::new(),
337 done: HashSet::new(),
338 parents: Parents::new(),
339 seq: 0,
340 }
341 }
342
343 /// Record that `state` is reachable at `cost` by way of `parent` and queue
344 /// it under `priority`. For Dijkstra the cost and the priority are one
345 /// number; for A\* the priority is `g + h` and the cost is `g`.
346 ///
347 /// `parent` is `None` only for the start, which is reached from nowhere.
348 fn push(&mut self, state: GcRef, parent: Option<GcRef>, cost: i64, priority: i64) {
349 let key = DynamicKey::new(state);
350 self.best.insert(key, cost);
351 if let Some(parent) = parent {
352 self.parents.insert(key, parent);
353 }
354 self.heap
355 .push(Reverse((priority, self.seq, StateEntry(state))));
356 self.seq += 1;
357 }
358
359 /// The next state to settle and the cost it settled at, or `None` when the
360 /// frontier is spent.
361 ///
362 /// A state queued again at a lower cost leaves its old entry behind, so a
363 /// pop is a loop: the later entries for a settled state are stale.
364 fn settle(&mut self) -> Option<(GcRef, i64)> {
365 while let Some(Reverse((_, _, StateEntry(state)))) = self.heap.pop() {
366 let key = DynamicKey::new(state);
367 if !self.done.insert(key) {
368 // Already settled by a cheaper entry; this one is stale.
369 continue;
370 }
371 // `best` is the settled cost: the entry that popped is the cheapest
372 // one for this state, and nothing lowers it after it is settled.
373 // The number in the entry is not it — for A\* that is `g + h`.
374 let cost = *self
375 .best
376 .get(&key)
377 .expect("a popped state has a known cost");
378 return Some((state, cost));
379 }
380 None
381 }
382
383 /// Relax every edge out of `state`, which settled at `cost`, queueing each
384 /// neighbour the edge improves under `priority_of`.
385 ///
386 /// Both refusals [`dijkstra_costs`] and [`best_route`] document are made
387 /// here, once: a negative edge weight is a [`FaultKind::NoAnswer`], because
388 /// a settled state is never reconsidered and a cost nobody paid is worse
389 /// than a stop; a cost that leaves the `Int` range is a
390 /// [`FaultKind::IntOverflow`] rather than a wrap, which is the same rule
391 /// ADR-058 applied to `abs(Int::MIN)`.
392 fn relax(
393 &mut self,
394 oracle: &mut dyn GraphOracle,
395 state: GcRef,
396 cost: i64,
397 priority_of: PriorityOf,
398 ) -> Result<(), Aborted> {
399 for next in oracle.neighbours(state)? {
400 oracle.retain(next);
401 let step = oracle.weight(state, next)?;
402 if step < 0 {
403 return Err(oracle.abort(FaultKind::NoAnswer));
404 }
405 let Some(through) = cost.checked_add(step) else {
406 return Err(oracle.abort(FaultKind::IntOverflow));
407 };
408 let next_key = DynamicKey::new(next);
409 if self.done.contains(&next_key) {
410 continue;
411 }
412 let improved = match self.best.get(&next_key) {
413 Some(known) => through < *known,
414 None => true,
415 };
416 if improved {
417 let priority = priority_of(oracle, next, through)?;
418 self.push(next, Some(state), through, priority);
419 }
420 }
421 Ok(())
422 }
423}
424
425/// `dijkstra(start, neighbours, weight)` — the least cost from `start` to every
426/// reachable state, as `(state, cost)` pairs.
427///
428/// The start is present at cost 0. An unreachable state is simply absent, which
429/// is why this answers with a table rather than with an `Option` per state.
430///
431/// A **negative edge weight faults**. Dijkstra settles a state the first time it
432/// pops it and never reconsiders, so a negative edge makes the answer quietly
433/// too large — and a cost nobody paid is worse than a stop (the rule ADR-058
434/// applied to `abs(Int::MIN)`). The refusal itself lives in `Frontier::relax`,
435/// which is why A\* makes it identically.
436pub fn dijkstra_costs(
437 oracle: &mut dyn GraphOracle,
438 start: GcRef,
439) -> Result<Vec<(GcRef, i64)>, Aborted> {
440 let mut frontier = Frontier::new();
441 let mut settled: Vec<(GcRef, i64)> = Vec::new();
442
443 oracle.retain(start);
444 frontier.push(start, None, 0, 0);
445
446 while let Some((state, cost)) = frontier.settle() {
447 settled.push((state, cost));
448 frontier.relax(oracle, state, cost, cost_itself)?;
449 }
450 Ok(settled)
451}
452
453/// The priority Dijkstra files an entry under: the cost, with nothing added. It
454/// takes the oracle it never asks so that it and [`estimate`] are one shape.
455fn cost_itself(_oracle: &mut dyn GraphOracle, _state: GcRef, cost: i64) -> Result<i64, Aborted> {
456 Ok(cost)
457}
458
459/// The one weighted goal-directed search, filed under `priority_of`: the
460/// cheapest route from `start` to a goal, or `None` when no goal is reachable.
461///
462/// Dijkstra and A\* are this loop under [`cost_itself`] and [`estimate`]
463/// respectively — the same argument [`Frontier`] itself is made for, at the one
464/// remaining seam. A search stops at the first goal it *settles*, and a settled
465/// state's cost is final, so that goal is the cheapest reachable one.
466///
467/// Both refusals live in `Frontier::relax`, and A\*'s third — a negative
468/// heuristic — in [`estimate`], so a distance and a path fault on exactly the
469/// same graphs.
470fn best_route(
471 oracle: &mut dyn GraphOracle,
472 start: GcRef,
473 priority_of: PriorityOf,
474) -> Result<Option<Route>, Aborted> {
475 let mut frontier = Frontier::new();
476
477 oracle.retain(start);
478 let start_priority = priority_of(oracle, start, 0)?;
479 frontier.push(start, None, 0, start_priority);
480
481 while let Some((state, cost)) = frontier.settle() {
482 if oracle.is_goal(state)? {
483 return Ok(Some(Route {
484 cost,
485 states: route_to(&frontier.parents, state),
486 }));
487 }
488 frontier.relax(oracle, state, cost, priority_of)?;
489 }
490 Ok(None)
491}
492
493/// `dijkstra_distance`/`dijkstra_path`'s one walk: the cheapest route from
494/// `start` to a goal, or `None` when no goal is reachable.
495///
496/// The same search [`dijkstra_costs`] runs, stopped at the first goal it
497/// settles instead of run to exhaustion — so it makes the same two refusals, a
498/// negative edge weight and a cost with no `Int`, through the same code.
499pub fn dijkstra_route(
500 oracle: &mut dyn GraphOracle,
501 start: GcRef,
502) -> Result<Option<Route>, Aborted> {
503 best_route(oracle, start, cost_itself)
504}
505
506/// `a_star_distance`/`a_star_path`'s one walk: the cheapest route from `start`
507/// to a goal, or `None` when no goal is reachable.
508///
509/// The frontier is ordered by `g + h`; a state is settled when it is popped, and
510/// the first goal popped is the cheapest one **provided the heuristic never
511/// overestimates**. A heuristic that does is the caller's error and the search
512/// cannot detect it — but a *negative* one can be detected, and is, for the same
513/// reason a negative weight is: it makes `f` decrease along a path, which is the
514/// condition the ordering relies on.
515pub fn a_star_route(oracle: &mut dyn GraphOracle, start: GcRef) -> Result<Option<Route>, Aborted> {
516 best_route(oracle, start, estimate)
517}
518
519/// `g + h` for a state, with the two refusals A\*'s ordering depends on: a
520/// negative estimate, and a sum with no `Int`.
521fn estimate(oracle: &mut dyn GraphOracle, state: GcRef, cost: i64) -> Result<i64, Aborted> {
522 let h = oracle.heuristic(state)?;
523 if h < 0 {
524 return Err(oracle.abort(FaultKind::NoAnswer));
525 }
526 match cost.checked_add(h) {
527 Some(f) => Ok(f),
528 None => Err(oracle.abort(FaultKind::IntOverflow)),
529 }
530}
531
532/// A state in a priority-queue entry, ordered as **equal to every other state**.
533///
534/// The queue's real key is the `(cost, sequence)` pair in front of this; a state
535/// has no order of its own and requiring one would exclude every type that is a
536/// legal `Map` key but not orderable — tuples and records, which is what a grid
537/// position is. Making the comparison total-and-constant here is what lets the
538/// tuple derive its `Ord` from the two fields that do order.
539#[derive(Clone, Copy)]
540struct StateEntry(GcRef);
541
542impl PartialEq for StateEntry {
543 fn eq(&self, _other: &Self) -> bool {
544 true
545 }
546}
547impl Eq for StateEntry {}
548impl PartialOrd for StateEntry {
549 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
550 Some(self.cmp(other))
551 }
552}
553impl Ord for StateEntry {
554 fn cmp(&self, _other: &Self) -> std::cmp::Ordering {
555 std::cmp::Ordering::Equal
556 }
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562 use crate::abi::{praxis_alloc_int, praxis_int_load};
563 use crate::context::{Runtime, RuntimeContext};
564
565 /// A graph written down as a table, so a walk can be tested without a JIT.
566 ///
567 /// States are boxed `Int`s allocated from a real runtime — real `GcRef`s
568 /// with real descriptors, so `DynamicKey` does the same structural
569 /// comparison it does for a program's own states. The adjacency, weights,
570 /// heuristic and goals are all keyed on the integer the state holds.
571 struct Table {
572 ctx: *mut RuntimeContext,
573 edges: Vec<(i64, Vec<i64>)>,
574 weights: Vec<((i64, i64), i64)>,
575 heuristics: Vec<(i64, i64)>,
576 goals: Vec<i64>,
577 /// Set by `abort`; the fault the walk raised, which a real context
578 /// would carry on its fault slot.
579 raised: Option<FaultKind>,
580 /// Every state handed to `retain`, in order — the rooting the ABI
581 /// implementation performs.
582 retained: Vec<i64>,
583 }
584
585 impl Table {
586 fn value(&self, state: GcRef) -> i64 {
587 // SAFETY: every state in these tests is an `Int` allocated below.
588 unsafe { praxis_int_load(self.ctx, state) }
589 }
590
591 fn state(&self, n: i64) -> GcRef {
592 // SAFETY: `ctx` is wired for the test's lifetime.
593 unsafe { praxis_alloc_int(self.ctx, n) }
594 }
595 }
596
597 impl GraphOracle for Table {
598 fn neighbours(&mut self, state: GcRef) -> Result<Vec<GcRef>, Aborted> {
599 let n = self.value(state);
600 let out = self
601 .edges
602 .iter()
603 .find(|(from, _)| *from == n)
604 .map(|(_, to)| to.clone())
605 .unwrap_or_default();
606 Ok(out.into_iter().map(|m| self.state(m)).collect())
607 }
608
609 fn weight(&mut self, from: GcRef, to: GcRef) -> Result<i64, Aborted> {
610 let pair = (self.value(from), self.value(to));
611 Ok(self
612 .weights
613 .iter()
614 .find(|(p, _)| *p == pair)
615 .map(|(_, w)| *w)
616 .unwrap_or(1))
617 }
618
619 fn heuristic(&mut self, state: GcRef) -> Result<i64, Aborted> {
620 let n = self.value(state);
621 Ok(self
622 .heuristics
623 .iter()
624 .find(|(s, _)| *s == n)
625 .map(|(_, h)| *h)
626 .unwrap_or(0))
627 }
628
629 fn is_goal(&mut self, state: GcRef) -> Result<bool, Aborted> {
630 Ok(self.goals.contains(&self.value(state)))
631 }
632
633 fn retain(&mut self, state: GcRef) {
634 let n = self.value(state);
635 self.retained.push(n);
636 }
637
638 fn abort(&mut self, kind: FaultKind) -> Aborted {
639 self.raised = Some(kind);
640 Aborted
641 }
642 }
643
644 /// A runtime plus a leaked context, and the table over it. The runtime has
645 /// to outlive every state, so both are returned together.
646 ///
647 /// The `Runtime` is **boxed**, and that is load-bearing: a context holds
648 /// `&mut rt.heap` as a raw pointer, so returning an unboxed `Runtime` by
649 /// value moves the heap out from under every context already minted from
650 /// it. Boxing keeps the address stable across the move.
651 fn table(edges: &[(i64, &[i64])]) -> (Box<Runtime>, Table) {
652 let mut rt = Box::new(Runtime::new());
653 let ctx: *mut RuntimeContext = Box::leak(Box::new(rt.context()));
654 let t = Table {
655 ctx,
656 edges: edges
657 .iter()
658 .map(|(from, to)| (*from, to.to_vec()))
659 .collect(),
660 weights: Vec::new(),
661 heuristics: Vec::new(),
662 goals: Vec::new(),
663 raised: None,
664 retained: Vec::new(),
665 };
666 (rt, t)
667 }
668
669 fn values(t: &Table, states: &[GcRef]) -> Vec<i64> {
670 states.iter().map(|s| t.value(*s)).collect()
671 }
672
673 /// The two orders are different walks over the same graph, and each one has
674 /// to be the order it names. A diamond `1 -> {2, 3}`, both to `4`,
675 /// distinguishes them: breadth-first is `1 2 3 4`, depth-first is
676 /// `1 2 4 3`.
677 #[test]
678 fn breadth_first_and_depth_first_visit_in_the_orders_they_name() {
679 let (_rt, mut t) = table(&[(1, &[2, 3]), (2, &[4]), (3, &[4]), (4, &[])]);
680 let start = t.state(1);
681 let bfs = bfs_order(&mut t, start).expect("no fault");
682 assert_eq!(values(&t, &bfs), vec![1, 2, 3, 4]);
683
684 let (_rt2, mut t2) = table(&[(1, &[2, 3]), (2, &[4]), (3, &[4]), (4, &[])]);
685 let start2 = t2.state(1);
686 let dfs = dfs_order(&mut t2, start2).expect("no fault");
687 assert_eq!(values(&t2, &dfs), vec![1, 2, 4, 3]);
688 }
689
690 /// A depth-first walk descends into the *first* neighbour a state reports.
691 /// The stack reverses the neighbour list, so a walk that pushed them in
692 /// order would visit the last one first and still look plausible on a
693 /// symmetric graph.
694 #[test]
695 fn a_depth_first_walk_takes_the_first_neighbour_first() {
696 let (_rt, mut t) = table(&[(1, &[2, 3]), (2, &[]), (3, &[])]);
697 let start = t.state(1);
698 let order = dfs_order(&mut t, start).expect("no fault");
699 assert_eq!(values(&t, &order), vec![1, 2, 3]);
700 }
701
702 /// A cycle terminates, and every state appears once. Without the visited
703 /// set both walks run forever; with a set that is consulted but not
704 /// *updated* on the queue path, a diamond enqueues its join twice.
705 #[test]
706 fn a_cycle_is_walked_once_and_terminates() {
707 let (_rt, mut t) = table(&[(1, &[2]), (2, &[3]), (3, &[1, 2])]);
708 let start = t.state(1);
709 let bfs = bfs_order(&mut t, start).expect("no fault");
710 assert_eq!(values(&t, &bfs), vec![1, 2, 3]);
711
712 let (_rt2, mut t2) = table(&[(1, &[2]), (2, &[3]), (3, &[1, 2])]);
713 let start2 = t2.state(1);
714 let dfs = dfs_order(&mut t2, start2).expect("no fault");
715 assert_eq!(values(&t2, &dfs), vec![1, 2, 3]);
716 }
717
718 /// A state with no neighbours is still reached: the walk answers with the
719 /// start alone rather than with nothing.
720 #[test]
721 fn a_lone_state_is_its_own_walk() {
722 let (_rt, mut t) = table(&[(1, &[])]);
723 let start = t.state(1);
724 let order = bfs_order(&mut t, start).unwrap();
725 assert_eq!(values(&t, &order), vec![1]);
726
727 let (_rt2, mut t2) = table(&[(1, &[])]);
728 let start2 = t2.state(1);
729 let reached = reachable(&mut t2, start2).unwrap();
730 assert_eq!(values(&t2, &reached), vec![1]);
731 }
732
733 /// Identity is structural, not by pointer. Two separately allocated `Int`s
734 /// holding `2` are the same state, so a graph whose neighbour function
735 /// mints a fresh object per call still terminates — which is what every
736 /// real neighbour closure does (`|p| [(p.0 + 1, p.1), …]` allocates).
737 #[test]
738 fn two_equal_states_are_one_state_however_they_were_allocated() {
739 let (_rt, mut t) = table(&[(1, &[2]), (2, &[1])]);
740 let start = t.state(1);
741 let order = bfs_order(&mut t, start).expect("no fault");
742 assert_eq!(values(&t, &order), vec![1, 2]);
743 // The neighbour function allocated a fresh `1` on the second step, and
744 // the walk recognized it as the state it started from.
745 assert!(t.retained.len() >= 3, "the fresh states were retained");
746 }
747
748 /// Every state the walk remembers was handed to `retain` first. This is the
749 /// rooting contract: a state in the visited set that the collector cannot
750 /// see is a dangling reference the next allocation creates.
751 #[test]
752 fn every_remembered_state_was_retained_first() {
753 let (_rt, mut t) = table(&[(1, &[2, 3]), (2, &[4]), (3, &[]), (4, &[])]);
754 let start = t.state(1);
755 let order = bfs_order(&mut t, start).expect("no fault");
756 for state in &order {
757 assert!(
758 t.retained.contains(&t.value(*state)),
759 "a visited state was never retained"
760 );
761 }
762 }
763
764 /// The cost of a route, which is what a `_distance` helper projects out of
765 /// it. Written once because every distance assertion below is this.
766 fn cost(found: Result<Option<Route>, Aborted>) -> Option<i64> {
767 found.expect("no fault").map(|r| r.cost)
768 }
769
770 /// The distance is the number of *steps*, the start is zero steps away, and
771 /// an unreachable goal is `None` rather than a sentinel.
772 #[test]
773 fn a_distance_counts_steps_and_absence_is_none() {
774 let (_rt, mut t) = table(&[(1, &[2]), (2, &[3]), (3, &[]), (9, &[])]);
775 t.goals = vec![3];
776 let start = t.state(1);
777 assert_eq!(cost(bfs_route(&mut t, start)), Some(2));
778
779 let (_rt2, mut t2) = table(&[(1, &[2]), (2, &[3]), (3, &[])]);
780 t2.goals = vec![1];
781 let start2 = t2.state(1);
782 assert_eq!(
783 cost(bfs_route(&mut t2, start2)),
784 Some(0),
785 "a start that is already a goal is zero steps, not one"
786 );
787
788 let (_rt3, mut t3) = table(&[(1, &[2]), (2, &[])]);
789 t3.goals = vec![99];
790 let start3 = t3.state(1);
791 assert_eq!(cost(bfs_route(&mut t3, start3)), None);
792 }
793
794 /// A breadth-first distance is the *shortest* one. The long way round is
795 /// enqueued first, so a walk that returned the first goal it enqueued
796 /// rather than the first it dequeued would answer 3 here.
797 #[test]
798 fn a_distance_is_the_shortest_path_not_the_first_found() {
799 let (_rt, mut t) = table(&[(1, &[2, 5]), (2, &[3]), (3, &[4]), (4, &[]), (5, &[4])]);
800 t.goals = vec![4];
801 let start = t.state(1);
802 assert_eq!(cost(bfs_route(&mut t, start)), Some(2));
803 }
804
805 /// The cost table holds the least cost to every reachable state, the start
806 /// at zero, and nothing for what cannot be reached. The cheap three-hop
807 /// path has to beat the expensive one-hop edge, which is the whole of
808 /// Dijkstra and the half a step-counting BFS gets wrong.
809 #[test]
810 fn a_cost_table_prefers_a_cheap_long_path_to_an_expensive_short_one() {
811 let (_rt, mut t) = table(&[(1, &[2, 4]), (2, &[3]), (3, &[4]), (4, &[]), (7, &[])]);
812 t.weights = vec![((1, 4), 10), ((1, 2), 1), ((2, 3), 1), ((3, 4), 1)];
813 let start = t.state(1);
814 let costs = dijkstra_costs(&mut t, start).expect("no fault");
815 let mut by_state: Vec<(i64, i64)> = costs.iter().map(|(s, c)| (t.value(*s), *c)).collect();
816 by_state.sort_unstable();
817 assert_eq!(by_state, vec![(1, 0), (2, 1), (3, 2), (4, 3)]);
818 assert!(
819 !by_state.iter().any(|(s, _)| *s == 7),
820 "an unreachable state is absent, not present at some cost"
821 );
822 }
823
824 /// A settled state is settled: a later, longer route to it does not add a
825 /// second entry to the table.
826 #[test]
827 fn each_state_is_settled_once() {
828 let (_rt, mut t) = table(&[(1, &[2, 3]), (2, &[4]), (3, &[4]), (4, &[])]);
829 let start = t.state(1);
830 let costs = dijkstra_costs(&mut t, start).expect("no fault");
831 assert_eq!(costs.len(), 4, "one entry per reachable state");
832 }
833
834 /// A negative edge weight faults rather than answering. Dijkstra never
835 /// reconsiders a settled state, so a negative edge makes the answer quietly
836 /// too large — and the same refusal covers A\*, which settles the same way.
837 #[test]
838 fn a_negative_edge_weight_faults_rather_than_answering() {
839 let (_rt, mut t) = table(&[(1, &[2]), (2, &[])]);
840 t.weights = vec![((1, 2), -1)];
841 let start = t.state(1);
842 assert_eq!(dijkstra_costs(&mut t, start), Err(Aborted));
843 assert_eq!(t.raised, Some(FaultKind::NoAnswer));
844
845 let (_rt2, mut t2) = table(&[(1, &[2]), (2, &[])]);
846 t2.weights = vec![((1, 2), -1)];
847 t2.goals = vec![2];
848 let start2 = t2.state(1);
849 assert_eq!(a_star_route(&mut t2, start2), Err(Aborted));
850 assert_eq!(t2.raised, Some(FaultKind::NoAnswer));
851 }
852
853 /// A path whose cost leaves the `Int` range faults rather than wrapping —
854 /// the rule ADR-058 applied to `abs(Int::MIN)`, at the one place a walk
855 /// does arithmetic the program did not write.
856 #[test]
857 fn a_cost_with_no_int_faults_rather_than_wrapping() {
858 let (_rt, mut t) = table(&[(1, &[2]), (2, &[3]), (3, &[])]);
859 t.weights = vec![((1, 2), i64::MAX), ((2, 3), 1)];
860 let start = t.state(1);
861 assert_eq!(dijkstra_costs(&mut t, start), Err(Aborted));
862 assert_eq!(t.raised, Some(FaultKind::IntOverflow));
863 }
864
865 /// A\* answers the cheapest cost to a goal, and the heuristic only changes
866 /// the order states are examined in — not the answer. The same graph is
867 /// searched twice, once with a zero heuristic (which is Dijkstra) and once
868 /// with an exact one.
869 #[test]
870 fn a_star_finds_the_cheapest_goal_whatever_the_heuristic_estimates() {
871 let edges: &[(i64, &[i64])] = &[(1, &[2, 4]), (2, &[3]), (3, &[4]), (4, &[])];
872 let weights = vec![((1, 4), 10), ((1, 2), 1), ((2, 3), 1), ((3, 4), 1)];
873
874 let (_rt, mut t) = table(edges);
875 t.weights = weights.clone();
876 t.goals = vec![4];
877 let start = t.state(1);
878 assert_eq!(cost(a_star_route(&mut t, start)), Some(3));
879
880 let (_rt2, mut t2) = table(edges);
881 t2.weights = weights;
882 t2.goals = vec![4];
883 // An exact remaining-cost estimate: still admissible, so still 3.
884 t2.heuristics = vec![(1, 3), (2, 2), (3, 1), (4, 0)];
885 let start2 = t2.state(1);
886 assert_eq!(cost(a_star_route(&mut t2, start2)), Some(3));
887 }
888
889 /// An unreachable goal is `None`, and a start that is already a goal costs
890 /// nothing.
891 #[test]
892 fn a_star_answers_nothing_for_an_unreachable_goal() {
893 let (_rt, mut t) = table(&[(1, &[2]), (2, &[])]);
894 t.goals = vec![99];
895 let start = t.state(1);
896 assert_eq!(cost(a_star_route(&mut t, start)), None);
897
898 let (_rt2, mut t2) = table(&[(1, &[2]), (2, &[])]);
899 t2.goals = vec![1];
900 let start2 = t2.state(1);
901 assert_eq!(cost(a_star_route(&mut t2, start2)), Some(0));
902 }
903
904 /// A negative heuristic faults. It is the one caller error A\* *can* see:
905 /// an inadmissible-but-positive estimate produces a wrong answer nothing
906 /// can detect, while a negative one breaks the ordering the search is built
907 /// on and is one comparison away.
908 #[test]
909 fn a_negative_heuristic_faults_rather_than_misordering_the_search() {
910 let (_rt, mut t) = table(&[(1, &[2]), (2, &[])]);
911 t.goals = vec![2];
912 t.heuristics = vec![(1, -5)];
913 let start = t.state(1);
914 assert_eq!(a_star_route(&mut t, start), Err(Aborted));
915 assert_eq!(t.raised, Some(FaultKind::NoAnswer));
916 }
917
918 // --- the route the distance is the cost of ------------------------------
919
920 /// A route runs from the start to the goal, both included, and its cost is
921 /// the price of *that* route: the edge count for a step-counting walk, the
922 /// weight sum for a weighted one. Three families, one shape.
923 #[test]
924 fn a_route_is_start_to_goal_inclusive_and_its_cost_is_its_own() {
925 let edges: &[(i64, &[i64])] = &[(1, &[2]), (2, &[3]), (3, &[])];
926 let weights = vec![((1, 2), 4), ((2, 3), 6)];
927
928 let (_rt, mut t) = table(edges);
929 t.goals = vec![3];
930 let start = t.state(1);
931 let route = bfs_route(&mut t, start).expect("no fault").expect("a goal");
932 assert_eq!(values(&t, &route.states), vec![1, 2, 3]);
933 assert_eq!(route.cost, 2, "a breadth-first cost counts edges");
934
935 let (_rt2, mut t2) = table(edges);
936 t2.weights = weights.clone();
937 t2.goals = vec![3];
938 let start2 = t2.state(1);
939 let route = dijkstra_route(&mut t2, start2)
940 .expect("no fault")
941 .expect("a goal");
942 assert_eq!(values(&t2, &route.states), vec![1, 2, 3]);
943 assert_eq!(route.cost, 10, "a weighted cost sums the weights");
944
945 let (_rt3, mut t3) = table(edges);
946 t3.weights = weights;
947 t3.goals = vec![3];
948 let start3 = t3.state(1);
949 let route = a_star_route(&mut t3, start3)
950 .expect("no fault")
951 .expect("a goal");
952 assert_eq!(values(&t3, &route.states), vec![1, 2, 3]);
953 assert_eq!(route.cost, 10);
954 }
955
956 /// A start that is already a goal is a route of one state at cost zero, not
957 /// an empty route and not `None`. All four searches agree, because "the
958 /// route to where I already am" has one answer.
959 #[test]
960 fn a_start_that_is_the_goal_is_a_route_of_one_state() {
961 for search in [bfs_route, dfs_route, dijkstra_route, a_star_route] {
962 let (_rt, mut t) = table(&[(1, &[2]), (2, &[])]);
963 t.goals = vec![1];
964 let start = t.state(1);
965 let route = search(&mut t, start).expect("no fault").expect("a goal");
966 assert_eq!(values(&t, &route.states), vec![1]);
967 assert_eq!(route.cost, 0);
968 }
969 }
970
971 /// An unreachable goal is `None` from every one of the four. A route the
972 /// search never found has no states *and* no cost, which is the whole
973 /// reason both forms answer an `Option`.
974 #[test]
975 fn an_unreachable_goal_is_nothing_from_every_search() {
976 for search in [bfs_route, dfs_route, dijkstra_route, a_star_route] {
977 let (_rt, mut t) = table(&[(1, &[2]), (2, &[])]);
978 t.goals = vec![99];
979 let start = t.state(1);
980 assert!(search(&mut t, start).expect("no fault").is_none());
981 }
982 }
983
984 /// The breadth-first route is *a* shortest one, and its length agrees with
985 /// the cost the same call reports. The long way round is discovered first,
986 /// so a walk that recorded a parent on every sighting rather than on the
987 /// first would reconstruct the three-hop route here.
988 #[test]
989 fn a_breadth_first_route_is_a_shortest_one() {
990 let (_rt, mut t) = table(&[(1, &[2, 5]), (2, &[3]), (3, &[4]), (4, &[]), (5, &[4])]);
991 t.goals = vec![4];
992 let start = t.state(1);
993 let route = bfs_route(&mut t, start).expect("no fault").expect("a goal");
994 assert_eq!(values(&t, &route.states), vec![1, 5, 4]);
995 assert_eq!(route.states.len() as i64 - 1, route.cost);
996 }
997
998 /// **The two families ask different questions.** On a graph whose cheapest
999 /// route is not its shortest, Dijkstra takes the cheap three-hop one and
1000 /// breadth-first takes the dear one-hop one — and each is right about the
1001 /// question it was asked.
1002 #[test]
1003 fn the_cheapest_route_and_the_shortest_route_are_different_routes() {
1004 let edges: &[(i64, &[i64])] = &[(1, &[2, 4]), (2, &[3]), (3, &[4]), (4, &[])];
1005 let weights = vec![((1, 4), 10), ((1, 2), 1), ((2, 3), 1), ((3, 4), 1)];
1006
1007 let (_rt, mut t) = table(edges);
1008 t.weights = weights.clone();
1009 t.goals = vec![4];
1010 let start = t.state(1);
1011 let cheap = dijkstra_route(&mut t, start)
1012 .expect("no fault")
1013 .expect("a goal");
1014 assert_eq!(values(&t, &cheap.states), vec![1, 2, 3, 4]);
1015 assert_eq!(cheap.cost, 3);
1016
1017 let (_rt2, mut t2) = table(edges);
1018 t2.weights = weights;
1019 t2.goals = vec![4];
1020 let start2 = t2.state(1);
1021 let short = bfs_route(&mut t2, start2)
1022 .expect("no fault")
1023 .expect("a goal");
1024 assert_eq!(values(&t2, &short.states), vec![1, 4]);
1025 assert_eq!(short.cost, 1, "one edge, whatever it costs");
1026 }
1027
1028 /// A state first queued expensively and then relaxed to a lower cost ends
1029 /// up with the **cheap** route's parent. `4` is reached from `1` at 10 and
1030 /// then from `3` at 3; the route has to go through `3`, which is only true
1031 /// if the improving push overwrote the first one's parent.
1032 #[test]
1033 fn a_relaxed_state_keeps_the_cheap_routes_parent() {
1034 let (_rt, mut t) = table(&[(1, &[4, 2]), (2, &[3]), (3, &[4]), (4, &[])]);
1035 t.weights = vec![((1, 4), 10), ((1, 2), 1), ((2, 3), 1), ((3, 4), 1)];
1036 t.goals = vec![4];
1037 let start = t.state(1);
1038 let route = dijkstra_route(&mut t, start)
1039 .expect("no fault")
1040 .expect("a goal");
1041 assert_eq!(values(&t, &route.states), vec![1, 2, 3, 4]);
1042 assert_eq!(route.cost, 3);
1043 }
1044
1045 /// A depth-first route is the one the descent found, **not** a shortest
1046 /// one. `1 -> 4` is one edge, and depth-first descends into `2` first and
1047 /// arrives at `4` three edges later — which is the honest reason
1048 /// `dfs_path` exists beside `bfs_path` rather than being the same helper.
1049 #[test]
1050 fn a_depth_first_route_need_not_be_a_short_one() {
1051 let edges: &[(i64, &[i64])] = &[(1, &[2, 4]), (2, &[3]), (3, &[4]), (4, &[])];
1052
1053 let (_rt, mut t) = table(edges);
1054 t.goals = vec![4];
1055 let start = t.state(1);
1056 let deep = dfs_route(&mut t, start).expect("no fault").expect("a goal");
1057 assert_eq!(values(&t, &deep.states), vec![1, 2, 3, 4]);
1058 assert_eq!(deep.cost, 3);
1059
1060 let (_rt2, mut t2) = table(edges);
1061 t2.goals = vec![4];
1062 let start2 = t2.state(1);
1063 let wide = bfs_route(&mut t2, start2)
1064 .expect("no fault")
1065 .expect("a goal");
1066 assert_eq!(values(&t2, &wide.states), vec![1, 4]);
1067 assert!(wide.cost < deep.cost);
1068 }
1069
1070 /// The refusals are the search's, not the wrapper's: a negative weight and
1071 /// a negative heuristic fault the route-answering forms too, because there
1072 /// is one search behind both the number and the route.
1073 #[test]
1074 fn a_route_refuses_the_graphs_a_cost_refuses() {
1075 let (_rt, mut t) = table(&[(1, &[2]), (2, &[])]);
1076 t.weights = vec![((1, 2), -1)];
1077 t.goals = vec![2];
1078 let start = t.state(1);
1079 assert_eq!(dijkstra_route(&mut t, start), Err(Aborted));
1080 assert_eq!(t.raised, Some(FaultKind::NoAnswer));
1081
1082 let (_rt2, mut t2) = table(&[(1, &[2]), (2, &[3]), (3, &[])]);
1083 t2.weights = vec![((1, 2), i64::MAX), ((2, 3), 1)];
1084 t2.goals = vec![99];
1085 let start2 = t2.state(1);
1086 assert_eq!(dijkstra_route(&mut t2, start2), Err(Aborted));
1087 assert_eq!(t2.raised, Some(FaultKind::IntOverflow));
1088
1089 let (_rt3, mut t3) = table(&[(1, &[2]), (2, &[])]);
1090 t3.goals = vec![2];
1091 t3.heuristics = vec![(1, -5)];
1092 let start3 = t3.state(1);
1093 assert_eq!(a_star_route(&mut t3, start3), Err(Aborted));
1094 assert_eq!(t3.raised, Some(FaultKind::NoAnswer));
1095 }
1096
1097 /// Every state on a route was handed to `retain` first. The parent table is
1098 /// a Rust structure like the visited set, and a state reachable only
1099 /// through it is one the collector would otherwise reclaim while the walk
1100 /// is still going to answer with it.
1101 #[test]
1102 fn every_state_on_a_route_was_retained_first() {
1103 let (_rt, mut t) = table(&[(1, &[2, 3]), (2, &[4]), (3, &[]), (4, &[])]);
1104 t.goals = vec![4];
1105 let start = t.state(1);
1106 let route = bfs_route(&mut t, start).expect("no fault").expect("a goal");
1107 for state in &route.states {
1108 assert!(
1109 t.retained.contains(&t.value(*state)),
1110 "a state on the route was never retained"
1111 );
1112 }
1113 }
1114}