Skip to main content

loopsmith_core/config/
gates.rs

1//! The layered exits, and the three checkpoints either side of them.
2//!
3//! `stop` is the original section F: the ceilings that end a run. The three
4//! lists beside it answer questions a ceiling cannot — may this run start at
5//! all, does this need a human before it proceeds, and has something gone
6//! badly enough to undo.
7//!
8//! All four reuse [`Detector`] rather than introducing an expression language.
9//! That is the whole design: a gate condition is the same kind of object as a
10//! validation condition, so it is evaluated by the same compiled code, obeys
11//! the same independence rules, and an author who has learned one has learned
12//! both. The reference specification models gate conditions as strings like
13//! `"risk_score < 0.40"`; parsing those would mean a new evaluator, a new
14//! failure surface, and a second answer to "how is a condition decided".
15
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18
19use super::validation::Detector;
20use super::yes;
21
22#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
23#[serde(deny_unknown_fields)]
24pub struct StopGates {
25    /// Hard ceiling on whole-loop iterations.
26    #[serde(default = "default_max_iterations")]
27    pub max_iterations: u32,
28    /// Per-node revision ceiling. A node that has been dispatched this many
29    /// times without its goals being satisfied stops being dispatched, so one
30    /// stuck node cannot burn the whole iteration budget.
31    #[serde(default = "default_max_revisions")]
32    pub max_revisions_per_node: u32,
33    /// Wall-clock budget for the whole run.
34    #[serde(default)]
35    pub max_wall_clock_seconds: Option<u64>,
36    /// Token budget for the whole run, summed across providers.
37    #[serde(default)]
38    pub max_tokens: Option<u64>,
39    /// Currency budget for the whole run.
40    #[serde(default)]
41    pub max_cost_usd: Option<f64>,
42    /// Halt when this many consecutive iterations produce no measurable
43    /// change. Jidoka: stop the line rather than spin.
44    #[serde(default = "default_no_progress")]
45    pub no_progress_iterations: u32,
46    /// Perturb the run after this many stalled iterations, instead of waiting
47    /// to halt at `no_progress_iterations`.
48    ///
49    /// Must be strictly less than `no_progress_iterations`: the point is to try
50    /// something different *before* giving up, and a threshold at or past the
51    /// halt point never fires. Leave it unset to halt without ever varying —
52    /// perturbation costs a provider call and changes what the loop does, so it
53    /// is opt-in.
54    #[serde(default)]
55    pub no_progress_iterations_randomness: Option<u32>,
56    /// Stop as soon as every `overall` success scenario is met.
57    #[serde(default = "yes")]
58    pub stop_on_overall_success: bool,
59}
60
61impl Default for StopGates {
62    fn default() -> Self {
63        Self {
64            max_iterations: default_max_iterations(),
65            max_revisions_per_node: default_max_revisions(),
66            max_wall_clock_seconds: None,
67            max_tokens: None,
68            max_cost_usd: None,
69            no_progress_iterations: default_no_progress(),
70            no_progress_iterations_randomness: None,
71            stop_on_overall_success: true,
72        }
73    }
74}
75
76fn default_max_iterations() -> u32 {
77    10
78}
79fn default_max_revisions() -> u32 {
80    3
81}
82fn default_no_progress() -> u32 {
83    3
84}
85
86/// What happens when a gate rule does not pass.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
88#[serde(rename_all = "snake_case")]
89pub enum GateOutcome {
90    /// End the run. Nothing further is dispatched.
91    Stop,
92    /// Halt and record an escalation for a human to answer. Resumable.
93    Escalate,
94    /// Halt without an escalation record. Resumable.
95    Pause,
96    /// Restore the last good checkpoint and continue from there.
97    Rollback,
98    /// Record it and carry on. The only non-blocking outcome.
99    Warn,
100}
101
102impl GateOutcome {
103    /// Whether this outcome stops the run from proceeding.
104    pub fn is_blocking(self) -> bool {
105        !matches!(self, GateOutcome::Warn)
106    }
107}
108
109/// One checkpoint, decided by a detector.
110#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
111#[serde(deny_unknown_fields)]
112pub struct GateRule {
113    pub id: String,
114    /// Natural-language statement of what this gate is for. Shown verbatim
115    /// when the gate blocks, so it is the whole explanation a stopped operator
116    /// gets — write it for them, not for the author.
117    pub statement: String,
118    pub detector: Detector,
119    #[serde(default = "default_on_fail")]
120    pub on_fail: GateOutcome,
121}
122
123fn default_on_fail() -> GateOutcome {
124    GateOutcome::Stop
125}
126
127/// Every gate the run is subject to.
128#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
129#[serde(deny_unknown_fields)]
130pub struct Gates {
131    /// The ceilings that end a run. Formerly the whole of section F.
132    #[serde(default)]
133    pub stop: StopGates,
134    /// Checked once, before the first iteration. A failing entry gate means
135    /// the run never starts — which is the cheapest possible failure.
136    #[serde(default)]
137    pub entry: Vec<GateRule>,
138    /// Checked after each iteration. A failing approval gate halts for a human
139    /// rather than ending the run.
140    #[serde(default)]
141    pub approval: Vec<GateRule>,
142    /// Checked after each iteration. A failing rollback gate restores the last
143    /// good checkpoint — for the case where continuing is worse than undoing.
144    #[serde(default)]
145    pub rollback: Vec<GateRule>,
146}
147
148impl Gates {
149    /// Every rule across the three lists, with the list it came from.
150    pub fn rules(&self) -> impl Iterator<Item = (GateKind, &GateRule)> {
151        self.entry
152            .iter()
153            .map(|r| (GateKind::Entry, r))
154            .chain(self.approval.iter().map(|r| (GateKind::Approval, r)))
155            .chain(self.rollback.iter().map(|r| (GateKind::Rollback, r)))
156    }
157}
158
159/// Which list a rule came from, and therefore when it is checked.
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
161#[serde(rename_all = "snake_case")]
162pub enum GateKind {
163    Entry,
164    Approval,
165    Rollback,
166}
167
168impl GateKind {
169    pub fn as_str(self) -> &'static str {
170        match self {
171            GateKind::Entry => "entry",
172            GateKind::Approval => "approval",
173            GateKind::Rollback => "rollback",
174        }
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn a_gate_defaults_to_blocking() {
184        // The safe default. A gate the author forgot to annotate should stop
185        // the run, not shrug.
186        assert_eq!(default_on_fail(), GateOutcome::Stop);
187        assert!(default_on_fail().is_blocking());
188    }
189
190    #[test]
191    fn only_warn_is_non_blocking() {
192        for o in [
193            GateOutcome::Stop,
194            GateOutcome::Escalate,
195            GateOutcome::Pause,
196            GateOutcome::Rollback,
197        ] {
198            assert!(o.is_blocking(), "{o:?} must block");
199        }
200        assert!(!GateOutcome::Warn.is_blocking());
201    }
202}