Commit the previously-untracked standard directories and catalog them in the
root README alongside proof:
- aac/ — communication cargo class: portable boards + a ten-level
offline-first cascade (Apache-2.0 code / CC-BY-4.0 spec).
- map/ — geospatial cargo class: range-requestable signed tilesets +
an F0–F4 fidelity cascade (Apache-2.0 code / CC-BY-4.0 spec).
- sandbox/— isolated, metered, attested execution across the heterogeneous
compute fabric (Apache-2.0 code / CC-BY-4.0 spec).
- jouleclaw-otel-authz, jouleclaw-trust — new jouleclaw reference crates.
Root README now lists aac, map, sandbox, and proof in the catalog, the tree,
and the contribution guide.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
162 lines
5.5 KiB
Rust
162 lines
5.5 KiB
Rust
//! Isochrones — the region reachable from a source within a cost budget.
|
|
//!
|
|
//! A bounded Dijkstra expansion over the [`RouteGraph`]: accumulate the least
|
|
//! cost (travel time or distance) to each node, pruning anything beyond the
|
|
//! budget. The result is every reachable node with its cost — the input a client
|
|
//! turns into a "what's within 15 minutes" overlay (e.g. by covering the reached
|
|
//! coordinates into cells via `map-index`).
|
|
|
|
use std::cmp::Reverse;
|
|
use std::collections::{BTreeMap, BinaryHeap};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{Coordinate, RouteGraph};
|
|
|
|
/// Which edge weight the budget is measured in.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum Metric {
|
|
/// Travel time, seconds (`edge.duration_secs`).
|
|
Duration,
|
|
/// Path length, meters (`edge.distance_m`).
|
|
Distance,
|
|
}
|
|
|
|
/// A node reached within budget, and the least cost to get there.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
|
pub struct Reach {
|
|
pub node: u64,
|
|
pub coord: Coordinate,
|
|
pub cost: f64,
|
|
}
|
|
|
|
/// The reachable set from a source within a budget.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct Isochrone {
|
|
pub source: u64,
|
|
pub metric: Metric,
|
|
pub budget: f64,
|
|
/// Reached nodes, ascending by cost (the source first, at cost 0).
|
|
pub reached: Vec<Reach>,
|
|
}
|
|
|
|
impl Isochrone {
|
|
/// The reachable coordinates (for covering into cells / drawing a hull).
|
|
pub fn coords(&self) -> Vec<Coordinate> {
|
|
self.reached.iter().map(|r| r.coord).collect()
|
|
}
|
|
/// The farthest reached cost (≤ budget), or 0 if only the source is reached.
|
|
pub fn frontier_cost(&self) -> f64 {
|
|
self.reached.last().map(|r| r.cost).unwrap_or(0.0)
|
|
}
|
|
}
|
|
|
|
/// Compute the isochrone from `source` within `budget` of `metric`.
|
|
///
|
|
/// Returns an empty `reached` (not even the source) if `source` is not in the
|
|
/// graph; otherwise the source is always included at cost 0.
|
|
pub fn isochrone(graph: &RouteGraph, source: u64, budget: f64, metric: Metric) -> Isochrone {
|
|
let mut out = Isochrone { source, metric, budget, reached: Vec::new() };
|
|
if !graph.nodes.contains_key(&source) || budget < 0.0 {
|
|
return out;
|
|
}
|
|
|
|
let weight = |e: &crate::RouteEdge| match metric {
|
|
Metric::Duration => e.duration_secs,
|
|
Metric::Distance => e.distance_m,
|
|
};
|
|
|
|
let mut best: BTreeMap<u64, f64> = BTreeMap::new();
|
|
best.insert(source, 0.0);
|
|
// Ordering key: cost scaled to an integer (ms / mm) for a min-heap.
|
|
let mut heap: BinaryHeap<Reverse<(u64, u64)>> = BinaryHeap::new();
|
|
heap.push(Reverse((0, source)));
|
|
|
|
while let Some(Reverse((cost_key, u))) = heap.pop() {
|
|
let cost_u = cost_key as f64 / 1000.0;
|
|
if let Some(&b) = best.get(&u) {
|
|
if cost_u > b + 1e-6 {
|
|
continue; // stale heap entry
|
|
}
|
|
}
|
|
if let Some(adj) = graph.adjacency.get(&u) {
|
|
for &edge_idx in adj {
|
|
let edge = &graph.edges[edge_idx];
|
|
let nd = cost_u + weight(edge);
|
|
if nd > budget + 1e-9 {
|
|
continue; // beyond the budget — prune
|
|
}
|
|
let cur = best.get(&edge.to).copied().unwrap_or(f64::INFINITY);
|
|
if nd < cur {
|
|
best.insert(edge.to, nd);
|
|
heap.push(Reverse(((nd * 1000.0) as u64, edge.to)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut reached: Vec<Reach> = best
|
|
.into_iter()
|
|
.filter_map(|(node, cost)| {
|
|
graph.nodes.get(&node).map(|n| Reach { node, coord: n.coord, cost })
|
|
})
|
|
.collect();
|
|
reached.sort_by(|a, b| a.cost.partial_cmp(&b.cost).unwrap_or(std::cmp::Ordering::Equal));
|
|
out.reached = reached;
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::{Coordinate, RoadType, RouteEdge, RouteGraph, RouteNode};
|
|
|
|
/// A chain 0→1→2→3, each edge 100 m / 60 s.
|
|
fn chain() -> RouteGraph {
|
|
let mut g = RouteGraph::new();
|
|
for id in 0..4 {
|
|
g.add_node(RouteNode { id, coord: Coordinate::new(0.0, id as f64 * 0.001) });
|
|
}
|
|
for from in 0..3u64 {
|
|
g.add_edge(RouteEdge {
|
|
from,
|
|
to: from + 1,
|
|
distance_m: 100.0,
|
|
duration_secs: 60.0,
|
|
road_type: RoadType::Residential,
|
|
});
|
|
}
|
|
g
|
|
}
|
|
|
|
#[test]
|
|
fn budget_bounds_the_reachable_set() {
|
|
let g = chain();
|
|
// 130 s budget → source + nodes 1 (60s) + 2 (120s); node 3 (180s) excluded.
|
|
let iso = isochrone(&g, 0, 130.0, Metric::Duration);
|
|
let ids: Vec<u64> = iso.reached.iter().map(|r| r.node).collect();
|
|
assert_eq!(ids, vec![0, 1, 2]);
|
|
assert_eq!(iso.reached[0].cost, 0.0); // source
|
|
assert!((iso.frontier_cost() - 120.0).abs() < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn distance_metric_and_full_reach() {
|
|
let g = chain();
|
|
let iso = isochrone(&g, 0, 1000.0, Metric::Distance);
|
|
assert_eq!(iso.reached.len(), 4); // all reachable within 1 km
|
|
assert!((iso.reached[3].cost - 300.0).abs() < 1e-6);
|
|
assert_eq!(iso.coords().len(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn source_only_and_missing_source() {
|
|
let g = chain();
|
|
// Zero budget → just the source.
|
|
let iso = isochrone(&g, 0, 0.0, Metric::Duration);
|
|
assert_eq!(iso.reached.len(), 1);
|
|
assert_eq!(iso.reached[0].node, 0);
|
|
// Missing source → empty.
|
|
assert!(isochrone(&g, 99, 1000.0, Metric::Duration).reached.is_empty());
|
|
}
|
|
}
|