car-verify 0.37.0

Formal verification for Agent IR — the novel contribution
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
//! The goal loop — a self-continuing agent loop with a **deterministic**
//! completion evaluator.
//!
//! See `docs/proposals/goal-loop.md`. The 2026 "loop engineering" shift made the
//! *loop* — not the prompt — the unit of work: state a recursive goal ("make the
//! test suite pass") and the agent iterates until it holds, no human between
//! steps. Claude Code shipped this as `/goal`, whose evaluator is a small fast
//! model that *cannot call tools* and so grades the maker's own transcript —
//! structurally vulnerable to the field's #1 failure, *hallucinated success*
//! ("done" without proof).
//!
//! CAR owns tool execution and logs it, so its stop condition can read
//! **unforgeable ground truth** instead. This module is the pure, deterministic
//! core: a composable [`GoalCondition`] evaluated over runtime-gathered
//! [`GoalInputs`], a hard [`GoalGovernor`] (real budgets, not condition prose a
//! model interprets), and the injected-closure driver [`run_goal_loop`] (like
//! [`crate::cwm::synthesize_cwm`], execution stays out of the crate so the loop
//! is unit-testable without a model or a subprocess).
//!
//! The pure core takes *already-projected* signals — `receipts_grounded`,
//! `plan_achieved`, etc. — so `car-verify` stays dependency-light; the live
//! bridge that computes them from a `Runtime`'s event log / state / substrate
//! lives in `car-engine` (per the house doctrine that decision cores are pure
//! and the live wiring is injected).

use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::future::Future;
use std::time::Instant;

/// What "done" means, as a composable predicate over runtime ground truth.
///
/// The leaf conditions each read a *deterministic* signal except
/// [`GoalCondition::ModelJudge`], the explicitly-weakest transcript-read
/// fallback (kept only for parity with `/goal` on conditions that genuinely
/// cannot be made deterministic; a run that relies on it is marked
/// **ungrounded** — see [`GoalVerdict::grounded`]).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum GoalCondition {
    /// Every child must be met (logical AND).
    AllOf { conditions: Vec<GoalCondition> },
    /// At least one child must be met (logical OR).
    AnyOf { conditions: Vec<GoalCondition> },
    /// The model's tool claims must be grounded against the runtime's receipts
    /// — no hallucinated success. Reads [`GoalInputs::receipts_grounded`].
    ToolReceiptsGrounded,
    /// A STRIPS plan check must report the goal facts achieved (no
    /// `goal_not_achieved` defect). Reads [`GoalInputs::plan_achieved`].
    PlanAchieved,
    /// Shared state must be transactionally consistent — no unresolved
    /// conflicts / belief divergence (the state-drift guard). Reads
    /// [`GoalInputs::state_consistent`].
    StateConsistent,
    /// A state key must equal an expected value (a read of *actual* state, not
    /// the transcript). Reads [`GoalInputs::state`].
    StatePredicate { key: String, equals: Value },
    /// A named deterministic command check (e.g. `npm test`) must exit with the
    /// expected code. Reads [`GoalInputs::command_exits`].
    Command { id: String, expect_exit: i32 },
    /// A labeled model-judge verdict — the ungrounded fallback. Reads
    /// [`GoalInputs::model_verdicts`].
    ModelJudge { id: String },
}

/// Runtime-gathered signals the caller projects from a `Runtime`'s event log,
/// state, and substrate before each evaluation. A signal absent when its
/// condition references it makes that condition **unmet** (fail-closed: asking
/// for a check you didn't gather is not a pass).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GoalInputs {
    /// From `Runtime::verify_tool_receipts(..).grounded`.
    #[serde(default)]
    pub receipts_grounded: Option<bool>,
    /// From `check_plan(..).valid` (no `goal_not_achieved`).
    #[serde(default)]
    pub plan_achieved: Option<bool>,
    /// From `check_transaction(..).consistent`.
    #[serde(default)]
    pub state_consistent: Option<bool>,
    /// A snapshot of shared state, for [`GoalCondition::StatePredicate`].
    #[serde(default)]
    pub state: HashMap<String, Value>,
    /// Exit codes of named command checks, for [`GoalCondition::Command`].
    #[serde(default)]
    pub command_exits: HashMap<String, i32>,
    /// Verdicts of named model judges, for [`GoalCondition::ModelJudge`].
    #[serde(default)]
    pub model_verdicts: HashMap<String, bool>,
}

/// The result of evaluating a [`GoalCondition`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GoalVerdict {
    /// Whether the goal condition holds.
    pub met: bool,
    /// Whether a `met` verdict was reached **without** relying on any
    /// [`GoalCondition::ModelJudge`] — i.e. the completion decision rests on
    /// deterministic ground truth. Always `true` when `met` is `false`
    /// (an unmet verdict relied on nothing). "Did this loop actually verify,
    /// or did it vibe-check?" as a first-class output — the thing `/goal`
    /// cannot tell you.
    pub grounded: bool,
    /// A short human/agent-readable reason, fed back as guidance on a `no`.
    pub reason: String,
}

/// Evaluate a [`GoalCondition`] against gathered [`GoalInputs`]. Pure and
/// deterministic — no I/O, no model.
pub fn evaluate_goal(condition: &GoalCondition, inputs: &GoalInputs) -> GoalVerdict {
    let e = eval(condition, inputs);
    GoalVerdict {
        met: e.met,
        // `grounded` is only meaningful for a met verdict; normalize an unmet
        // verdict to grounded=true so a `ModelJudge` branch that *failed* can't
        // poison an `AnyOf` that another branch satisfied deterministically.
        grounded: if e.met { e.grounded } else { true },
        reason: e.reason,
    }
}

struct Eval {
    met: bool,
    grounded: bool,
    reason: String,
}

fn eval(condition: &GoalCondition, inputs: &GoalInputs) -> Eval {
    match condition {
        GoalCondition::AllOf { conditions } => {
            if conditions.is_empty() {
                return Eval {
                    met: true,
                    grounded: true,
                    reason: "no conditions (vacuously met)".into(),
                };
            }
            let mut all_grounded = true;
            for c in conditions {
                let r = eval(c, inputs);
                if !r.met {
                    // First unmet child decides the AND and carries the reason.
                    return Eval {
                        met: false,
                        grounded: true,
                        reason: r.reason,
                    };
                }
                all_grounded &= r.grounded;
            }
            Eval {
                met: true,
                grounded: all_grounded,
                reason: format!("all {} conditions met", conditions.len()),
            }
        }
        GoalCondition::AnyOf { conditions } => {
            if conditions.is_empty() {
                return Eval {
                    met: false,
                    grounded: true,
                    reason: "no conditions (vacuously unmet)".into(),
                };
            }
            let mut met_grounded = false;
            let mut met_any = false;
            let mut first_met_reason = String::new();
            let mut reasons = Vec::new();
            for c in conditions {
                let r = eval(c, inputs);
                if r.met {
                    if !met_any {
                        first_met_reason = r.reason.clone();
                    }
                    met_any = true;
                    // Prefer a grounded satisfier: an AnyOf is grounded as long
                    // as at least one *deterministic* child is met, even if a
                    // ModelJudge child would also satisfy it.
                    met_grounded |= r.grounded;
                }
                reasons.push(r.reason);
            }
            if met_any {
                Eval {
                    met: true,
                    grounded: met_grounded,
                    reason: first_met_reason,
                }
            } else {
                Eval {
                    met: false,
                    grounded: true,
                    reason: format!(
                        "none of {} conditions met: {}",
                        conditions.len(),
                        reasons.join("; ")
                    ),
                }
            }
        }
        GoalCondition::ToolReceiptsGrounded => match inputs.receipts_grounded {
            Some(true) => grounded_leaf("tool claims are grounded against the receipts"),
            Some(false) => unmet("tool claims are NOT grounded — hallucinated tool use detected"),
            None => unmet("tool-receipt grounding was not gathered"),
        },
        GoalCondition::PlanAchieved => match inputs.plan_achieved {
            Some(true) => grounded_leaf("plan check reports the goal facts achieved"),
            Some(false) => unmet("plan check reports the goal facts are not yet achieved"),
            None => unmet("plan check was not gathered"),
        },
        GoalCondition::StateConsistent => match inputs.state_consistent {
            Some(true) => grounded_leaf("shared state is transactionally consistent"),
            Some(false) => unmet("shared state is inconsistent (unresolved conflict / drift)"),
            None => unmet("state consistency was not gathered"),
        },
        GoalCondition::StatePredicate { key, equals } => match inputs.state.get(key) {
            Some(v) if values_equal(v, equals) => {
                grounded_leaf(&format!("state['{key}'] == {equals}"))
            }
            Some(v) => unmet(&format!("state['{key}'] is {v}, expected {equals}")),
            None => unmet(&format!("state key '{key}' is not present")),
        },
        GoalCondition::Command { id, expect_exit } => match inputs.command_exits.get(id) {
            Some(code) if code == expect_exit => {
                grounded_leaf(&format!("command '{id}' exited {expect_exit}"))
            }
            Some(code) => unmet(&format!(
                "command '{id}' exited {code}, expected {expect_exit}"
            )),
            None => unmet(&format!("command check '{id}' was not run")),
        },
        GoalCondition::ModelJudge { id } => match inputs.model_verdicts.get(id) {
            // A satisfied ModelJudge is met but NOT grounded — this is the
            // whole point of the flag.
            Some(true) => Eval {
                met: true,
                grounded: false,
                reason: format!("model judge '{id}' says met (UNGROUNDED — transcript-only)"),
            },
            Some(false) => unmet(&format!("model judge '{id}' says not yet met")),
            None => unmet(&format!("model judge '{id}' returned no verdict")),
        },
    }
}

fn grounded_leaf(reason: &str) -> Eval {
    Eval {
        met: true,
        grounded: true,
        reason: reason.to_string(),
    }
}

fn unmet(reason: &str) -> Eval {
    Eval {
        met: false,
        grounded: true,
        reason: reason.to_string(),
    }
}

/// Numeric-aware JSON equality (so `1` and `1.0` compare equal), matching
/// [`crate::transaction`]'s value comparison.
fn values_equal(a: &Value, b: &Value) -> bool {
    match (a, b) {
        (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) {
            (Some(fx), Some(fy)) => fx == fy,
            _ => x == y,
        },
        _ => a == b,
    }
}

// --- Governor (hard bounds) ---

/// Hard, deterministic bounds the driver enforces — the Governor role. Unlike
/// `/goal`'s soft "or stop after 20 turns" clause (written into the condition
/// text and judged by the same model from the chat), these are counters the
/// loop checks itself. Any `None` field is unbounded.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GoalGovernor {
    /// Maximum iterations before halting.
    #[serde(default)]
    pub max_turns: Option<u32>,
    /// Maximum cumulative cost in USD before halting (reads the same monotonic
    /// counter `metrics.alerts` uses, so a retention trim never un-bounds it).
    #[serde(default)]
    pub max_cost_usd: Option<f64>,
    /// Maximum wall-clock seconds before halting.
    #[serde(default)]
    pub max_wall_secs: Option<u64>,
    /// Halt after this many consecutive iterations with no observable progress
    /// (the compounding-errors / thrash guard).
    #[serde(default)]
    pub no_progress_turns: Option<u32>,
}

/// Mutable running totals the governor checks against.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GoalRunState {
    pub turns: u32,
    pub cost_usd: f64,
    pub elapsed_secs: u64,
    pub turns_since_progress: u32,
}

/// Why a goal loop stopped short of its condition.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GoalHalt {
    /// Hit `max_turns`.
    TurnBudget,
    /// Hit `max_cost_usd`.
    CostBudget,
    /// Hit `max_wall_secs`.
    WallClock,
    /// Hit `no_progress_turns` consecutive no-progress iterations.
    NoProgress,
    /// The caller cancelled the run (e.g. a user interrupt / `agent.chat.cancel`).
    Cancelled,
}

impl GoalHalt {
    pub fn as_str(&self) -> &'static str {
        match self {
            GoalHalt::TurnBudget => "turn budget exhausted",
            GoalHalt::CostBudget => "cost budget exhausted",
            GoalHalt::WallClock => "wall-clock budget exhausted",
            GoalHalt::NoProgress => "no progress across the allowed iterations",
            GoalHalt::Cancelled => "cancelled",
        }
    }
}

/// Return the first governor bound that has been crossed, if any. Pure.
pub fn governor_check(gov: &GoalGovernor, state: &GoalRunState) -> Option<GoalHalt> {
    if let Some(max) = gov.max_turns {
        if state.turns >= max {
            return Some(GoalHalt::TurnBudget);
        }
    }
    if let Some(max) = gov.max_cost_usd {
        if state.cost_usd >= max {
            return Some(GoalHalt::CostBudget);
        }
    }
    if let Some(max) = gov.max_wall_secs {
        if state.elapsed_secs >= max {
            return Some(GoalHalt::WallClock);
        }
    }
    if let Some(max) = gov.no_progress_turns {
        if state.turns_since_progress >= max {
            return Some(GoalHalt::NoProgress);
        }
    }
    None
}

// --- The loop driver ---

/// A full goal specification: the objective text (the drift anchor), the
/// completion condition, and the governor.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoalSpec {
    pub goal: String,
    pub condition: GoalCondition,
    #[serde(default)]
    pub governor: GoalGovernor,
}

/// What one iteration reported back to the loop.
#[derive(Debug, Clone, Default)]
pub struct IterationOutcome {
    /// Cost of this iteration in USD (folded into the governor budget).
    pub cost_usd: f64,
    /// Whether this iteration made observable progress (drives the no-progress
    /// governor). The caller derives this from ground truth — e.g. a new
    /// `ActionSucceeded` in the event log or a changed state key.
    pub made_progress: bool,
}

/// Terminal state of a goal loop.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum GoalStatus {
    /// The condition held.
    Achieved,
    /// A governor bound stopped the loop first.
    Halted { halt: GoalHalt },
}

/// The record of a completed goal loop.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoalRun {
    pub status: GoalStatus,
    pub iterations: u32,
    /// Whether the achieving verdict rested on deterministic ground truth
    /// (false if it completed only via a `ModelJudge`). For a halted run this
    /// reflects the last verdict.
    pub grounded: bool,
    pub cost_usd: f64,
    /// The final verdict's reason (why it stopped).
    pub last_reason: String,
    /// Per-iteration verdicts — the auditable "why did it continue?" trail
    /// (mirrors the `GoalEvaluated` events the live driver emits).
    pub evidence: Vec<GoalVerdict>,
}

/// Re-derive the per-iteration directive from the pinned goal text — never from
/// mutable state — so no iteration can repoint the objective (the goal-drift
/// guard, mirroring `car-workflow`'s reserved `goal` anchor). The most recent
/// unmet reason is appended as guidance (the `/goal` "no → keep working with the
/// reason" behavior, but the reason is grounded).
pub fn anchor_directive(goal: &str, last_reason: &str) -> String {
    if last_reason.is_empty() {
        format!("Overall goal: {goal}")
    } else {
        format!("Overall goal: {goal}\n\nNot yet met — {last_reason}\nContinue working toward the goal.")
    }
}

/// Drive a goal loop to completion or a governor halt.
///
/// Execution is fully injected (the [`crate::cwm::synthesize_cwm`] pattern), so
/// this is unit-testable without a model or subprocess:
/// - `run_iteration(directive)` performs one iteration (one native turn, or one
///   external Codex/Claude Code run) and returns its [`IterationOutcome`].
/// - `gather_inputs()` projects the current [`GoalInputs`] from ground truth
///   after that iteration.
///
/// Each round: governor pre-check → anchor the directive → run one iteration →
/// gather ground truth → [`evaluate_goal`] → return on met, else feed the reason
/// back and repeat.
pub async fn run_goal_loop<Run, RunFut, Gather, GatherFut>(
    spec: &GoalSpec,
    mut run_iteration: Run,
    mut gather_inputs: Gather,
) -> GoalRun
where
    Run: FnMut(String) -> RunFut,
    RunFut: Future<Output = IterationOutcome>,
    Gather: FnMut() -> GatherFut,
    GatherFut: Future<Output = GoalInputs>,
{
    let start = Instant::now();
    let mut run_state = GoalRunState::default();
    let mut evidence: Vec<GoalVerdict> = Vec::new();
    let mut last_reason = String::new();

    loop {
        run_state.elapsed_secs = start.elapsed().as_secs();
        // Governor pre-check: don't spend another iteration past a hard bound.
        if let Some(halt) = governor_check(&spec.governor, &run_state) {
            return GoalRun {
                status: GoalStatus::Halted { halt },
                iterations: run_state.turns,
                grounded: evidence.last().map(|v| v.grounded).unwrap_or(true),
                cost_usd: run_state.cost_usd,
                last_reason: if last_reason.is_empty() {
                    halt.as_str().to_string()
                } else {
                    format!("{} ({})", halt.as_str(), last_reason)
                },
                evidence,
            };
        }

        // Anchor the directive from the pinned goal (drift guard) and run.
        let directive = anchor_directive(&spec.goal, &last_reason);
        let outcome = run_iteration(directive).await;
        run_state.turns += 1;
        run_state.cost_usd += outcome.cost_usd;
        if outcome.made_progress {
            run_state.turns_since_progress = 0;
        } else {
            run_state.turns_since_progress += 1;
        }
        run_state.elapsed_secs = start.elapsed().as_secs();

        // Evaluate against freshly-gathered ground truth.
        let inputs = gather_inputs().await;
        let verdict = evaluate_goal(&spec.condition, &inputs);
        evidence.push(verdict.clone());

        if verdict.met {
            return GoalRun {
                status: GoalStatus::Achieved,
                iterations: run_state.turns,
                grounded: verdict.grounded,
                cost_usd: run_state.cost_usd,
                last_reason: verdict.reason,
                evidence,
            };
        }
        last_reason = verdict.reason;
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use std::cell::Cell;

    fn inputs() -> GoalInputs {
        GoalInputs::default()
    }

    #[test]
    fn receipts_grounded_leaf() {
        let mut i = inputs();
        i.receipts_grounded = Some(true);
        let v = evaluate_goal(&GoalCondition::ToolReceiptsGrounded, &i);
        assert!(v.met && v.grounded);

        i.receipts_grounded = Some(false);
        let v = evaluate_goal(&GoalCondition::ToolReceiptsGrounded, &i);
        assert!(!v.met);
        assert!(v.reason.contains("hallucinated"));
    }

    #[test]
    fn missing_input_fails_closed() {
        // Asking for a check you didn't gather is NOT a pass.
        let v = evaluate_goal(&GoalCondition::ToolReceiptsGrounded, &inputs());
        assert!(!v.met);
        assert!(v.reason.contains("not gathered"));
    }

    #[test]
    fn state_predicate_numeric_equality() {
        let mut i = inputs();
        i.state.insert("count".into(), json!(3));
        let c = GoalCondition::StatePredicate {
            key: "count".into(),
            equals: json!(3.0),
        };
        assert!(evaluate_goal(&c, &i).met, "1 and 1.0 must compare equal");

        let c2 = GoalCondition::StatePredicate {
            key: "count".into(),
            equals: json!(4),
        };
        let v = evaluate_goal(&c2, &i);
        assert!(!v.met);
        assert!(v.reason.contains("expected"));
    }

    #[test]
    fn command_exit_code() {
        let mut i = inputs();
        i.command_exits.insert("tests".into(), 0);
        let ok = GoalCondition::Command {
            id: "tests".into(),
            expect_exit: 0,
        };
        assert!(evaluate_goal(&ok, &i).met);

        i.command_exits.insert("tests".into(), 1);
        let v = evaluate_goal(&ok, &i);
        assert!(!v.met);
        assert!(v.reason.contains("exited 1"));
    }

    #[test]
    fn all_of_requires_every_child() {
        let mut i = inputs();
        i.receipts_grounded = Some(true);
        i.command_exits.insert("tests".into(), 0);
        let c = GoalCondition::AllOf {
            conditions: vec![
                GoalCondition::ToolReceiptsGrounded,
                GoalCondition::Command {
                    id: "tests".into(),
                    expect_exit: 0,
                },
            ],
        };
        let v = evaluate_goal(&c, &i);
        assert!(v.met && v.grounded);
        assert!(v.reason.contains("all 2 conditions met"));

        // Flip one child unmet -> whole AND unmet, reason names it.
        i.command_exits.insert("tests".into(), 1);
        let v = evaluate_goal(&c, &i);
        assert!(!v.met);
        assert!(v.reason.contains("command 'tests' exited 1"));
    }

    #[test]
    fn any_of_needs_one() {
        let mut i = inputs();
        i.command_exits.insert("a".into(), 1);
        i.command_exits.insert("b".into(), 0);
        let c = GoalCondition::AnyOf {
            conditions: vec![
                GoalCondition::Command {
                    id: "a".into(),
                    expect_exit: 0,
                },
                GoalCondition::Command {
                    id: "b".into(),
                    expect_exit: 0,
                },
            ],
        };
        assert!(evaluate_goal(&c, &i).met);
    }

    #[test]
    fn model_judge_makes_verdict_ungrounded() {
        let mut i = inputs();
        i.model_verdicts.insert("clarity".into(), true);
        let v = evaluate_goal(
            &GoalCondition::ModelJudge {
                id: "clarity".into(),
            },
            &i,
        );
        assert!(v.met);
        assert!(
            !v.grounded,
            "a ModelJudge-satisfied verdict must be ungrounded"
        );
    }

    #[test]
    fn any_of_grounded_when_a_deterministic_branch_satisfies() {
        // Both a ModelJudge and a Command would satisfy; the deterministic
        // branch keeps the AnyOf grounded.
        let mut i = inputs();
        i.model_verdicts.insert("j".into(), true);
        i.command_exits.insert("tests".into(), 0);
        let c = GoalCondition::AnyOf {
            conditions: vec![
                GoalCondition::ModelJudge { id: "j".into() },
                GoalCondition::Command {
                    id: "tests".into(),
                    expect_exit: 0,
                },
            ],
        };
        let v = evaluate_goal(&c, &i);
        assert!(v.met && v.grounded);
    }

    #[test]
    fn any_of_ungrounded_when_only_model_judge_satisfies() {
        let mut i = inputs();
        i.model_verdicts.insert("j".into(), true);
        i.command_exits.insert("tests".into(), 1); // fails
        let c = GoalCondition::AnyOf {
            conditions: vec![
                GoalCondition::ModelJudge { id: "j".into() },
                GoalCondition::Command {
                    id: "tests".into(),
                    expect_exit: 0,
                },
            ],
        };
        let v = evaluate_goal(&c, &i);
        assert!(v.met);
        assert!(!v.grounded, "met only via ModelJudge -> ungrounded");
    }

    // --- governor ---

    #[test]
    fn governor_turn_budget() {
        let gov = GoalGovernor {
            max_turns: Some(3),
            ..Default::default()
        };
        let mut s = GoalRunState::default();
        s.turns = 2;
        assert!(governor_check(&gov, &s).is_none());
        s.turns = 3;
        assert_eq!(governor_check(&gov, &s), Some(GoalHalt::TurnBudget));
    }

    #[test]
    fn governor_cost_and_wall_and_no_progress() {
        let gov = GoalGovernor {
            max_cost_usd: Some(1.0),
            max_wall_secs: Some(60),
            no_progress_turns: Some(2),
            ..Default::default()
        };
        let mut s = GoalRunState::default();
        s.cost_usd = 1.5;
        assert_eq!(governor_check(&gov, &s), Some(GoalHalt::CostBudget));
        s.cost_usd = 0.0;
        s.elapsed_secs = 61;
        assert_eq!(governor_check(&gov, &s), Some(GoalHalt::WallClock));
        s.elapsed_secs = 0;
        s.turns_since_progress = 2;
        assert_eq!(governor_check(&gov, &s), Some(GoalHalt::NoProgress));
    }

    #[test]
    fn anchor_pins_goal_and_appends_reason() {
        let d = anchor_directive("make tests pass", "");
        assert_eq!(d, "Overall goal: make tests pass");
        let d = anchor_directive("make tests pass", "command 'tests' exited 1");
        assert!(d.starts_with("Overall goal: make tests pass"));
        assert!(d.contains("Not yet met — command 'tests' exited 1"));
    }

    // --- driver ---

    #[tokio::test]
    async fn loop_converges_after_progress() {
        // A mock agent that "fixes the tests" on the 3rd iteration.
        let iters = Cell::new(0u32);
        let spec = GoalSpec {
            goal: "make tests pass".into(),
            condition: GoalCondition::Command {
                id: "tests".into(),
                expect_exit: 0,
            },
            governor: GoalGovernor {
                max_turns: Some(10),
                ..Default::default()
            },
        };
        let run = |_directive: String| {
            let n = iters.get() + 1;
            iters.set(n);
            async move {
                IterationOutcome {
                    cost_usd: 0.01,
                    made_progress: true,
                }
            }
        };
        let gather = || {
            let passing = iters.get() >= 3;
            async move {
                let mut i = GoalInputs::default();
                i.command_exits
                    .insert("tests".into(), if passing { 0 } else { 1 });
                i
            }
        };
        let run = run_goal_loop(&spec, run, gather).await;
        assert_eq!(run.status, GoalStatus::Achieved);
        assert_eq!(run.iterations, 3);
        assert!(run.grounded);
        assert!((run.cost_usd - 0.03).abs() < 1e-9);
        assert_eq!(run.evidence.len(), 3);
    }

    #[tokio::test]
    async fn loop_halts_on_turn_budget_when_never_converges() {
        let spec = GoalSpec {
            goal: "impossible".into(),
            condition: GoalCondition::Command {
                id: "tests".into(),
                expect_exit: 0,
            },
            governor: GoalGovernor {
                max_turns: Some(4),
                ..Default::default()
            },
        };
        let run = |_d: String| async {
            IterationOutcome {
                cost_usd: 0.0,
                made_progress: true,
            }
        };
        let gather = || async {
            let mut i = GoalInputs::default();
            i.command_exits.insert("tests".into(), 1); // always failing
            i
        };
        let run = run_goal_loop(&spec, run, gather).await;
        assert_eq!(
            run.status,
            GoalStatus::Halted {
                halt: GoalHalt::TurnBudget
            }
        );
        assert_eq!(run.iterations, 4);
    }

    #[tokio::test]
    async fn loop_halts_on_no_progress() {
        let spec = GoalSpec {
            goal: "stuck".into(),
            condition: GoalCondition::Command {
                id: "tests".into(),
                expect_exit: 0,
            },
            governor: GoalGovernor {
                max_turns: Some(100),
                no_progress_turns: Some(2),
                ..Default::default()
            },
        };
        let run = |_d: String| async {
            IterationOutcome {
                cost_usd: 0.0,
                made_progress: false, // thrashing
            }
        };
        let gather = || async {
            let mut i = GoalInputs::default();
            i.command_exits.insert("tests".into(), 1);
            i
        };
        let run = run_goal_loop(&spec, run, gather).await;
        assert_eq!(
            run.status,
            GoalStatus::Halted {
                halt: GoalHalt::NoProgress
            }
        );
        // Two no-progress iterations, then the pre-check halts the third.
        assert_eq!(run.iterations, 2);
    }

    #[tokio::test]
    async fn loop_reports_ungrounded_when_completed_via_model_judge() {
        let spec = GoalSpec {
            goal: "reads clearly".into(),
            condition: GoalCondition::ModelJudge {
                id: "clarity".into(),
            },
            governor: GoalGovernor {
                max_turns: Some(5),
                ..Default::default()
            },
        };
        let run = |_d: String| async {
            IterationOutcome {
                cost_usd: 0.0,
                made_progress: true,
            }
        };
        let gather = || async {
            let mut i = GoalInputs::default();
            i.model_verdicts.insert("clarity".into(), true);
            i
        };
        let run = run_goal_loop(&spec, run, gather).await;
        assert_eq!(run.status, GoalStatus::Achieved);
        assert!(
            !run.grounded,
            "a run that completed only via a model judge must report ungrounded"
        );
    }

    #[test]
    fn goal_condition_round_trips_through_json() {
        let c = GoalCondition::AllOf {
            conditions: vec![
                GoalCondition::ToolReceiptsGrounded,
                GoalCondition::StatePredicate {
                    key: "clean".into(),
                    equals: json!(true),
                },
            ],
        };
        let s = serde_json::to_string(&c).unwrap();
        let back: GoalCondition = serde_json::from_str(&s).unwrap();
        assert_eq!(c, back);
    }
}