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}