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}