Skip to main content

car_engine/
goal.rs

1//! Live bridge for the goal loop: project [`GoalInputs`] from a [`Runtime`]'s
2//! **ground truth** — its event-log receipts, its shared state, its
3//! transactional consistency — so `car-verify`'s pure
4//! [`car_verify::goal::evaluate_goal`] decides completion against what actually
5//! happened, not a transcript read.
6//!
7//! See `docs/proposals/goal-loop.md`. This is the deterministic Evaluator half
8//! of the goal loop. It is the seam the flagship assistant and the external
9//! (Codex / Claude Code) executors plug into: each iteration, the caller runs
10//! any command / model-judge checks it owns, then calls
11//! [`Runtime::gather_goal_inputs`] to fold in the runtime-owned signals, then
12//! runs `evaluate_goal`. The loop driver itself
13//! ([`car_verify::goal::run_goal_loop`]) stays injected and crate-free.
14
15use crate::Runtime;
16use car_eventlog::tool_receipts::ToolClaim;
17use car_eventlog::EventKind;
18use car_ir::ActionProposal;
19use car_verify::goal::{GoalCondition, GoalInputs};
20use car_verify::plan_check::{check_plan, PlanCheckRequest};
21use car_verify::transaction::check_transaction;
22use std::collections::HashMap;
23
24/// What to project into a [`GoalInputs`] for one evaluation. The
25/// runtime-owned signals (`claims`, `plan`, `transaction_proposal`,
26/// `state_keys`) are gathered by [`Runtime::gather_goal_inputs`]; the
27/// caller-owned checks (`command_exits`, `model_verdicts`) are passed through
28/// verbatim, since running a shell check or a model judge belongs to the
29/// executor, not the runtime (the same injected split the driver uses).
30#[derive(Debug, Clone, Default)]
31pub struct GoalGather {
32    /// The model's tool claims for this iteration (from its tool_calls / IR),
33    /// cross-checked against the event-log receipts. Empty ⇒ nothing claimed,
34    /// which is vacuously grounded.
35    pub claims: Vec<ToolClaim>,
36    /// Scope the receipt check to one proposal's events.
37    pub proposal_id: Option<String>,
38    /// A STRIPS plan to forward-check (drives `plan_achieved`).
39    pub plan: Option<PlanCheckRequest>,
40    /// A proposal to check for transactional consistency against current state
41    /// (drives `state_consistent` — the state-drift guard).
42    pub transaction_proposal: Option<ActionProposal>,
43    /// State keys to include in the snapshot for `StatePredicate` conditions;
44    /// empty ⇒ the whole state.
45    pub state_keys: Vec<String>,
46    /// Exit codes of command checks the caller already ran on the substrate.
47    pub command_exits: HashMap<String, i32>,
48    /// Verdicts of model judges the caller already gathered.
49    pub model_verdicts: HashMap<String, bool>,
50}
51
52impl Runtime {
53    /// Project a [`GoalInputs`] from this runtime's ground truth plus the
54    /// caller-supplied checks. Reads receipts from the event log
55    /// ([`Runtime::verify_tool_receipts`]), the live state snapshot, and — when
56    /// a proposal is supplied — transactional consistency. Never fabricates a
57    /// signal: a check whose input wasn't requested is left `None`, so the
58    /// pure evaluator fails it closed.
59    pub async fn gather_goal_inputs(&self, gather: &GoalGather) -> GoalInputs {
60        // Receipts: always computed (empty claims ⇒ grounded=true, vacuously),
61        // so a no-claims iteration still satisfies a `ToolReceiptsGrounded`
62        // leaf rather than failing it closed for lack of input.
63        let receipts_grounded = Some(
64            self.verify_tool_receipts(&gather.claims, gather.proposal_id.as_deref())
65                .await
66                .grounded,
67        );
68
69        let plan_achieved = gather.plan.as_ref().map(|p| check_plan(p).valid);
70
71        let full = self.state.snapshot();
72        let state = if gather.state_keys.is_empty() {
73            full
74        } else {
75            gather
76                .state_keys
77                .iter()
78                .filter_map(|k| full.get(k).map(|v| (k.clone(), v.clone())))
79                .collect()
80        };
81
82        let state_consistent = gather.transaction_proposal.as_ref().map(|p| {
83            let (values, versions) = self.state.versioned_snapshot();
84            check_transaction(p, &versions, Some(&values)).consistent
85        });
86
87        GoalInputs {
88            receipts_grounded,
89            plan_achieved,
90            state_consistent,
91            state,
92            command_exits: gather.command_exits.clone(),
93            model_verdicts: gather.model_verdicts.clone(),
94        }
95    }
96
97    /// Append a durable goal-verifier result to the runtime event log.
98    ///
99    /// This is the audit counterpart to the live `GoalEvaluated` stream event:
100    /// hosts can miss a websocket frame, but the session journal still records
101    /// why the verifier continued or accepted the run. `model`/`model_tier`
102    /// attribute the verdict to the model that produced the turn, so an
103    /// ungrounded completion is queryable as "fails on local, passes on cloud"
104    /// — the same provenance `record_turn_completed` stamps on the default path.
105    pub async fn record_goal_evaluated(
106        &self,
107        goal: &str,
108        condition: &GoalCondition,
109        iteration: u32,
110        met: bool,
111        grounded: bool,
112        reason: &str,
113        model: &str,
114    ) {
115        let mut data = HashMap::new();
116        data.insert("goal".to_string(), serde_json::json!(goal));
117        data.insert(
118            "condition".to_string(),
119            serde_json::to_value(condition).unwrap_or(serde_json::Value::Null),
120        );
121        data.insert("iteration".to_string(), serde_json::json!(iteration));
122        data.insert("met".to_string(), serde_json::json!(met));
123        data.insert("grounded".to_string(), serde_json::json!(grounded));
124        data.insert("reason".to_string(), serde_json::json!(reason));
125        data.insert("model_id".to_string(), serde_json::json!(model));
126        data.insert(
127            "model_tier".to_string(),
128            serde_json::json!(model_tier(model)),
129        );
130        self.log
131            .lock()
132            .await
133            .append(EventKind::GoalEvaluated, None, None, data);
134    }
135
136    /// Append a durable completion-decision record for an assistant/coder turn
137    /// loop. The default (goal-less) loop declares success the instant the model
138    /// emits no tool calls, with no truncation or outcome check — so a truncated
139    /// or turn-capped run is indistinguishable from a real finish at the
140    /// terminal. Recording `decision`/`stop_reason`/`was_truncated`/`turns` makes
141    /// that decision a first-class, queryable event: the false-success and
142    /// never-finished signals live here on the ungrounded default path, which
143    /// emits no `GoalEvaluated`.
144    pub async fn record_turn_completed(
145        &self,
146        decision: &str,
147        stop_reason: Option<&str>,
148        was_truncated: bool,
149        turns: u32,
150        model: &str,
151    ) {
152        let data = turn_completed_data(decision, stop_reason, was_truncated, turns, model);
153        self.log
154            .lock()
155            .await
156            .append(EventKind::TurnCompleted, None, None, data);
157    }
158}
159
160/// Build the durable `TurnCompleted` event-data map.
161///
162/// Shared by the assistant loop's [`Runtime::record_turn_completed`] and the
163/// coder loop's terminal (`EventSink::record_turn_completed` in
164/// `car-server-core`), so the two paths never drift on the keys the harness
165/// miners parse (`harness_adapt::diagnose`, `failed_trace_events`): `decision`,
166/// `stop_reason`, `was_truncated`, `turns`, and the P0c provenance
167/// `model_id` (authoritative) / `model_tier` (derived local|cloud grouping).
168///
169/// The coder loop stamps one further key on top of these — `models_served`,
170/// every model that served a turn in that iteration (car#1333) — which no miner
171/// parses and which the assistant path has no answer to, since nothing there
172/// consumes authorship. Non-drift is a promise about the keys above, not about
173/// the whole record.
174pub fn turn_completed_data(
175    decision: &str,
176    stop_reason: Option<&str>,
177    was_truncated: bool,
178    turns: u32,
179    model: &str,
180) -> HashMap<String, serde_json::Value> {
181    let mut data = HashMap::new();
182    data.insert("decision".to_string(), serde_json::json!(decision));
183    data.insert("stop_reason".to_string(), serde_json::json!(stop_reason));
184    data.insert(
185        "was_truncated".to_string(),
186        serde_json::json!(was_truncated),
187    );
188    data.insert("turns".to_string(), serde_json::json!(turns));
189    data.insert("model_id".to_string(), serde_json::json!(model));
190    data.insert(
191        "model_tier".to_string(),
192        serde_json::json!(model_tier(model)),
193    );
194    data
195}
196
197/// Best-effort `local` | `cloud` | `unknown` tier from a model id's provider
198/// prefix (the segment before the first `/`).
199///
200/// `model_id` is the authoritative attribution field; `model_tier` is a
201/// convenience grouping so "fails on local vs cloud" — the framing of Matt's
202/// field report (works on Codex/Opus, breaks on a local model) — can be
203/// queried without re-deriving the prefix. Shared by the `GoalEvaluated` /
204/// `TurnCompleted` event emitters and the offline bench corpus (`car-bench`)
205/// so the provider allow-lists never drift between call sites.
206pub fn model_tier(model: &str) -> &'static str {
207    match model.split('/').next().unwrap_or("") {
208        "mlx" | "mlx-vlm" | "vllm-mlx" | "local" | "apple" | "qwen" => "local",
209        "anthropic" | "openai" | "google" | "gemini" | "parslee" => "cloud",
210        _ => "unknown",
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::ToolExecutor;
218    use car_eventlog::tool_receipts::{ClaimKind, ToolClaim};
219    use car_ir::{Action, ActionProposal, ActionType};
220    use car_verify::goal::{evaluate_goal, GoalCondition};
221    use serde_json::Value;
222    use std::sync::Arc;
223
224    struct EchoExec;
225    #[async_trait::async_trait]
226    impl ToolExecutor for EchoExec {
227        async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
228            match tool {
229                "echo" => Ok(params.get("message").cloned().unwrap_or(Value::Null)),
230                _ => Err(format!("unknown tool: {tool}")),
231            }
232        }
233    }
234
235    fn tool_call(tool: &str, params: HashMap<String, Value>) -> Action {
236        let mut a = Action::tool_call(tool);
237        a.parameters = params;
238        a
239    }
240
241    fn state_write(key: &str, value: Value) -> Action {
242        let mut a = Action::new(ActionType::StateWrite);
243        a.parameters = [
244            ("key".to_string(), Value::from(key)),
245            ("value".to_string(), value),
246        ]
247        .into();
248        a
249    }
250
251    fn proposal(actions: Vec<Action>) -> ActionProposal {
252        ActionProposal {
253            id: "goal-test".to_string(),
254            source: "test".to_string(),
255            actions,
256            timestamp: chrono::Utc::now(),
257            context: HashMap::new(),
258        }
259    }
260
261    /// End-to-end over REAL ground truth: execute a proposal that calls a tool
262    /// and writes state, then gather inputs and evaluate a goal condition —
263    /// proving `receipts_grounded` reads the event log and `StatePredicate`
264    /// reads live state.
265    #[tokio::test]
266    async fn gather_reads_real_receipts_and_state() {
267        let rt = Runtime::new().with_executor(Arc::new(EchoExec));
268        rt.register_tool("echo").await;
269
270        let p = proposal(vec![
271            tool_call("echo", [("message".to_string(), Value::from("hi"))].into()),
272            state_write("tests_passing", Value::Bool(true)),
273        ]);
274        let r = rt.execute(&p).await;
275        assert!(r.all_succeeded(), "setup proposal must succeed");
276
277        // A truthful claim ("I invoked echo") + the real state write.
278        let gather = GoalGather {
279            claims: vec![ToolClaim {
280                kind: ClaimKind::Invoked,
281                tool: "echo".to_string(),
282                call_id: None,
283                count: None,
284                text: None,
285            }],
286            proposal_id: Some("goal-test".to_string()),
287            ..Default::default()
288        };
289        let inputs = rt.gather_goal_inputs(&gather).await;
290        assert_eq!(inputs.receipts_grounded, Some(true));
291        assert_eq!(inputs.state.get("tests_passing"), Some(&Value::Bool(true)));
292
293        let cond = GoalCondition::AllOf {
294            conditions: vec![
295                GoalCondition::ToolReceiptsGrounded,
296                GoalCondition::StatePredicate {
297                    key: "tests_passing".into(),
298                    equals: Value::Bool(true),
299                },
300            ],
301        };
302        let verdict = evaluate_goal(&cond, &inputs);
303        assert!(verdict.met && verdict.grounded, "{}", verdict.reason);
304    }
305
306    /// The `/goal` blindspot, closed: a fabricated tool claim makes the
307    /// grounded-receipts condition fail even though the model "said" it ran.
308    #[tokio::test]
309    async fn fabricated_claim_fails_the_receipts_condition() {
310        let rt = Runtime::new().with_executor(Arc::new(EchoExec));
311        rt.register_tool("echo").await;
312        let r = rt
313            .execute(&proposal(vec![tool_call(
314                "echo",
315                [("message".to_string(), Value::from("hi"))].into(),
316            )]))
317            .await;
318        assert!(r.all_succeeded());
319
320        // The model claims it deployed — but no deploy ever ran.
321        let gather = GoalGather {
322            claims: vec![ToolClaim {
323                kind: ClaimKind::Invoked,
324                tool: "deploy".to_string(),
325                call_id: None,
326                count: None,
327                text: Some("I deployed it".to_string()),
328            }],
329            proposal_id: Some("goal-test".to_string()),
330            ..Default::default()
331        };
332        let inputs = rt.gather_goal_inputs(&gather).await;
333        assert_eq!(inputs.receipts_grounded, Some(false));
334
335        let verdict = evaluate_goal(&GoalCondition::ToolReceiptsGrounded, &inputs);
336        assert!(
337            !verdict.met,
338            "a hallucinated tool claim must not satisfy the goal"
339        );
340        assert!(verdict.reason.contains("hallucinated"));
341    }
342
343    #[tokio::test]
344    async fn record_goal_evaluated_appends_auditable_event() {
345        let rt = Runtime::new();
346        let condition = GoalCondition::Command {
347            id: "tests".to_string(),
348            expect_exit: 0,
349        };
350        rt.record_goal_evaluated(
351            "make tests pass",
352            &condition,
353            2,
354            true,
355            true,
356            "command `test` exited 0",
357            "mlx/qwen3-8b:4bit",
358        )
359        .await;
360
361        let log = rt.log.lock().await;
362        let event = log.events().last().expect("goal event should be recorded");
363        assert_eq!(event.kind, car_eventlog::EventKind::GoalEvaluated);
364        assert_eq!(
365            event.data.get("goal"),
366            Some(&serde_json::json!("make tests pass"))
367        );
368        assert_eq!(
369            event.data.get("condition"),
370            Some(&serde_json::json!({"kind": "command", "id": "tests", "expect_exit": 0}))
371        );
372        assert_eq!(event.data.get("iteration"), Some(&serde_json::json!(2)));
373        assert_eq!(event.data.get("met"), Some(&serde_json::json!(true)));
374        assert_eq!(event.data.get("grounded"), Some(&serde_json::json!(true)));
375        assert_eq!(
376            event.data.get("reason"),
377            Some(&serde_json::json!("command `test` exited 0"))
378        );
379        // Model provenance stamped on the goal path too (mirrors TurnCompleted),
380        // so an ungrounded completion is attributable to local vs cloud.
381        assert_eq!(
382            event.data.get("model_id"),
383            Some(&serde_json::json!("mlx/qwen3-8b:4bit"))
384        );
385        assert_eq!(
386            event.data.get("model_tier"),
387            Some(&serde_json::json!("local"))
388        );
389    }
390
391    #[test]
392    fn model_tier_maps_provider_prefixes() {
393        // Local inference backends.
394        assert_eq!(model_tier("mlx/qwen3-8b:4bit"), "local");
395        assert_eq!(model_tier("qwen/qwen3-8b"), "local");
396        assert_eq!(model_tier("apple/foundation"), "local");
397        // Cloud providers.
398        assert_eq!(model_tier("anthropic/claude-opus-4-8"), "cloud");
399        assert_eq!(model_tier("openai/gpt-5.5"), "cloud");
400        assert_eq!(model_tier("google/gemini-2.5-pro"), "cloud");
401        assert_eq!(model_tier("parslee/reasoning"), "cloud");
402        // Unrecognized prefix and the empty-model (no generate completed) case
403        // both fall through to "unknown" rather than being misattributed.
404        assert_eq!(model_tier("acme/whatever"), "unknown");
405        assert_eq!(model_tier(""), "unknown");
406    }
407
408    #[tokio::test]
409    async fn record_turn_completed_appends_auditable_event() {
410        let rt = Runtime::new();
411        rt.record_turn_completed(
412            "empty_tool_calls",
413            Some("length"),
414            true,
415            7,
416            "mlx/qwen3-8b:4bit",
417        )
418        .await;
419
420        let log = rt.log.lock().await;
421        let event = log.events().last().expect("turn event should be recorded");
422        assert_eq!(event.kind, car_eventlog::EventKind::TurnCompleted);
423        assert_eq!(
424            event.data.get("decision"),
425            Some(&serde_json::json!("empty_tool_calls"))
426        );
427        assert_eq!(
428            event.data.get("stop_reason"),
429            Some(&serde_json::json!("length"))
430        );
431        assert_eq!(
432            event.data.get("was_truncated"),
433            Some(&serde_json::json!(true))
434        );
435        assert_eq!(event.data.get("turns"), Some(&serde_json::json!(7)));
436        assert_eq!(
437            event.data.get("model_id"),
438            Some(&serde_json::json!("mlx/qwen3-8b:4bit"))
439        );
440        // provider prefix "mlx" → local tier (the local-vs-cloud attribution).
441        assert_eq!(
442            event.data.get("model_tier"),
443            Some(&serde_json::json!("local"))
444        );
445    }
446}