Skip to main content

kernel/plan/
tree.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//! The plan as a tree: placement, division, queries, claims, and progress.
7//! The part `crates/kernel/spec/Plan.lean` specifies this module with the
8//! rest of `kernel::plan`.
9
10use std::collections::{BTreeMap, BTreeSet};
11
12use crate::completion::{PlannedProgress, Progress};
13use crate::error::{AxCode, AxError};
14use crate::node_id::NodeId;
15use crate::share::{self, Share};
16use crate::spine::{EvidenceCell, RoadmapRow, RoadmapStatus};
17
18use super::PlanTree;
19use super::node::{Held, PlanNode};
20
21impl PlanTree {
22    /// Places every row and works out what each one is worth.
23    ///
24    /// # Errors
25    /// Fail-closed, in the same shape as `crate::locator`: a plan that
26    /// cannot be read is refused rather than guessed at, because every
27    /// progress figure in the city is divided by it. Five refusals — a
28    /// repeated index, a node whose parent is missing, a dependency on a
29    /// node that is not there, a dependency in a circle, and a branch
30    /// marked done over a child that is not.
31    pub fn build(rows: Vec<RoadmapRow>) -> Result<PlanTree, AxError> {
32        let mut nodes: BTreeMap<NodeId, PlanNode> = BTreeMap::new();
33        for row in rows {
34            let id = row.id.clone();
35            if nodes.contains_key(&id) {
36                return Err(super::blocking::refusal(
37                    "place a plan node",
38                    format!("index {id} appears twice"),
39                    "give every row its own index; two rows with one number are two plans",
40                ));
41            }
42            nodes.insert(
43                id,
44                PlanNode {
45                    row,
46                    share: Share::NONE,
47                    children: Vec::new(),
48                },
49            );
50        }
51        let placed: Vec<NodeId> = nodes.keys().cloned().collect();
52        for id in &placed {
53            if let Some(parent) = id.parent() {
54                let Some(held) = nodes.get_mut(&parent) else {
55                    return Err(super::blocking::refusal(
56                        "place a plan node",
57                        format!("{id} hangs under {parent}, which the table does not carry"),
58                        "write the parent row first; a plan cannot branch from nothing",
59                    ));
60                };
61                held.children.push(id.clone());
62            }
63        }
64        Self::check_dependencies(&nodes)?;
65        Self::check_branch_claims(&nodes)?;
66        let mut tree = PlanTree { nodes };
67        tree.divide()?;
68        Ok(tree)
69    }
70
71    /// Every dependency names a node that exists, is not the node
72    /// itself, and does not close a circle.
73    fn check_dependencies(nodes: &BTreeMap<NodeId, PlanNode>) -> Result<(), AxError> {
74        for (id, node) in nodes {
75            for need in &node.row.needs {
76                if !nodes.contains_key(need) {
77                    return Err(super::blocking::refusal(
78                        "read a plan dependency",
79                        format!("{id} needs {need}, which the table does not carry"),
80                        "name a row the plan holds, or drop the dependency",
81                    ));
82                }
83                if need == id {
84                    return Err(super::blocking::refusal(
85                        "read a plan dependency",
86                        format!("{id} needs itself"),
87                        "a node cannot wait for its own result",
88                    ));
89                }
90            }
91        }
92        if let Some(circle) = super::blocking::first_cycle(nodes) {
93            let drawn: Vec<String> = circle.iter().map(NodeId::to_string).collect();
94            return Err(super::blocking::refusal(
95                "read a plan dependency",
96                format!("{} runs in a circle", drawn.join(" → ")),
97                "drop one of those dependencies; nothing in a circle can ever start",
98            ));
99        }
100        Ok(())
101    }
102
103    /// A branch that says `Done` over a child that is not is refused,
104    /// which is what lets the status column of a branch be read as a
105    /// summary instead of as a second opinion.
106    fn check_branch_claims(nodes: &BTreeMap<NodeId, PlanNode>) -> Result<(), AxError> {
107        for (id, node) in nodes {
108            if node.children.is_empty() || node.row.status != RoadmapStatus::Done {
109                continue;
110            }
111            for child in &node.children {
112                let unfinished = nodes
113                    .get(child)
114                    .is_some_and(|held| held.row.status != RoadmapStatus::Done);
115                if unfinished {
116                    return Err(super::blocking::refusal(
117                        "read a plan branch",
118                        format!("{id} says done while {child} does not"),
119                        "a branch is done when its children are; finish the child or reopen the \
120                         branch",
121                    ));
122                }
123            }
124        }
125        Ok(())
126    }
127
128    /// Divides the whole plan down the tree, top-level rows first.
129    fn divide(&mut self) -> Result<(), AxError> {
130        let roots: Vec<NodeId> = self
131            .nodes
132            .keys()
133            .filter(|id| id.parent().is_none())
134            .cloned()
135            .collect();
136        super::share::hand_out(self, Share::WHOLE, &roots)?;
137        Ok(())
138    }
139
140    #[must_use]
141    pub fn get(&self, id: &NodeId) -> Option<&PlanNode> {
142        self.nodes.get(id)
143    }
144
145    /// Every node, in table order.
146    pub fn nodes(&self) -> impl Iterator<Item = &PlanNode> {
147        self.nodes.values()
148    }
149
150    #[must_use]
151    pub fn len(&self) -> usize {
152        self.nodes.len()
153    }
154
155    #[must_use]
156    pub fn is_empty(&self) -> bool {
157        self.nodes.is_empty()
158    }
159
160    /// What a node waits for, including everything its ancestors wait
161    /// for. A child of a branch that is waiting is waiting too;
162    /// answering otherwise would let `2.3.1` start while the reason
163    /// `2.3` cannot start is still unresolved.
164    #[must_use]
165    pub fn needs_of(&self, id: &NodeId) -> BTreeSet<NodeId> {
166        let mut found = BTreeSet::new();
167        for step in id
168            .ancestors()
169            .into_iter()
170            .chain(std::iter::once(id.clone()))
171        {
172            if let Some(node) = self.nodes.get(&step) {
173                found.extend(node.row.needs.iter().cloned());
174            }
175        }
176        found
177    }
178
179    /// What may be started right now: leaves nobody has taken, whose
180    /// dependencies — their own and their branch's — are all done.
181    ///
182    /// Pure, and that is the point: a city with no ready nodes and
183    /// nothing running has finished, and a stop condition that depended
184    /// on I/O could not be trusted to say so.
185    #[must_use]
186    pub fn ready(&self) -> Vec<NodeId> {
187        self.nodes
188            .values()
189            .filter(|node| node.is_leaf() && node.row.status == RoadmapStatus::NotStarted)
190            .filter(|node| {
191                self.needs_of(&node.row.id).iter().all(|need| {
192                    self.nodes
193                        .get(need)
194                        .is_some_and(|held| held.row.status == RoadmapStatus::Done)
195                })
196            })
197            .map(|node| node.row.id.clone())
198            .collect()
199    }
200
201    /// Takes a node to work on.
202    ///
203    /// # Errors
204    /// Refuses everything [`Self::ready`] leaves out, and says which of
205    /// the three reasons applies: the index is not in the table, the
206    /// node is a branch or already taken, or it is still waiting on
207    /// something. The third names what it waits for, because "no" sends
208    /// a model round the loop again while "1.4 is not done" does not.
209    pub fn claim(&self, id: &NodeId) -> Result<Held, AxError> {
210        let node = self.nodes.get(id).ok_or_else(|| {
211            super::blocking::refusal(
212                "claim a plan node",
213                format!("no row numbered {id}"),
214                format!("list the plan first; it carries {} rows", self.nodes.len()),
215            )
216        })?;
217        if !node.is_leaf() {
218            return Err(super::blocking::refusal(
219                "claim a plan node",
220                format!("{id} is a branch with {} children", node.children.len()),
221                "claim one of its children; a branch is done when its children are",
222            ));
223        }
224        if node.row.status != RoadmapStatus::NotStarted {
225            // Two runs wanting one node is a goal conflict, and the
226            // caller reads the code rather than the sentence.
227            return Err(AxError::failure(
228                AxCode::GoalConflict,
229                "claim a plan node",
230                format!("{id} is `{}`", node.row.status.spelling()),
231            )
232            .with_recovery(self.somewhere_else()));
233        }
234        let waiting: Vec<String> = self
235            .needs_of(id)
236            .into_iter()
237            .filter(|need| {
238                self.nodes
239                    .get(need)
240                    .is_none_or(|held| held.row.status != RoadmapStatus::Done)
241            })
242            .map(|need| need.to_string())
243            .collect();
244        if !waiting.is_empty() {
245            return Err(super::blocking::refusal(
246                "claim a plan node",
247                format!("{id} waits for {}", waiting.join(", ")),
248                self.somewhere_else(),
249            ));
250        }
251        Ok(Held::of(id.clone()))
252    }
253
254    /// The third part of a refusal: a node the caller may actually take.
255    fn somewhere_else(&self) -> String {
256        self.ready().first().map_or_else(
257            || "nothing is ready; report to the User rather than picking a node".to_owned(),
258            |id| format!("claim {id}, which is ready"),
259        )
260    }
261
262    /// The plan's own reading of itself: leaves counted, and the share
263    /// of the whole those leaves carry.
264    ///
265    /// Both figures travel because they answer different questions and
266    /// mislead alone. A share says how much of the plan is behind you; a
267    /// leaf count says how many pieces the plan turned out to have, and
268    /// it is the figure that does not move when somebody divides their
269    /// own branch generously.
270    #[must_use]
271    pub fn progress(&self) -> Progress {
272        let mut done: u32 = 0;
273        let mut blocked: u32 = 0;
274        let mut total: u32 = 0;
275        let mut done_parts = Vec::new();
276        let mut blocked_parts = Vec::new();
277        for node in self.nodes.values() {
278            if !node.is_leaf() {
279                continue;
280            }
281            total = total.saturating_add(1);
282            match (&node.row.status, &node.row.evidence) {
283                (RoadmapStatus::Done, EvidenceCell::Present(_)) => {
284                    done = done.saturating_add(1);
285                    done_parts.push(node.share);
286                }
287                (RoadmapStatus::Blocked | RoadmapStatus::AwaitingApproval, _) => {
288                    blocked = blocked.saturating_add(1);
289                    blocked_parts.push(node.share);
290                }
291                _ => {}
292            }
293        }
294        Progress::Planned(PlannedProgress {
295            done,
296            blocked,
297            total,
298            done_ppb: share::gather(&done_parts).ppb(),
299            blocked_ppb: share::gather(&blocked_parts).ppb(),
300        })
301    }
302}
303
304#[cfg(test)]
305#[allow(
306    clippy::unwrap_used,
307    clippy::expect_used,
308    clippy::panic,
309    clippy::indexing_slicing,
310    clippy::arithmetic_side_effects,
311    reason = "test code"
312)]
313mod tests;