Skip to main content

kernel/
blockage.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Red, and how far it reaches.
7//! The part `crates/kernel/spec/Blockage.lean` specifies this module.
8//!
9//! **Red is not a new mechanism.** Every source of it is a fact the city
10//! already records — a run that froze without citing anything, a door
11//! that has waited too long for a person, `crate::stall` finding a run
12//! going nowhere, or a resident saying in as many words that it is
13//! stuck. This module answers the one question those facts do not: given
14//! that `2.3.1` is red, what else in the plan cannot move?
15//!
16//! **The answer names the source, not the symptom.** A plan with one
17//! real problem produces one blockage that says which node it is and
18//! which nodes are waiting behind it, rather than seventeen red dots a
19//! person has to trace back by hand. That is the whole reason to
20//! propagate at all: the first screen has room for a cause, not for a
21//! list of consequences.
22//!
23//! Red travels two ways, and both are the same relation seen from two
24//! sides: **up the tree**, because a branch whose child is stuck is
25//! stuck, and **along the dependency edges**, because a node that waits
26//! for a red node waits for something that is not coming. Nothing here
27//! reads a clock or a disk; the caller supplies the facts and gets a
28//! verdict.
29
30use std::collections::{BTreeMap, BTreeSet};
31
32use crate::node_id::NodeId;
33use crate::plan::{PlanTree, StopCause};
34
35/// One node the city has a reason to call red.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct RedNode {
38    pub at: NodeId,
39    pub why: StopCause,
40}
41
42/// One cause and everything waiting behind it.
43///
44/// `reaches` never contains `source`: a reader draws the source once,
45/// and a list that repeated it would make a single problem look like
46/// two.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Blockage {
49    pub source: NodeId,
50    pub why: StopCause,
51    /// Every node stuck because of this one, in reading order.
52    pub reaches: Vec<NodeId>,
53}
54
55impl Blockage {
56    /// The sentence the first screen shows: what is stuck, where, and
57    /// why. One clause each, in the order a reader needs them.
58    #[must_use]
59    pub fn line(&self) -> String {
60        let branch = self
61            .source
62            .ancestors()
63            .last()
64            .map_or_else(|| self.source.to_string(), |top| top.to_string());
65        format!(
66            "branch {branch} is stuck at {}: {}",
67            self.source,
68            self.why.line()
69        )
70    }
71}
72
73/// Somebody to tell, and what about.
74///
75/// The city already has a way for one resident to reach another; what it
76/// lacked was a fact that triggers one without a person asking. A node
77/// going red is that fact.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Notice {
80    /// Who holds a node that is now waiting for a red one.
81    pub to: String,
82    pub about: NodeId,
83    pub line: String,
84}
85
86/// Which nodes each red one reaches.
87///
88/// Reported per cause, sorted by the cause's own index, and a node
89/// already red on its own account is never listed as somebody else's
90/// consequence — it has its own entry, and a reader following the list
91/// downwards meets each problem once.
92#[must_use]
93pub fn spread(tree: &PlanTree, red: &[RedNode]) -> Vec<Blockage> {
94    let sources: BTreeSet<NodeId> = red.iter().map(|held| held.at.clone()).collect();
95    let dependents = reverse_edges(tree);
96    let mut found: BTreeMap<NodeId, Blockage> = BTreeMap::new();
97    for held in red {
98        if tree.get(&held.at).is_none() {
99            continue;
100        }
101        let mut reaches: BTreeSet<NodeId> = BTreeSet::new();
102        walk(&held.at, &dependents, &mut reaches);
103        reaches.remove(&held.at);
104        for other in &sources {
105            reaches.remove(other);
106        }
107        found.insert(
108            held.at.clone(),
109            Blockage {
110                source: held.at.clone(),
111                why: held.why.clone(),
112                reaches: reaches.into_iter().collect(),
113            },
114        );
115    }
116    found.into_values().collect()
117}
118
119/// One line for each resident holding work behind a red node.
120///
121/// A resident is told once per blockage rather than once per node it
122/// holds: a mailbox with four copies of one problem is a mailbox nobody
123/// reads. The resident that caused the blockage is not told about its
124/// own — it is the one that reported it.
125#[must_use]
126pub fn notices(blocked: &[Blockage], holders: &BTreeMap<NodeId, String>) -> Vec<Notice> {
127    let mut out = Vec::new();
128    for blockage in blocked {
129        let source_holder = holders.get(&blockage.source);
130        let mut told: BTreeSet<&String> = BTreeSet::new();
131        for node in &blockage.reaches {
132            let Some(who) = holders.get(node) else {
133                continue;
134            };
135            if Some(who) == source_holder || !told.insert(who) {
136                continue;
137            }
138            out.push(Notice {
139                to: who.clone(),
140                about: blockage.source.clone(),
141                line: blockage.line(),
142            });
143        }
144    }
145    out
146}
147
148/// Everything downstream of one node, through both relations.
149///
150/// Bounded by the size of the plan: `seen` grows by one every time the
151/// walk goes further, and stops when it does not.
152fn walk(from: &NodeId, dependents: &BTreeMap<NodeId, Vec<NodeId>>, seen: &mut BTreeSet<NodeId>) {
153    if !seen.insert(from.clone()) {
154        return;
155    }
156    for parent in from.ancestors() {
157        walk(&parent, dependents, seen);
158    }
159    for waiting in dependents.get(from).into_iter().flatten() {
160        walk(waiting, dependents, seen);
161    }
162}
163
164/// Who waits for whom, the other way round.
165///
166/// A node's own `needs` plus the `needs` of every branch above it: a
167/// child waits for what its branch waits for, which is the same rule
168/// `PlanTree::ready` applies, read backwards.
169fn reverse_edges(tree: &PlanTree) -> BTreeMap<NodeId, Vec<NodeId>> {
170    let mut edges: BTreeMap<NodeId, Vec<NodeId>> = BTreeMap::new();
171    for node in tree.nodes() {
172        for need in tree.needs_of(&node.row.id) {
173            edges.entry(need).or_default().push(node.row.id.clone());
174        }
175    }
176    edges
177}
178
179#[cfg(test)]
180#[allow(
181    clippy::unwrap_used,
182    clippy::expect_used,
183    clippy::panic,
184    clippy::indexing_slicing,
185    reason = "test code"
186)]
187mod tests {
188    use super::*;
189    use crate::spine::{RoadmapShape, check_roadmap_shape};
190
191    fn tree(text: &str) -> PlanTree {
192        let RoadmapShape::WellFormed { rows } = check_roadmap_shape(text) else {
193            panic!("the fixture parses");
194        };
195        PlanTree::build(rows).expect("the fixture is a tree")
196    }
197
198    fn node(raw: &str) -> NodeId {
199        NodeId::parse(raw).unwrap()
200    }
201
202    fn stuck(at: &str) -> RedNode {
203        RedNode {
204            at: node(at),
205            why: StopCause::Blocked {
206                note: "the kiln is cold".to_owned(),
207            },
208        }
209    }
210
211    const PLAN: &str = "\
212| # | Item | Weight | Needs | Status | Evidence |
213|---|------|--------|-------|--------|----------|
214| 1 | groundwork | 1 |  | Not started |  |
215| 2 | build | 1 |  | Not started |  |
216| 2.1 | frame | 1 | 1 | Not started |  |
217| 2.2 | roof | 1 | 2.1 | Not started |  |
218| 2.3 | door | 1 | 2.2 | Not started |  |
219| 3 | ship | 1 | 2 | Not started |  |
220";
221
222    #[test]
223    fn red_climbs_the_branch_and_runs_down_the_dependencies() {
224        let blocked = spread(&tree(PLAN), &[stuck("2.1")]);
225        assert_eq!(blocked.len(), 1);
226        let reached: Vec<&str> = blocked[0].reaches.iter().map(NodeId::as_str).collect();
227        assert_eq!(
228            reached,
229            ["2", "2.2", "2.3", "3"],
230            "the branch above, what waits for it, and what waits for those"
231        );
232        assert!(!blocked[0].reaches.contains(&node("1")), "1 is upstream");
233        assert!(!blocked[0].reaches.contains(&node("2.1")), "not itself");
234    }
235
236    /// The reason this module exists: one problem is one line naming the
237    /// source, not four red dots a person has to trace.
238    #[test]
239    fn the_line_names_the_branch_and_the_node_it_is_stuck_at() {
240        let blocked = spread(&tree(PLAN), &[stuck("2.1")]);
241        assert_eq!(
242            blocked[0].line(),
243            "branch 2 is stuck at 2.1: the kiln is cold"
244        );
245    }
246
247    #[test]
248    fn a_node_red_on_its_own_account_is_not_listed_as_somebody_elses_consequence() {
249        let blocked = spread(&tree(PLAN), &[stuck("2.1"), stuck("2.3")]);
250        assert_eq!(blocked.len(), 2);
251        assert!(
252            !blocked[0].reaches.contains(&node("2.3")),
253            "2.3 has its own entry"
254        );
255        assert_eq!(blocked[0].source, node("2.1"));
256        assert_eq!(blocked[1].source, node("2.3"));
257    }
258
259    #[test]
260    fn a_red_node_the_plan_does_not_carry_is_dropped_rather_than_invented() {
261        assert!(spread(&tree(PLAN), &[stuck("9.9")]).is_empty());
262    }
263
264    #[test]
265    fn everyone_waiting_behind_it_is_told_once_and_the_reporter_is_not() {
266        let blocked = spread(&tree(PLAN), &[stuck("2.1")]);
267        let mut holders = BTreeMap::new();
268        holders.insert(node("2.1"), "mason@yard.1".to_owned());
269        holders.insert(node("2.2"), "roofer@yard.1".to_owned());
270        holders.insert(node("2.3"), "roofer@yard.1".to_owned());
271        holders.insert(node("3"), "carter@yard.1".to_owned());
272        let told = notices(&blocked, &holders);
273        let who: Vec<&str> = told.iter().map(|notice| notice.to.as_str()).collect();
274        assert_eq!(
275            who,
276            ["roofer@yard.1", "carter@yard.1"],
277            "the roofer holds two of them and hears once; the mason reported it"
278        );
279        assert_eq!(told[0].about, node("2.1"));
280    }
281
282    #[test]
283    fn nothing_red_tells_nobody() {
284        let blocked = spread(&tree(PLAN), &[]);
285        assert!(blocked.is_empty());
286        assert!(notices(&blocked, &BTreeMap::new()).is_empty());
287    }
288}