1use 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#[derive(Debug, Clone, Default)]
31pub struct GoalGather {
32 pub claims: Vec<ToolClaim>,
36 pub proposal_id: Option<String>,
38 pub plan: Option<PlanCheckRequest>,
40 pub transaction_proposal: Option<ActionProposal>,
43 pub state_keys: Vec<String>,
46 pub command_exits: HashMap<String, i32>,
48 pub model_verdicts: HashMap<String, bool>,
50}
51
52impl Runtime {
53 pub async fn gather_goal_inputs(&self, gather: &GoalGather) -> GoalInputs {
60 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 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 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
160pub 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
197pub 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 #[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 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 #[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 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 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 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 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 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 assert_eq!(
442 event.data.get("model_tier"),
443 Some(&serde_json::json!("local"))
444 );
445 }
446}