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    ///
106    /// Returns the appended event. The event names no session, so a caller
107    /// whose runtime is shared by several conversations keeps it to say which
108    /// conversation it belongs to (car#2284).
109    pub async fn record_goal_evaluated(
110        &self,
111        goal: &str,
112        condition: &GoalCondition,
113        iteration: u32,
114        met: bool,
115        grounded: bool,
116        reason: &str,
117        model: &str,
118    ) -> car_eventlog::Event {
119        let mut data = HashMap::new();
120        data.insert("goal".to_string(), serde_json::json!(goal));
121        data.insert(
122            "condition".to_string(),
123            serde_json::to_value(condition).unwrap_or(serde_json::Value::Null),
124        );
125        data.insert("iteration".to_string(), serde_json::json!(iteration));
126        data.insert("met".to_string(), serde_json::json!(met));
127        data.insert("grounded".to_string(), serde_json::json!(grounded));
128        data.insert("reason".to_string(), serde_json::json!(reason));
129        data.insert("model_id".to_string(), serde_json::json!(model));
130        data.insert(
131            "model_tier".to_string(),
132            serde_json::json!(model_tier(model)),
133        );
134        self.log
135            .lock()
136            .await
137            .append(EventKind::GoalEvaluated, None, None, data)
138            .clone()
139    }
140
141    /// Append a durable completion-decision record for an assistant/coder turn
142    /// loop. The default (goal-less) loop declares success the instant the model
143    /// emits no tool calls, with no truncation or outcome check — so a truncated
144    /// or turn-capped run is indistinguishable from a real finish at the
145    /// terminal. Recording `decision`/`stop_reason`/`was_truncated`/`turns` makes
146    /// that decision a first-class, queryable event: the false-success and
147    /// never-finished signals live here on the ungrounded default path, which
148    /// emits no `GoalEvaluated`.
149    ///
150    /// Returns the appended event, for the same reason as
151    /// [`Self::record_goal_evaluated`].
152    pub async fn record_turn_completed(
153        &self,
154        decision: &str,
155        stop_reason: Option<&str>,
156        was_truncated: bool,
157        turns: u32,
158        model: &str,
159    ) -> car_eventlog::Event {
160        let data = turn_completed_data(decision, stop_reason, was_truncated, turns, model);
161        self.log
162            .lock()
163            .await
164            .append(EventKind::TurnCompleted, None, None, data)
165            .clone()
166    }
167}
168
169/// Build the durable `TurnCompleted` event-data map.
170///
171/// Shared by the assistant loop's [`Runtime::record_turn_completed`] and the
172/// coder loop's terminal (`EventSink::record_turn_completed` in
173/// `car-server-core`), so the two paths never drift on the keys the harness
174/// miners parse (`harness_adapt::diagnose`, `failed_trace_events`): `decision`,
175/// `stop_reason`, `was_truncated`, `turns`, and the P0c provenance
176/// `model_id` (authoritative) / `model_tier` (derived local|cloud grouping).
177///
178/// The coder loop stamps one further key on top of these — `models_served`,
179/// every model that served a turn in that iteration (car#1333) — which no miner
180/// parses and which the assistant path has no answer to, since nothing there
181/// consumes authorship. Non-drift is a promise about the keys above, not about
182/// the whole record.
183pub fn turn_completed_data(
184    decision: &str,
185    stop_reason: Option<&str>,
186    was_truncated: bool,
187    turns: u32,
188    model: &str,
189) -> HashMap<String, serde_json::Value> {
190    let mut data = HashMap::new();
191    data.insert("decision".to_string(), serde_json::json!(decision));
192    data.insert("stop_reason".to_string(), serde_json::json!(stop_reason));
193    data.insert(
194        "was_truncated".to_string(),
195        serde_json::json!(was_truncated),
196    );
197    data.insert("turns".to_string(), serde_json::json!(turns));
198    data.insert("model_id".to_string(), serde_json::json!(model));
199    data.insert(
200        "model_tier".to_string(),
201        serde_json::json!(model_tier(model)),
202    );
203    data
204}
205
206/// Best-effort `local` | `cloud` | `unknown` tier from a model id's provider
207/// prefix (the segment before the first `/`).
208///
209/// `model_id` is the authoritative attribution field; `model_tier` is a
210/// convenience grouping so "fails on local vs cloud" — the framing of Matt's
211/// field report (works on Codex/Opus, breaks on a local model) — can be
212/// queried without re-deriving the prefix. Shared by the `GoalEvaluated` /
213/// `TurnCompleted` event emitters and the offline bench corpus (`car-bench`)
214/// so the provider allow-lists never drift between call sites.
215pub fn model_tier(model: &str) -> &'static str {
216    match model.split('/').next().unwrap_or("") {
217        "mlx" | "mlx-vlm" | "vllm-mlx" | "local" | "apple" | "qwen" => "local",
218        "anthropic" | "openai" | "google" | "gemini" | "parslee" => "cloud",
219        _ => "unknown",
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use crate::ToolExecutor;
227    use car_eventlog::tool_receipts::{ClaimKind, ToolClaim};
228    use car_ir::{Action, ActionProposal, ActionType};
229    use car_verify::goal::{evaluate_goal, GoalCondition};
230    use serde_json::Value;
231    use std::sync::Arc;
232
233    struct EchoExec;
234    #[async_trait::async_trait]
235    impl ToolExecutor for EchoExec {
236        async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
237            match tool {
238                "echo" => Ok(params.get("message").cloned().unwrap_or(Value::Null)),
239                _ => Err(format!("unknown tool: {tool}")),
240            }
241        }
242    }
243
244    fn tool_call(tool: &str, params: HashMap<String, Value>) -> Action {
245        let mut a = Action::tool_call(tool);
246        a.parameters = params;
247        a
248    }
249
250    fn state_write(key: &str, value: Value) -> Action {
251        let mut a = Action::new(ActionType::StateWrite);
252        a.parameters = [
253            ("key".to_string(), Value::from(key)),
254            ("value".to_string(), value),
255        ]
256        .into();
257        a
258    }
259
260    fn proposal(actions: Vec<Action>) -> ActionProposal {
261        ActionProposal {
262            id: "goal-test".to_string(),
263            source: "test".to_string(),
264            actions,
265            timestamp: chrono::Utc::now(),
266            context: HashMap::new(),
267        }
268    }
269
270    /// End-to-end over REAL ground truth: execute a proposal that calls a tool
271    /// and writes state, then gather inputs and evaluate a goal condition —
272    /// proving `receipts_grounded` reads the event log and `StatePredicate`
273    /// reads live state.
274    #[tokio::test]
275    async fn gather_reads_real_receipts_and_state() {
276        let rt = Runtime::new().with_executor(Arc::new(EchoExec));
277        rt.register_tool("echo").await;
278
279        let p = proposal(vec![
280            tool_call("echo", [("message".to_string(), Value::from("hi"))].into()),
281            state_write("tests_passing", Value::Bool(true)),
282        ]);
283        let r = rt.execute(&p).await;
284        assert!(r.all_succeeded(), "setup proposal must succeed");
285
286        // A truthful claim ("I invoked echo") + the real state write.
287        let gather = GoalGather {
288            claims: vec![ToolClaim {
289                kind: ClaimKind::Invoked,
290                tool: "echo".to_string(),
291                call_id: None,
292                count: None,
293                text: None,
294            }],
295            proposal_id: Some("goal-test".to_string()),
296            ..Default::default()
297        };
298        let inputs = rt.gather_goal_inputs(&gather).await;
299        assert_eq!(inputs.receipts_grounded, Some(true));
300        assert_eq!(inputs.state.get("tests_passing"), Some(&Value::Bool(true)));
301
302        let cond = GoalCondition::AllOf {
303            conditions: vec![
304                GoalCondition::ToolReceiptsGrounded,
305                GoalCondition::StatePredicate {
306                    key: "tests_passing".into(),
307                    equals: Value::Bool(true),
308                },
309            ],
310        };
311        let verdict = evaluate_goal(&cond, &inputs);
312        assert!(verdict.met && verdict.grounded, "{}", verdict.reason);
313    }
314
315    /// The `/goal` blindspot, closed: a fabricated tool claim makes the
316    /// grounded-receipts condition fail even though the model "said" it ran.
317    #[tokio::test]
318    async fn fabricated_claim_fails_the_receipts_condition() {
319        let rt = Runtime::new().with_executor(Arc::new(EchoExec));
320        rt.register_tool("echo").await;
321        let r = rt
322            .execute(&proposal(vec![tool_call(
323                "echo",
324                [("message".to_string(), Value::from("hi"))].into(),
325            )]))
326            .await;
327        assert!(r.all_succeeded());
328
329        // The model claims it deployed — but no deploy ever ran.
330        let gather = GoalGather {
331            claims: vec![ToolClaim {
332                kind: ClaimKind::Invoked,
333                tool: "deploy".to_string(),
334                call_id: None,
335                count: None,
336                text: Some("I deployed it".to_string()),
337            }],
338            proposal_id: Some("goal-test".to_string()),
339            ..Default::default()
340        };
341        let inputs = rt.gather_goal_inputs(&gather).await;
342        assert_eq!(inputs.receipts_grounded, Some(false));
343
344        let verdict = evaluate_goal(&GoalCondition::ToolReceiptsGrounded, &inputs);
345        assert!(
346            !verdict.met,
347            "a hallucinated tool claim must not satisfy the goal"
348        );
349        assert!(verdict.reason.contains("hallucinated"));
350    }
351
352    #[tokio::test]
353    async fn record_goal_evaluated_appends_auditable_event() {
354        let rt = Runtime::new();
355        let condition = GoalCondition::Command {
356            id: "tests".to_string(),
357            expect_exit: 0,
358        };
359        rt.record_goal_evaluated(
360            "make tests pass",
361            &condition,
362            2,
363            true,
364            true,
365            "command `test` exited 0",
366            "mlx/qwen3-8b:4bit",
367        )
368        .await;
369
370        let log = rt.log.lock().await;
371        let event = log.events().last().expect("goal event should be recorded");
372        assert_eq!(event.kind, car_eventlog::EventKind::GoalEvaluated);
373        assert_eq!(
374            event.data.get("goal"),
375            Some(&serde_json::json!("make tests pass"))
376        );
377        assert_eq!(
378            event.data.get("condition"),
379            Some(&serde_json::json!({"kind": "command", "id": "tests", "expect_exit": 0}))
380        );
381        assert_eq!(event.data.get("iteration"), Some(&serde_json::json!(2)));
382        assert_eq!(event.data.get("met"), Some(&serde_json::json!(true)));
383        assert_eq!(event.data.get("grounded"), Some(&serde_json::json!(true)));
384        assert_eq!(
385            event.data.get("reason"),
386            Some(&serde_json::json!("command `test` exited 0"))
387        );
388        // Model provenance stamped on the goal path too (mirrors TurnCompleted),
389        // so an ungrounded completion is attributable to local vs cloud.
390        assert_eq!(
391            event.data.get("model_id"),
392            Some(&serde_json::json!("mlx/qwen3-8b:4bit"))
393        );
394        assert_eq!(
395            event.data.get("model_tier"),
396            Some(&serde_json::json!("local"))
397        );
398    }
399
400    #[test]
401    fn model_tier_maps_provider_prefixes() {
402        // Local inference backends.
403        assert_eq!(model_tier("mlx/qwen3-8b:4bit"), "local");
404        assert_eq!(model_tier("qwen/qwen3-8b"), "local");
405        assert_eq!(model_tier("apple/foundation"), "local");
406        // Cloud providers.
407        assert_eq!(model_tier("anthropic/claude-opus-4-8"), "cloud");
408        assert_eq!(model_tier("openai/gpt-5.5"), "cloud");
409        assert_eq!(model_tier("google/gemini-2.5-pro"), "cloud");
410        assert_eq!(model_tier("parslee/reasoning"), "cloud");
411        // Unrecognized prefix and the empty-model (no generate completed) case
412        // both fall through to "unknown" rather than being misattributed.
413        assert_eq!(model_tier("acme/whatever"), "unknown");
414        assert_eq!(model_tier(""), "unknown");
415    }
416
417    #[tokio::test]
418    async fn record_turn_completed_appends_auditable_event() {
419        let rt = Runtime::new();
420        rt.record_turn_completed(
421            "empty_tool_calls",
422            Some("length"),
423            true,
424            7,
425            "mlx/qwen3-8b:4bit",
426        )
427        .await;
428
429        let log = rt.log.lock().await;
430        let event = log.events().last().expect("turn event should be recorded");
431        assert_eq!(event.kind, car_eventlog::EventKind::TurnCompleted);
432        assert_eq!(
433            event.data.get("decision"),
434            Some(&serde_json::json!("empty_tool_calls"))
435        );
436        assert_eq!(
437            event.data.get("stop_reason"),
438            Some(&serde_json::json!("length"))
439        );
440        assert_eq!(
441            event.data.get("was_truncated"),
442            Some(&serde_json::json!(true))
443        );
444        assert_eq!(event.data.get("turns"), Some(&serde_json::json!(7)));
445        assert_eq!(
446            event.data.get("model_id"),
447            Some(&serde_json::json!("mlx/qwen3-8b:4bit"))
448        );
449        // provider prefix "mlx" → local tier (the local-vs-cloud attribution).
450        assert_eq!(
451            event.data.get("model_tier"),
452            Some(&serde_json::json!("local"))
453        );
454    }
455}