car-eventlog 0.35.0

Event log with JSONL persistence for Common Agent Runtime
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
//! Tool-receipt hallucination detection (arXiv 2603.10060, *Tool Receipts, Not
//! Zero-Knowledge Proofs: Practical Hallucination Detection for AI Agents*).
//!
//! See `docs/proposals/tool-receipt-verification.md`. Agents routinely
//! hallucinate tool results — claiming a tool ran that never did, misstating an
//! output count, or asserting a false absence ("no results found" when the tool
//! returned rows). CAR's runtime owns tool execution and records every call, so
//! it holds the unforgeable ground truth (the *receipt*); this module is the
//! deterministic cross-check that flags the three hallucination classes by
//! comparing the model's claims against those receipts.
//!
//! Pure and zero-inference — the paper's whole point ("not zero-knowledge
//! proofs"): the check is cheap, exact, and runtime-owned, like
//! [`crate::harness_metrics`].

use serde::{Deserialize, Serialize};

/// What the model asserted about a tool.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClaimKind {
    /// "I used / called tool X."
    Invoked,
    /// "Tool X returned N results."
    Count,
    /// "Tool X found nothing / no results."
    Absence,
}

/// A claim the model made in its response about tool use.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolClaim {
    pub kind: ClaimKind,
    pub tool: String,
    /// The specific call this claim is about, if the model named one.
    #[serde(default)]
    pub call_id: Option<String>,
    /// The asserted result count, for [`ClaimKind::Count`] claims.
    #[serde(default)]
    pub count: Option<u64>,
    /// The claim's surface text, carried through to explanations (optional).
    #[serde(default)]
    pub text: Option<String>,
}

/// A receipt of a tool execution the runtime actually performed — the
/// ground truth, projected from the event log.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolReceipt {
    pub tool: String,
    #[serde(default)]
    pub call_id: Option<String>,
    /// Whether the call succeeded (a failed call still *executed*).
    #[serde(default)]
    pub ok: bool,
    /// Number of results the call returned, when the runtime recorded one.
    #[serde(default)]
    pub result_count: Option<u64>,
}

/// The class of a detected hallucination.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HallucinationKind {
    /// The model referenced a tool/call with no matching receipt.
    FabricatedToolReference,
    /// A count claim disagrees with the receipt's recorded result count.
    CountMisstatement,
    /// An absence claim, but the receipt shows results were returned.
    FalseAbsence,
}

/// A single detected hallucination.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Hallucination {
    pub kind: HallucinationKind,
    pub tool: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub claim_text: Option<String>,
    pub explanation: String,
}

/// A claim the check could neither ground nor refute because the receipt
/// window is incomplete — the events it references may have been evicted by
/// retention. Deliberately **not** a [`Hallucination`]: accusing the model of
/// fabrication because the runtime forgot its own receipts would be a false
/// positive (review A6).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UngroundableClaim {
    pub tool: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub claim_text: Option<String>,
    pub explanation: String,
}

/// The cross-check result. `grounded` is true iff no hallucination was found;
/// `ungroundable` (empty unless the caller declared the receipt window
/// incomplete) lists claims that could not be checked either way — a distinct,
/// non-accusatory outcome, never counted as a hallucination.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ReceiptReport {
    pub grounded: bool,
    pub hallucinations: Vec<Hallucination>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub ungroundable: Vec<UngroundableClaim>,
}

/// Find the receipt a claim refers to: by `call_id` when the claim names one,
/// otherwise the first receipt for the same tool.
fn matching_receipt<'a>(claim: &ToolClaim, receipts: &'a [ToolReceipt]) -> Option<&'a ToolReceipt> {
    if let Some(cid) = &claim.call_id {
        if let Some(r) = receipts
            .iter()
            .find(|r| r.call_id.as_deref() == Some(cid.as_str()))
        {
            return Some(r);
        }
        // A named call_id with no matching receipt is unmatched — fabricated.
        return None;
    }
    receipts.iter().find(|r| r.tool == claim.tool)
}

/// Project ground-truth [`ToolReceipt`]s from an event-log slice (A6).
///
/// Folds `ActionSucceeded` / `ActionFailed` events that carry a `tool` key
/// (recorded by the executor at tool-dispatch) into receipts: `ok` reflects
/// success/failure, `result_count` is carried through when present, and the
/// event's `action_id` becomes the receipt `call_id`. Events without a
/// `tool` key (state writes, validation rejections, replan telemetry) are
/// not tool executions and are skipped. This is the unforgeable ground
/// truth the runtime holds because it — not the model — ran the tools.
pub fn receipts_from_events(events: &[crate::Event]) -> Vec<ToolReceipt> {
    receipts_from_events_scoped(events, None)
}

/// [`receipts_from_events`], scoped to a single proposal's window (A6).
///
/// When `proposal_id` is `Some`, only events stamped with that proposal id
/// are projected — the natural cross-check boundary, so a claim about *this*
/// run is never judged against another proposal's receipts (or their
/// absence). `None` projects the whole slice, identical to
/// [`receipts_from_events`].
pub fn receipts_from_events_scoped(
    events: &[crate::Event],
    proposal_id: Option<&str>,
) -> Vec<ToolReceipt> {
    use crate::EventKind;
    let mut receipts = Vec::new();
    for ev in events {
        let is_action = matches!(
            ev.kind,
            EventKind::ActionSucceeded | EventKind::ActionFailed
        );
        if !is_action {
            continue;
        }
        if let Some(pid) = proposal_id {
            if ev.proposal_id.as_deref() != Some(pid) {
                continue;
            }
        }
        let Some(tool) = ev.data.get("tool").and_then(|v| v.as_str()) else {
            continue;
        };
        let ok = ev
            .data
            .get("ok")
            .and_then(|v| v.as_bool())
            .unwrap_or(ev.kind == EventKind::ActionSucceeded);
        let result_count = ev.data.get("result_count").and_then(|v| v.as_u64());
        receipts.push(ToolReceipt {
            tool: tool.to_string(),
            call_id: ev.action_id.clone(),
            ok,
            result_count,
        });
    }
    receipts
}

/// Cross-check model claims against the runtime's tool-execution receipts.
/// Deterministic and pure — a given `(claims, receipts)` always yields the same
/// report. Assumes the receipt window is **complete** (no receipts evicted);
/// when retention may have trimmed the window, use
/// [`verify_tool_claims_windowed`] with `window_complete = false`.
pub fn verify_tool_claims(claims: &[ToolClaim], receipts: &[ToolReceipt]) -> ReceiptReport {
    verify_tool_claims_windowed(claims, receipts, true)
}

/// [`verify_tool_claims`] with an explicit window-completeness bit (A6).
///
/// `window_complete = false` declares that the receipts were projected from a
/// log whose relevant events may have been evicted by retention. A claim with
/// no matching receipt is then reported as [`ReceiptReport::ungroundable`]
/// ("window evicted") instead of `fabricated_tool_reference` — absence of a
/// receipt is not evidence of fabrication when the runtime itself dropped
/// receipts. Claims that *do* match a retained receipt are still fully
/// checked (a count misstatement against a real receipt is a real
/// hallucination regardless of the window).
pub fn verify_tool_claims_windowed(
    claims: &[ToolClaim],
    receipts: &[ToolReceipt],
    window_complete: bool,
) -> ReceiptReport {
    let mut hallucinations = Vec::new();
    let mut ungroundable = Vec::new();

    for claim in claims {
        match matching_receipt(claim, receipts) {
            None if !window_complete => ungroundable.push(UngroundableClaim {
                tool: claim.tool.clone(),
                claim_text: claim.text.clone(),
                explanation: format!(
                    "ungroundable — window evicted: no receipt for tool '{}' is retained, but \
                     the event log was trimmed by retention, so the execution this claim \
                     references may have been evicted rather than never run",
                    claim.tool
                ),
            }),
            None => hallucinations.push(Hallucination {
                kind: HallucinationKind::FabricatedToolReference,
                tool: claim.tool.clone(),
                claim_text: claim.text.clone(),
                explanation: match &claim.call_id {
                    Some(cid) => format!(
                        "claim references tool '{}' call '{}' but no such execution was recorded",
                        claim.tool, cid
                    ),
                    None => format!(
                        "claim references tool '{}' but it was never executed",
                        claim.tool
                    ),
                },
            }),
            Some(receipt) => match claim.kind {
                ClaimKind::Invoked => {} // grounded: it really ran
                ClaimKind::Count => {
                    if let (Some(claimed), Some(actual)) = (claim.count, receipt.result_count) {
                        if claimed != actual {
                            hallucinations.push(Hallucination {
                                kind: HallucinationKind::CountMisstatement,
                                tool: claim.tool.clone(),
                                claim_text: claim.text.clone(),
                                explanation: format!(
                                    "claim states tool '{}' returned {claimed} result(s), but the \
                                     receipt records {actual}",
                                    claim.tool
                                ),
                            });
                        }
                    }
                }
                ClaimKind::Absence => {
                    if receipt.result_count.map(|c| c > 0).unwrap_or(false) {
                        hallucinations.push(Hallucination {
                            kind: HallucinationKind::FalseAbsence,
                            tool: claim.tool.clone(),
                            claim_text: claim.text.clone(),
                            explanation: format!(
                                "claim asserts tool '{}' found nothing, but the receipt records \
                                 {} result(s)",
                                claim.tool,
                                receipt.result_count.unwrap_or(0)
                            ),
                        });
                    }
                }
            },
        }
    }

    ReceiptReport {
        grounded: hallucinations.is_empty(),
        hallucinations,
        ungroundable,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn claim(kind: ClaimKind, tool: &str) -> ToolClaim {
        ToolClaim {
            kind,
            tool: tool.into(),
            call_id: None,
            count: None,
            text: None,
        }
    }

    fn receipt(tool: &str, ok: bool, count: Option<u64>) -> ToolReceipt {
        ToolReceipt {
            tool: tool.into(),
            call_id: None,
            ok,
            result_count: count,
        }
    }

    #[test]
    fn invoked_claim_with_receipt_is_grounded() {
        let r = verify_tool_claims(
            &[claim(ClaimKind::Invoked, "search")],
            &[receipt("search", true, Some(3))],
        );
        assert!(r.grounded);
    }

    #[test]
    fn fabricated_tool_reference_is_flagged() {
        let r = verify_tool_claims(&[claim(ClaimKind::Invoked, "search")], &[]);
        assert!(!r.grounded);
        assert_eq!(
            r.hallucinations[0].kind,
            HallucinationKind::FabricatedToolReference
        );
    }

    #[test]
    fn count_misstatement_is_flagged() {
        let mut c = claim(ClaimKind::Count, "search");
        c.count = Some(12);
        let r = verify_tool_claims(&[c], &[receipt("search", true, Some(3))]);
        assert_eq!(
            r.hallucinations[0].kind,
            HallucinationKind::CountMisstatement
        );
    }

    #[test]
    fn correct_count_is_grounded() {
        let mut c = claim(ClaimKind::Count, "search");
        c.count = Some(3);
        let r = verify_tool_claims(&[c], &[receipt("search", true, Some(3))]);
        assert!(r.grounded);
    }

    #[test]
    fn false_absence_is_flagged() {
        let r = verify_tool_claims(
            &[claim(ClaimKind::Absence, "search")],
            &[receipt("search", true, Some(5))],
        );
        assert_eq!(r.hallucinations[0].kind, HallucinationKind::FalseAbsence);
    }

    #[test]
    fn true_absence_is_grounded() {
        let r = verify_tool_claims(
            &[claim(ClaimKind::Absence, "search")],
            &[receipt("search", true, Some(0))],
        );
        assert!(r.grounded);
    }

    #[test]
    fn unverifiable_count_without_recorded_result_is_not_flagged() {
        // Receipt didn't record a result_count — can't refute the claim.
        let mut c = claim(ClaimKind::Count, "search");
        c.count = Some(9);
        let r = verify_tool_claims(&[c], &[receipt("search", true, None)]);
        assert!(r.grounded);
    }

    #[test]
    fn call_id_mismatch_is_fabricated() {
        let mut c = claim(ClaimKind::Invoked, "search");
        c.call_id = Some("call-2".into());
        let mut rc = receipt("search", true, Some(1));
        rc.call_id = Some("call-1".into());
        let r = verify_tool_claims(&[c], &[rc]);
        assert_eq!(
            r.hallucinations[0].kind,
            HallucinationKind::FabricatedToolReference
        );
    }

    #[test]
    fn evicted_window_yields_ungroundable_not_fabricated() {
        // Regression (review A6): a truthful claim about a tool call whose
        // events were trimmed by retention must not be flagged as fabricated.
        let r = verify_tool_claims_windowed(&[claim(ClaimKind::Invoked, "search")], &[], false);
        assert!(r.grounded, "non-accusatory: not a hallucination");
        assert!(r.hallucinations.is_empty());
        assert_eq!(r.ungroundable.len(), 1);
        assert_eq!(r.ungroundable[0].tool, "search");
        assert!(r.ungroundable[0].explanation.contains("window evicted"));
    }

    #[test]
    fn incomplete_window_still_flags_mismatch_against_retained_receipt() {
        // A retained receipt is still ground truth even when the window is
        // incomplete: a count misstatement against it is a real hallucination.
        let mut c = claim(ClaimKind::Count, "search");
        c.count = Some(12);
        let r = verify_tool_claims_windowed(&[c], &[receipt("search", true, Some(3))], false);
        assert!(!r.grounded);
        assert_eq!(
            r.hallucinations[0].kind,
            HallucinationKind::CountMisstatement
        );
        assert!(r.ungroundable.is_empty());
    }

    #[test]
    fn receipts_projection_scopes_to_proposal() {
        use crate::{EventKind, EventLog};
        let mut log = EventLog::new();
        log.append(
            EventKind::ActionSucceeded,
            Some("a1"),
            Some("p1"),
            [("tool".to_string(), serde_json::Value::from("search"))].into(),
        );
        log.append(
            EventKind::ActionSucceeded,
            Some("a2"),
            Some("p2"),
            [("tool".to_string(), serde_json::Value::from("deploy"))].into(),
        );
        // Unscoped: both receipts. Scoped: only the named proposal's.
        assert_eq!(receipts_from_events(log.events()).len(), 2);
        let scoped = receipts_from_events_scoped(log.events(), Some("p1"));
        assert_eq!(scoped.len(), 1);
        assert_eq!(scoped[0].tool, "search");
        assert!(receipts_from_events_scoped(log.events(), Some("p3")).is_empty());
    }

    #[test]
    fn call_id_match_binds_the_right_receipt() {
        let mut c = claim(ClaimKind::Count, "search");
        c.call_id = Some("call-2".into());
        c.count = Some(2);
        let mut r1 = receipt("search", true, Some(99));
        r1.call_id = Some("call-1".into());
        let mut r2 = receipt("search", true, Some(2));
        r2.call_id = Some("call-2".into());
        let r = verify_tool_claims(&[c], &[r1, r2]);
        assert!(r.grounded, "{:?}", r.hallucinations);
    }
}