1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
// Copyright (c) 2026 2youg1 and the sprawling contributors
//! A plan node's address: the dotted path that says where it hangs.
//! The part `crates/kernel/spec/NodeId.lean` specifies this module.
//!
//! **A value, and that is why it is not in `kernel::plan`.** The tree
//! decides — what may be started, what a branch is worth, which of the
//! two exits a held node takes. This only says what a well-formed
//! address is, and refuses everything else at the one construction
//! point, so nothing downstream has to ask again. `plan` is shape 1;
//! this is shape 2 (ARCHITECTURE.md section 9).
//!
//! The hand-written `Deserialize` is the load-bearing part. A derived
//! one would accept any string off the wire and hand back a `NodeId`
//! that never passed `parse`, which is the whole of what this type is
//! for.
use serde::{Deserialize, Serialize};
use crate::error::{AxCode, AxError};
/// The longest branch a plan may grow. Ten levels of `1.1.1.…` is a
/// depth no plan has ever needed and a length past which the index
/// column stops being readable; refusing it also bounds every walk in
/// this module.
pub const NODE_DEPTH_MAX: usize = 10;
/// A node's place in the plan, as the first column spells it: `2`,
/// `2.3`, `2.3.1`. The parent is the prefix, so the tree needs no second
/// field to say where a node hangs.
///
/// Ordering is the reading order of the table — `1`, `1.1`, `1.2`, `2` —
/// because segment-wise comparison is exactly that order.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct NodeId(String);
impl NodeId {
/// Reads an index cell.
///
/// # Errors
/// Refuses an empty cell, a segment that is not a number, a zero
/// segment (the table is one-based, and `0` next to `1` reads as a
/// different node than it is), a leading zero (`01` and `1` would be
/// two spellings of one node), and a branch deeper than
/// [`NODE_DEPTH_MAX`].
pub fn parse(raw: &str) -> Result<NodeId, AxError> {
let text = raw.trim();
let refuse = |why: String| {
AxError::failure(AxCode::InvalidArgs, "read a plan index", why)
.with_recovery("write the index as dotted numbers from one, such as `2.3.1`")
};
if text.is_empty() {
return Err(refuse("the cell is empty".to_owned()));
}
let segments: Vec<&str> = text.split('.').collect();
if segments.len() > NODE_DEPTH_MAX {
return Err(refuse(format!(
"{} levels deep, the plan holds {NODE_DEPTH_MAX}",
segments.len()
)));
}
for segment in &segments {
if segment.is_empty() || !segment.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(refuse(format!("`{segment}` is not a number")));
}
if segment.starts_with('0') {
return Err(refuse(format!("`{segment}` has a leading zero")));
}
if segment.parse::<u32>().is_err() {
return Err(refuse(format!("`{segment}` does not fit a plan index")));
}
}
Ok(NodeId(segments.join(".")))
}
/// The node this one hangs under, or `None` for a top-level node.
#[must_use]
pub fn parent(&self) -> Option<NodeId> {
self.0
.rsplit_once('.')
.map(|(head, _)| NodeId(head.to_owned()))
}
/// The last segment: which child of its parent this is.
#[must_use]
pub fn ordinal(&self) -> u32 {
self.0
.rsplit_once('.')
.map_or(self.0.as_str(), |(_, tail)| tail)
.parse()
.unwrap_or(0)
}
/// How many levels down this node sits; a top-level node is 1.
#[must_use]
pub fn depth(&self) -> usize {
self.0.split('.').count()
}
/// Whether `other` hangs below this node, at any depth. A node is
/// not its own ancestor.
#[must_use]
pub fn is_ancestor_of(&self, other: &NodeId) -> bool {
other.0.len() > self.0.len()
&& other.0.starts_with(&self.0)
&& other.0.as_bytes().get(self.0.len()) == Some(&b'.')
}
/// Every node between the root and this one, closest last.
#[must_use]
pub fn ancestors(&self) -> Vec<NodeId> {
let mut found = Vec::new();
let mut walking = self.parent();
while let Some(held) = walking {
walking = held.parent();
found.push(held);
}
found.reverse();
found
}
/// This node's `n`-th child.
///
/// # Errors
/// Refuses an ordinal of zero and a child past [`NODE_DEPTH_MAX`].
pub fn child(&self, ordinal: u32) -> Result<NodeId, AxError> {
NodeId::parse(&format!("{}.{ordinal}", self.0))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for NodeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl<'de> Deserialize<'de> for NodeId {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let raw = String::deserialize(deserializer)?;
NodeId::parse(&raw).map_err(serde::de::Error::custom)
}
}