Skip to main content

runtime/
mode.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//! Run modes and admission. A run sits in exactly one mode; the
7//! catalog lists only that one (progressive disclosure — the other
8//! mode's text stays out of the window).
9//!
10//! One exception, and it is one line long: [`dev_entry`] tells a run
11//! that this city's own code is changeable, and names the evidence a
12//! change can be asked to carry. Without it an agent working in this
13//! city never learns that the city's own code and SPECs are changeable
14//! at all, or under what discipline — and a capability nobody is told
15//! about is one nobody uses.
16//!
17//! The half that decides is [`admits`]: it says whether what a run
18//! produced may land, given the run policy it was dispatched under
19//! (`crates/runtime/spec/Mode.lean` §8-54). The evidence arrives as plain answers rather
20//! than as an instrument's type, because the instruments live in
21//! citysim, outside this crate, and the question here is not how
22//! evidence was gathered but whether enough of it exists.
23
24use crate::catalog::CatalogEntry;
25use kernel::{AdmissionRequirement, LandingPolicy, Mode, RunPolicy};
26
27/// The name of the catalog row that opens the developer discipline.
28pub const DEV_ENTRY: &str = "dev";
29
30/// The row that tells a run this city is changeable, and nothing more.
31///
32/// One line in the resident segment, and the whole discipline behind an
33/// expansion. That split is the point: most sessions never change this
34/// city, so they pay a line; a session that is about to change it asks
35/// once and gets the evidence a change can be asked for, the reading
36/// order and what to do next. The same progressive disclosure the tool
37/// rows use, applied to the one capability an agent would otherwise
38/// never learn it has.
39#[must_use]
40pub fn dev_entry() -> CatalogEntry {
41    CatalogEntry {
42        name: DEV_ENTRY.to_owned(),
43        disclosure: "when the work is to change this city's own code, SPECs or tools, open this \
44                     entry before you touch anything"
45            .to_owned(),
46        expansion: "This city is built from small components, each with its SPEC beside it at \
47                    `crates/<crate>/<crate>-SPEC.md`. Read that SPEC, then the code, then the \
48                    tests next to the code - in that order, and before you change any of them. \
49                    Where the implementation would differ from the SPEC, the SPEC changes first \
50                    and says why.\n\nThe person chooses what a change must prove before it is \
51                    merged:\n- tested: the asset's own tests ran and passed.\n- contract_kept: \
52                    the asset's observable contract did not move.\n- double_validated: the change \
53                    holds on held-in and on held-out evidence.\nAnd where it lands: \
54                    ordinary work takes the building's own road, while an experiment works in a \
55                    tree of its own and nothing in it is merged.\n\nYour next step: say which \
56                    evidence this work needs and why, and wait for the person to dispatch it \
57                    with that requirement. Choosing a requirement does not provide the evidence."
58            .to_owned(),
59        // Text this build holds, not a document on a shelf: there is
60        // nothing behind it that could change while nobody is looking.
61        hash: None,
62        package: None,
63    }
64}
65
66/// The catalog row for this mode: disclosure one-liner plus the
67/// expansion text. The chat row is the whole of what chat mode does:
68/// one line telling the resident that the person is talking with it.
69#[must_use]
70pub fn catalog_entry(mode: Mode) -> CatalogEntry {
71    let (disclosure, expansion) = match mode {
72        Mode::Chat => (
73            "chat mode: focus on conversing with the person; answer what they said, in their language",
74            "Reply in the conversation. Start work, plans or dispatches only when the person asks for them.",
75        ),
76        Mode::Work => (
77            "work mode: carry out the task towards the stated goal; report when the goal is met",
78            "When the task asks for a plan first, write it into Roadmap.md with the `plan` tool \
79             before you change anything, then work through it. Report what you did and the \
80             evidence that the goal is met.",
81        ),
82    };
83    CatalogEntry {
84        name: format!("mode:{}", mode.as_str()),
85        disclosure: disclosure.to_owned(),
86        expansion: expansion.to_owned(),
87        hash: None,
88        package: None,
89    }
90}
91
92/// What a run has to show for itself.
93///
94/// `None` is not `Some(false)`: a suite that was never run and a suite
95/// that failed are different facts, and a requirement that treated them
96/// alike would let "we did not check" pass as "we checked and it was
97/// fine".
98/// The tools a session in `mode` carries in its request; every other
99/// admitted tool waits in the dormant index (`crates/runtime/Spec.lean`
100/// §8-60). A name here travels only where the building admitted it: the
101/// hall registers no `exec`, so `exec` is in no tier there.
102///
103/// The two doors of the truncation lock are in every mode, because a
104/// dormant capability is reachable only through them. Chat answers the
105/// person and reads what it is asked about; work also edits and runs
106/// commands, which is what carrying out a task is made of. `status` is in
107/// both because the city prompt sends every resident to it for its
108/// situation.
109#[must_use]
110pub fn core_tools(mode: Mode) -> &'static [&'static str] {
111    match mode {
112        Mode::Chat => &[
113            crate::tools::CallTool::NAME,
114            crate::tools::DescribeTool::NAME,
115            crate::tools::ReadTool::NAME,
116            crate::tools::SearchTool::NAME,
117            crate::tools::StatusTool::NAME,
118        ],
119        Mode::Work => &[
120            crate::tools::CallTool::NAME,
121            crate::tools::DescribeTool::NAME,
122            crate::tools::EditTool::NAME,
123            kernel::ToolName::EXEC,
124            crate::tools::ReadTool::NAME,
125            crate::tools::SearchTool::NAME,
126            crate::tools::StatusTool::NAME,
127        ],
128    }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
132pub struct Produced {
133    /// The asset's own tests ran and passed.
134    pub tests_passed: Option<bool>,
135    /// The observable contract moved under the renovation.
136    pub contract_moved: bool,
137    /// Held-in evidence: it did not get worse on what it was built from.
138    pub held_in: Option<bool>,
139    /// Held-out evidence: it stands up away from what it was built from.
140    pub held_out: Option<bool>,
141}
142
143/// Whether this may land, and if not, what to do about it.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum Admission {
146    Lands,
147    Refused {
148        because: &'static str,
149        alternative: &'static str,
150    },
151}
152
153/// Whether what a run produced may be merged, under the policy it was
154/// dispatched with (`crates/runtime/spec/Mode.lean` §8-54).
155///
156/// The landing policy is asked first: an experiment lands nothing
157/// however well it went. Then the evidence requirement, exhaustively,
158/// so a new requirement has to say what it demands. The mode takes no
159/// part: talk and work produce things that meet the same merge.
160#[must_use]
161pub fn admits(policy: &RunPolicy, produced: &Produced) -> Admission {
162    match policy.landing {
163        LandingPolicy::Experiment => Admission::Refused {
164            because: "nothing produced in an experiment lands",
165            alternative: "write what you learned into Memo.md, then ask for the work again \
166                          with the ordinary landing",
167        },
168        LandingPolicy::Ordinary => admits_evidence(policy.admit, produced),
169    }
170}
171
172/// The evidence half of [`admits`].
173///
174/// `Standing` adds nothing: the building's own checks were made where
175/// they are made. `Tested` wants the asset proven by its own tests.
176/// `ContractKept` wants the contract to have stayed where it was,
177/// because a renovation that moves it is not a renovation.
178/// `DoubleValidated` wants both halves of the double validation: it is
179/// asked for a change to how the city behaves, so confidence is not the
180/// currency — evidence is.
181fn admits_evidence(required: AdmissionRequirement, produced: &Produced) -> Admission {
182    match required {
183        AdmissionRequirement::Standing => Admission::Lands,
184        AdmissionRequirement::Tested => match produced.tests_passed {
185            Some(true) => Admission::Lands,
186            Some(false) => Admission::Refused {
187                because: "the asset's own tests did not pass",
188                alternative: "fix the asset or the test, then offer it again",
189            },
190            None => Admission::Refused {
191                because: "the asset has no tests of its own",
192                alternative: "write the test that would fail if this asset broke",
193            },
194        },
195        AdmissionRequirement::ContractKept => {
196            if produced.contract_moved {
197                Admission::Refused {
198                    because: "the asset's observable contract moved",
199                    alternative: "keep the contract and renovate behind it, or ask for the work \
200                                  again under double_validated with held-out evidence",
201                }
202            } else {
203                Admission::Lands
204            }
205        }
206        AdmissionRequirement::DoubleValidated => match (produced.held_in, produced.held_out) {
207            (Some(true), Some(true)) => Admission::Lands,
208            (Some(false), _) => Admission::Refused {
209                because: "it got worse on the held-in set",
210                alternative: "a change that costs more than it buys is not adopted; revise it",
211            },
212            (_, Some(false)) => Admission::Refused {
213                because: "it did not stand up on the held-out set",
214                alternative: "a gain that only shows where it was built is a gain in fitting, \
215                              not in ability",
216            },
217            _ => Admission::Refused {
218                because: "one half of the double validation is missing",
219                alternative: "run the suite on both sets; an unmeasured change is not adopted on \
220                              confidence",
221            },
222        },
223    }
224}
225
226#[cfg(test)]
227#[allow(
228    clippy::unwrap_used,
229    clippy::expect_used,
230    clippy::panic,
231    reason = "test code"
232)]
233mod tests {
234    use super::*;
235
236    fn work(admit: AdmissionRequirement) -> RunPolicy {
237        RunPolicy {
238            admit,
239            ..RunPolicy::of(Mode::Work)
240        }
241    }
242
243    /// A requirement chosen is not evidence held: work that asked for
244    /// its own tests and ran none does not land.
245    #[test]
246    fn a_work_run_without_the_evidence_it_chose_does_not_land() {
247        let Admission::Refused { because, .. } =
248            admits(&work(AdmissionRequirement::Tested), &Produced::default())
249        else {
250            panic!("work that chose `tested` and ran no test landed");
251        };
252        assert!(because.contains("no tests"), "{because}");
253    }
254
255    /// A chat run is told one thing, to talk with the person.
256    #[test]
257    fn chat_is_one_line_about_the_conversation() {
258        let entry = catalog_entry(Mode::Chat);
259        assert_eq!(entry.name, "mode:chat");
260        assert!(entry.disclosure.contains("convers"), "{}", entry.disclosure);
261        assert!(!entry.disclosure.contains('\n'), "one line");
262        assert!(
263            !entry.expansion.is_empty(),
264            "a read of the entry says something"
265        );
266        assert_eq!(catalog_entry(Mode::Work).name, "mode:work");
267    }
268
269    /// The building's own checks are made where they are made; the
270    /// standing requirement adds none, in either mode.
271    #[test]
272    fn standing_adds_nothing_in_either_mode() {
273        for mode in Mode::ALL {
274            assert_eq!(
275                admits(&RunPolicy::of(mode), &Produced::default()),
276                Admission::Lands
277            );
278        }
279    }
280
281    #[test]
282    fn tested_wants_the_asset_proven_and_says_which_way_it_failed() {
283        let policy = work(AdmissionRequirement::Tested);
284        let failed = Produced {
285            tests_passed: Some(false),
286            ..Produced::default()
287        };
288        let Admission::Refused { because, .. } = admits(&policy, &failed) else {
289            panic!("a failing asset does not land");
290        };
291        assert!(
292            because.contains("did not pass"),
293            "not the untested sentence"
294        );
295        let passed = Produced {
296            tests_passed: Some(true),
297            ..Produced::default()
298        };
299        assert_eq!(admits(&policy, &passed), Admission::Lands);
300    }
301
302    #[test]
303    fn contract_kept_refuses_a_renovation_that_moved_the_contract() {
304        let policy = work(AdmissionRequirement::ContractKept);
305        assert_eq!(admits(&policy, &Produced::default()), Admission::Lands);
306        let moved = Produced {
307            contract_moved: true,
308            ..Produced::default()
309        };
310        let Admission::Refused { alternative, .. } = admits(&policy, &moved) else {
311            panic!("a moved contract is not a renovation");
312        };
313        assert!(
314            alternative.contains("double_validated"),
315            "the refusal names the requirement that would take it"
316        );
317    }
318
319    #[test]
320    fn double_validated_takes_nothing_on_confidence() {
321        let policy = work(AdmissionRequirement::DoubleValidated);
322        let both = Produced {
323            held_in: Some(true),
324            held_out: Some(true),
325            ..Produced::default()
326        };
327        assert_eq!(admits(&policy, &both), Admission::Lands);
328        for missing in [
329            Produced {
330                held_in: Some(true),
331                ..Produced::default()
332            },
333            Produced {
334                held_out: Some(true),
335                ..Produced::default()
336            },
337            Produced::default(),
338        ] {
339            assert!(
340                matches!(admits(&policy, &missing), Admission::Refused { .. }),
341                "half of a double validation is not a double validation"
342            );
343        }
344        let regressed = Produced {
345            held_in: Some(true),
346            held_out: Some(false),
347            ..Produced::default()
348        };
349        let Admission::Refused { because, .. } = admits(&policy, &regressed) else {
350            panic!("a change that only holds where it was built does not land");
351        };
352        assert!(because.contains("held-out"));
353    }
354
355    /// An experiment lands nothing, whatever it proved and whatever it
356    /// was allowed to write.
357    #[test]
358    fn an_experiment_lands_nothing_however_well_it_went() {
359        let excellent = Produced {
360            tests_passed: Some(true),
361            contract_moved: false,
362            held_in: Some(true),
363            held_out: Some(true),
364        };
365        for admit in AdmissionRequirement::ALL {
366            let policy = RunPolicy {
367                landing: LandingPolicy::Experiment,
368                write: kernel::WriteLimit::Create,
369                ..work(admit)
370            };
371            assert!(matches!(
372                admits(&policy, &excellent),
373                Admission::Refused { .. }
374            ));
375        }
376    }
377}