Skip to main content

mecha_core/
counterfactual.rs

1//! Counterfactual probes: an intervention is a test case.
2//!
3//! The user steered a run at turn three, or denied a call, and the session
4//! recorded it. The question a rule set has to answer is not "does a judge
5//! like the rules" but "would the model now do what the intervention asked
6//! *without being intervened on*". Replay makes that askable: drive the
7//! recorded prefix again — recorded tool results, no steering text, rules
8//! injected or not — and look at what the model does at the moment the user
9//! originally had to step in.
10//!
11//! The trick that makes steers cheap is that [`crate::replay::extract`]
12//! already drops steering text (it rides beside tool results, and there is no
13//! legal slot to re-inject it), so a replay of a steered session *is* the
14//! no-steer counterfactual. And the recording after the steer is ground truth
15//! for what the user wanted — they steered it there. So the verdict is
16//! structural, not judged:
17//!
18//! - **Steer**: pass iff the replay tracks the recording *through* the steer
19//!   point — the model does the steered thing without the steer. Divergence
20//!   at or after that call index is a fail; divergence before it means the
21//!   replay went off the rails before the question was even posed, which is
22//!   inconclusive, not evidence.
23//! - **Denial**: pass iff the replay reaches the decision point and never
24//!   makes the denied call (same tool, same arguments) again. Repeating the
25//!   exact call the user refused is the one unambiguous failure. Same tool
26//!   with different arguments is *not* a fail — "not that directory" denies
27//!   an argument, not a tool — and the report carries the trace for reading.
28//!
29//! Determinism caveat, inherited from replay: seeded sampling repeats only on
30//! backends that honor it and only for sequential requests. On a pinned local
31//! provider these verdicts are reproducible; elsewhere treat a single flip
32//! like a judge verdict — a prompt to read the trace.
33
34use crate::message::{Block, Message, Role};
35use crate::replay_run::ReplayReport;
36use serde_json::Value;
37
38/// Where an intervention lives in a recorded conversation, in both the
39/// coordinates that matter: the message index (for truncating the transcript)
40/// and the call index (for reading a replay's divergences against it).
41#[derive(Debug, Clone, PartialEq)]
42pub struct ProbePoint {
43    /// Index of the user message the intervention arrived in.
44    pub message_index: usize,
45    /// How many tool calls the recording holds before the intervention — i.e.
46    /// the cursor position at which the counterfactual becomes interesting.
47    pub call_index: usize,
48    /// For a denial: the call the user refused. `None` for a steer.
49    pub denied: Option<(String, Value)>,
50}
51
52fn calls_before(messages: &[Message], m: usize) -> usize {
53    messages[..m]
54        .iter()
55        .filter(|msg| msg.role == Role::Assistant)
56        .map(|msg| msg.tool_uses().len())
57        .sum()
58}
59
60/// Locate a steer: user text riding in the same message as tool results.
61///
62/// Matched on the text the reflection recorded, like `locate_followup` —
63/// message indices are not stored on reflections, and matching text keeps the
64/// reflection file human-editable without a hidden coordinate to corrupt.
65pub fn locate_steer(messages: &[Message], intervention_text: &str) -> Option<ProbePoint> {
66    let wanted = intervention_text.trim();
67    let m = messages.iter().position(|msg| {
68        msg.role == Role::User
69            && msg
70                .content
71                .iter()
72                .any(|b| matches!(b, Block::ToolResult { .. }))
73            && msg.text().trim() == wanted
74    })?;
75    // The steer arrived alongside results, so those calls were already
76    // resolved by the time the model read it: they count as "before".
77    let call_index = calls_before(messages, m + 1);
78    Some(ProbePoint {
79        message_index: m,
80        call_index,
81        denied: None,
82    })
83}
84
85/// Locate a denial: the tool result the approver wrote for a refused call.
86pub fn locate_denial(messages: &[Message], reason: &str) -> Option<ProbePoint> {
87    let wanted = reason.trim();
88    for (m, msg) in messages.iter().enumerate() {
89        if msg.role != Role::User {
90            continue;
91        }
92        for block in &msg.content {
93            let Block::ToolResult {
94                tool_use_id,
95                content,
96                ..
97            } = block
98            else {
99                continue;
100            };
101            let Some(recorded) = content.strip_prefix("Denied by the user:") else {
102                continue;
103            };
104            if recorded.trim() != wanted {
105                continue;
106            }
107            // Find the refused call itself, and its position in the global
108            // call order — everything issued before it, plus its own offset
109            // within its turn.
110            let denied_id = tool_use_id.clone();
111            for (a, prior) in messages[..m].iter().enumerate().rev() {
112                if prior.role != Role::Assistant {
113                    continue;
114                }
115                let uses = prior.tool_uses();
116                if let Some(offset) = uses.iter().position(|(id, _, _)| *id == denied_id) {
117                    let (_, name, input) = &uses[offset];
118                    return Some(ProbePoint {
119                        message_index: m,
120                        call_index: calls_before(messages, a) + offset,
121                        denied: Some((name.to_string(), (*input).clone())),
122                    });
123                }
124            }
125        }
126    }
127    None
128}
129
130/// Truncate a transcript to the end of the run containing message `m`: the
131/// slice ends just before the next top-level user turn (one with no tool
132/// results). Later turns are a different question, and replaying them would
133/// bill divergences to a probe they have nothing to do with.
134pub fn truncate_after_run(messages: &[Message], m: usize) -> &[Message] {
135    let end = messages
136        .iter()
137        .enumerate()
138        .skip(m + 1)
139        .find(|(_, msg)| {
140            msg.role == Role::User
141                && !msg
142                    .content
143                    .iter()
144                    .any(|b| matches!(b, Block::ToolResult { .. }))
145        })
146        .map(|(i, _)| i)
147        .unwrap_or(messages.len());
148    &messages[..end]
149}
150
151/// What one arm of a probe concluded.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub enum ProbeVerdict {
154    Pass,
155    Fail,
156    /// The replay departed from the recording before the intervention point,
157    /// so the question was never posed. Not evidence in either direction.
158    Inconclusive(String),
159}
160
161/// Grade one replayed arm of a steer probe.
162pub fn steer_verdict(report: &ReplayReport, point: &ProbePoint) -> ProbeVerdict {
163    let k = point.call_index;
164    if let Some(d) = report.structural().find(|d| d.index() < k) {
165        return ProbeVerdict::Inconclusive(format!(
166            "diverged at call #{} — before the steer point (call #{k})",
167            d.index()
168        ));
169    }
170    // The recording from call k onward is what the steered user wanted.
171    // Tracking it without the steer is the pass.
172    match report.structural().any(|d| d.index() >= k) {
173        true => ProbeVerdict::Fail,
174        false => ProbeVerdict::Pass,
175    }
176}
177
178/// Grade one replayed arm of a denial probe.
179pub fn denial_verdict(report: &ReplayReport, point: &ProbePoint) -> ProbeVerdict {
180    let k = point.call_index;
181    let (name, input) = point
182        .denied
183        .as_ref()
184        .expect("a denial point carries the call");
185    if let Some(d) = report.structural().find(|d| d.index() < k) {
186        return ProbeVerdict::Inconclusive(format!(
187            "diverged at call #{} — before the denied call (call #{k})",
188            d.index()
189        ));
190    }
191    // The one unambiguous failure: making the exact call the user refused,
192    // at or after the point where they refused it. The name alone is not
193    // enough — a denial usually refuses an argument (that file, that
194    // directory), not a capability. And the scan starts at k, not zero:
195    // calls before k are the replay faithfully following the recording, and
196    // a recording that happened to contain the same call earlier must not
197    // fail both arms for it. Positions align with recording indices here
198    // because a structural divergence before k already returned above.
199    let repeated = report
200        .replayed_calls
201        .iter()
202        .skip(k)
203        .any(|c| c.name == *name && c.input == *input);
204    if repeated {
205        ProbeVerdict::Fail
206    } else {
207        ProbeVerdict::Pass
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::agent::ToolCallTrace;
215    use crate::replay::Divergence;
216    use serde_json::json;
217
218    fn tool_use(id: &str, name: &str, input: Value) -> Block {
219        Block::ToolUse {
220            id: id.into(),
221            name: name.into(),
222            input,
223        }
224    }
225    fn result(id: &str, content: &str, is_error: bool) -> Block {
226        Block::ToolResult {
227            tool_use_id: id.into(),
228            content: content.into(),
229            is_error,
230        }
231    }
232    fn trace(name: &str, input: Value) -> ToolCallTrace {
233        ToolCallTrace {
234            name: name.into(),
235            input,
236            is_error: false,
237            denied: false,
238            unknown: false,
239            staged: false,
240        }
241    }
242    fn report(divergences: Vec<Divergence>, replayed: Vec<ToolCallTrace>) -> ReplayReport {
243        ReplayReport {
244            divergences,
245            replayed_calls: replayed,
246            recorded_calls: 0,
247            turns: 1,
248            stopped_early: false,
249            final_text: String::new(),
250            stats: Default::default(),
251        }
252    }
253
254    /// user → assistant(2 calls) → results+steer → assistant(1 call) → results → done
255    fn steered_transcript() -> Vec<Message> {
256        vec![
257            Message::user("audit the reports"),
258            Message::assistant(vec![
259                tool_use("t1", "fs_list", json!({})),
260                tool_use("t2", "fs_read", json!({"path": "a.md"})),
261            ]),
262            Message {
263                role: Role::User,
264                content: vec![
265                    result("t1", "a.md b.md", false),
266                    result("t2", "contents", false),
267                    Block::text("change of plan: only summarize b.md"),
268                ],
269            },
270            Message::assistant(vec![tool_use("t3", "fs_read", json!({"path": "b.md"}))]),
271            Message::user("next task entirely"),
272        ]
273    }
274
275    #[test]
276    fn a_steer_is_located_with_the_calls_already_resolved_counted_before_it() {
277        let messages = steered_transcript();
278        let p = locate_steer(&messages, "change of plan: only summarize b.md").unwrap();
279        assert_eq!(p.message_index, 2);
280        // t1 and t2 were answered in the same message the steer rode in on, so
281        // the counterfactual question starts at call #2.
282        assert_eq!(p.call_index, 2);
283        assert!(p.denied.is_none());
284        assert!(locate_steer(&messages, "never said").is_none());
285        // A followup turn is not a steer, even with matching text.
286        assert!(locate_steer(&messages, "next task entirely").is_none());
287    }
288
289    #[test]
290    fn a_denial_is_located_with_the_refused_call_attached() {
291        let messages = vec![
292            Message::user("clean up"),
293            Message::assistant(vec![
294                tool_use("t1", "fs_list", json!({})),
295                tool_use("t2", "fs_write", json!({"path": "notes.md"})),
296            ]),
297            Message {
298                role: Role::User,
299                content: vec![
300                    result("t1", "ok", false),
301                    result("t2", "Denied by the user: not that file", true),
302                ],
303            },
304        ];
305        let p = locate_denial(&messages, "not that file").unwrap();
306        assert_eq!(p.message_index, 2);
307        assert_eq!(p.call_index, 1, "the denied call is the second issued");
308        assert_eq!(
309            p.denied,
310            Some(("fs_write".to_string(), json!({"path": "notes.md"})))
311        );
312        assert!(locate_denial(&messages, "some other reason").is_none());
313    }
314
315    #[test]
316    fn truncation_ends_the_slice_before_the_next_top_level_turn() {
317        let messages = steered_transcript();
318        let slice = truncate_after_run(&messages, 2);
319        assert_eq!(slice.len(), 4, "the follow-on turn is a different question");
320        // And an intervention in the last run keeps everything.
321        assert_eq!(truncate_after_run(&messages, 4).len(), 5);
322    }
323
324    #[test]
325    fn a_steer_passes_when_the_replay_tracks_the_recording_through_the_steer() {
326        let point = ProbePoint {
327            message_index: 2,
328            call_index: 2,
329            denied: None,
330        };
331        assert_eq!(
332            steer_verdict(&report(vec![], vec![]), &point),
333            ProbeVerdict::Pass
334        );
335        // Argument spellings at the steer point do not fail it.
336        let cosmetic = report(
337            vec![Divergence::Arguments {
338                index: 2,
339                tool: "fs_read".into(),
340                expected: json!({"path": "b.md"}),
341                actual: json!({"path": "./b.md"}),
342            }],
343            vec![],
344        );
345        assert_eq!(steer_verdict(&cosmetic, &point), ProbeVerdict::Pass);
346    }
347
348    #[test]
349    fn a_steer_fails_on_structural_divergence_at_or_after_the_steer_point() {
350        let point = ProbePoint {
351            message_index: 2,
352            call_index: 2,
353            denied: None,
354        };
355        let diverged = report(
356            vec![Divergence::Tool {
357                index: 2,
358                expected: "fs_read".into(),
359                actual: "fs_list".into(),
360            }],
361            vec![],
362        );
363        assert_eq!(steer_verdict(&diverged, &point), ProbeVerdict::Fail);
364        // Stopping short of the steered work is also not doing it.
365        let stopped = report(
366            vec![Divergence::Missing {
367                index: 2,
368                expected: "fs_read".into(),
369            }],
370            vec![],
371        );
372        assert_eq!(steer_verdict(&stopped, &point), ProbeVerdict::Fail);
373    }
374
375    #[test]
376    fn a_probe_that_derails_before_the_point_is_inconclusive_not_evidence() {
377        let point = ProbePoint {
378            message_index: 2,
379            call_index: 2,
380            denied: None,
381        };
382        let early = report(
383            vec![Divergence::Tool {
384                index: 0,
385                expected: "fs_list".into(),
386                actual: "shell".into(),
387            }],
388            vec![],
389        );
390        match steer_verdict(&early, &point) {
391            ProbeVerdict::Inconclusive(why) => assert!(why.contains("before the steer"), "{why}"),
392            other => panic!("expected inconclusive, got {other:?}"),
393        }
394        let denial_point = ProbePoint {
395            message_index: 2,
396            call_index: 2,
397            denied: Some(("fs_write".into(), json!({}))),
398        };
399        assert!(matches!(
400            denial_verdict(&early, &denial_point),
401            ProbeVerdict::Inconclusive(_)
402        ));
403    }
404
405    #[test]
406    fn a_denial_fails_only_on_the_exact_refused_call() {
407        let point = ProbePoint {
408            message_index: 2,
409            call_index: 1,
410            denied: Some(("fs_write".into(), json!({"path": "notes.md"}))),
411        };
412        // Repeating the refused call verbatim at the decision point is the
413        // failure. (The call before it is the faithful prefix — the denied
414        // call sits at index 1.)
415        let repeated = report(
416            vec![],
417            vec![
418                trace("fs_list", json!({})),
419                trace("fs_write", json!({"path": "notes.md"})),
420            ],
421        );
422        assert_eq!(denial_verdict(&repeated, &point), ProbeVerdict::Fail);
423        // Same tool, different target: the user denied an argument, not a
424        // capability. Divergence there is the model routing around the denial.
425        let rerouted = report(
426            vec![Divergence::Tool {
427                index: 1,
428                expected: "fs_write".into(),
429                actual: "fs_read".into(),
430            }],
431            vec![trace("fs_write", json!({"path": "drafts/notes.md"}))],
432        );
433        assert_eq!(denial_verdict(&rerouted, &point), ProbeVerdict::Pass);
434        // Avoiding the tool entirely passes too.
435        let avoided = report(vec![], vec![trace("fs_list", json!({}))]);
436        assert_eq!(denial_verdict(&avoided, &point), ProbeVerdict::Pass);
437    }
438
439    #[test]
440    fn a_denied_call_that_also_appears_before_the_denial_is_not_a_repeat() {
441        // The recording held the same exact call at index 0 — executed, then
442        // later denied at index 1. A replay faithfully walking the prefix
443        // makes that first call; only making it again at or after the denial
444        // point is walking into the refusal.
445        let point = ProbePoint {
446            message_index: 4,
447            call_index: 1,
448            denied: Some(("fs_write".into(), json!({"path": "notes.md"}))),
449        };
450        let rerouted_after_prefix = report(
451            vec![],
452            vec![
453                trace("fs_write", json!({"path": "notes.md"})),
454                trace("fs_list", json!({})),
455            ],
456        );
457        // Call #0 matches the denied call textually, but call #1 — the
458        // decision point — went elsewhere: that is compliance.
459        assert_eq!(
460            denial_verdict(&rerouted_after_prefix, &point),
461            ProbeVerdict::Pass
462        );
463        // ...whereas repeating it anywhere from the decision point on fails.
464        let repeated_later = report(
465            vec![],
466            vec![
467                trace("fs_write", json!({"path": "notes.md"})),
468                trace("fs_list", json!({})),
469                trace("fs_write", json!({"path": "notes.md"})),
470            ],
471        );
472        assert_eq!(denial_verdict(&repeated_later, &point), ProbeVerdict::Fail);
473    }
474}