Skip to main content

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}