Skip to main content

layover_core/
graph.rs

1//! The route map as a directed graph.
2//!
3//! The graph answers two questions the Tower asks constantly: whether an edge is permitted, and
4//! which agents could still be reached from a given set of live runs. The second is what allows
5//! an unreachable rendezvous barrier to be abandoned rather than parked forever.
6
7use std::collections::{BTreeMap, BTreeSet, VecDeque};
8
9use crate::agent::AgentName;
10use crate::config::Config;
11use crate::route::Join;
12
13/// The rendezvous condition attached to a receiving agent.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct JoinSpec {
16    /// Agents whose flights are parked until the condition is met.
17    pub upstreams: BTreeSet<AgentName>,
18    /// The release condition.
19    pub join: Join,
20    /// Backstop for a barrier that never becomes unreachable but also never completes.
21    pub timeout_sec: Option<u64>,
22}
23
24/// The route map, indexed for traversal.
25#[derive(Debug, Clone, Default)]
26pub struct RouteGraph {
27    edges: BTreeMap<AgentName, BTreeSet<AgentName>>,
28    /// Edges that open a new itinerary rather than continuing the current one.
29    ///
30    /// Kept apart from `edges` because a spawn is a permission like any other but a *distance* of
31    /// a different kind: the receiver starts a fresh chain with fresh Hops, so counting a spawn as
32    /// one more step of the same chain measures something that does not exist.
33    spawns: BTreeMap<AgentName, BTreeSet<AgentName>>,
34    joins: BTreeMap<AgentName, JoinSpec>,
35}
36
37impl RouteGraph {
38    /// Builds a graph from a factory definition.
39    ///
40    /// Routes that name several senders and several receivers expand to the full cross product,
41    /// which is what makes `from = ["a", "b"]` with `to = "c"` a rendezvous and `from = "a"` with
42    /// `to = ["b", "c"]` a fan-out.
43    #[must_use]
44    pub fn from_config(config: &Config) -> Self {
45        let mut graph = Self::default();
46
47        for route in &config.routes {
48            for from in &route.from {
49                for to in &route.to {
50                    graph
51                        .edges
52                        .entry(from.clone())
53                        .or_default()
54                        .insert(to.clone());
55
56                    if route.is_spawn() {
57                        graph
58                            .spawns
59                            .entry(from.clone())
60                            .or_default()
61                            .insert(to.clone());
62                    }
63                }
64            }
65
66            if let Some(join) = route.join {
67                for to in &route.to {
68                    graph.joins.insert(
69                        to.clone(),
70                        JoinSpec {
71                            upstreams: route.from.iter().cloned().collect(),
72                            join,
73                            timeout_sec: route.timeout_sec,
74                        },
75                    );
76                }
77            }
78        }
79
80        graph
81    }
82
83    /// Returns `true` when this edge opens a new itinerary rather than continuing one.
84    #[must_use]
85    pub fn is_spawn(&self, from: &AgentName, to: &AgentName) -> bool {
86        self.spawns.get(from).is_some_and(|tos| tos.contains(to))
87    }
88
89    /// Returns `true` if `from` is permitted to send to `to`.
90    #[must_use]
91    pub fn permits(&self, from: &AgentName, to: &AgentName) -> bool {
92        self.edges.get(from).is_some_and(|tos| tos.contains(to))
93    }
94
95    /// Returns the agents `from` may send to.
96    pub fn successors(&self, from: &AgentName) -> impl Iterator<Item = &AgentName> {
97        self.edges.get(from).into_iter().flatten()
98    }
99
100    /// Every agent reached by a spawn edge.
101    ///
102    /// These begin chains of their own, so for any question about hop depth they are entry points
103    /// rather than destinations.
104    pub fn spawn_targets(&self) -> impl Iterator<Item = &AgentName> {
105        self.spawns.values().flatten()
106    }
107
108    /// Every agent belonging to the workflow that starts at `entry`.
109    ///
110    /// Unlike [`RouteGraph::reachable_from`] this **does** cross spawn edges. The two questions
111    /// are different: reachability asks what could still deliver into *this* itinerary, and a
112    /// spawned chain never can. Workflow membership asks what this way in sets in motion, and a
113    /// reviewer spawned by a sweep is unarguably part of the sweep.
114    ///
115    /// Agents shared between workflows appear in both, which is the honest answer — the
116    /// developer really is in the triage pipeline and the follow-up pipeline.
117    #[must_use]
118    pub fn workflow_from(&self, entry: &AgentName) -> BTreeSet<AgentName> {
119        let mut seen: BTreeSet<AgentName> = BTreeSet::new();
120        let mut queue = VecDeque::from([entry.clone()]);
121        seen.insert(entry.clone());
122
123        while let Some(current) = queue.pop_front() {
124            for next in self.successors(&current) {
125                if seen.insert(next.clone()) {
126                    queue.push_back(next.clone());
127                }
128            }
129        }
130
131        seen
132    }
133
134    /// Every spawn edge, as a sender/receiver pair.
135    pub fn spawn_edges(&self) -> impl Iterator<Item = (&AgentName, &AgentName)> {
136        self.spawns
137            .iter()
138            .flat_map(|(from, tos)| tos.iter().map(move |to| (from, to)))
139    }
140
141    /// Returns the rendezvous condition guarding `agent`, if any.
142    #[must_use]
143    pub fn join_for(&self, agent: &AgentName) -> Option<&JoinSpec> {
144        self.joins.get(agent)
145    }
146
147    /// Returns every agent reachable from `sources`, including the sources themselves.
148    ///
149    /// Hops are deliberately ignored, which makes the answer conservative: an agent that Hops
150    /// would actually prevent from being reached is still reported as reachable. A barrier is
151    /// therefore never abandoned prematurely, and `timeout_sec` remains the backstop.
152    pub fn reachable_from<'a>(
153        &self,
154        sources: impl IntoIterator<Item = &'a AgentName>,
155    ) -> BTreeSet<AgentName> {
156        self.distances_from(sources).into_keys().collect()
157    }
158
159    /// Returns every agent reachable from `sources`, each with its distance in edges.
160    ///
161    /// The sources sit at distance zero. An agent at distance `d` is therefore woken by flight
162    /// `d + 1` of a chain that began at a source, which is what makes the figure directly
163    /// comparable against `max_hops` — see [`mod@crate::validate`].
164    ///
165    /// Shortest paths are an optimistic bound. A factory whose agents loop will spend far more
166    /// hops than the distance suggests, so this proves an agent *can* be reached, never that a
167    /// particular itinerary will get there.
168    pub fn distances_from<'a>(
169        &self,
170        sources: impl IntoIterator<Item = &'a AgentName>,
171    ) -> BTreeMap<AgentName, u32> {
172        let mut seen: BTreeMap<AgentName, u32> = BTreeMap::new();
173        let mut queue: VecDeque<AgentName> = VecDeque::new();
174
175        for source in sources {
176            if !seen.contains_key(source) {
177                seen.insert(source.clone(), 0);
178                queue.push_back(source.clone());
179            }
180        }
181
182        while let Some(current) = queue.pop_front() {
183            let depth = seen[&current];
184            for next in self.successors(&current) {
185                // A spawn edge is a permission, not a step. The receiver starts a fresh itinerary
186                // with fresh Hops, so counting it as one more hop of this chain measures a
187                // distance that does not exist — and for barrier abandonment it is worse than
188                // wrong: a spawned agent runs under a different itinerary and can never deliver
189                // to this one's barrier, so treating it as reachable keeps a dead barrier parked.
190                if self.is_spawn(&current, next) {
191                    continue;
192                }
193                if !seen.contains_key(next) {
194                    seen.insert(next.clone(), depth + 1);
195                    queue.push_back(next.clone());
196                }
197            }
198        }
199
200        seen
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    fn graph_from(toml: &str) -> RouteGraph {
209        let config = Config::from_toml(toml, "test.toml").expect("config parses");
210        RouteGraph::from_config(&config)
211    }
212
213    #[test]
214    fn edges_are_directed() {
215        let graph = graph_from(
216            r#"
217            [[routes]]
218            from = "planner"
219            to = "coder"
220            "#,
221        );
222
223        assert!(graph.permits(&"planner".into(), &"coder".into()));
224        assert!(!graph.permits(&"coder".into(), &"planner".into()));
225    }
226
227    #[test]
228    fn fan_out_expands_to_one_edge_per_target() {
229        let graph = graph_from(
230            r#"
231            [[routes]]
232            from = "planner"
233            to = ["probe_a", "probe_b"]
234            "#,
235        );
236
237        assert!(graph.permits(&"planner".into(), &"probe_a".into()));
238        assert!(graph.permits(&"planner".into(), &"probe_b".into()));
239        assert!(graph.join_for(&"probe_a".into()).is_none());
240    }
241
242    #[test]
243    fn join_route_records_every_upstream() {
244        let graph = graph_from(
245            r#"
246            [[routes]]
247            from = ["probe_a", "probe_b"]
248            to = "collector"
249            join = "all"
250            timeout_sec = 60
251            "#,
252        );
253
254        let spec = graph.join_for(&"collector".into()).expect("join recorded");
255        assert_eq!(spec.join, Join::All);
256        assert_eq!(spec.timeout_sec, Some(60));
257        assert!(spec.upstreams.contains(&"probe_a".into()));
258        assert!(spec.upstreams.contains(&"probe_b".into()));
259    }
260
261    #[test]
262    fn reachability_follows_edges_transitively() {
263        let graph = graph_from(
264            r#"
265            [[routes]]
266            from = "planner"
267            to = "probe_a"
268
269            [[routes]]
270            from = "probe_a"
271            to = "collector"
272
273            [[routes]]
274            from = "orphan"
275            to = "elsewhere"
276            "#,
277        );
278
279        let reachable = graph.reachable_from([&AgentName::from("planner")]);
280
281        assert!(reachable.contains(&"planner".into()));
282        assert!(reachable.contains(&"collector".into()));
283        assert!(!reachable.contains(&"orphan".into()));
284    }
285
286    #[test]
287    fn reachability_terminates_on_cycles() {
288        let graph = graph_from(
289            r#"
290            [[routes]]
291            from = "a"
292            to = "b"
293
294            [[routes]]
295            from = "b"
296            to = "a"
297            "#,
298        );
299
300        let reachable = graph.reachable_from([&AgentName::from("a")]);
301        assert_eq!(reachable.len(), 2);
302    }
303
304    #[test]
305    fn distance_counts_edges_from_the_source() {
306        let graph = graph_from(
307            r#"
308            [[routes]]
309            from = "planner"
310            to = "probe_a"
311
312            [[routes]]
313            from = "probe_a"
314            to = "collector"
315            "#,
316        );
317
318        let distances = graph.distances_from([&AgentName::from("planner")]);
319
320        assert_eq!(distances[&AgentName::from("planner")], 0);
321        assert_eq!(distances[&AgentName::from("probe_a")], 1);
322        assert_eq!(distances[&AgentName::from("collector")], 2);
323    }
324
325    #[test]
326    fn distance_is_the_shortest_path_not_the_longest() {
327        // `collector` is two edges away the long way round and one edge away directly. Hops are
328        // spent along whichever path an agent actually chooses, so the short answer is a bound
329        // and not a prediction.
330        let graph = graph_from(
331            r#"
332            [[routes]]
333            from = "planner"
334            to = ["probe_a", "collector"]
335
336            [[routes]]
337            from = "probe_a"
338            to = "collector"
339            "#,
340        );
341
342        let distances = graph.distances_from([&AgentName::from("planner")]);
343
344        assert_eq!(distances[&AgentName::from("collector")], 1);
345    }
346
347    #[test]
348    fn distance_omits_agents_no_edge_leads_to() {
349        let graph = graph_from(
350            r#"
351            [[routes]]
352            from = "planner"
353            to = "probe_a"
354
355            [[routes]]
356            from = "orphan"
357            to = "elsewhere"
358            "#,
359        );
360
361        let distances = graph.distances_from([&AgentName::from("planner")]);
362
363        assert!(!distances.contains_key(&AgentName::from("orphan")));
364    }
365}