Skip to main content

mecha_core/
boredom.rs

1//! Nothing is being learned from this approach — `docs/GOAL-SYSTEM-DESIGN.md`
2//! §9.1, rungs 1 and 3.
3//!
4//! **The loop guard is the crudest possible version of this**, and until now
5//! the only one: it fires on an identical call with an identical result inside
6//! a window after a compaction, and its response is rung 5 — end the run. So a
7//! run that is going nowhere had exactly two states, *proceeding* and *dead*.
8//! This is the graded version, and it fires earlier for the reason §4.4 gives
9//! for predicting context pressure rather than reacting to it: acting before a
10//! deviation beats reacting to one.
11//!
12//! **It spends nothing, which is what makes it ungated.** The run was going to
13//! happen; boredom only changes *how*. That is the whole distinction from
14//! curiosity (§9.2), which starts work nobody asked for and is therefore
15//! preempted by everything with a person attached.
16//!
17//! Three properties, each of which is a bug if undone:
18//!
19//! - **Keyed on the call *and* its result**, on the loop guard's rule.
20//!   Identical arguments with a changing result is polling, and a poll must
21//!   never grade as stuck. The key is `compact::target_of` rather than the raw
22//!   arguments, so two different tools that read the same file and get the same
23//!   bytes count as the same thing learned twice — which is exactly what this
24//!   is looking for.
25//! - **Once per rung, never per turn.** The count is compared with `==` rather
26//!   than `>=`, so crossing a rung fires exactly once. A notice repeated every
27//!   turn would be the distractor shape `evict_superseded_results` exists to
28//!   remove, and worse than that: a model is measurably likelier to fail a step
29//!   when its context holds its own earlier errors, so nagging about being
30//!   stuck is a way of making it stick.
31//! - **The response is the model's.** The harness names the condition and what
32//!   is actually reachable; it does not change the approach, because the
33//!   approach is the model's. Rungs 4 and 5 — ask, and stop — are not here:
34//!   `questions.rs` and the loop guard already own them.
35//!
36//! **Rung 2 — consult — is deliberately missing, and the reason is not
37//! sequencing.** §9.1 offers two things to consult: a marker for this
38//! situation, which is §7.4's and does not exist, and a skill, which does —
39//! but nothing in the `Tool` trait identifies the tool that loads one.
40//! `narrows_surface_to` is the closest and answers `None` until a skill is
41//! already loaded, so it recognises the state this notice exists to escape
42//! only after the escape has been taken. Naming `skill` by name from the loop
43//! is the alternative and is the thing the trait family exists to avoid: the
44//! loop learns that *some* tool has a property, never which tool has it. So
45//! the rung waits for a property worth adding rather than being approximated
46//! by a string.
47
48use std::collections::HashMap;
49
50/// Identical outcomes before an approach counts as going nowhere.
51///
52/// Two is ordinary work — a retry is how things get done, and the eval rig's
53/// own rule is that one failure among successes is recovery. Three identical
54/// outcomes is the model not learning anything from the last two. Deliberately
55/// *not* the loop guard's threshold of two: that one fires only after a
56/// compaction, where the failure is specific and expensive, and it kills the
57/// run. This one watches all of ordinary work and only speaks, so it has to be
58/// slower to accuse.
59const STUCK: u32 = 3;
60
61/// …and after which the cheap escapes have demonstrably not worked.
62const STILL_STUCK: u32 = 6;
63
64/// What one run may say about being stuck.
65///
66/// A notice stays true — unlike a headroom reading, it does not go stale — so
67/// the bound is about bulk and about self-conditioning rather than about
68/// accuracy. Three is enough for a run that is stuck on genuinely different
69/// things and short of the point where the transcript is mostly the harness
70/// talking about the harness.
71const MAX_NOTICES: u32 = 3;
72
73/// How many turns may pass between two occurrences of the same target before
74/// they stop counting as one streak.
75///
76/// Without this, `seen` accumulates for the life of the run, so "three
77/// identical outcomes" meant three *anywhere*, not three in a row — the same
78/// `shell: git status` at three natural checkpoints an hour apart would trip
79/// it exactly as a genuinely stuck run would, on a detector whose only job is
80/// telling those two apart. The loop guard this is modelled on is explicitly
81/// windowed for the same reason; this one was not. `STUCK` itself is the
82/// natural size: the window has to admit at least the ordinary work between
83/// two repeats of a stuck call, and cannot be wider than the count that
84/// defines "stuck" without the two numbers arguing with each other.
85const RECENCY_WINDOW: u32 = STUCK;
86
87/// How every notice opens.
88///
89/// **A constant, because the transcript is the only thing that can tell a
90/// reader who spoke.** A notice is folded into the message carrying the tool
91/// results — steering's slot — and `learning::extract_interventions` reads text
92/// riding beside tool results as *the user steering*, which is right for
93/// everything else that lands there. Without a stem the harness's own words
94/// would be mined as a correction from a person who never spoke, and rules
95/// learned from them would ride in every future prompt: the
96/// `"Blocked by a hook:"` mistake in a third costume. `agent::is_harness_voice`
97/// is the one place that recognition lives, and it matches on this.
98pub const NOTICE_STEM: &str = "Nothing is being learned here:";
99
100/// Which rung of §9.1's ladder the run has reached.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum Rung {
103    /// Rung 1: change approach.
104    Change,
105    /// Rung 3: a fresh `Conversation` — the strongest available escape from a
106    /// context that has talked itself into a corner.
107    Delegate,
108}
109
110/// What a bored run can actually reach, read off the registry by the loop.
111///
112/// Named rather than assumed, on `compact`'s rule about not naming `todo`:
113/// pointing the model at a tool that is not registered spends a turn on a call
114/// that can only fail. A run with nothing here still gets the notice — *stop
115/// repeating this* is the part that does the work, and the rest is where to
116/// go instead.
117#[derive(Debug, Clone, Default)]
118pub struct Escapes {
119    /// A tool that runs its work in a conversation of its own, found by
120    /// [`Tool::runs_a_fresh_conversation`](crate::tool::Tool::runs_a_fresh_conversation).
121    pub delegate: Option<String>,
122}
123
124#[derive(Debug, Default)]
125pub struct Boredom {
126    enabled: bool,
127    /// Per key: how many turns have produced this outcome, the tool that
128    /// produced it, and the turn number of the most recent one — the third
129    /// is what lets a gap past `RECENCY_WINDOW` reset the streak instead of
130    /// letting it accumulate for the life of the run. The name is kept and
131    /// the arguments are not — the notice needs something concrete to point
132    /// at, and a rendered argument list can be most of a turn and can hold
133    /// the user's data.
134    seen: HashMap<u64, (u32, String, u32)>,
135    /// Turns observed so far. Monotonic within one `Boredom`, meaningless
136    /// outside it — the same shape as `step::next_run`.
137    turn: u32,
138    notices: u32,
139}
140
141impl Boredom {
142    pub fn new(enabled: bool) -> Self {
143        Boredom {
144            enabled,
145            ..Boredom::default()
146        }
147    }
148
149    /// How many times this run has been told it is going nowhere — the
150    /// counter that makes the thresholds above falsifiable.
151    pub fn notices(&self) -> u32 {
152        self.notices
153    }
154
155    /// One outcome, as this counts them.
156    ///
157    /// The **target** rather than the raw arguments, so two different tools
158    /// that reach the same file and get the same bytes are one thing learned
159    /// twice — which is the signal, not an approximation of it. And the result
160    /// as well as the call, on the loop guard's rule: identical arguments with
161    /// a changing result is polling, and a poll must never grade as stuck.
162    ///
163    /// A 64-bit hash rather than the strings: nothing adversarial is being
164    /// resisted, a collision needs two different outcomes to repeat three
165    /// times each, and keeping the text would make this a second copy of the
166    /// transcript — including of the user's data.
167    pub fn key(name: &str, input: &serde_json::Value, result: &str) -> u64 {
168        use std::hash::{Hash, Hasher};
169        let mut hasher = std::collections::hash_map::DefaultHasher::new();
170        crate::compact::target_of(name, input).hash(&mut hasher);
171        result.hash(&mut hasher);
172        hasher.finish()
173    }
174
175    /// Record one *turn's* executed calls, and say whether it just crossed a
176    /// rung.
177    ///
178    /// Per turn rather than per call, on the loop guard's reasoning: a model
179    /// that emits the same call twice in one parallel batch is being wasteful,
180    /// not stuck, and the repetition this watches for is across turns. At most
181    /// one notice per turn, because two at once is one thing to say.
182    pub fn observe_turn<'a>(
183        &mut self,
184        turn: impl IntoIterator<Item = (&'a str, u64)>,
185    ) -> Option<(Rung, String)> {
186        if !self.enabled || self.notices >= MAX_NOTICES {
187            return None;
188        }
189        self.turn += 1;
190        let now = self.turn;
191        let mut crossed: Option<(Rung, String)> = None;
192        let mut this_turn = std::collections::HashSet::new();
193        for (name, key) in turn {
194            if !this_turn.insert(key) {
195                continue;
196            }
197            let entry = self.seen.entry(key).or_insert((0, name.to_string(), now));
198            // A gap past the window is a fresh streak, not a continuation —
199            // the same target read again after enough ordinary work in
200            // between is not the same finding as three in a row.
201            if now.saturating_sub(entry.2) > RECENCY_WINDOW {
202                entry.0 = 0;
203            }
204            entry.0 += 1;
205            entry.2 = now;
206            // `==`, not `>=`: a rung is crossed once. A run that keeps
207            // repeating past the last rung is left to the loop guard and the
208            // turn ceiling, which is the honest end of this ladder — rungs 4
209            // and 5 belong to mechanisms that already exist.
210            let rung = match entry.0 {
211                STUCK => Rung::Change,
212                STILL_STUCK => Rung::Delegate,
213                _ => continue,
214            };
215            // The higher rung wins if a turn somehow crosses both.
216            if crossed.as_ref().is_none_or(|(r, _)| *r == Rung::Change) {
217                crossed = Some((rung, entry.1.clone()));
218            }
219        }
220        if crossed.is_some() {
221            self.notices += 1;
222        }
223        crossed
224    }
225}
226
227impl Rung {
228    /// What the model is told, folded into the message carrying the tool
229    /// results.
230    ///
231    /// **Wording is load-bearing**, on `EMPTY_TURN_NUDGE`'s evidence: a vague
232    /// nudge invites a model to start the task over from the top, which burns
233    /// the budget that was already the problem. So each line names the cause,
234    /// forbids the repeat rather than the task, and offers concrete
235    /// continuations — and never more than the run can actually reach.
236    pub fn notice(self, tool: &str, escapes: &Escapes) -> String {
237        match self {
238            Rung::Change => format!(
239                "{NOTICE_STEM} `{tool}` has now returned exactly the same thing \
240                 {STUCK} times. Do not start the task over — keep what you have \
241                 worked out, and either take a different route to this one piece or \
242                 revise the plan if the step itself is the wrong shape."
243            ),
244            Rung::Delegate => {
245                let mut s = format!(
246                    "{NOTICE_STEM} `{tool}` has returned the same thing {STILL_STUCK} \
247                     times now, and changing the approach inside this conversation has \
248                     not moved it."
249                );
250                match &escapes.delegate {
251                    Some(delegate) => s.push_str(&format!(
252                        " Hand this piece to `{delegate}`, which starts from a clean \
253                         conversation — write the task for someone with no memory of \
254                         this one."
255                    )),
256                    None => s.push_str(
257                        " Say what is blocking it and what you would need, rather than \
258                         trying it again.",
259                    ),
260                }
261                s
262            }
263        }
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    fn turn(b: &mut Boredom, key: u64) -> Option<(Rung, String)> {
272        b.observe_turn([("build", key)])
273    }
274
275    #[test]
276    fn ordinary_repetition_is_the_model_s_business() {
277        let mut b = Boredom::new(true);
278        assert!(turn(&mut b, 1).is_none());
279        assert!(turn(&mut b, 1).is_none(), "a retry is how work gets done");
280    }
281
282    #[test]
283    fn a_third_identical_outcome_crosses_the_first_rung_once() {
284        let mut b = Boredom::new(true);
285        turn(&mut b, 1);
286        turn(&mut b, 1);
287        assert_eq!(turn(&mut b, 1).unwrap().0, Rung::Change);
288        assert!(
289            turn(&mut b, 1).is_none(),
290            "a rung is crossed once; a notice every turn is the distractor shape"
291        );
292        assert!(turn(&mut b, 1).is_none());
293        // Six.
294        assert_eq!(turn(&mut b, 1).unwrap().0, Rung::Delegate);
295        assert!(
296            turn(&mut b, 1).is_none(),
297            "and then the loop guard's problem"
298        );
299    }
300
301    /// The bug this guards: `seen` used to accumulate for the life of the
302    /// run with no recency window, so three occurrences of the same target
303    /// *anywhere* — a `shell: git status` at three natural checkpoints an
304    /// hour apart — read as three in a row.
305    #[test]
306    fn a_repeat_far_apart_does_not_accumulate_toward_the_rung() {
307        let mut b = Boredom::new(true);
308        assert!(turn(&mut b, 1).is_none());
309        // More turns of unrelated work than the recency window allows.
310        for k in 100..100 + RECENCY_WINDOW + 1 {
311            assert!(turn(&mut b, k as u64).is_none());
312        }
313        // Two more repeats, close together — a streak of two since the gap
314        // reset it, not three, so still no rung.
315        assert!(
316            turn(&mut b, 1).is_none(),
317            "the gap past the window should have reset the streak"
318        );
319        assert!(
320            turn(&mut b, 1).is_none(),
321            "three occurrences spread across a long run are not three in a row"
322        );
323    }
324
325    /// The window has to be wide enough to admit ordinary interleaved work —
326    /// a gap *inside* it must not reset the streak, or the detector would
327    /// never fire on the commonest stuck shape (a failing call retried with
328    /// something else attempted in between).
329    #[test]
330    fn a_gap_inside_the_window_still_counts_toward_the_rung() {
331        let mut b = Boredom::new(true);
332        assert!(turn(&mut b, 1).is_none());
333        // Turns-since-last-occurrence must stay at or under the window,
334        // counting the repeat's own turn: `RECENCY_WINDOW - 1` calls of
335        // unrelated work leaves exactly `RECENCY_WINDOW` turns of gap.
336        for k in 100..100 + RECENCY_WINDOW - 1 {
337            assert!(turn(&mut b, k as u64).is_none());
338        }
339        assert!(turn(&mut b, 1).is_none());
340        assert_eq!(
341            turn(&mut b, 1).unwrap().0,
342            Rung::Change,
343            "a gap within the window is still one streak"
344        );
345    }
346
347    #[test]
348    fn a_changing_result_is_polling_and_never_stuck() {
349        let mut b = Boredom::new(true);
350        for key in 0..10 {
351            assert!(turn(&mut b, key).is_none());
352        }
353    }
354
355    #[test]
356    fn the_same_call_twice_in_one_batch_is_waste_and_not_a_loop() {
357        let mut b = Boredom::new(true);
358        // Three turns' worth of repetition, all inside one turn.
359        assert!(b
360            .observe_turn([("build", 1), ("build", 1), ("build", 1)])
361            .is_none());
362    }
363
364    #[test]
365    fn a_run_stops_talking_about_itself_eventually() {
366        let mut b = Boredom::new(true);
367        for key in 0..5 {
368            for _ in 0..STUCK {
369                turn(&mut b, key);
370            }
371        }
372        assert_eq!(b.notices, MAX_NOTICES);
373    }
374
375    #[test]
376    fn switched_off_it_says_nothing() {
377        let mut b = Boredom::new(false);
378        for _ in 0..20 {
379            assert!(turn(&mut b, 1).is_none());
380        }
381    }
382
383    #[test]
384    fn a_notice_names_only_what_the_run_can_reach() {
385        let bare = Escapes::default();
386        let change = Rung::Change.notice("build", &bare);
387        assert!(change.contains("`build`") && change.contains("different route"));
388        assert!(
389            change.starts_with(NOTICE_STEM),
390            "every notice is recognisable"
391        );
392        // Forbids the repeat rather than the task — the nudge that sends a
393        // model back to the top burns the budget that was already the problem.
394        assert!(change.contains("Do not start the task over"));
395
396        let full = Escapes {
397            delegate: Some("researcher".into()),
398        };
399        let delegate = Rung::Delegate.notice("build", &full);
400        assert!(delegate.starts_with(NOTICE_STEM));
401        assert!(delegate.contains("`researcher`") && delegate.contains("no memory"));
402
403        // With nothing to delegate to, the fallback says what is true rather
404        // than pointing at a tool that is not there.
405        let alone = Rung::Delegate.notice("build", &bare);
406        assert!(alone.contains("blocking") && !alone.contains("researcher"));
407    }
408}