kernel/pursuit.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//! A pursuit: a goal the city keeps working towards, and the one
7//! condition under which a city that holds one stops.
8//! The part `crates/kernel/spec/Pursuit.lean` specifies this module.
9//!
10//! The construction plan called this Endless. It is named for what it
11//! is rather than for how long it lasts, because three other things in
12//! this repository are already called standing something.
13//!
14//! **A society does not stop because somebody shouts stop. It stops
15//! because there is nothing ready to do.** That is the whole criterion:
16//! the ready set is empty and no run is still going. Both halves are
17//! needed — an empty ready set while four runs are working means the
18//! work is in somebody's hands, not that it is finished.
19//!
20//! Money is deliberately not a criterion. The cost surface in this city
21//! is material an agent optimises against, not a brake somebody pulls,
22//! and a stop condition that read a budget would answer a question
23//! nobody here asked.
24//!
25//! **A pursuit is the person's, and a delegate cannot declare
26//! one.** [`Pursuit::declare`] takes the depth-zero position by
27//! reference, and a `Delegate` value has no way to produce one — the
28//! same two-layer guard `crate::delegation` uses, for the same reason:
29//! a sub-agent that could set the city working until it runs out of work
30//! is a sub-agent that can spend the night on its own idea.
31//!
32//! Pausing and clearing are different actions and both exist. Pause
33//! keeps the goal and stops taking work; clearing is dropping the value,
34//! so a cleared goal leaves nothing behind to be resumed by accident.
35//! Cancelling a *run* is a third thing again, and it lives where runs do.
36
37use crate::delegation::Delegator;
38use crate::node_id::NodeId;
39
40/// Whether a pursuit is taking work right now.
41///
42/// Carries serde because a page renders it. The value that must not be
43/// deserialisable is [`Pursuit`] itself — and that still is not.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
45#[serde(rename_all = "snake_case")]
46#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
47pub enum PursuitState {
48 Running,
49 Paused,
50}
51
52/// A goal that keeps taking work until the work runs out.
53///
54/// No serde: a pursuit that could be deserialised would be a pursuit a
55/// wire frame could declare, and the point of the `Delegator` argument
56/// is that declaring one is the person's.
57#[derive(Debug)]
58pub struct Pursuit {
59 goal: String,
60 state: PursuitState,
61}
62
63impl Pursuit {
64 /// Declares one. The `Delegator` is the depth-zero position: holding
65 /// one is assembly's discipline, and a `Delegate` cannot produce
66 /// one.
67 ///
68 /// # Errors
69 /// Refuses a blank goal. An empty string here would mean "keep
70 /// working on nothing in particular", and the city already has a
71 /// word for that situation — it is a plan with a ready set.
72 pub fn declare(_at: &Delegator, goal: String) -> Result<Pursuit, crate::error::AxError> {
73 if goal.trim().is_empty() {
74 return Err(crate::error::AxError::failure(
75 crate::error::AxCode::InvalidArgs,
76 "declare a pursuit",
77 "the goal is blank",
78 )
79 .with_recovery("say what the city is working towards, in one sentence"));
80 }
81 Ok(Pursuit {
82 goal,
83 state: PursuitState::Running,
84 })
85 }
86
87 #[must_use]
88 pub fn goal(&self) -> &str {
89 &self.goal
90 }
91
92 #[must_use]
93 pub fn state(&self) -> PursuitState {
94 self.state
95 }
96
97 /// Stops taking work without forgetting what the work was for.
98 pub fn pause(&mut self) {
99 self.state = PursuitState::Paused;
100 }
101
102 pub fn resume(&mut self) {
103 self.state = PursuitState::Running;
104 }
105}
106
107/// What a city holding a pursuit does next. Exhaustive: every arm is
108/// something the caller has to do, and a fifth would be a state nobody
109/// wrote an action for.
110///
111/// Carries serde because the page says it: the wire holds the kind, and
112/// the client takes the words for each kind from its own `lang.json`.
113#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
114#[serde(tag = "kind", rename_all = "snake_case")]
115#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
116pub enum PursuitVerdict {
117 /// Start this node. The first of the ready set, so the order is the
118 /// plan's own and two cities with the same plan pick the same node.
119 Work { next: NodeId },
120 /// Nothing is ready and somebody is still working: the ready set
121 /// will change when they are done.
122 Waiting { in_flight: u32 },
123 /// The pursuit is held but not taking work.
124 Paused,
125 /// Nothing ready, nobody working. The city has finished, and nobody
126 /// had to say so.
127 Finished,
128}
129
130/// The stop condition, as a pure function of what the plan says and what
131/// is running.
132///
133/// Takes the state rather than the pursuit: the verdict does not depend
134/// on what the goal says, and a reader that had to hold a [`Pursuit`] to
135/// ask this question would need the depth-zero position to *read* the
136/// city. Declaring is the guarded act; looking is not.
137///
138/// No clock, no disk, no counter of its own: the caller already knows
139/// both facts, and a stop condition that went and looked for them could
140/// not be replayed.
141#[must_use]
142pub fn observe(state: PursuitState, ready: &[NodeId], in_flight: u32) -> PursuitVerdict {
143 if state == PursuitState::Paused {
144 return PursuitVerdict::Paused;
145 }
146 match ready.first() {
147 Some(next) => PursuitVerdict::Work { next: next.clone() },
148 None if in_flight > 0 => PursuitVerdict::Waiting { in_flight },
149 None => PursuitVerdict::Finished,
150 }
151}
152
153#[cfg(test)]
154#[allow(
155 clippy::unwrap_used,
156 clippy::expect_used,
157 clippy::panic,
158 clippy::indexing_slicing,
159 reason = "test code"
160)]
161mod tests {
162 use super::*;
163
164 fn goal() -> Pursuit {
165 Pursuit::declare(&Delegator::root(), "raise the east wing".to_owned()).unwrap()
166 }
167
168 fn node(raw: &str) -> NodeId {
169 NodeId::parse(raw).unwrap()
170 }
171
172 #[test]
173 fn work_is_taken_from_the_front_of_the_ready_set() {
174 let verdict = observe(goal().state(), &[node("1.2"), node("3")], 0);
175 assert_eq!(verdict, PursuitVerdict::Work { next: node("1.2") });
176 }
177
178 /// The criterion, both halves. An empty ready set alone does not
179 /// mean finished: the work may be in somebody's hands.
180 #[test]
181 fn a_city_stops_when_nothing_is_ready_and_nobody_is_working() {
182 assert_eq!(
183 observe(goal().state(), &[], 2),
184 PursuitVerdict::Waiting { in_flight: 2 }
185 );
186 assert_eq!(observe(goal().state(), &[], 0), PursuitVerdict::Finished);
187 }
188
189 #[test]
190 fn a_paused_goal_takes_nothing_and_keeps_what_it_was_for() {
191 let mut pursuit = goal();
192 pursuit.pause();
193 assert_eq!(
194 observe(pursuit.state(), &[node("1")], 0),
195 PursuitVerdict::Paused
196 );
197 assert_eq!(pursuit.goal(), "raise the east wing");
198 pursuit.resume();
199 assert_eq!(
200 observe(pursuit.state(), &[node("1")], 0),
201 PursuitVerdict::Work { next: node("1") }
202 );
203 }
204
205 #[test]
206 fn a_blank_goal_is_refused_where_it_is_declared() {
207 let refusal = Pursuit::declare(&Delegator::root(), " ".to_owned()).unwrap_err();
208 assert_eq!(refusal.code(), &crate::error::AxCode::InvalidArgs);
209 assert!(refusal.recovery().contains("one sentence"));
210 }
211}