use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClaimKind {
Invoked,
Count,
Absence,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolClaim {
pub kind: ClaimKind,
pub tool: String,
#[serde(default)]
pub call_id: Option<String>,
#[serde(default)]
pub count: Option<u64>,
#[serde(default)]
pub text: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolReceipt {
pub tool: String,
#[serde(default)]
pub call_id: Option<String>,
#[serde(default)]
pub ok: bool,
#[serde(default)]
pub result_count: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HallucinationKind {
FabricatedToolReference,
CountMisstatement,
FalseAbsence,
}
#[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,
}
#[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,
}
#[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>,
}
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);
}
return None;
}
receipts.iter().find(|r| r.tool == claim.tool)
}
pub fn receipts_from_events(events: &[crate::Event]) -> Vec<ToolReceipt> {
receipts_from_events_scoped(events, None)
}
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
}
pub fn verify_tool_claims(claims: &[ToolClaim], receipts: &[ToolReceipt]) -> ReceiptReport {
verify_tool_claims_windowed(claims, receipts, true)
}
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 => {} 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() {
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() {
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() {
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(),
);
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);
}
}